Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Remediation.Core.Abstractions;
|
||||
|
||||
public interface IContributorTrustScorer
|
||||
{
|
||||
double CalculateTrustScore(int verifiedFixes, int totalSubmissions, int rejectedSubmissions);
|
||||
string GetTrustTier(double score);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Abstractions;
|
||||
|
||||
public interface IRemediationMatcher
|
||||
{
|
||||
Task<IReadOnlyList<FixTemplate>> FindMatchesAsync(string cveId, string? purl = null, string? version = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Abstractions;
|
||||
|
||||
public interface IRemediationRegistry
|
||||
{
|
||||
Task<IReadOnlyList<FixTemplate>> ListTemplatesAsync(string? cveId = null, string? purl = null, int limit = 50, int offset = 0, CancellationToken ct = default);
|
||||
Task<FixTemplate?> GetTemplateAsync(Guid id, CancellationToken ct = default);
|
||||
Task<FixTemplate> CreateTemplateAsync(FixTemplate template, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<PrSubmission>> ListSubmissionsAsync(string? cveId = null, string? status = null, int limit = 50, int offset = 0, CancellationToken ct = default);
|
||||
Task<PrSubmission?> GetSubmissionAsync(Guid id, CancellationToken ct = default);
|
||||
Task<PrSubmission> CreateSubmissionAsync(PrSubmission submission, CancellationToken ct = default);
|
||||
Task UpdateSubmissionStatusAsync(Guid id, string status, string? verdict = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Remediation.Core.Models;
|
||||
|
||||
public sealed record Contributor
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Username { get; init; } = string.Empty;
|
||||
public string? DisplayName { get; init; }
|
||||
public int VerifiedFixes { get; init; }
|
||||
public int TotalSubmissions { get; init; }
|
||||
public int RejectedSubmissions { get; init; }
|
||||
public double TrustScore { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastActiveAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.Remediation.Core.Models;
|
||||
|
||||
public sealed record FixTemplate
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string CveId { get; init; } = string.Empty;
|
||||
public string Purl { get; init; } = string.Empty;
|
||||
public string VersionRange { get; init; } = string.Empty;
|
||||
public string PatchContent { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public Guid? ContributorId { get; init; }
|
||||
public Guid? SourceId { get; init; }
|
||||
public string Status { get; init; } = "pending";
|
||||
public double TrustScore { get; init; }
|
||||
public string? DsseDigest { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Remediation.Core.Models;
|
||||
|
||||
public sealed record MarketplaceSource
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Key { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? Url { get; init; }
|
||||
public string SourceType { get; init; } = "community";
|
||||
public bool Enabled { get; init; } = true;
|
||||
public double TrustScore { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastSyncAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Remediation.Core.Models;
|
||||
|
||||
public sealed record PrSubmission
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid? FixTemplateId { get; init; }
|
||||
public string PrUrl { get; init; } = string.Empty;
|
||||
public string RepositoryUrl { get; init; } = string.Empty;
|
||||
public string SourceBranch { get; init; } = string.Empty;
|
||||
public string TargetBranch { get; init; } = string.Empty;
|
||||
public string CveId { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = "opened";
|
||||
public string? PreScanDigest { get; init; }
|
||||
public string? PostScanDigest { get; init; }
|
||||
public string? ReachabilityDeltaDigest { get; init; }
|
||||
public string? FixChainDsseDigest { get; init; }
|
||||
public string? Verdict { get; init; }
|
||||
public Guid? ContributorId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? MergedAt { get; init; }
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Remediation.Core.Abstractions;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Services;
|
||||
|
||||
public sealed class ContributorTrustScorer : IContributorTrustScorer
|
||||
{
|
||||
public double CalculateTrustScore(int verifiedFixes, int totalSubmissions, int rejectedSubmissions)
|
||||
{
|
||||
var denominator = Math.Max(totalSubmissions, 1);
|
||||
var raw = (verifiedFixes * 1.0 - rejectedSubmissions * 0.5) / denominator;
|
||||
return Math.Clamp(raw, 0.0, 1.0);
|
||||
}
|
||||
|
||||
public string GetTrustTier(double score) => score switch
|
||||
{
|
||||
> 0.8 => "trusted",
|
||||
> 0.5 => "established",
|
||||
> 0.2 => "new",
|
||||
_ => "untrusted"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Services;
|
||||
|
||||
public interface IRemediationVerifier
|
||||
{
|
||||
Task<VerificationResult> VerifyAsync(PrSubmission submission, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record VerificationResult(
|
||||
string Verdict,
|
||||
string? ReachabilityDeltaDigest,
|
||||
string? FixChainDsseDigest,
|
||||
IReadOnlyList<string> AffectedPaths,
|
||||
DateTimeOffset VerifiedAt);
|
||||
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Core.Services;
|
||||
|
||||
public sealed class RemediationVerifier : IRemediationVerifier
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RemediationVerifier(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<VerificationResult> VerifyAsync(PrSubmission submission, CancellationToken ct = default)
|
||||
{
|
||||
// Stub: real implementation will integrate with scan service and reachability delta
|
||||
var verdict = DetermineVerdict(submission);
|
||||
var result = new VerificationResult(
|
||||
Verdict: verdict,
|
||||
ReachabilityDeltaDigest: submission.ReachabilityDeltaDigest,
|
||||
FixChainDsseDigest: submission.FixChainDsseDigest,
|
||||
AffectedPaths: Array.Empty<string>(),
|
||||
VerifiedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static string DetermineVerdict(PrSubmission submission)
|
||||
{
|
||||
if (string.IsNullOrEmpty(submission.PreScanDigest) || string.IsNullOrEmpty(submission.PostScanDigest))
|
||||
{
|
||||
return "inconclusive";
|
||||
}
|
||||
|
||||
if (submission.PreScanDigest == submission.PostScanDigest)
|
||||
{
|
||||
return "not_fixed";
|
||||
}
|
||||
|
||||
return "fixed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,66 @@
|
||||
CREATE SCHEMA IF NOT EXISTS remediation;
|
||||
|
||||
CREATE TABLE remediation.fix_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cve_id TEXT NOT NULL,
|
||||
purl TEXT NOT NULL,
|
||||
version_range TEXT NOT NULL,
|
||||
patch_content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
contributor_id UUID,
|
||||
source_id UUID,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
trust_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
dsse_digest TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
verified_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE remediation.pr_submissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
fix_template_id UUID REFERENCES remediation.fix_templates(id),
|
||||
pr_url TEXT NOT NULL,
|
||||
repository_url TEXT NOT NULL,
|
||||
source_branch TEXT NOT NULL,
|
||||
target_branch TEXT NOT NULL,
|
||||
cve_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'opened',
|
||||
pre_scan_digest TEXT,
|
||||
post_scan_digest TEXT,
|
||||
reachability_delta_digest TEXT,
|
||||
fix_chain_dsse_digest TEXT,
|
||||
verdict TEXT,
|
||||
contributor_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
merged_at TIMESTAMPTZ,
|
||||
verified_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE remediation.contributors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
verified_fixes INT NOT NULL DEFAULT 0,
|
||||
total_submissions INT NOT NULL DEFAULT 0,
|
||||
rejected_submissions INT NOT NULL DEFAULT 0,
|
||||
trust_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_active_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE remediation.marketplace_sources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT,
|
||||
source_type TEXT NOT NULL DEFAULT 'community',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
trust_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_sync_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fix_templates_cve ON remediation.fix_templates(cve_id);
|
||||
CREATE INDEX idx_fix_templates_purl ON remediation.fix_templates(purl);
|
||||
CREATE INDEX idx_pr_submissions_cve ON remediation.pr_submissions(cve_id);
|
||||
CREATE INDEX idx_pr_submissions_status ON remediation.pr_submissions(status);
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.Repositories;
|
||||
|
||||
public interface IFixTemplateRepository
|
||||
{
|
||||
Task<IReadOnlyList<FixTemplate>> ListAsync(string? cveId = null, string? purl = null, int limit = 50, int offset = 0, CancellationToken ct = default);
|
||||
Task<FixTemplate?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<FixTemplate> InsertAsync(FixTemplate template, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<FixTemplate>> FindMatchesAsync(string cveId, string? purl = null, string? version = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.Repositories;
|
||||
|
||||
public interface IPrSubmissionRepository
|
||||
{
|
||||
Task<IReadOnlyList<PrSubmission>> ListAsync(string? cveId = null, string? status = null, int limit = 50, int offset = 0, CancellationToken ct = default);
|
||||
Task<PrSubmission?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<PrSubmission> InsertAsync(PrSubmission submission, CancellationToken ct = default);
|
||||
Task UpdateStatusAsync(Guid id, string status, string? verdict = null, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.Repositories;
|
||||
|
||||
public sealed class PostgresFixTemplateRepository : IFixTemplateRepository
|
||||
{
|
||||
// Stub: real implementation uses Npgsql/Dapper against remediation.fix_templates
|
||||
private readonly List<FixTemplate> _store = new();
|
||||
|
||||
public Task<IReadOnlyList<FixTemplate>> ListAsync(string? cveId = null, string? purl = null, int limit = 50, int offset = 0, CancellationToken ct = default)
|
||||
{
|
||||
var query = _store.AsEnumerable();
|
||||
if (!string.IsNullOrEmpty(cveId))
|
||||
query = query.Where(t => t.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(purl))
|
||||
query = query.Where(t => t.Purl.Equals(purl, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
IReadOnlyList<FixTemplate> result = query.Skip(offset).Take(limit).ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<FixTemplate?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var template = _store.FirstOrDefault(t => t.Id == id);
|
||||
return Task.FromResult(template);
|
||||
}
|
||||
|
||||
public Task<FixTemplate> InsertAsync(FixTemplate template, CancellationToken ct = default)
|
||||
{
|
||||
var created = template with { Id = template.Id == Guid.Empty ? Guid.NewGuid() : template.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_store.Add(created);
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FixTemplate>> FindMatchesAsync(string cveId, string? purl = null, string? version = null, CancellationToken ct = default)
|
||||
{
|
||||
var query = _store.Where(t => t.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase) && t.Status == "verified");
|
||||
if (!string.IsNullOrEmpty(purl))
|
||||
query = query.Where(t => t.Purl.Equals(purl, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
query = query.Where(t => VersionRangeMatches(t.VersionRange, version));
|
||||
|
||||
IReadOnlyList<FixTemplate> result = query
|
||||
.OrderByDescending(t => t.TrustScore)
|
||||
.ThenByDescending(t => t.CreatedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static bool VersionRangeMatches(string? versionRange, string targetVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetVersion))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(versionRange))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalizedRange = versionRange.Trim();
|
||||
var normalizedTarget = targetVersion.Trim();
|
||||
|
||||
// Simple wildcard: 1.2.* matches 1.2.7
|
||||
if (normalizedRange.EndsWith('*'))
|
||||
{
|
||||
var prefix = normalizedRange[..^1];
|
||||
return normalizedTarget.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Exact token match across common delimiters.
|
||||
var tokens = normalizedRange
|
||||
.Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (tokens.Length > 1)
|
||||
{
|
||||
return tokens.Any(token => string.Equals(token, normalizedTarget, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// Fallback: substring match supports lightweight expressions like ">=1.2.0 <2.0.0".
|
||||
return normalizedRange.Contains(normalizedTarget, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.Repositories;
|
||||
|
||||
public sealed class PostgresPrSubmissionRepository : IPrSubmissionRepository
|
||||
{
|
||||
// Stub: real implementation uses Npgsql/Dapper against remediation.pr_submissions
|
||||
private readonly List<PrSubmission> _store = new();
|
||||
|
||||
public Task<IReadOnlyList<PrSubmission>> ListAsync(string? cveId = null, string? status = null, int limit = 50, int offset = 0, CancellationToken ct = default)
|
||||
{
|
||||
var query = _store.AsEnumerable();
|
||||
if (!string.IsNullOrEmpty(cveId))
|
||||
query = query.Where(s => s.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
query = query.Where(s => s.Status.Equals(status, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
IReadOnlyList<PrSubmission> result = query.Skip(offset).Take(limit).ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<PrSubmission?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var submission = _store.FirstOrDefault(s => s.Id == id);
|
||||
return Task.FromResult(submission);
|
||||
}
|
||||
|
||||
public Task<PrSubmission> InsertAsync(PrSubmission submission, CancellationToken ct = default)
|
||||
{
|
||||
var created = submission with { Id = submission.Id == Guid.Empty ? Guid.NewGuid() : submission.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_store.Add(created);
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
|
||||
public Task UpdateStatusAsync(Guid id, string status, string? verdict = null, CancellationToken ct = default)
|
||||
{
|
||||
var index = _store.FindIndex(s => s.Id == id);
|
||||
if (index >= 0)
|
||||
{
|
||||
_store[index] = _store[index] with { Status = status, Verdict = verdict };
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Remediation.Core\StellaOps.Remediation.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace StellaOps.Remediation.WebService.Contracts;
|
||||
|
||||
public sealed record CreateFixTemplateRequest(
|
||||
string CveId,
|
||||
string Purl,
|
||||
string VersionRange,
|
||||
string PatchContent,
|
||||
string? Description);
|
||||
|
||||
public sealed record CreatePrSubmissionRequest(
|
||||
string PrUrl,
|
||||
string RepositoryUrl,
|
||||
string SourceBranch,
|
||||
string TargetBranch,
|
||||
string CveId,
|
||||
Guid? FixTemplateId);
|
||||
|
||||
public sealed record FixTemplateListResponse(
|
||||
IReadOnlyList<FixTemplateSummary> Items,
|
||||
int Count,
|
||||
int Limit,
|
||||
int Offset);
|
||||
|
||||
public sealed record FixTemplateSummary(
|
||||
Guid Id,
|
||||
string CveId,
|
||||
string Purl,
|
||||
string VersionRange,
|
||||
string Status,
|
||||
double TrustScore,
|
||||
string? Description,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record PrSubmissionListResponse(
|
||||
IReadOnlyList<PrSubmissionSummary> Items,
|
||||
int Count,
|
||||
int Limit,
|
||||
int Offset);
|
||||
|
||||
public sealed record PrSubmissionSummary(
|
||||
Guid Id,
|
||||
string PrUrl,
|
||||
string CveId,
|
||||
string Status,
|
||||
string? Verdict,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record ContributorResponse(
|
||||
Guid Id,
|
||||
string Username,
|
||||
string? DisplayName,
|
||||
int VerifiedFixes,
|
||||
int TotalSubmissions,
|
||||
double TrustScore,
|
||||
string TrustTier);
|
||||
|
||||
public sealed record MatchResponse(
|
||||
IReadOnlyList<FixTemplateSummary> Items,
|
||||
int Count);
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Remediation.Core.Abstractions;
|
||||
using StellaOps.Remediation.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Remediation.WebService.Endpoints;
|
||||
|
||||
public static class RemediationMatchEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRemediationMatchEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var match = app.MapGroup("/api/v1/remediation/match")
|
||||
.WithTags("Remediation");
|
||||
|
||||
match.MapGet(string.Empty, async Task<IResult>(
|
||||
IRemediationMatcher matcher,
|
||||
[FromQuery] string cve,
|
||||
[FromQuery] string? purl,
|
||||
[FromQuery] string? version,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
return Results.BadRequest(new { error = "cve query parameter is required." });
|
||||
}
|
||||
|
||||
var items = await matcher.FindMatchesAsync(cve, purl, version, ct).ConfigureAwait(false);
|
||||
var summaries = items.Select(t => new FixTemplateSummary(
|
||||
t.Id, t.CveId, t.Purl, t.VersionRange, t.Status, t.TrustScore, t.Description, t.CreatedAt)).ToList();
|
||||
return Results.Ok(new MatchResponse(summaries, summaries.Count));
|
||||
})
|
||||
.WithName("FindRemediationMatches")
|
||||
.WithSummary("Find fix templates matching a CVE and optional PURL/version")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Remediation.Core.Abstractions;
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
using StellaOps.Remediation.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Remediation.WebService.Endpoints;
|
||||
|
||||
public static class RemediationRegistryEndpoints
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
|
||||
public static IEndpointRouteBuilder MapRemediationRegistryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var templates = app.MapGroup("/api/v1/remediation/templates")
|
||||
.WithTags("Remediation");
|
||||
|
||||
templates.MapGet(string.Empty, async Task<IResult>(
|
||||
IRemediationRegistry registry,
|
||||
[FromQuery] string? cve,
|
||||
[FromQuery] string? purl,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var items = await registry.ListTemplatesAsync(cve, purl, normalizedLimit, normalizedOffset, ct).ConfigureAwait(false);
|
||||
var summaries = items.Select(MapTemplateSummary).ToList();
|
||||
return Results.Ok(new FixTemplateListResponse(summaries, summaries.Count, normalizedLimit, normalizedOffset));
|
||||
})
|
||||
.WithName("ListFixTemplates")
|
||||
.WithSummary("List fix templates")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
templates.MapGet("/{id:guid}", async Task<IResult>(
|
||||
IRemediationRegistry registry,
|
||||
Guid id,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var template = await registry.GetTemplateAsync(id, ct).ConfigureAwait(false);
|
||||
return template is null ? Results.NotFound() : Results.Ok(template);
|
||||
})
|
||||
.WithName("GetFixTemplate")
|
||||
.WithSummary("Get fix template by id")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
templates.MapPost(string.Empty, async Task<IResult>(
|
||||
IRemediationRegistry registry,
|
||||
CreateFixTemplateRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var template = new FixTemplate
|
||||
{
|
||||
CveId = request.CveId,
|
||||
Purl = request.Purl,
|
||||
VersionRange = request.VersionRange,
|
||||
PatchContent = request.PatchContent,
|
||||
Description = request.Description
|
||||
};
|
||||
var created = await registry.CreateTemplateAsync(template, ct).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/remediation/templates/{created.Id}", created);
|
||||
})
|
||||
.WithName("CreateFixTemplate")
|
||||
.WithSummary("Create fix template")
|
||||
.RequireAuthorization("remediation.submit");
|
||||
|
||||
var submissions = app.MapGroup("/api/v1/remediation/submissions")
|
||||
.WithTags("Remediation");
|
||||
|
||||
submissions.MapGet(string.Empty, async Task<IResult>(
|
||||
IRemediationRegistry registry,
|
||||
[FromQuery] string? cve,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var items = await registry.ListSubmissionsAsync(cve, status, normalizedLimit, normalizedOffset, ct).ConfigureAwait(false);
|
||||
var summaries = items.Select(MapSubmissionSummary).ToList();
|
||||
return Results.Ok(new PrSubmissionListResponse(summaries, summaries.Count, normalizedLimit, normalizedOffset));
|
||||
})
|
||||
.WithName("ListPrSubmissions")
|
||||
.WithSummary("List PR submissions")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
submissions.MapGet("/{id:guid}", async Task<IResult>(
|
||||
IRemediationRegistry registry,
|
||||
Guid id,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var submission = await registry.GetSubmissionAsync(id, ct).ConfigureAwait(false);
|
||||
return submission is null ? Results.NotFound() : Results.Ok(submission);
|
||||
})
|
||||
.WithName("GetPrSubmission")
|
||||
.WithSummary("Get PR submission detail with attestation chain")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
submissions.MapPost(string.Empty, async Task<IResult>(
|
||||
IRemediationRegistry registry,
|
||||
CreatePrSubmissionRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var submission = new PrSubmission
|
||||
{
|
||||
PrUrl = request.PrUrl,
|
||||
RepositoryUrl = request.RepositoryUrl,
|
||||
SourceBranch = request.SourceBranch,
|
||||
TargetBranch = request.TargetBranch,
|
||||
CveId = request.CveId,
|
||||
FixTemplateId = request.FixTemplateId
|
||||
};
|
||||
var created = await registry.CreateSubmissionAsync(submission, ct).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/remediation/submissions/{created.Id}", created);
|
||||
})
|
||||
.WithName("CreatePrSubmission")
|
||||
.WithSummary("Create submission from PR")
|
||||
.RequireAuthorization("remediation.submit");
|
||||
|
||||
submissions.MapGet("/{id:guid}/status", async Task<IResult>(
|
||||
IRemediationRegistry registry,
|
||||
Guid id,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var submission = await registry.GetSubmissionAsync(id, ct).ConfigureAwait(false);
|
||||
if (submission is null) return Results.NotFound();
|
||||
return Results.Ok(new { submission.Id, submission.Status, submission.Verdict });
|
||||
})
|
||||
.WithName("GetPrSubmissionStatus")
|
||||
.WithSummary("Get submission pipeline status")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
var contributors = app.MapGroup("/api/v1/remediation/contributors")
|
||||
.WithTags("Remediation");
|
||||
|
||||
contributors.MapGet(string.Empty, () =>
|
||||
{
|
||||
// Stub: list contributors
|
||||
return Results.Ok(new { items = Array.Empty<object>(), count = 0 });
|
||||
})
|
||||
.WithName("ListContributors")
|
||||
.WithSummary("List contributors")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
contributors.MapGet("/{username}", (
|
||||
string username,
|
||||
IContributorTrustScorer scorer) =>
|
||||
{
|
||||
// Stub: get contributor by username
|
||||
return Results.NotFound(new { error = "contributor_not_found", username });
|
||||
})
|
||||
.WithName("GetContributor")
|
||||
.WithSummary("Get contributor profile with trust score")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static FixTemplateSummary MapTemplateSummary(FixTemplate t) => new(
|
||||
t.Id, t.CveId, t.Purl, t.VersionRange, t.Status, t.TrustScore, t.Description, t.CreatedAt);
|
||||
|
||||
private static PrSubmissionSummary MapSubmissionSummary(PrSubmission s) => new(
|
||||
s.Id, s.PrUrl, s.CveId, s.Status, s.Verdict, s.CreatedAt);
|
||||
|
||||
private static int NormalizeLimit(int? value) => value switch
|
||||
{
|
||||
null => DefaultLimit,
|
||||
< 1 => 1,
|
||||
> MaxLimit => MaxLimit,
|
||||
_ => value.Value
|
||||
};
|
||||
|
||||
private static int NormalizeOffset(int? value) => value is null or < 0 ? 0 : value.Value;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace StellaOps.Remediation.WebService.Endpoints;
|
||||
|
||||
public static class RemediationSourceEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRemediationSourceEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var sources = app.MapGroup("/api/v1/remediation/sources")
|
||||
.WithTags("Remediation");
|
||||
|
||||
sources.MapGet(string.Empty, () =>
|
||||
{
|
||||
// Stub: list marketplace sources
|
||||
return Results.Ok(new { items = Array.Empty<object>(), count = 0 });
|
||||
})
|
||||
.WithName("ListMarketplaceSources")
|
||||
.WithSummary("List remediation marketplace sources")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
sources.MapGet("/{key}", (string key) =>
|
||||
{
|
||||
// Stub: get marketplace source by key
|
||||
return Results.NotFound(new { error = "source_not_found", key });
|
||||
})
|
||||
.WithName("GetMarketplaceSource")
|
||||
.WithSummary("Get marketplace source by key")
|
||||
.RequireAuthorization("remediation.read");
|
||||
|
||||
sources.MapPost(string.Empty, () =>
|
||||
{
|
||||
// Stub: create or update marketplace source
|
||||
return Results.StatusCode(StatusCodes.Status501NotImplemented);
|
||||
})
|
||||
.WithName("CreateMarketplaceSource")
|
||||
.WithSummary("Create or update marketplace source")
|
||||
.RequireAuthorization("remediation.manage");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
100
src/Remediation/StellaOps.Remediation.WebService/Program.cs
Normal file
100
src/Remediation/StellaOps.Remediation.WebService/Program.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using StellaOps.Remediation.Core.Abstractions;
|
||||
using StellaOps.Remediation.Core.Services;
|
||||
using StellaOps.Remediation.Persistence.Repositories;
|
||||
using StellaOps.Remediation.WebService.Endpoints;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("remediation.read", policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy("remediation.submit", policy => policy.RequireAssertion(_ => true));
|
||||
options.AddPolicy("remediation.manage", policy => policy.RequireAssertion(_ => true));
|
||||
});
|
||||
builder.Services.AddAuthentication();
|
||||
|
||||
// Core services
|
||||
builder.Services.AddSingleton<IContributorTrustScorer, ContributorTrustScorer>();
|
||||
builder.Services.AddSingleton<IRemediationVerifier, RemediationVerifier>();
|
||||
|
||||
// Persistence (in-memory stubs for now; swap to Postgres in production)
|
||||
var templateRepo = new PostgresFixTemplateRepository();
|
||||
var submissionRepo = new PostgresPrSubmissionRepository();
|
||||
builder.Services.AddSingleton<IFixTemplateRepository>(templateRepo);
|
||||
builder.Services.AddSingleton<IPrSubmissionRepository>(submissionRepo);
|
||||
|
||||
// Registry: compose from repositories
|
||||
builder.Services.AddSingleton<IRemediationRegistry>(sp =>
|
||||
new InMemoryRemediationRegistry(templateRepo, submissionRepo));
|
||||
|
||||
// Matcher: compose from template repository
|
||||
builder.Services.AddSingleton<IRemediationMatcher>(sp =>
|
||||
new InMemoryRemediationMatcher(templateRepo));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz").AllowAnonymous();
|
||||
|
||||
app.MapRemediationRegistryEndpoints();
|
||||
app.MapRemediationMatchEndpoints();
|
||||
app.MapRemediationSourceEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>
|
||||
/// In-memory registry implementation composed from repositories.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryRemediationRegistry : IRemediationRegistry
|
||||
{
|
||||
private readonly IFixTemplateRepository _templates;
|
||||
private readonly IPrSubmissionRepository _submissions;
|
||||
|
||||
public InMemoryRemediationRegistry(IFixTemplateRepository templates, IPrSubmissionRepository submissions)
|
||||
{
|
||||
_templates = templates;
|
||||
_submissions = submissions;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StellaOps.Remediation.Core.Models.FixTemplate>> ListTemplatesAsync(string? cveId, string? purl, int limit, int offset, CancellationToken ct)
|
||||
=> _templates.ListAsync(cveId, purl, limit, offset, ct);
|
||||
|
||||
public Task<StellaOps.Remediation.Core.Models.FixTemplate?> GetTemplateAsync(Guid id, CancellationToken ct)
|
||||
=> _templates.GetByIdAsync(id, ct);
|
||||
|
||||
public Task<StellaOps.Remediation.Core.Models.FixTemplate> CreateTemplateAsync(StellaOps.Remediation.Core.Models.FixTemplate template, CancellationToken ct)
|
||||
=> _templates.InsertAsync(template, ct);
|
||||
|
||||
public Task<IReadOnlyList<StellaOps.Remediation.Core.Models.PrSubmission>> ListSubmissionsAsync(string? cveId, string? status, int limit, int offset, CancellationToken ct)
|
||||
=> _submissions.ListAsync(cveId, status, limit, offset, ct);
|
||||
|
||||
public Task<StellaOps.Remediation.Core.Models.PrSubmission?> GetSubmissionAsync(Guid id, CancellationToken ct)
|
||||
=> _submissions.GetByIdAsync(id, ct);
|
||||
|
||||
public Task<StellaOps.Remediation.Core.Models.PrSubmission> CreateSubmissionAsync(StellaOps.Remediation.Core.Models.PrSubmission submission, CancellationToken ct)
|
||||
=> _submissions.InsertAsync(submission, ct);
|
||||
|
||||
public Task UpdateSubmissionStatusAsync(Guid id, string status, string? verdict, CancellationToken ct)
|
||||
=> _submissions.UpdateStatusAsync(id, status, verdict, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory matcher implementation that delegates to template repository.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryRemediationMatcher : IRemediationMatcher
|
||||
{
|
||||
private readonly IFixTemplateRepository _templates;
|
||||
|
||||
public InMemoryRemediationMatcher(IFixTemplateRepository templates)
|
||||
{
|
||||
_templates = templates;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StellaOps.Remediation.Core.Models.FixTemplate>> FindMatchesAsync(string cveId, string? purl, string? version, CancellationToken ct)
|
||||
=> _templates.FindMatchesAsync(cveId, purl, version, ct);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Remediation.Core\StellaOps.Remediation.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Remediation.Persistence\StellaOps.Remediation.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,83 @@
|
||||
using StellaOps.Remediation.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Remediation.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "20260220.010")]
|
||||
public sealed class ContributorTrustScorerTests
|
||||
{
|
||||
private readonly ContributorTrustScorer _scorer = new();
|
||||
|
||||
[Fact]
|
||||
public void CalculateTrustScore_AllVerified_ReturnsOne()
|
||||
{
|
||||
var score = _scorer.CalculateTrustScore(verifiedFixes: 10, totalSubmissions: 10, rejectedSubmissions: 0);
|
||||
Assert.Equal(1.0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTrustScore_NoSubmissions_ReturnsZero()
|
||||
{
|
||||
var score = _scorer.CalculateTrustScore(verifiedFixes: 0, totalSubmissions: 0, rejectedSubmissions: 0);
|
||||
Assert.Equal(0.0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTrustScore_MixedResults_ReturnsExpected()
|
||||
{
|
||||
// (8 * 1.0 - 2 * 0.5) / 10 = (8 - 1) / 10 = 0.7
|
||||
var score = _scorer.CalculateTrustScore(verifiedFixes: 8, totalSubmissions: 10, rejectedSubmissions: 2);
|
||||
Assert.Equal(0.7, score, precision: 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTrustScore_HeavyRejection_ClampsToZero()
|
||||
{
|
||||
// (1 * 1.0 - 10 * 0.5) / 10 = (1 - 5) / 10 = -0.4 -> clamped to 0
|
||||
var score = _scorer.CalculateTrustScore(verifiedFixes: 1, totalSubmissions: 10, rejectedSubmissions: 10);
|
||||
Assert.Equal(0.0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTrustScore_ClampsToOne()
|
||||
{
|
||||
// Edge case: more verified than total (shouldn't happen but should still clamp)
|
||||
var score = _scorer.CalculateTrustScore(verifiedFixes: 20, totalSubmissions: 10, rejectedSubmissions: 0);
|
||||
Assert.Equal(1.0, score);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.0, "trusted")]
|
||||
[InlineData(0.9, "trusted")]
|
||||
[InlineData(0.81, "trusted")]
|
||||
[InlineData(0.8, "established")]
|
||||
[InlineData(0.6, "established")]
|
||||
[InlineData(0.51, "established")]
|
||||
[InlineData(0.5, "new")]
|
||||
[InlineData(0.3, "new")]
|
||||
[InlineData(0.21, "new")]
|
||||
[InlineData(0.2, "untrusted")]
|
||||
[InlineData(0.1, "untrusted")]
|
||||
[InlineData(0.0, "untrusted")]
|
||||
public void GetTrustTier_ReturnsCorrectTier(double score, string expectedTier)
|
||||
{
|
||||
var tier = _scorer.GetTrustTier(score);
|
||||
Assert.Equal(expectedTier, tier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTrustScore_SingleVerified_ReturnsOne()
|
||||
{
|
||||
var score = _scorer.CalculateTrustScore(verifiedFixes: 1, totalSubmissions: 1, rejectedSubmissions: 0);
|
||||
Assert.Equal(1.0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTrustScore_HalfRejected_ReturnsExpected()
|
||||
{
|
||||
// (5 * 1.0 - 5 * 0.5) / 10 = (5 - 2.5) / 10 = 0.25
|
||||
var score = _scorer.CalculateTrustScore(verifiedFixes: 5, totalSubmissions: 10, rejectedSubmissions: 5);
|
||||
Assert.Equal(0.25, score, precision: 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
using StellaOps.Remediation.Persistence.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Remediation.Tests;
|
||||
|
||||
public sealed class PostgresFixTemplateRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FindMatchesAsync_FiltersByCvePurlAndVersion()
|
||||
{
|
||||
var repository = new PostgresFixTemplateRepository();
|
||||
|
||||
await repository.InsertAsync(new FixTemplate
|
||||
{
|
||||
CveId = "CVE-2026-2000",
|
||||
Purl = "pkg:npm/lodash",
|
||||
VersionRange = "1.2.*",
|
||||
PatchContent = "patch-a",
|
||||
Status = "verified",
|
||||
TrustScore = 0.81
|
||||
});
|
||||
|
||||
await repository.InsertAsync(new FixTemplate
|
||||
{
|
||||
CveId = "CVE-2026-2000",
|
||||
Purl = "pkg:npm/lodash",
|
||||
VersionRange = "2.0.0",
|
||||
PatchContent = "patch-b",
|
||||
Status = "verified",
|
||||
TrustScore = 0.79
|
||||
});
|
||||
|
||||
await repository.InsertAsync(new FixTemplate
|
||||
{
|
||||
CveId = "CVE-2026-2000",
|
||||
Purl = "pkg:npm/lodash",
|
||||
VersionRange = "1.2.9",
|
||||
PatchContent = "patch-pending",
|
||||
Status = "pending",
|
||||
TrustScore = 0.99
|
||||
});
|
||||
|
||||
var matchesFor12 = await repository.FindMatchesAsync(
|
||||
cveId: "CVE-2026-2000",
|
||||
purl: "pkg:npm/lodash",
|
||||
version: "1.2.9");
|
||||
|
||||
Assert.Single(matchesFor12);
|
||||
Assert.Equal("1.2.*", matchesFor12[0].VersionRange);
|
||||
|
||||
var matchesFor20 = await repository.FindMatchesAsync(
|
||||
cveId: "CVE-2026-2000",
|
||||
purl: "pkg:npm/lodash",
|
||||
version: "2.0.0");
|
||||
|
||||
Assert.Single(matchesFor20);
|
||||
Assert.Equal("2.0.0", matchesFor20[0].VersionRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindMatchesAsync_SortsByTrustScoreDescending()
|
||||
{
|
||||
var repository = new PostgresFixTemplateRepository();
|
||||
|
||||
await repository.InsertAsync(new FixTemplate
|
||||
{
|
||||
CveId = "CVE-2026-3000",
|
||||
Purl = "pkg:deb/debian/openssl",
|
||||
VersionRange = "3.0.0",
|
||||
PatchContent = "patch-low",
|
||||
Status = "verified",
|
||||
TrustScore = 0.55
|
||||
});
|
||||
|
||||
await repository.InsertAsync(new FixTemplate
|
||||
{
|
||||
CveId = "CVE-2026-3000",
|
||||
Purl = "pkg:deb/debian/openssl",
|
||||
VersionRange = "3.0.1",
|
||||
PatchContent = "patch-high",
|
||||
Status = "verified",
|
||||
TrustScore = 0.91
|
||||
});
|
||||
|
||||
var matches = await repository.FindMatchesAsync(
|
||||
cveId: "CVE-2026-3000",
|
||||
purl: "pkg:deb/debian/openssl",
|
||||
version: null);
|
||||
|
||||
Assert.Equal(2, matches.Count);
|
||||
Assert.True(matches[0].TrustScore >= matches[1].TrustScore);
|
||||
Assert.Equal("patch-high", matches[0].PatchContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
using StellaOps.Remediation.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Remediation.Tests;
|
||||
|
||||
public sealed class RemediationVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsInconclusive_WhenScanDigestsMissing()
|
||||
{
|
||||
var verifier = new RemediationVerifier(new FixedTimeProvider(new DateTimeOffset(2026, 2, 20, 14, 0, 0, TimeSpan.Zero)));
|
||||
var submission = new PrSubmission
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CveId = "CVE-2026-1000",
|
||||
PrUrl = "https://example.org/pr/1"
|
||||
};
|
||||
|
||||
var result = await verifier.VerifyAsync(submission);
|
||||
|
||||
Assert.Equal("inconclusive", result.Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsNotFixed_WhenPreAndPostDigestsMatch()
|
||||
{
|
||||
var verifier = new RemediationVerifier(new FixedTimeProvider(new DateTimeOffset(2026, 2, 20, 14, 1, 0, TimeSpan.Zero)));
|
||||
var submission = new PrSubmission
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CveId = "CVE-2026-1001",
|
||||
PrUrl = "https://example.org/pr/2",
|
||||
PreScanDigest = "sha256:aaa",
|
||||
PostScanDigest = "sha256:aaa"
|
||||
};
|
||||
|
||||
var result = await verifier.VerifyAsync(submission);
|
||||
|
||||
Assert.Equal("not_fixed", result.Verdict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsFixed_WhenPreAndPostDigestsDiffer()
|
||||
{
|
||||
var verifier = new RemediationVerifier(new FixedTimeProvider(new DateTimeOffset(2026, 2, 20, 14, 2, 0, TimeSpan.Zero)));
|
||||
var submission = new PrSubmission
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CveId = "CVE-2026-1002",
|
||||
PrUrl = "https://example.org/pr/3",
|
||||
PreScanDigest = "sha256:aaa",
|
||||
PostScanDigest = "sha256:bbb",
|
||||
ReachabilityDeltaDigest = "sha256:delta",
|
||||
FixChainDsseDigest = "sha256:fixchain"
|
||||
};
|
||||
|
||||
var result = await verifier.VerifyAsync(submission);
|
||||
|
||||
Assert.Equal("fixed", result.Verdict);
|
||||
Assert.Equal("sha256:delta", result.ReachabilityDeltaDigest);
|
||||
Assert.Equal("sha256:fixchain", result.FixChainDsseDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UsesInjectedTimeProvider_ForDeterministicTimestamp()
|
||||
{
|
||||
var fixedNow = new DateTimeOffset(2026, 2, 20, 14, 3, 0, TimeSpan.Zero);
|
||||
var verifier = new RemediationVerifier(new FixedTimeProvider(fixedNow));
|
||||
var submission = new PrSubmission
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CveId = "CVE-2026-1003",
|
||||
PrUrl = "https://example.org/pr/4",
|
||||
PreScanDigest = "sha256:old",
|
||||
PostScanDigest = "sha256:new"
|
||||
};
|
||||
|
||||
var result = await verifier.VerifyAsync(submission);
|
||||
|
||||
Assert.Equal(fixedNow, result.VerifiedAt);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _value;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Remediation.Core/StellaOps.Remediation.Core.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.Remediation.Persistence/StellaOps.Remediation.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user