Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Remediation.Core.Abstractions;
public interface IContributorTrustScorer
{
double CalculateTrustScore(int verifiedFixes, int totalSubmissions, int rejectedSubmissions);
string GetTrustTier(double score);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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"
};
}

View File

@@ -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);

View File

@@ -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";
}
}

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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);
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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>