Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -2,10 +2,16 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Unknowns.Configuration;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
@@ -331,6 +337,35 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.Contains(result.Warnings, warning => warning.Contains("Git-sourced", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_UnknownBudgetExceeded_BlocksEvaluation()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var budgetService = CreateBudgetService();
|
||||
var evaluator = new PolicyEvaluator(budgetService: budgetService);
|
||||
|
||||
var context = new PolicyEvaluationContext(
|
||||
new PolicyEvaluationSeverity("High"),
|
||||
new PolicyEvaluationEnvironment(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["name"] = "prod"
|
||||
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
|
||||
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
|
||||
PolicyEvaluationVexEvidence.Empty,
|
||||
PolicyEvaluationSbom.Empty,
|
||||
PolicyEvaluationExceptions.Empty,
|
||||
ImmutableArray.Create(CreateUnknown(UnknownReasonCode.Reachability)),
|
||||
ImmutableArray<ExceptionObject>.Empty,
|
||||
PolicyEvaluationReachability.Unknown,
|
||||
PolicyEvaluationEntropy.Unknown);
|
||||
|
||||
var result = evaluator.Evaluate(new PolicyEvaluationRequest(document, context));
|
||||
|
||||
Assert.Equal("blocked", result.Status);
|
||||
Assert.Equal(PolicyFailureReason.UnknownBudgetExceeded, result.FailureReason);
|
||||
Assert.NotNull(result.UnknownBudgetStatus);
|
||||
}
|
||||
|
||||
private PolicyIrDocument CompileBaseline()
|
||||
{
|
||||
var compilation = compiler.Compile(BaselinePolicy);
|
||||
@@ -354,10 +389,69 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
PolicyEvaluationVexEvidence.Empty,
|
||||
PolicyEvaluationSbom.Empty,
|
||||
exceptions ?? PolicyEvaluationExceptions.Empty,
|
||||
ImmutableArray<Unknown>.Empty,
|
||||
ImmutableArray<ExceptionObject>.Empty,
|
||||
PolicyEvaluationReachability.Unknown,
|
||||
PolicyEvaluationEntropy.Unknown);
|
||||
}
|
||||
|
||||
private static UnknownBudgetService CreateBudgetService()
|
||||
{
|
||||
var options = new UnknownBudgetOptions
|
||||
{
|
||||
Budgets = new Dictionary<string, UnknownBudget>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["prod"] = new UnknownBudget
|
||||
{
|
||||
Environment = "prod",
|
||||
TotalLimit = 0,
|
||||
Action = BudgetAction.Block
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return new UnknownBudgetService(
|
||||
new TestOptionsMonitor<UnknownBudgetOptions>(options),
|
||||
NullLogger<UnknownBudgetService>.Instance);
|
||||
}
|
||||
|
||||
private static Unknown CreateUnknown(UnknownReasonCode reasonCode)
|
||||
{
|
||||
var timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
return new Unknown
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = Guid.NewGuid(),
|
||||
PackageId = "pkg:npm/lodash",
|
||||
PackageVersion = "4.17.21",
|
||||
Band = UnknownBand.Hot,
|
||||
Score = 80m,
|
||||
UncertaintyFactor = 0.5m,
|
||||
ExploitPressure = 0.7m,
|
||||
ReasonCode = reasonCode,
|
||||
FirstSeenAt = timestamp,
|
||||
LastEvaluatedAt = timestamp,
|
||||
CreatedAt = timestamp,
|
||||
UpdatedAt = timestamp
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T>(T current) : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _current = current;
|
||||
|
||||
public T CurrentValue => _current;
|
||||
public T Get(string? name) => _current;
|
||||
public IDisposable OnChange(Action<T, string?> listener) => NoopDisposable.Instance;
|
||||
}
|
||||
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public static readonly NoopDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
|
||||
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
Assert.Equal("pack-1", response.PackId);
|
||||
Assert.Equal(1, response.Version);
|
||||
Assert.NotNull(response.PolicyDigest);
|
||||
Assert.NotNull(response.Confidence);
|
||||
Assert.False(response.Cached);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
public sealed class EvidenceRequirementValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidateForApprovalAsync_NoHooks_ReturnsValid()
|
||||
{
|
||||
var validator = CreateValidator(new StubHookRegistry([]));
|
||||
var exception = CreateException();
|
||||
|
||||
var result = await validator.ValidateForApprovalAsync(exception);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.MissingEvidence.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateForApprovalAsync_MissingEvidence_ReturnsInvalid()
|
||||
{
|
||||
var hooks = ImmutableArray.Create(new EvidenceHook
|
||||
{
|
||||
HookId = "hook-1",
|
||||
Type = EvidenceType.FeatureFlagDisabled,
|
||||
Description = "Feature flag disabled",
|
||||
IsMandatory = true
|
||||
});
|
||||
|
||||
var validator = CreateValidator(new StubHookRegistry(hooks));
|
||||
var exception = CreateException();
|
||||
|
||||
var result = await validator.ValidateForApprovalAsync(exception);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.MissingEvidence.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateForApprovalAsync_TrustScoreTooLow_ReturnsInvalid()
|
||||
{
|
||||
var hooks = ImmutableArray.Create(new EvidenceHook
|
||||
{
|
||||
HookId = "hook-1",
|
||||
Type = EvidenceType.BackportMerged,
|
||||
Description = "Backport merged",
|
||||
IsMandatory = true,
|
||||
MinTrustScore = 0.8m
|
||||
});
|
||||
|
||||
var validator = CreateValidator(
|
||||
new StubHookRegistry(hooks),
|
||||
trustScore: 0.5m);
|
||||
|
||||
var exception = CreateException(new EvidenceRequirements
|
||||
{
|
||||
Hooks = hooks,
|
||||
SubmittedEvidence = ImmutableArray.Create(new SubmittedEvidence
|
||||
{
|
||||
EvidenceId = "e-1",
|
||||
HookId = "hook-1",
|
||||
Type = EvidenceType.BackportMerged,
|
||||
Reference = "ref",
|
||||
SubmittedAt = DateTimeOffset.UtcNow,
|
||||
SubmittedBy = "tester",
|
||||
ValidationStatus = EvidenceValidationStatus.Valid
|
||||
})
|
||||
});
|
||||
|
||||
var result = await validator.ValidateForApprovalAsync(exception);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.InvalidEvidence.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
private static EvidenceRequirementValidator CreateValidator(
|
||||
IEvidenceHookRegistry registry,
|
||||
decimal trustScore = 1.0m,
|
||||
bool schemaValid = true,
|
||||
bool signatureValid = true)
|
||||
{
|
||||
return new EvidenceRequirementValidator(
|
||||
registry,
|
||||
new StubAttestationVerifier(signatureValid),
|
||||
new StubTrustScoreService(trustScore),
|
||||
new StubSchemaValidator(schemaValid),
|
||||
NullLogger<EvidenceRequirementValidator>.Instance);
|
||||
}
|
||||
|
||||
private static ExceptionObject CreateException(EvidenceRequirements? requirements = null)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = "EXC-TEST",
|
||||
Version = 1,
|
||||
Status = ExceptionStatus.Active,
|
||||
Type = ExceptionType.Vulnerability,
|
||||
Scope = new ExceptionScope { VulnerabilityId = "CVE-2024-0001" },
|
||||
OwnerId = "owner",
|
||||
RequesterId = "requester",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
ReasonCode = ExceptionReason.AcceptedRisk,
|
||||
Rationale = "This rationale is long enough to satisfy the minimum character requirement.",
|
||||
EvidenceRequirements = requirements
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHookRegistry(ImmutableArray<EvidenceHook> hooks) : IEvidenceHookRegistry
|
||||
{
|
||||
public Task<ImmutableArray<EvidenceHook>> GetRequiredHooksAsync(
|
||||
ExceptionType exceptionType,
|
||||
ExceptionScope scope,
|
||||
CancellationToken ct = default) => Task.FromResult(hooks);
|
||||
}
|
||||
|
||||
private sealed class StubAttestationVerifier(bool isValid) : IAttestationVerifier
|
||||
{
|
||||
public Task<EvidenceVerificationResult> VerifyAsync(string dsseEnvelope, CancellationToken ct = default) =>
|
||||
Task.FromResult(new EvidenceVerificationResult(isValid, isValid ? null : "invalid"));
|
||||
}
|
||||
|
||||
private sealed class StubTrustScoreService(decimal score) : ITrustScoreService
|
||||
{
|
||||
public Task<decimal> GetScoreAsync(string reference, CancellationToken ct = default) => Task.FromResult(score);
|
||||
}
|
||||
|
||||
private sealed class StubSchemaValidator(bool isValid) : IEvidenceSchemaValidator
|
||||
{
|
||||
public Task<EvidenceSchemaValidationResult> ValidateAsync(
|
||||
string schemaId,
|
||||
string? content,
|
||||
CancellationToken ct = default) =>
|
||||
Task.FromResult(new EvidenceSchemaValidationResult(isValid, isValid ? null : "schema invalid"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
public sealed class EvidenceRequirementsTests
|
||||
{
|
||||
[Fact]
|
||||
public void EvidenceRequirements_ShouldBeSatisfied_WhenAllMandatoryHooksValid()
|
||||
{
|
||||
var hooks = ImmutableArray.Create(
|
||||
new EvidenceHook
|
||||
{
|
||||
HookId = "hook-1",
|
||||
Type = EvidenceType.FeatureFlagDisabled,
|
||||
Description = "Feature flag disabled",
|
||||
IsMandatory = true
|
||||
},
|
||||
new EvidenceHook
|
||||
{
|
||||
HookId = "hook-2",
|
||||
Type = EvidenceType.BackportMerged,
|
||||
Description = "Backport merged",
|
||||
IsMandatory = false
|
||||
});
|
||||
|
||||
var submitted = ImmutableArray.Create(new SubmittedEvidence
|
||||
{
|
||||
EvidenceId = "e-1",
|
||||
HookId = "hook-1",
|
||||
Type = EvidenceType.FeatureFlagDisabled,
|
||||
Reference = "attestation:feature-flag",
|
||||
SubmittedAt = DateTimeOffset.UtcNow,
|
||||
SubmittedBy = "tester",
|
||||
ValidationStatus = EvidenceValidationStatus.Valid
|
||||
});
|
||||
|
||||
var requirements = new EvidenceRequirements
|
||||
{
|
||||
Hooks = hooks,
|
||||
SubmittedEvidence = submitted
|
||||
};
|
||||
|
||||
requirements.IsSatisfied.Should().BeTrue();
|
||||
requirements.MissingEvidence.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvidenceRequirements_ShouldReportMissing_WhenMandatoryHookMissing()
|
||||
{
|
||||
var hooks = ImmutableArray.Create(new EvidenceHook
|
||||
{
|
||||
HookId = "hook-1",
|
||||
Type = EvidenceType.CompensatingControl,
|
||||
Description = "Compensating control",
|
||||
IsMandatory = true
|
||||
});
|
||||
|
||||
var requirements = new EvidenceRequirements
|
||||
{
|
||||
Hooks = hooks,
|
||||
SubmittedEvidence = []
|
||||
};
|
||||
|
||||
requirements.IsSatisfied.Should().BeFalse();
|
||||
requirements.MissingEvidence.Should().HaveCount(1);
|
||||
requirements.MissingEvidence[0].HookId.Should().Be("hook-1");
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,40 @@ public sealed class ExceptionObjectTests
|
||||
exception.EvidenceRefs.Should().Contain("sha256:evidence1hash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionObject_IsBlockedByRecheck_WhenBlockTriggered_ShouldBeTrue()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(recheckResult: new RecheckEvaluationResult
|
||||
{
|
||||
IsTriggered = true,
|
||||
TriggeredConditions = [],
|
||||
RecommendedAction = RecheckAction.Block,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
exception.IsBlockedByRecheck.Should().BeTrue();
|
||||
exception.RequiresReapproval.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionObject_RequiresReapproval_WhenReapprovalTriggered_ShouldBeTrue()
|
||||
{
|
||||
// Arrange
|
||||
var exception = CreateException(recheckResult: new RecheckEvaluationResult
|
||||
{
|
||||
IsTriggered = true,
|
||||
TriggeredConditions = [],
|
||||
RecommendedAction = RecheckAction.RequireReapproval,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
exception.RequiresReapproval.Should().BeTrue();
|
||||
exception.IsBlockedByRecheck.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExceptionObject_WithMetadata_ShouldStoreKeyValuePairs()
|
||||
{
|
||||
@@ -265,7 +299,8 @@ public sealed class ExceptionObjectTests
|
||||
DateTimeOffset? expiresAt = null,
|
||||
ImmutableArray<string>? approverIds = null,
|
||||
ImmutableArray<string>? evidenceRefs = null,
|
||||
ImmutableDictionary<string, string>? metadata = null)
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
RecheckEvaluationResult? recheckResult = null)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
@@ -287,7 +322,9 @@ public sealed class ExceptionObjectTests
|
||||
Rationale = "This is a test rationale that meets the minimum character requirement of 50 characters.",
|
||||
EvidenceRefs = evidenceRefs ?? [],
|
||||
CompensatingControls = [],
|
||||
Metadata = metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
Metadata = metadata ?? ImmutableDictionary<string, string>.Empty,
|
||||
LastRecheckResult = recheckResult,
|
||||
LastRecheckAt = recheckResult?.EvaluatedAt
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Exceptions.Tests;
|
||||
|
||||
public sealed class RecheckEvaluationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NoPolicy_ReturnsNoTrigger()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
var exception = CreateException(recheckPolicy: null);
|
||||
var context = new RecheckEvaluationContext
|
||||
{
|
||||
ArtifactDigest = "sha256:abc",
|
||||
Environment = "prod",
|
||||
EvaluatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var result = await service.EvaluateAsync(exception, context);
|
||||
|
||||
result.IsTriggered.Should().BeFalse();
|
||||
result.RecommendedAction.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EpssAbove_Triggers()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
var policy = new RecheckPolicy
|
||||
{
|
||||
PolicyId = "policy-1",
|
||||
Name = "EPSS gate",
|
||||
DefaultAction = RecheckAction.Warn,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Conditions = ImmutableArray.Create(new RecheckCondition
|
||||
{
|
||||
Type = RecheckConditionType.EPSSAbove,
|
||||
Threshold = 0.5m,
|
||||
Action = RecheckAction.RequireReapproval
|
||||
})
|
||||
};
|
||||
|
||||
var exception = CreateException(recheckPolicy: policy);
|
||||
var context = new RecheckEvaluationContext
|
||||
{
|
||||
ArtifactDigest = "sha256:abc",
|
||||
Environment = "prod",
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
EpssScore = 0.9m
|
||||
};
|
||||
|
||||
var result = await service.EvaluateAsync(exception, context);
|
||||
|
||||
result.IsTriggered.Should().BeTrue();
|
||||
result.TriggeredConditions.Should().HaveCount(1);
|
||||
result.RecommendedAction.Should().Be(RecheckAction.RequireReapproval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EnvironmentScope_FiltersConditions()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
var policy = new RecheckPolicy
|
||||
{
|
||||
PolicyId = "policy-1",
|
||||
Name = "Env gate",
|
||||
DefaultAction = RecheckAction.Warn,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Conditions = ImmutableArray.Create(new RecheckCondition
|
||||
{
|
||||
Type = RecheckConditionType.KEVFlagged,
|
||||
Action = RecheckAction.Block,
|
||||
EnvironmentScope = ["prod"]
|
||||
})
|
||||
};
|
||||
|
||||
var exception = CreateException(recheckPolicy: policy);
|
||||
var context = new RecheckEvaluationContext
|
||||
{
|
||||
ArtifactDigest = "sha256:abc",
|
||||
Environment = "dev",
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
KevFlagged = true
|
||||
};
|
||||
|
||||
var result = await service.EvaluateAsync(exception, context);
|
||||
|
||||
result.IsTriggered.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ActionPriority_PicksBlock()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
var policy = new RecheckPolicy
|
||||
{
|
||||
PolicyId = "policy-1",
|
||||
Name = "Priority gate",
|
||||
DefaultAction = RecheckAction.Warn,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Conditions = ImmutableArray.Create(
|
||||
new RecheckCondition
|
||||
{
|
||||
Type = RecheckConditionType.ExpiryWithin,
|
||||
Threshold = 10,
|
||||
Action = RecheckAction.Warn
|
||||
},
|
||||
new RecheckCondition
|
||||
{
|
||||
Type = RecheckConditionType.KEVFlagged,
|
||||
Action = RecheckAction.Block
|
||||
})
|
||||
};
|
||||
|
||||
var exception = CreateException(
|
||||
expiresAt: DateTimeOffset.UtcNow.AddDays(1),
|
||||
recheckPolicy: policy);
|
||||
var context = new RecheckEvaluationContext
|
||||
{
|
||||
ArtifactDigest = "sha256:abc",
|
||||
Environment = "prod",
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
KevFlagged = true
|
||||
};
|
||||
|
||||
var result = await service.EvaluateAsync(exception, context);
|
||||
|
||||
result.IsTriggered.Should().BeTrue();
|
||||
result.RecommendedAction.Should().Be(RecheckAction.Block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ExpiryWithin_UsesThreshold()
|
||||
{
|
||||
var service = new RecheckEvaluationService();
|
||||
var policy = new RecheckPolicy
|
||||
{
|
||||
PolicyId = "policy-1",
|
||||
Name = "Expiry gate",
|
||||
DefaultAction = RecheckAction.Warn,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Conditions = ImmutableArray.Create(new RecheckCondition
|
||||
{
|
||||
Type = RecheckConditionType.ExpiryWithin,
|
||||
Threshold = 5,
|
||||
Action = RecheckAction.Warn
|
||||
})
|
||||
};
|
||||
|
||||
var exception = CreateException(
|
||||
expiresAt: DateTimeOffset.UtcNow.AddDays(3),
|
||||
recheckPolicy: policy);
|
||||
var context = new RecheckEvaluationContext
|
||||
{
|
||||
ArtifactDigest = "sha256:abc",
|
||||
Environment = "prod",
|
||||
EvaluatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var result = await service.EvaluateAsync(exception, context);
|
||||
|
||||
result.IsTriggered.Should().BeTrue();
|
||||
}
|
||||
|
||||
private static ExceptionObject CreateException(
|
||||
DateTimeOffset? expiresAt = null,
|
||||
RecheckPolicy? recheckPolicy = null)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = "EXC-TEST",
|
||||
Version = 1,
|
||||
Status = ExceptionStatus.Active,
|
||||
Type = ExceptionType.Vulnerability,
|
||||
Scope = new ExceptionScope { VulnerabilityId = "CVE-2024-0001" },
|
||||
OwnerId = "owner",
|
||||
RequesterId = "requester",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30),
|
||||
ReasonCode = ExceptionReason.AcceptedRisk,
|
||||
Rationale = "This rationale is long enough to satisfy the minimum character requirement.",
|
||||
RecheckPolicy = recheckPolicy
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,45 @@ public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
|
||||
created.Version.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_PersistsRecheckTrackingFields()
|
||||
{
|
||||
// Arrange
|
||||
var lastResult = new RecheckEvaluationResult
|
||||
{
|
||||
IsTriggered = true,
|
||||
TriggeredConditions = ImmutableArray.Create(
|
||||
new TriggeredCondition(
|
||||
RecheckConditionType.EPSSAbove,
|
||||
"EPSS above threshold",
|
||||
CurrentValue: 0.7m,
|
||||
ThresholdValue: 0.5m,
|
||||
Action: RecheckAction.Block)),
|
||||
RecommendedAction = RecheckAction.Block,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var exception = CreateVulnerabilityException("CVE-2024-12345") with
|
||||
{
|
||||
RecheckPolicyId = "policy-critical",
|
||||
LastRecheckResult = lastResult,
|
||||
LastRecheckAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
await _repository.CreateAsync(exception, "creator@example.com");
|
||||
var fetched = await _repository.GetByIdAsync(exception.ExceptionId);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.RecheckPolicyId.Should().Be("policy-critical");
|
||||
fetched.LastRecheckResult.Should().NotBeNull();
|
||||
fetched.LastRecheckResult!.RecommendedAction.Should().Be(RecheckAction.Block);
|
||||
fetched.LastRecheckResult!.TriggeredConditions.Should().ContainSingle(
|
||||
c => c.Type == RecheckConditionType.EPSSAbove);
|
||||
fetched.LastRecheckAt.Should().BeCloseTo(exception.LastRecheckAt!.Value, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_RecordsCreatedEvent()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
public sealed class RecheckEvidenceMigrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PolicyPostgresFixture _fixture;
|
||||
private readonly PolicyDataSource _dataSource;
|
||||
|
||||
public RecheckEvidenceMigrationTests(PolicyPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
_dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Migration_CreatesRecheckAndEvidenceTables()
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync("default", "reader", CancellationToken.None);
|
||||
|
||||
await AssertTableExistsAsync(connection, "policy.recheck_policies");
|
||||
await AssertTableExistsAsync(connection, "policy.evidence_hooks");
|
||||
await AssertTableExistsAsync(connection, "policy.submitted_evidence");
|
||||
}
|
||||
|
||||
private static async Task AssertTableExistsAsync(NpgsqlConnection connection, string tableName)
|
||||
{
|
||||
await using var command = new NpgsqlCommand("SELECT to_regclass(@name)", connection);
|
||||
command.Parameters.AddWithValue("name", tableName);
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
result.Should().NotBeNull($"{tableName} should exist after migrations");
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Storage.Postgres\StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Unknowns\StellaOps.Policy.Unknowns.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(PolicyPostgresCollection.Name)]
|
||||
public sealed class UnknownsRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PolicyPostgresFixture _fixture;
|
||||
private readonly PolicyDataSource _dataSource;
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
|
||||
public UnknownsRepositoryTests(PolicyPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
_dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
|
||||
public async Task DisposeAsync() => await _dataSource.DisposeAsync();
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAndGetById_RoundTripsReasonCodeAndEvidence()
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId.ToString());
|
||||
var repository = new UnknownsRepository(connection);
|
||||
var now = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero);
|
||||
|
||||
var unknown = CreateUnknown(
|
||||
reasonCode: UnknownReasonCode.Reachability,
|
||||
remediationHint: "Run reachability analysis",
|
||||
evidenceRefs: new List<EvidenceRef>
|
||||
{
|
||||
new("reachability", "proofs/unknowns/unk-123/evidence.json", "sha256:abc123")
|
||||
},
|
||||
assumptions: new List<string> { "assume-dynamic-imports" },
|
||||
timestamp: now);
|
||||
|
||||
var created = await repository.CreateAsync(unknown);
|
||||
var fetched = await repository.GetByIdAsync(_tenantId, created.Id);
|
||||
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ReasonCode.Should().Be(UnknownReasonCode.Reachability);
|
||||
fetched.RemediationHint.Should().Be("Run reachability analysis");
|
||||
fetched.EvidenceRefs.Should().ContainSingle();
|
||||
fetched.EvidenceRefs[0].Type.Should().Be("reachability");
|
||||
fetched.EvidenceRefs[0].Uri.Should().Contain("evidence.json");
|
||||
fetched.Assumptions.Should().ContainSingle("assume-dynamic-imports");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PersistsReasonCodeAndAssumptions()
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId.ToString());
|
||||
var repository = new UnknownsRepository(connection);
|
||||
var now = new DateTimeOffset(2025, 2, 3, 4, 5, 6, TimeSpan.Zero);
|
||||
|
||||
var unknown = CreateUnknown(
|
||||
reasonCode: UnknownReasonCode.Identity,
|
||||
remediationHint: null,
|
||||
evidenceRefs: Array.Empty<EvidenceRef>(),
|
||||
assumptions: Array.Empty<string>(),
|
||||
timestamp: now);
|
||||
|
||||
var created = await repository.CreateAsync(unknown);
|
||||
|
||||
var updated = created with
|
||||
{
|
||||
ReasonCode = UnknownReasonCode.VexConflict,
|
||||
RemediationHint = "Publish authoritative VEX",
|
||||
EvidenceRefs = new List<EvidenceRef>
|
||||
{
|
||||
new("vex", "proofs/unknowns/unk-123/vex.json", "sha256:def456")
|
||||
},
|
||||
Assumptions = new List<string> { "assume-vex-defaults" }
|
||||
};
|
||||
|
||||
var result = await repository.UpdateAsync(updated);
|
||||
var fetched = await repository.GetByIdAsync(_tenantId, created.Id);
|
||||
|
||||
result.Should().BeTrue();
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ReasonCode.Should().Be(UnknownReasonCode.VexConflict);
|
||||
fetched.RemediationHint.Should().Be("Publish authoritative VEX");
|
||||
fetched.Assumptions.Should().ContainSingle("assume-vex-defaults");
|
||||
}
|
||||
|
||||
private Unknown CreateUnknown(
|
||||
UnknownReasonCode reasonCode,
|
||||
string? remediationHint,
|
||||
IReadOnlyList<EvidenceRef> evidenceRefs,
|
||||
IReadOnlyList<string> assumptions,
|
||||
DateTimeOffset timestamp) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = _tenantId,
|
||||
PackageId = "pkg:npm/lodash",
|
||||
PackageVersion = "4.17.21",
|
||||
Band = UnknownBand.Hot,
|
||||
Score = 90.5m,
|
||||
UncertaintyFactor = 0.75m,
|
||||
ExploitPressure = 0.9m,
|
||||
ReasonCode = reasonCode,
|
||||
RemediationHint = remediationHint,
|
||||
EvidenceRefs = evidenceRefs,
|
||||
Assumptions = assumptions,
|
||||
FirstSeenAt = timestamp,
|
||||
LastEvaluatedAt = timestamp,
|
||||
CreatedAt = timestamp,
|
||||
UpdatedAt = timestamp
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Confidence.Configuration;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Confidence.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Confidence;
|
||||
|
||||
public sealed class ConfidenceCalculatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 12, 22, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Calculate_AllHighFactors_ReturnsVeryHighConfidence()
|
||||
{
|
||||
var calculator = CreateCalculator();
|
||||
var input = CreateInput(
|
||||
reachability: ReachabilityState.ConfirmedUnreachable,
|
||||
runtime: RuntimePosture.Supports,
|
||||
vex: VexStatus.NotAffected,
|
||||
provenance: ProvenanceLevel.SlsaLevel3,
|
||||
policyStrength: 1.0m);
|
||||
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
result.Tier.Should().Be(ConfidenceTier.VeryHigh);
|
||||
result.Value.Should().BeGreaterThanOrEqualTo(0.9m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_AllLowFactors_ReturnsLowConfidence()
|
||||
{
|
||||
var calculator = CreateCalculator();
|
||||
var input = CreateInput(
|
||||
reachability: ReachabilityState.Unknown,
|
||||
runtime: RuntimePosture.Contradicts,
|
||||
vex: VexStatus.UnderInvestigation,
|
||||
provenance: ProvenanceLevel.Unsigned,
|
||||
policyStrength: 0.3m);
|
||||
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
result.Tier.Should().Be(ConfidenceTier.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_MissingEvidence_UsesFallbackValues()
|
||||
{
|
||||
var calculator = CreateCalculator();
|
||||
var input = new ConfidenceInput();
|
||||
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
result.Value.Should().BeApproximately(0.47m, 0.05m);
|
||||
result.Factors.Should().AllSatisfy(f => f.Reason.Should().Contain("No"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_GeneratesImprovements_ForLowFactors()
|
||||
{
|
||||
var calculator = CreateCalculator();
|
||||
var input = CreateInput(reachability: ReachabilityState.Unknown);
|
||||
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
result.Improvements.Should().Contain(i => i.Factor == ConfidenceFactorType.Reachability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WeightsSumToOne()
|
||||
{
|
||||
var options = new ConfidenceWeightOptions();
|
||||
|
||||
options.Validate().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_FactorContributions_SumToValue()
|
||||
{
|
||||
var calculator = CreateCalculator();
|
||||
var input = CreateInput();
|
||||
|
||||
var result = calculator.Calculate(input);
|
||||
|
||||
var sumOfContributions = result.Factors.Sum(f => f.Contribution);
|
||||
result.Value.Should().BeApproximately(sumOfContributions, 0.001m);
|
||||
}
|
||||
|
||||
private static ConfidenceInput CreateInput(
|
||||
ReachabilityState reachability = ReachabilityState.StaticUnreachable,
|
||||
RuntimePosture runtime = RuntimePosture.Supports,
|
||||
VexStatus vex = VexStatus.NotAffected,
|
||||
ProvenanceLevel provenance = ProvenanceLevel.Signed,
|
||||
decimal policyStrength = 0.8m)
|
||||
{
|
||||
return new ConfidenceInput
|
||||
{
|
||||
Reachability = new ReachabilityEvidence
|
||||
{
|
||||
State = reachability,
|
||||
AnalysisConfidence = 1.0m,
|
||||
GraphDigests = ["sha256:reachability"]
|
||||
},
|
||||
Runtime = new RuntimeEvidence
|
||||
{
|
||||
Posture = runtime,
|
||||
ObservationCount = 3,
|
||||
LastObserved = FixedTimestamp,
|
||||
SessionDigests = ["sha256:runtime"]
|
||||
},
|
||||
Vex = new VexEvidence
|
||||
{
|
||||
Statements =
|
||||
[
|
||||
new VexStatement
|
||||
{
|
||||
Status = vex,
|
||||
Issuer = "NVD",
|
||||
TrustScore = 0.95m,
|
||||
Timestamp = FixedTimestamp,
|
||||
StatementDigest = "sha256:vex"
|
||||
}
|
||||
]
|
||||
},
|
||||
Provenance = new ProvenanceEvidence
|
||||
{
|
||||
Level = provenance,
|
||||
SbomCompleteness = 0.95m,
|
||||
AttestationDigests = ["sha256:attestation"]
|
||||
},
|
||||
Policy = new PolicyEvidence
|
||||
{
|
||||
RuleName = "rule-1",
|
||||
MatchStrength = policyStrength,
|
||||
EvaluationDigest = "sha256:policy"
|
||||
},
|
||||
EvaluationTimestamp = FixedTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
private static ConfidenceCalculator CreateCalculator()
|
||||
{
|
||||
return new ConfidenceCalculator(new StaticOptionsMonitor<ConfidenceWeightOptions>(new ConfidenceWeightOptions()));
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value) => _value = value;
|
||||
|
||||
public T CurrentValue => _value;
|
||||
|
||||
public T Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Freshness;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.Freshness;
|
||||
|
||||
public sealed class EvidenceTtlEnforcerTests
|
||||
{
|
||||
private readonly EvidenceTtlEnforcer _enforcer;
|
||||
private readonly EvidenceTtlOptions _options;
|
||||
|
||||
public EvidenceTtlEnforcerTests()
|
||||
{
|
||||
_options = new EvidenceTtlOptions();
|
||||
_enforcer = new EvidenceTtlEnforcer(
|
||||
Options.Create(_options),
|
||||
NullLogger<EvidenceTtlEnforcer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckFreshness_AllFresh_ReturnsFresh()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bundle = new EvidenceBundle
|
||||
{
|
||||
Reachability = new ReachabilityEvidence { ComputedAt = now.AddHours(-1) },
|
||||
VexStatus = new VexEvidence { Timestamp = now.AddHours(-1) },
|
||||
Provenance = new ProvenanceEvidence { BuildTime = now.AddDays(-1) }
|
||||
};
|
||||
|
||||
var result = _enforcer.CheckFreshness(bundle, now);
|
||||
|
||||
Assert.Equal(FreshnessStatus.Fresh, result.OverallStatus);
|
||||
Assert.True(result.IsAcceptable);
|
||||
Assert.False(result.HasWarnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckFreshness_ReachabilityNearExpiry_ReturnsWarning()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bundle = new EvidenceBundle
|
||||
{
|
||||
// 7 day TTL, 20% warning threshold = warn after 5.6 days
|
||||
// At 6 days old, should be in warning state
|
||||
Reachability = new ReachabilityEvidence { ComputedAt = now.AddDays(-6) }
|
||||
};
|
||||
|
||||
var result = _enforcer.CheckFreshness(bundle, now);
|
||||
|
||||
Assert.Equal(FreshnessStatus.Warning, result.OverallStatus);
|
||||
Assert.True(result.IsAcceptable);
|
||||
Assert.True(result.HasWarnings);
|
||||
|
||||
var reachabilityCheck = result.Checks.First(c => c.Type == EvidenceType.Reachability);
|
||||
Assert.Equal(FreshnessStatus.Warning, reachabilityCheck.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckFreshness_BoundaryExpired_ReturnsStale()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bundle = new EvidenceBundle
|
||||
{
|
||||
// 72 hour TTL, so 5 days is definitely expired
|
||||
Boundary = new BoundaryEvidence { ObservedAt = now.AddDays(-5) }
|
||||
};
|
||||
|
||||
var result = _enforcer.CheckFreshness(bundle, now);
|
||||
|
||||
Assert.Equal(FreshnessStatus.Stale, result.OverallStatus);
|
||||
Assert.False(result.IsAcceptable);
|
||||
|
||||
var boundaryCheck = result.Checks.First(c => c.Type == EvidenceType.Boundary);
|
||||
Assert.Equal(FreshnessStatus.Stale, boundaryCheck.Status);
|
||||
Assert.True(boundaryCheck.Remaining == TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EvidenceType.Sbom, 30)]
|
||||
[InlineData(EvidenceType.Boundary, 3)]
|
||||
[InlineData(EvidenceType.Reachability, 7)]
|
||||
[InlineData(EvidenceType.Vex, 14)]
|
||||
[InlineData(EvidenceType.PolicyDecision, 1)]
|
||||
[InlineData(EvidenceType.HumanApproval, 30)]
|
||||
[InlineData(EvidenceType.CallStack, 7)]
|
||||
public void GetTtl_ReturnsConfiguredValue(EvidenceType type, int expectedDays)
|
||||
{
|
||||
var ttl = _enforcer.GetTtl(type);
|
||||
|
||||
Assert.Equal(expectedDays, ttl.TotalDays, precision: 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckFreshness_MixedStates_ReturnsStaleOverall()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bundle = new EvidenceBundle
|
||||
{
|
||||
Reachability = new ReachabilityEvidence { ComputedAt = now.AddHours(-1) }, // Fresh
|
||||
Boundary = new BoundaryEvidence { ObservedAt = now.AddDays(-5) }, // Stale (72h TTL)
|
||||
VexStatus = new VexEvidence { Timestamp = now.AddDays(-2) } // Fresh
|
||||
};
|
||||
|
||||
var result = _enforcer.CheckFreshness(bundle, now);
|
||||
|
||||
Assert.Equal(FreshnessStatus.Stale, result.OverallStatus);
|
||||
Assert.False(result.IsAcceptable);
|
||||
Assert.Equal(3, result.Checks.Count);
|
||||
|
||||
var freshChecks = result.Checks.Where(c => c.Status == FreshnessStatus.Fresh).ToList();
|
||||
var staleChecks = result.Checks.Where(c => c.Status == FreshnessStatus.Stale).ToList();
|
||||
|
||||
Assert.Equal(2, freshChecks.Count);
|
||||
Assert.Single(staleChecks);
|
||||
Assert.Equal(EvidenceType.Boundary, staleChecks[0].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeExpiration_CalculatesCorrectly()
|
||||
{
|
||||
var createdAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var expiresAt = _enforcer.ComputeExpiration(EvidenceType.Boundary, createdAt);
|
||||
|
||||
// Boundary TTL is 72 hours = 3 days
|
||||
Assert.Equal(createdAt.AddHours(72), expiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckFreshness_EmptyBundle_ReturnsEmptyChecks()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bundle = new EvidenceBundle();
|
||||
|
||||
var result = _enforcer.CheckFreshness(bundle, now);
|
||||
|
||||
Assert.Equal(FreshnessStatus.Fresh, result.OverallStatus);
|
||||
Assert.Empty(result.Checks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckFreshness_CustomOptions_UsesCustomTtl()
|
||||
{
|
||||
var customOptions = new EvidenceTtlOptions
|
||||
{
|
||||
BoundaryTtl = TimeSpan.FromDays(1), // Custom: 1 day instead of default 3 days
|
||||
WarningThresholdPercent = 0.5 // Custom: 50% instead of default 20%
|
||||
};
|
||||
|
||||
var customEnforcer = new EvidenceTtlEnforcer(
|
||||
Options.Create(customOptions),
|
||||
NullLogger<EvidenceTtlEnforcer>.Instance);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bundle = new EvidenceBundle
|
||||
{
|
||||
// 1 day TTL with 50% warning threshold = warn after 12 hours
|
||||
// At 16 hours old, should be in warning state
|
||||
Boundary = new BoundaryEvidence { ObservedAt = now.AddHours(-16) }
|
||||
};
|
||||
|
||||
var result = customEnforcer.CheckFreshness(bundle, now);
|
||||
|
||||
Assert.Equal(FreshnessStatus.Warning, result.OverallStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckType_GeneratesCorrectMessage()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bundle = new EvidenceBundle
|
||||
{
|
||||
Reachability = new ReachabilityEvidence { ComputedAt = now.AddDays(-8) } // Expired (7 day TTL)
|
||||
};
|
||||
|
||||
var result = _enforcer.CheckFreshness(bundle, now);
|
||||
|
||||
var check = result.Checks.First();
|
||||
Assert.Equal(EvidenceType.Reachability, check.Type);
|
||||
Assert.Contains("expired", check.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("ago", check.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckFreshness_RecommendedAction_BasedOnConfiguration()
|
||||
{
|
||||
var blockOptions = new EvidenceTtlOptions
|
||||
{
|
||||
StaleAction = StaleEvidenceAction.Block
|
||||
};
|
||||
|
||||
var blockEnforcer = new EvidenceTtlEnforcer(
|
||||
Options.Create(blockOptions),
|
||||
NullLogger<EvidenceTtlEnforcer>.Instance);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bundle = new EvidenceBundle
|
||||
{
|
||||
Boundary = new BoundaryEvidence { ObservedAt = now.AddDays(-5) } // Stale
|
||||
};
|
||||
|
||||
var result = blockEnforcer.CheckFreshness(bundle, now);
|
||||
|
||||
Assert.Equal(StaleEvidenceAction.Block, result.RecommendedAction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.TrustLattice;
|
||||
|
||||
public sealed class ClaimScoreMergerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Merge_SelectsHighestScore()
|
||||
{
|
||||
var claims = new List<(VexClaim, ClaimScoreResult)>
|
||||
{
|
||||
(new VexClaim
|
||||
{
|
||||
SourceId = "source-a",
|
||||
Status = VexStatus.NotAffected,
|
||||
ScopeSpecificity = 2,
|
||||
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
|
||||
}, new ClaimScoreResult { Score = 0.7, BaseTrust = 0.7, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
|
||||
(new VexClaim
|
||||
{
|
||||
SourceId = "source-b",
|
||||
Status = VexStatus.NotAffected,
|
||||
ScopeSpecificity = 3,
|
||||
IssuedAt = DateTimeOffset.Parse("2025-01-02T00:00:00Z"),
|
||||
}, new ClaimScoreResult { Score = 0.9, BaseTrust = 0.9, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
|
||||
};
|
||||
|
||||
var merger = new ClaimScoreMerger();
|
||||
var result = merger.Merge(claims, new MergePolicy());
|
||||
|
||||
result.Status.Should().Be(VexStatus.NotAffected);
|
||||
result.WinningClaim.SourceId.Should().Be("source-b");
|
||||
result.Confidence.Should().Be(0.9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_AppliesConflictPenalty()
|
||||
{
|
||||
var claims = new List<(VexClaim, ClaimScoreResult)>
|
||||
{
|
||||
(new VexClaim
|
||||
{
|
||||
SourceId = "source-a",
|
||||
Status = VexStatus.NotAffected,
|
||||
ScopeSpecificity = 2,
|
||||
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
|
||||
}, new ClaimScoreResult { Score = 0.8, BaseTrust = 0.8, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
|
||||
(new VexClaim
|
||||
{
|
||||
SourceId = "source-b",
|
||||
Status = VexStatus.Affected,
|
||||
ScopeSpecificity = 1,
|
||||
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
|
||||
}, new ClaimScoreResult { Score = 0.7, BaseTrust = 0.7, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
|
||||
};
|
||||
|
||||
var merger = new ClaimScoreMerger();
|
||||
var result = merger.Merge(claims, new MergePolicy { ConflictPenalty = 0.25 });
|
||||
|
||||
result.HasConflicts.Should().BeTrue();
|
||||
result.RequiresReplayProof.Should().BeTrue();
|
||||
result.Conflicts.Should().HaveCount(1);
|
||||
result.AllClaims.Should().Contain(c => c.SourceId == "source-b" && c.AdjustedScore == 0.525);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_IsDeterministic()
|
||||
{
|
||||
var claims = new List<(VexClaim, ClaimScoreResult)>
|
||||
{
|
||||
(new VexClaim
|
||||
{
|
||||
SourceId = "source-a",
|
||||
Status = VexStatus.Fixed,
|
||||
ScopeSpecificity = 1,
|
||||
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
|
||||
}, new ClaimScoreResult { Score = 0.6, BaseTrust = 0.6, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
|
||||
(new VexClaim
|
||||
{
|
||||
SourceId = "source-b",
|
||||
Status = VexStatus.Fixed,
|
||||
ScopeSpecificity = 1,
|
||||
IssuedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
|
||||
}, new ClaimScoreResult { Score = 0.6, BaseTrust = 0.6, StrengthMultiplier = 1, FreshnessMultiplier = 1 }),
|
||||
};
|
||||
|
||||
var merger = new ClaimScoreMerger();
|
||||
var expected = merger.Merge(claims, new MergePolicy());
|
||||
|
||||
for (var i = 0; i < 1000; i++)
|
||||
{
|
||||
merger.Merge(claims, new MergePolicy()).WinningClaim.SourceId.Should().Be(expected.WinningClaim.SourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.TrustLattice;
|
||||
|
||||
public sealed class PolicyGateRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Registry_StopsOnFirstFailure()
|
||||
{
|
||||
var registry = new PolicyGateRegistry(new StubServiceProvider(), new PolicyGateRegistryOptions { StopOnFirstFailure = true });
|
||||
registry.Register<FailingGate>("fail");
|
||||
registry.Register<PassingGate>("pass");
|
||||
|
||||
var mergeResult = CreateMergeResult();
|
||||
var context = new PolicyGateContext();
|
||||
|
||||
var evaluation = await registry.EvaluateAsync(mergeResult, context);
|
||||
|
||||
evaluation.Results.Should().HaveCount(1);
|
||||
evaluation.Results[0].GateName.Should().Be("fail");
|
||||
evaluation.AllPassed.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registry_CollectsAllWhenConfigured()
|
||||
{
|
||||
var registry = new PolicyGateRegistry(new StubServiceProvider(), new PolicyGateRegistryOptions { StopOnFirstFailure = false });
|
||||
registry.Register<FailingGate>("fail");
|
||||
registry.Register<PassingGate>("pass");
|
||||
|
||||
var mergeResult = CreateMergeResult();
|
||||
var context = new PolicyGateContext();
|
||||
|
||||
var evaluation = await registry.EvaluateAsync(mergeResult, context);
|
||||
|
||||
evaluation.Results.Should().HaveCount(2);
|
||||
evaluation.Results.Select(r => r.GateName).Should().ContainInOrder("fail", "pass");
|
||||
}
|
||||
|
||||
private static MergeResult CreateMergeResult()
|
||||
{
|
||||
var winner = new ScoredClaim
|
||||
{
|
||||
SourceId = "source",
|
||||
Status = VexStatus.NotAffected,
|
||||
OriginalScore = 0.9,
|
||||
AdjustedScore = 0.9,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "winner",
|
||||
};
|
||||
|
||||
return new MergeResult
|
||||
{
|
||||
Status = VexStatus.NotAffected,
|
||||
Confidence = 0.9,
|
||||
HasConflicts = false,
|
||||
RequiresReplayProof = false,
|
||||
WinningClaim = winner,
|
||||
AllClaims = ImmutableArray.Create(winner),
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
private sealed class FailingGate : IPolicyGate
|
||||
{
|
||||
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
|
||||
=> Task.FromResult(new GateResult
|
||||
{
|
||||
GateName = nameof(FailingGate),
|
||||
Passed = false,
|
||||
Reason = "fail",
|
||||
Details = ImmutableDictionary<string, object>.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class PassingGate : IPolicyGate
|
||||
{
|
||||
public Task<GateResult> EvaluateAsync(MergeResult mergeResult, PolicyGateContext context, CancellationToken ct = default)
|
||||
=> Task.FromResult(new GateResult
|
||||
{
|
||||
GateName = nameof(PassingGate),
|
||||
Passed = true,
|
||||
Reason = null,
|
||||
Details = ImmutableDictionary<string, object>.Empty,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
using VexStatus = StellaOps.Policy.Confidence.Models.VexStatus;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tests.TrustLattice;
|
||||
|
||||
public sealed class PolicyGatesTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MinimumConfidenceGate_FailsBelowThreshold()
|
||||
{
|
||||
var gate = new MinimumConfidenceGate();
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected, 0.7);
|
||||
var context = new PolicyGateContext { Environment = "production" };
|
||||
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Reason.Should().Be("confidence_below_threshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownsBudgetGate_FailsWhenBudgetExceeded()
|
||||
{
|
||||
var gate = new UnknownsBudgetGate(new UnknownsBudgetGateOptions { MaxUnknownCount = 1, MaxCumulativeUncertainty = 0.5 });
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected, 0.9);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
UnknownCount = 2,
|
||||
UnknownClaimScores = new[] { 0.4, 0.3 }
|
||||
};
|
||||
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Reason.Should().Be("unknowns_budget_exceeded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SourceQuotaGate_FailsWithoutCorroboration()
|
||||
{
|
||||
var gate = new SourceQuotaGate(new SourceQuotaGateOptions { MaxInfluencePercent = 60, CorroborationDelta = 0.10 });
|
||||
var mergeResult = new MergeResult
|
||||
{
|
||||
Status = VexStatus.NotAffected,
|
||||
Confidence = 0.8,
|
||||
HasConflicts = false,
|
||||
RequiresReplayProof = false,
|
||||
WinningClaim = new ScoredClaim
|
||||
{
|
||||
SourceId = "source-a",
|
||||
Status = VexStatus.NotAffected,
|
||||
OriginalScore = 0.9,
|
||||
AdjustedScore = 0.9,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "winner",
|
||||
},
|
||||
AllClaims = ImmutableArray.Create(
|
||||
new ScoredClaim
|
||||
{
|
||||
SourceId = "source-a",
|
||||
Status = VexStatus.NotAffected,
|
||||
OriginalScore = 0.9,
|
||||
AdjustedScore = 0.9,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "winner",
|
||||
},
|
||||
new ScoredClaim
|
||||
{
|
||||
SourceId = "source-b",
|
||||
Status = VexStatus.NotAffected,
|
||||
OriginalScore = 0.1,
|
||||
AdjustedScore = 0.1,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = false,
|
||||
Reason = "initial",
|
||||
}),
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty,
|
||||
};
|
||||
|
||||
var result = await gate.EvaluateAsync(mergeResult, new PolicyGateContext());
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Reason.Should().Be("source_quota_exceeded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReachabilityRequirementGate_FailsWithoutProof()
|
||||
{
|
||||
var gate = new ReachabilityRequirementGate();
|
||||
var mergeResult = CreateMergeResult(VexStatus.NotAffected, 0.9);
|
||||
var context = new PolicyGateContext
|
||||
{
|
||||
Severity = "CRITICAL",
|
||||
HasReachabilityProof = false,
|
||||
};
|
||||
|
||||
var result = await gate.EvaluateAsync(mergeResult, context);
|
||||
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Reason.Should().Be("reachability_proof_missing");
|
||||
}
|
||||
|
||||
private static MergeResult CreateMergeResult(VexStatus status, double confidence)
|
||||
{
|
||||
var winner = new ScoredClaim
|
||||
{
|
||||
SourceId = "source-a",
|
||||
Status = status,
|
||||
OriginalScore = confidence,
|
||||
AdjustedScore = confidence,
|
||||
ScopeSpecificity = 1,
|
||||
Accepted = true,
|
||||
Reason = "winner",
|
||||
};
|
||||
|
||||
return new MergeResult
|
||||
{
|
||||
Status = status,
|
||||
Confidence = confidence,
|
||||
HasConflicts = false,
|
||||
RequiresReplayProof = false,
|
||||
WinningClaim = winner,
|
||||
AllClaims = ImmutableArray.Create(winner),
|
||||
Conflicts = ImmutableArray<ConflictRecord>.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Unknowns.Configuration;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Unknowns.Tests.Services;
|
||||
|
||||
public sealed class UnknownBudgetServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetBudgetForEnvironment_KnownEnv_ReturnsBudget()
|
||||
{
|
||||
var service = CreateService(new UnknownBudget
|
||||
{
|
||||
Environment = "prod",
|
||||
TotalLimit = 3
|
||||
});
|
||||
|
||||
var budget = service.GetBudgetForEnvironment("prod");
|
||||
|
||||
budget.TotalLimit.Should().Be(3);
|
||||
budget.Environment.Should().Be("prod");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_WithinLimit_ReturnsSuccess()
|
||||
{
|
||||
var service = CreateService(new UnknownBudget
|
||||
{
|
||||
Environment = "prod",
|
||||
TotalLimit = 3,
|
||||
Action = BudgetAction.Block
|
||||
});
|
||||
|
||||
var result = service.CheckBudget("prod", CreateUnknowns(count: 2));
|
||||
|
||||
result.IsWithinBudget.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_ExceedsTotal_ReturnsViolation()
|
||||
{
|
||||
var service = CreateService(new UnknownBudget
|
||||
{
|
||||
Environment = "prod",
|
||||
TotalLimit = 3,
|
||||
Action = BudgetAction.Block
|
||||
});
|
||||
|
||||
var result = service.CheckBudget("prod", CreateUnknowns(count: 5));
|
||||
|
||||
result.IsWithinBudget.Should().BeFalse();
|
||||
result.RecommendedAction.Should().Be(BudgetAction.Block);
|
||||
result.TotalUnknowns.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudget_ExceedsReasonLimit_ReturnsSpecificViolation()
|
||||
{
|
||||
var service = CreateService(new UnknownBudget
|
||||
{
|
||||
Environment = "prod",
|
||||
TotalLimit = 5,
|
||||
ReasonLimits = new Dictionary<UnknownReasonCode, int>
|
||||
{
|
||||
[UnknownReasonCode.Reachability] = 0
|
||||
},
|
||||
Action = BudgetAction.Block
|
||||
});
|
||||
|
||||
var unknowns = CreateUnknowns(reachability: 2, identity: 1);
|
||||
var result = service.CheckBudget("prod", unknowns);
|
||||
|
||||
result.Violations.Should().ContainKey(UnknownReasonCode.Reachability);
|
||||
result.Violations[UnknownReasonCode.Reachability].Count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckBudgetWithEscalation_ExceptionCovers_AllowsOperation()
|
||||
{
|
||||
var service = CreateService(new UnknownBudget
|
||||
{
|
||||
Environment = "prod",
|
||||
TotalLimit = 1,
|
||||
ReasonLimits = new Dictionary<UnknownReasonCode, int>
|
||||
{
|
||||
[UnknownReasonCode.Reachability] = 0
|
||||
},
|
||||
Action = BudgetAction.WarnUnlessException
|
||||
});
|
||||
|
||||
var unknowns = CreateUnknowns(reachability: 1);
|
||||
var exceptions = new[] { CreateException(UnknownReasonCode.Reachability) };
|
||||
|
||||
var result = service.CheckBudgetWithEscalation("prod", unknowns, exceptions);
|
||||
|
||||
result.IsWithinBudget.Should().BeTrue();
|
||||
result.Message.Should().Contain("covered by approved exceptions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldBlock_BlockAction_ReturnsTrue()
|
||||
{
|
||||
var service = CreateService(new UnknownBudget { Environment = "prod" });
|
||||
|
||||
var result = new BudgetCheckResult
|
||||
{
|
||||
IsWithinBudget = false,
|
||||
RecommendedAction = BudgetAction.Block,
|
||||
TotalUnknowns = 4
|
||||
};
|
||||
|
||||
service.ShouldBlock(result).Should().BeTrue();
|
||||
}
|
||||
|
||||
private static UnknownBudgetService CreateService(UnknownBudget prodBudget)
|
||||
{
|
||||
var options = new UnknownBudgetOptions
|
||||
{
|
||||
Budgets = new Dictionary<string, UnknownBudget>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["prod"] = prodBudget,
|
||||
["default"] = new UnknownBudget
|
||||
{
|
||||
Environment = "default",
|
||||
TotalLimit = 5,
|
||||
Action = BudgetAction.Warn
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return new UnknownBudgetService(
|
||||
new TestOptionsMonitor<UnknownBudgetOptions>(options),
|
||||
NullLogger<UnknownBudgetService>.Instance);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Unknown> CreateUnknowns(
|
||||
int count = 0,
|
||||
int reachability = 0,
|
||||
int identity = 0)
|
||||
{
|
||||
var results = new List<Unknown>();
|
||||
|
||||
results.AddRange(Enumerable.Range(0, reachability).Select(_ => CreateUnknown(UnknownReasonCode.Reachability)));
|
||||
results.AddRange(Enumerable.Range(0, identity).Select(_ => CreateUnknown(UnknownReasonCode.Identity)));
|
||||
|
||||
var remaining = Math.Max(0, count - results.Count);
|
||||
results.AddRange(Enumerable.Range(0, remaining).Select(_ => CreateUnknown(UnknownReasonCode.FeedGap)));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Unknown CreateUnknown(UnknownReasonCode reasonCode)
|
||||
{
|
||||
var timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
return new Unknown
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = Guid.NewGuid(),
|
||||
PackageId = "pkg:npm/lodash",
|
||||
PackageVersion = "4.17.21",
|
||||
Band = UnknownBand.Hot,
|
||||
Score = 80m,
|
||||
UncertaintyFactor = 0.5m,
|
||||
ExploitPressure = 0.7m,
|
||||
ReasonCode = reasonCode,
|
||||
FirstSeenAt = timestamp,
|
||||
LastEvaluatedAt = timestamp,
|
||||
CreatedAt = timestamp,
|
||||
UpdatedAt = timestamp
|
||||
};
|
||||
}
|
||||
|
||||
private static ExceptionObject CreateException(UnknownReasonCode reasonCode)
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = "EXC-UNKNOWN-001",
|
||||
Version = 1,
|
||||
Status = ExceptionStatus.Approved,
|
||||
Type = ExceptionType.Unknown,
|
||||
Scope = new ExceptionScope
|
||||
{
|
||||
Environments = ImmutableArray.Create("prod")
|
||||
},
|
||||
OwnerId = "owner",
|
||||
RequesterId = "requester",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
ExpiresAt = now.AddDays(30),
|
||||
ReasonCode = ExceptionReason.AcceptedRisk,
|
||||
Rationale = "Approved exception for unknown budget coverage",
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("unknown_reason_codes", reasonCode.ToString())
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T>(T current) : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _current = current;
|
||||
|
||||
public T CurrentValue => _current;
|
||||
public T Get(string? name) => _current;
|
||||
public IDisposable OnChange(Action<T, string?> listener) => NoopDisposable.Instance;
|
||||
}
|
||||
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public static readonly NoopDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
|
||||
@@ -10,6 +11,7 @@ namespace StellaOps.Policy.Unknowns.Tests.Services;
|
||||
public class UnknownRankerTests
|
||||
{
|
||||
private readonly UnknownRanker _ranker = new();
|
||||
private static readonly DateTimeOffset DefaultAsOf = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
@@ -17,7 +19,7 @@ public class UnknownRankerTests
|
||||
public void Rank_SameInput_ReturnsSameResult()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: false,
|
||||
HasReachabilityData: false,
|
||||
HasConflictingSources: true,
|
||||
@@ -38,7 +40,7 @@ public class UnknownRankerTests
|
||||
public void Rank_MultipleExecutions_ProducesIdenticalScores()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: false,
|
||||
HasConflictingSources: false,
|
||||
@@ -67,7 +69,7 @@ public class UnknownRankerTests
|
||||
public void ComputeUncertainty_MissingVex_Adds040()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: false, // Missing VEX = +0.40
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -87,7 +89,7 @@ public class UnknownRankerTests
|
||||
public void ComputeUncertainty_MissingReachability_Adds030()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: false, // Missing reachability = +0.30
|
||||
HasConflictingSources: false,
|
||||
@@ -107,7 +109,7 @@ public class UnknownRankerTests
|
||||
public void ComputeUncertainty_ConflictingSources_Adds020()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: true, // Conflicts = +0.20
|
||||
@@ -127,7 +129,7 @@ public class UnknownRankerTests
|
||||
public void ComputeUncertainty_StaleAdvisory_Adds010()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -147,7 +149,7 @@ public class UnknownRankerTests
|
||||
public void ComputeUncertainty_AllFactors_SumsTo100()
|
||||
{
|
||||
// Arrange - All uncertainty factors active (0.40 + 0.30 + 0.20 + 0.10 = 1.00)
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: false,
|
||||
HasReachabilityData: false,
|
||||
HasConflictingSources: true,
|
||||
@@ -167,7 +169,7 @@ public class UnknownRankerTests
|
||||
public void ComputeUncertainty_NoFactors_ReturnsZero()
|
||||
{
|
||||
// Arrange - All uncertainty factors inactive
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -191,7 +193,7 @@ public class UnknownRankerTests
|
||||
public void ComputeExploitPressure_InKev_Adds050()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -211,7 +213,7 @@ public class UnknownRankerTests
|
||||
public void ComputeExploitPressure_HighEpss_Adds030()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -231,7 +233,7 @@ public class UnknownRankerTests
|
||||
public void ComputeExploitPressure_MediumEpss_Adds015()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -251,7 +253,7 @@ public class UnknownRankerTests
|
||||
public void ComputeExploitPressure_CriticalCvss_Adds005()
|
||||
{
|
||||
// Arrange
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -271,7 +273,7 @@ public class UnknownRankerTests
|
||||
public void ComputeExploitPressure_AllFactors_SumsCorrectly()
|
||||
{
|
||||
// Arrange - KEV (0.50) + high EPSS (0.30) + critical CVSS (0.05) = 0.85
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -291,7 +293,7 @@ public class UnknownRankerTests
|
||||
public void ComputeExploitPressure_EpssThresholds_AreMutuallyExclusive()
|
||||
{
|
||||
// Arrange - High EPSS should NOT also add medium EPSS bonus
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -318,7 +320,7 @@ public class UnknownRankerTests
|
||||
// Uncertainty: 0.40 (missing VEX)
|
||||
// Pressure: 0.50 (KEV)
|
||||
// Expected: (0.40 × 50) + (0.50 × 50) = 20 + 25 = 45
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: false,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -341,7 +343,7 @@ public class UnknownRankerTests
|
||||
// Uncertainty: 1.00 (all factors)
|
||||
// Pressure: 0.85 (KEV + high EPSS + critical CVSS, capped at 1.00)
|
||||
// Expected: (1.00 × 50) + (0.85 × 50) = 50 + 42.5 = 92.50
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: false,
|
||||
HasReachabilityData: false,
|
||||
HasConflictingSources: true,
|
||||
@@ -361,7 +363,7 @@ public class UnknownRankerTests
|
||||
public void Rank_MinimumScore_IsZero()
|
||||
{
|
||||
// Arrange - No uncertainty, no pressure
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -379,6 +381,198 @@ public class UnknownRankerTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reason Code Tests
|
||||
|
||||
[Fact]
|
||||
public void Rank_AnalyzerUnsupported_AssignsAnalyzerLimit()
|
||||
{
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
IsStaleAdvisory: false,
|
||||
IsInKev: false,
|
||||
EpssScore: 0,
|
||||
CvssScore: 0,
|
||||
IsAnalyzerSupported: false);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.ReasonCode.Should().Be(UnknownReasonCode.AnalyzerLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rank_MissingReachability_AssignsReachability()
|
||||
{
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: false,
|
||||
HasConflictingSources: false,
|
||||
IsStaleAdvisory: false,
|
||||
IsInKev: false,
|
||||
EpssScore: 0,
|
||||
CvssScore: 0);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.ReasonCode.Should().Be(UnknownReasonCode.Reachability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rank_MissingDigest_AssignsIdentity()
|
||||
{
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
IsStaleAdvisory: false,
|
||||
IsInKev: false,
|
||||
EpssScore: 0,
|
||||
CvssScore: 0,
|
||||
HasPackageDigest: false);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.ReasonCode.Should().Be(UnknownReasonCode.Identity);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Decay Factor Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeDecay_NullLastEvaluated_Returns100Percent()
|
||||
{
|
||||
var input = CreateInputWithAge(lastEvaluatedAt: null);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.DecayFactor.Should().Be(1.00m);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1.00)]
|
||||
[InlineData(7, 1.00)]
|
||||
[InlineData(8, 0.90)]
|
||||
[InlineData(30, 0.90)]
|
||||
[InlineData(31, 0.75)]
|
||||
[InlineData(90, 0.75)]
|
||||
[InlineData(91, 0.60)]
|
||||
[InlineData(180, 0.60)]
|
||||
[InlineData(181, 0.40)]
|
||||
[InlineData(365, 0.40)]
|
||||
[InlineData(366, 0.20)]
|
||||
[InlineData(1000, 0.20)]
|
||||
public void ComputeDecay_AgeBuckets_ReturnsCorrectMultiplier(int ageDays, decimal expected)
|
||||
{
|
||||
var input = CreateInputWithAge(ageDays: ageDays);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.DecayFactor.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rank_WithDecay_AppliesMultiplierToScore()
|
||||
{
|
||||
var input = CreateHighScoreInput(ageDays: 100);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.Score.Should().Be(30.00m);
|
||||
result.DecayFactor.Should().Be(0.60m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rank_DecayDisabled_ReturnsFullScore()
|
||||
{
|
||||
var options = new UnknownRankerOptions { EnableDecay = false };
|
||||
var ranker = new UnknownRanker(Options.Create(options));
|
||||
var input = CreateHighScoreInput(ageDays: 100);
|
||||
|
||||
var result = ranker.Rank(input);
|
||||
|
||||
result.DecayFactor.Should().Be(1.0m);
|
||||
result.Score.Should().Be(50.00m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rank_Decay_Determinism_SameInputSameOutput()
|
||||
{
|
||||
var input = CreateInputWithAge(ageDays: 45);
|
||||
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => _ranker.Rank(input))
|
||||
.ToList();
|
||||
|
||||
results.Should().AllBeEquivalentTo(results[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Containment Reduction Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeContainmentReduction_NullInputs_ReturnsZero()
|
||||
{
|
||||
var input = CreateInputWithContainment(blastRadius: null, containment: null);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.ContainmentReduction.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContainmentReduction_IsolatedPackage_Returns15Percent()
|
||||
{
|
||||
var blast = new BlastRadius { Dependents = 0, NetFacing = true };
|
||||
var input = CreateInputWithContainment(blastRadius: blast);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.ContainmentReduction.Should().Be(0.15m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContainmentReduction_AllContainmentFactors_CapsAt40Percent()
|
||||
{
|
||||
var blast = new BlastRadius { Dependents = 0, NetFacing = false, Privilege = "none" };
|
||||
var contain = new ContainmentSignals { Seccomp = "enforced", FileSystem = "ro", NetworkPolicy = "isolated" };
|
||||
var input = CreateInputWithContainment(blastRadius: blast, containment: contain);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.ContainmentReduction.Should().Be(0.40m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rank_WithContainment_AppliesReductionToScore()
|
||||
{
|
||||
var blast = new BlastRadius { Dependents = 0 };
|
||||
var input = CreateHighScoreInputWithContainment(blast);
|
||||
|
||||
var result = _ranker.Rank(input);
|
||||
|
||||
result.Score.Should().Be(48.00m);
|
||||
result.ContainmentReduction.Should().Be(0.20m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rank_ContainmentDisabled_NoReduction()
|
||||
{
|
||||
var options = new UnknownRankerOptions { EnableContainmentReduction = false };
|
||||
var ranker = new UnknownRanker(Options.Create(options));
|
||||
var blast = new BlastRadius { Dependents = 0 };
|
||||
var input = CreateHighScoreInputWithContainment(blast);
|
||||
|
||||
var result = ranker.Rank(input);
|
||||
|
||||
result.ContainmentReduction.Should().Be(0m);
|
||||
result.Score.Should().Be(60.00m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Band Assignment Tests
|
||||
|
||||
[Theory]
|
||||
@@ -404,7 +598,7 @@ public class UnknownRankerTests
|
||||
public void Rank_ScoreAbove75_AssignsHotBand()
|
||||
{
|
||||
// Arrange - Score = (1.00 × 50) + (0.50 × 50) = 75.00
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: false,
|
||||
HasReachabilityData: false,
|
||||
HasConflictingSources: true,
|
||||
@@ -426,7 +620,7 @@ public class UnknownRankerTests
|
||||
{
|
||||
// Arrange - Score = (0.70 × 50) + (0.50 × 50) = 35 + 25 = 60
|
||||
// Uncertainty: 0.70 (missing VEX + missing reachability)
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: false,
|
||||
HasReachabilityData: false,
|
||||
HasConflictingSources: false,
|
||||
@@ -447,7 +641,7 @@ public class UnknownRankerTests
|
||||
public void Rank_ScoreBetween25And50_AssignsColdBand()
|
||||
{
|
||||
// Arrange - Score = (0.40 × 50) + (0.15 × 50) = 20 + 7.5 = 27.5
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: false,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -468,7 +662,7 @@ public class UnknownRankerTests
|
||||
public void Rank_ScoreBelow25_AssignsResolvedBand()
|
||||
{
|
||||
// Arrange - Score = (0.10 × 50) + (0.05 × 50) = 5 + 2.5 = 7.5
|
||||
var input = new UnknownRankInput(
|
||||
var input = CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
@@ -486,4 +680,113 @@ public class UnknownRankerTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static UnknownRankInput CreateInput(
|
||||
bool HasVexStatement,
|
||||
bool HasReachabilityData,
|
||||
bool HasConflictingSources,
|
||||
bool IsStaleAdvisory,
|
||||
bool IsInKev,
|
||||
decimal EpssScore,
|
||||
decimal CvssScore,
|
||||
DateTimeOffset? FirstSeenAt = null,
|
||||
DateTimeOffset? LastEvaluatedAt = null,
|
||||
DateTimeOffset? AsOfDateTime = null,
|
||||
BlastRadius? BlastRadius = null,
|
||||
ContainmentSignals? Containment = null,
|
||||
bool HasPackageDigest = true,
|
||||
bool HasProvenanceAttestation = true,
|
||||
bool HasVexConflicts = false,
|
||||
bool HasFeedCoverage = true,
|
||||
bool HasConfigVisibility = true,
|
||||
bool IsAnalyzerSupported = true)
|
||||
{
|
||||
var asOf = AsOfDateTime ?? DefaultAsOf;
|
||||
|
||||
return new UnknownRankInput(
|
||||
HasVexStatement,
|
||||
HasReachabilityData,
|
||||
HasConflictingSources,
|
||||
IsStaleAdvisory,
|
||||
IsInKev,
|
||||
EpssScore,
|
||||
CvssScore,
|
||||
FirstSeenAt,
|
||||
LastEvaluatedAt,
|
||||
asOf,
|
||||
BlastRadius,
|
||||
Containment,
|
||||
HasPackageDigest,
|
||||
HasProvenanceAttestation,
|
||||
HasVexConflicts,
|
||||
HasFeedCoverage,
|
||||
HasConfigVisibility,
|
||||
IsAnalyzerSupported);
|
||||
}
|
||||
|
||||
private static UnknownRankInput CreateInputWithAge(
|
||||
int? ageDays = null,
|
||||
DateTimeOffset? lastEvaluatedAt = null,
|
||||
DateTimeOffset? asOfDateTime = null)
|
||||
{
|
||||
var asOf = asOfDateTime ?? DefaultAsOf;
|
||||
var evaluatedAt = lastEvaluatedAt ?? (ageDays.HasValue ? asOf.AddDays(-ageDays.Value) : null);
|
||||
|
||||
return CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
IsStaleAdvisory: false,
|
||||
IsInKev: false,
|
||||
EpssScore: 0,
|
||||
CvssScore: 0,
|
||||
LastEvaluatedAt: evaluatedAt,
|
||||
AsOfDateTime: asOf);
|
||||
}
|
||||
|
||||
private static UnknownRankInput CreateHighScoreInput(int ageDays)
|
||||
{
|
||||
var asOf = DefaultAsOf;
|
||||
|
||||
return CreateInput(
|
||||
HasVexStatement: false,
|
||||
HasReachabilityData: false,
|
||||
HasConflictingSources: true,
|
||||
IsStaleAdvisory: true,
|
||||
IsInKev: false,
|
||||
EpssScore: 0,
|
||||
CvssScore: 0,
|
||||
LastEvaluatedAt: asOf.AddDays(-ageDays),
|
||||
AsOfDateTime: asOf);
|
||||
}
|
||||
|
||||
private static UnknownRankInput CreateInputWithContainment(
|
||||
BlastRadius? blastRadius = null,
|
||||
ContainmentSignals? containment = null)
|
||||
{
|
||||
return CreateInput(
|
||||
HasVexStatement: true,
|
||||
HasReachabilityData: true,
|
||||
HasConflictingSources: false,
|
||||
IsStaleAdvisory: false,
|
||||
IsInKev: false,
|
||||
EpssScore: 0,
|
||||
CvssScore: 0,
|
||||
BlastRadius: blastRadius,
|
||||
Containment: containment);
|
||||
}
|
||||
|
||||
private static UnknownRankInput CreateHighScoreInputWithContainment(BlastRadius blastRadius)
|
||||
{
|
||||
return CreateInput(
|
||||
HasVexStatement: false,
|
||||
HasReachabilityData: false,
|
||||
HasConflictingSources: false,
|
||||
IsStaleAdvisory: false,
|
||||
IsInKev: true,
|
||||
EpssScore: 0,
|
||||
CvssScore: 0,
|
||||
BlastRadius: blastRadius);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Unknowns/StellaOps.Policy.Unknowns.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user