save progress

This commit is contained in:
StellaOps Bot
2026-01-02 15:52:31 +02:00
parent 2dec7e6a04
commit f46bde5575
174 changed files with 20793 additions and 8307 deletions

View File

@@ -6,7 +6,13 @@ public sealed class GhsaOptions
{
public static string HttpClientName => "source.ghsa";
public Uri BaseEndpoint { get; set; } = new("https://api.github.com/", UriKind.Absolute);
private Uri _baseEndpoint = new("https://api.github.com/", UriKind.Absolute);
public Uri BaseEndpoint
{
get => _baseEndpoint;
set => _baseEndpoint = EnsureHttps(value);
}
public string ApiToken { get; set; } = string.Empty;
@@ -72,4 +78,29 @@ public sealed class GhsaOptions
throw new InvalidOperationException("SecondaryRateLimitBackoff must be greater than zero.");
}
}
private static Uri EnsureHttps(Uri? endpoint)
{
if (endpoint is null)
{
return new Uri("https://api.github.com/", UriKind.Absolute);
}
if (!endpoint.IsAbsoluteUri)
{
return endpoint;
}
if (string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase))
{
var builder = new UriBuilder(endpoint)
{
Scheme = Uri.UriSchemeHttps,
Port = -1
};
return builder.Uri;
}
return endpoint;
}
}

View File

@@ -0,0 +1,115 @@
using System.Collections.Concurrent;
namespace StellaOps.Concelier.Core.Jobs;
/// <summary>
/// In-memory job store for development and tests when no persistent store is configured.
/// </summary>
public sealed class InMemoryJobStore : IJobStore
{
private readonly ConcurrentDictionary<Guid, JobRunSnapshot> _runs = new();
public Task<JobRunSnapshot> CreateAsync(JobRunCreateRequest request, CancellationToken cancellationToken)
{
var run = new JobRunSnapshot(
Guid.NewGuid(),
request.Kind,
JobRunStatus.Pending,
request.CreatedAt,
null,
null,
request.Trigger,
request.ParametersHash,
null,
request.Timeout,
request.LeaseDuration,
request.Parameters);
_runs[run.RunId] = run;
return Task.FromResult(run);
}
public Task<JobRunSnapshot?> TryStartAsync(Guid runId, DateTimeOffset startedAt, CancellationToken cancellationToken)
{
if (_runs.TryGetValue(runId, out var run))
{
var updated = run with { Status = JobRunStatus.Running, StartedAt = startedAt };
_runs[runId] = updated;
return Task.FromResult<JobRunSnapshot?>(updated);
}
return Task.FromResult<JobRunSnapshot?>(null);
}
public Task<JobRunSnapshot?> TryCompleteAsync(Guid runId, JobRunCompletion completion, CancellationToken cancellationToken)
{
if (_runs.TryGetValue(runId, out var run))
{
var updated = run with
{
Status = completion.Status,
CompletedAt = completion.CompletedAt,
Error = completion.Error
};
_runs[runId] = updated;
return Task.FromResult<JobRunSnapshot?>(updated);
}
return Task.FromResult<JobRunSnapshot?>(null);
}
public Task<JobRunSnapshot?> FindAsync(Guid runId, CancellationToken cancellationToken)
=> Task.FromResult(_runs.TryGetValue(runId, out var run) ? run : null);
public Task<IReadOnlyList<JobRunSnapshot>> GetRecentRunsAsync(string? kind, int limit, CancellationToken cancellationToken)
{
var query = _runs.Values.AsEnumerable();
if (!string.IsNullOrWhiteSpace(kind))
{
query = query.Where(r => string.Equals(r.Kind, kind, StringComparison.Ordinal));
}
var list = query
.OrderByDescending(r => r.CreatedAt)
.Take(limit)
.ToArray();
return Task.FromResult<IReadOnlyList<JobRunSnapshot>>(list);
}
public Task<IReadOnlyList<JobRunSnapshot>> GetActiveRunsAsync(CancellationToken cancellationToken)
{
var list = _runs.Values
.Where(r => r.Status is JobRunStatus.Pending or JobRunStatus.Running)
.ToArray();
return Task.FromResult<IReadOnlyList<JobRunSnapshot>>(list);
}
public Task<JobRunSnapshot?> GetLastRunAsync(string kind, CancellationToken cancellationToken)
{
var run = _runs.Values
.Where(r => string.Equals(r.Kind, kind, StringComparison.Ordinal))
.OrderByDescending(r => r.CreatedAt)
.FirstOrDefault();
return Task.FromResult<JobRunSnapshot?>(run);
}
public Task<IReadOnlyDictionary<string, JobRunSnapshot>> GetLastRunsAsync(IEnumerable<string> kinds, CancellationToken cancellationToken)
{
var results = new Dictionary<string, JobRunSnapshot>(StringComparer.Ordinal);
foreach (var kind in kinds.Distinct(StringComparer.Ordinal))
{
var run = _runs.Values
.Where(r => string.Equals(r.Kind, kind, StringComparison.Ordinal))
.OrderByDescending(r => r.CreatedAt)
.FirstOrDefault();
if (run is not null)
{
results[kind] = run;
}
}
return Task.FromResult<IReadOnlyDictionary<string, JobRunSnapshot>>(results);
}
}

View File

@@ -233,5 +233,6 @@ public sealed record Advisory
/// Semantic merge hash for provenance-scoped deduplication.
/// Nullable during migration; computed from (CVE + PURL + version-range + CWE + patch-lineage).
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MergeHash { get; }
}

View File

@@ -269,16 +269,18 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
}
}
var normalizedVersions = BuildNormalizedVersions(versionRanges);
var (platform, normalizedVersions) = ReadDatabaseSpecific(a.DatabaseSpecific);
var effectivePlatform = platform ?? ResolvePlatformFromRanges(versionRanges);
var resolvedNormalizedVersions = normalizedVersions ?? BuildNormalizedVersions(versionRanges);
return new AffectedPackage(
MapEcosystemToType(a.Ecosystem),
a.PackageName,
null,
effectivePlatform,
versionRanges,
Array.Empty<AffectedPackageStatus>(),
Array.Empty<AdvisoryProvenance>(),
normalizedVersions);
resolvedNormalizedVersions);
}).ToArray();
// Parse provenance if available
@@ -391,7 +393,7 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
"pub" => "semver",
"rpm" => "rpm",
"deb" => "deb",
"apk" => "semver",
"apk" => "apk",
"cpe" => "cpe",
"vendor" => "vendor",
"ics" => "ics-vendor",
@@ -399,4 +401,75 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
_ => "semver"
};
}
private static (string? Platform, IReadOnlyList<NormalizedVersionRule>? NormalizedVersions) ReadDatabaseSpecific(string? databaseSpecific)
{
if (string.IsNullOrWhiteSpace(databaseSpecific) || databaseSpecific == "{}")
{
return (null, null);
}
try
{
using var document = JsonDocument.Parse(databaseSpecific);
var root = document.RootElement;
string? platform = null;
if (root.TryGetProperty("platform", out var platformValue) && platformValue.ValueKind == JsonValueKind.String)
{
platform = platformValue.GetString();
}
IReadOnlyList<NormalizedVersionRule>? normalizedVersions = null;
if (root.TryGetProperty("normalizedVersions", out var normalizedValue) && normalizedValue.ValueKind == JsonValueKind.Array)
{
normalizedVersions = JsonSerializer.Deserialize<NormalizedVersionRule[]>(normalizedValue.GetRawText(), JsonOptions);
}
return (platform, normalizedVersions);
}
catch (JsonException)
{
return (null, null);
}
}
private static string? ResolvePlatformFromRanges(IEnumerable<AffectedVersionRange> ranges)
{
foreach (var range in ranges)
{
var extensions = range.Primitives?.VendorExtensions;
if (extensions is null || extensions.Count == 0)
{
continue;
}
if (extensions.TryGetValue("debian.release", out var debRelease) && !string.IsNullOrWhiteSpace(debRelease))
{
return debRelease;
}
if (extensions.TryGetValue("ubuntu.release", out var ubuntuRelease) && !string.IsNullOrWhiteSpace(ubuntuRelease))
{
return ubuntuRelease;
}
if (extensions.TryGetValue("alpine.distroversion", out var alpineRelease) && !string.IsNullOrWhiteSpace(alpineRelease))
{
if (extensions.TryGetValue("alpine.repo", out var alpineRepo) && !string.IsNullOrWhiteSpace(alpineRepo))
{
return $"{alpineRelease}/{alpineRepo}";
}
return alpineRelease;
}
if (extensions.TryGetValue("suse.platform", out var susePlatform) && !string.IsNullOrWhiteSpace(susePlatform))
{
return susePlatform;
}
}
return null;
}
}

View File

@@ -99,6 +99,7 @@ public sealed class AdvisoryConverter
{
var ecosystem = MapTypeToEcosystem(pkg.Type);
var versionRangeJson = JsonSerializer.Serialize(pkg.VersionRanges, JsonOptions);
var databaseSpecificJson = BuildDatabaseSpecific(pkg);
affectedEntities.Add(new AdvisoryAffectedEntity
{
@@ -110,7 +111,7 @@ public sealed class AdvisoryConverter
VersionRange = versionRangeJson,
VersionsAffected = null,
VersionsFixed = ExtractFixedVersions(pkg.VersionRanges),
DatabaseSpecific = null,
DatabaseSpecific = databaseSpecificJson,
CreatedAt = now
});
}
@@ -245,6 +246,29 @@ public sealed class AdvisoryConverter
_ => null
};
private static string? BuildDatabaseSpecific(AffectedPackage package)
{
if (package is null)
{
return null;
}
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(package.Platform))
{
payload["platform"] = package.Platform;
}
if (!package.NormalizedVersions.IsEmpty)
{
payload["normalizedVersions"] = package.NormalizedVersions;
}
return payload.Count == 0
? null
: JsonSerializer.Serialize(payload, JsonOptions);
}
private static string[]? ExtractFixedVersions(IEnumerable<AffectedVersionRange> ranges)
{
var fixedVersions = ranges

View File

@@ -271,10 +271,10 @@ public static partial class ChangelogParser
[GeneratedRegex(@"^\* (.+) - (.+)")]
private static partial Regex RpmHeaderRegex();
[GeneratedRegex(@" ([\d\.\-]+):")]
[GeneratedRegex(@"^\s{2}([0-9A-Za-z\.\-_+]+):")]
private static partial Regex AlpineVersionRegex();
[GeneratedRegex(@"CVE-\d{4}-\d{4,}")]
[GeneratedRegex(@"CVE-\d{4}-[0-9A-Za-z]{4,}")]
private static partial Regex CvePatternRegex();
}

View File

@@ -113,6 +113,11 @@ public static partial class PatchHeaderParser
private static double CalculateConfidence(int cveCount, string description, string origin)
{
if (cveCount == 0)
{
return 0.0;
}
// Base confidence for patch header CVE mention
var confidence = 0.80;
@@ -137,7 +142,7 @@ public static partial class PatchHeaderParser
return Math.Min(confidence, 0.95);
}
[GeneratedRegex(@"CVE-\d{4}-\d{4,}")]
[GeneratedRegex(@"CVE-\d{4}-[0-9A-Za-z]{4,}")]
private static partial Regex CvePatternRegex();
}