audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -203,14 +203,14 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
// For now, return a message that SSH will be validated on first scan
return Task.FromResult(new ConnectionTestResult
{
Success = true,
Message = "SSH configuration accepted - connection will be validated on first scan",
Success = false,
Message = "SSH connection not validated - verify connectivity during the first scan",
TestedAt = _timeProvider.GetUtcNow(),
Details = new Dictionary<string, object>
{
["repositoryUrl"] = config.RepositoryUrl,
["authMethod"] = config.AuthMethod.ToString(),
["note"] = "Full SSH validation requires runtime execution"
["note"] = "SSH validation is not performed by the connection tester"
}
});
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using StellaOps.Determinism;
namespace StellaOps.Scanner.Sources.Domain;
@@ -118,6 +119,7 @@ public sealed class SbomSource
JsonDocument configuration,
string createdBy,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? description = null,
string? authRef = null,
string? cronSchedule = null,
@@ -126,7 +128,7 @@ public sealed class SbomSource
var now = timeProvider.GetUtcNow();
var source = new SbomSource
{
SourceId = Guid.NewGuid(),
SourceId = guidProvider.NewGuid(),
TenantId = tenantId,
Name = name,
Description = description,

View File

@@ -1,3 +1,5 @@
using StellaOps.Determinism;
namespace StellaOps.Scanner.Sources.Domain;
#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary
@@ -40,7 +42,7 @@ public sealed class SbomSourceRun
if (CompletedAt.HasValue)
return (long)(CompletedAt.Value - StartedAt).TotalMilliseconds;
var now = timeProvider?.GetUtcNow() ?? DateTimeOffset.UtcNow;
var now = (timeProvider ?? TimeProvider.System).GetUtcNow();
return (long)(now - StartedAt).TotalMilliseconds;
}
@@ -84,11 +86,12 @@ public sealed class SbomSourceRun
SbomSourceRunTrigger trigger,
string correlationId,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? triggerDetails = null)
{
return new SbomSourceRun
{
RunId = Guid.NewGuid(),
RunId = guidProvider.NewGuid(),
SourceId = sourceId,
TenantId = tenantId,
Trigger = trigger,

View File

@@ -306,7 +306,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
};
}
private static (string Repository, string? Tag) ParseReference(string reference)
internal static (string Repository, string? Tag) ParseReference(string reference)
{
// Handle digest references
if (reference.Contains('@'))
@@ -316,18 +316,21 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
}
// Handle tag references
if (reference.Contains(':'))
var lastSlash = reference.LastIndexOf('/');
var lastColon = reference.LastIndexOf(':');
if (lastColon > -1 && lastColon > lastSlash)
{
var lastColon = reference.LastIndexOf(':');
return (reference[..lastColon], reference[(lastColon + 1)..]);
}
return (reference, null);
}
private static string BuildFullReference(string registryUrl, string repository, string tag)
internal static string BuildFullReference(string registryUrl, string repository, string tag)
{
var host = new Uri(registryUrl).Host;
var uri = new Uri(registryUrl);
var host = uri.Host;
var authority = uri.Authority;
// Docker Hub special case
if (host.Contains("docker.io") || host.Contains("docker.com"))
@@ -339,6 +342,6 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
return $"{repository}:{tag}";
}
return $"{host}/{repository}:{tag}";
return $"{authority}/{repository}:{tag}";
}
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.RegularExpressions;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Handlers.Zastava;
@@ -132,9 +133,9 @@ public sealed class ImageDiscoveryService : IImageDiscoveryService
return new SemVer
{
Major = int.Parse(match.Groups["major"].Value),
Minor = int.Parse(match.Groups["minor"].Value),
Patch = int.Parse(match.Groups["patch"].Value),
Major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture),
Minor = int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture),
Patch = int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture),
PreRelease = match.Groups["prerelease"].Success
? match.Groups["prerelease"].Value
: null,

View File

@@ -0,0 +1,25 @@
using System.Globalization;
using System.Text;
namespace StellaOps.Scanner.Sources.Persistence;
internal static class CursorEncoding
{
public static int Decode(string cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
{
throw new ArgumentException("Cursor is required.", nameof(cursor));
}
var bytes = Convert.FromBase64String(cursor);
var text = Encoding.UTF8.GetString(bytes);
return int.Parse(text, CultureInfo.InvariantCulture);
}
public static string Encode(int offset)
{
var text = offset.ToString(CultureInfo.InvariantCulture);
return Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
}
}

View File

@@ -13,6 +13,11 @@ public interface ISbomSourceRepository
/// </summary>
Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Get a source by ID across all tenants.
/// </summary>
Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Get a source by name.
/// </summary>

View File

@@ -47,6 +47,21 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
ct);
}
public async Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE source_id = @sourceId
""";
return await QuerySingleOrDefaultAsync(
"__system__",
sql,
cmd => AddParameter(cmd, "sourceId", sourceId),
MapSource,
ct);
}
public async Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
{
const string sql = $"""
@@ -113,8 +128,7 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
if (!string.IsNullOrEmpty(request.Cursor))
{
// Cursor is base64 encoded offset
var offset = int.Parse(
Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
var offset = CursorEncoding.Decode(request.Cursor);
sb.Append($" OFFSET {offset}");
}
@@ -136,9 +150,8 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
{
var currentOffset = string.IsNullOrEmpty(request.Cursor)
? 0
: int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
nextCursor = Convert.ToBase64String(
Encoding.UTF8.GetBytes((currentOffset + request.Limit).ToString()));
: CursorEncoding.Decode(request.Cursor);
nextCursor = CursorEncoding.Encode(currentOffset + request.Limit);
items = items.Take(request.Limit).ToList();
}

View File

@@ -89,8 +89,7 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
if (!string.IsNullOrEmpty(request.Cursor))
{
var offset = int.Parse(
Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
var offset = CursorEncoding.Decode(request.Cursor);
sb.Append($" OFFSET {offset}");
}
@@ -112,9 +111,8 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
{
var currentOffset = string.IsNullOrEmpty(request.Cursor)
? 0
: int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
nextCursor = Convert.ToBase64String(
Encoding.UTF8.GetBytes((currentOffset + request.Limit).ToString()));
: CursorEncoding.Decode(request.Cursor);
nextCursor = CursorEncoding.Encode(currentOffset + request.Limit);
items = items.Take(request.Limit).ToList();
}

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
@@ -18,6 +19,7 @@ public sealed class SbomSourceService : ISbomSourceService
private readonly ISourceConnectionTester _connectionTester;
private readonly ILogger<SbomSourceService> _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public SbomSourceService(
ISbomSourceRepository sourceRepository,
@@ -25,7 +27,8 @@ public sealed class SbomSourceService : ISbomSourceService
ISourceConfigValidator configValidator,
ISourceConnectionTester connectionTester,
ILogger<SbomSourceService> logger,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null)
{
_sourceRepository = sourceRepository;
_runRepository = runRepository;
@@ -33,6 +36,7 @@ public sealed class SbomSourceService : ISbomSourceService
_connectionTester = connectionTester;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
public async Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
@@ -102,6 +106,7 @@ public sealed class SbomSourceService : ISbomSourceService
request.Configuration,
createdBy,
_timeProvider,
_guidProvider,
request.Description,
request.AuthRef,
request.CronSchedule,
@@ -267,6 +272,7 @@ public sealed class SbomSourceService : ISbomSourceService
request.Configuration,
"__test__",
_timeProvider,
_guidProvider,
authRef: request.AuthRef);
return await _connectionTester.TestAsync(tempSource, request.TestCredentials, ct);
@@ -342,8 +348,9 @@ public sealed class SbomSourceService : ISbomSourceService
sourceId,
tenantId,
SbomSourceRunTrigger.Manual,
Guid.NewGuid().ToString("N"),
_guidProvider.NewGuid().ToString("N"),
_timeProvider,
_guidProvider,
$"Triggered by {triggeredBy}");
await _runRepository.CreateAsync(run, ct);

View File

@@ -24,4 +24,9 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>StellaOps.Scanner.Sources.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -63,7 +63,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
context.Trigger, sourceId, context.CorrelationId);
// 1. Get the source
var source = await _sourceRepository.GetByIdAsync(null!, sourceId, ct);
var source = await _sourceRepository.GetByIdAnyTenantAsync(sourceId, ct);
if (source == null)
{
_logger.LogWarning("Source {SourceId} not found", sourceId);
@@ -85,6 +85,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
context.Trigger,
context.CorrelationId,
_timeProvider,
_guidProvider,
context.TriggerDetails);
failedRun.Fail(canTrigger.Error!, _timeProvider);
await _runRepository.CreateAsync(failedRun, ct);
@@ -104,6 +105,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
context.Trigger,
context.CorrelationId,
_timeProvider,
_guidProvider,
context.TriggerDetails);
await _runRepository.CreateAsync(run, ct);
@@ -227,7 +229,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
{
try
{
var context = TriggerContext.Scheduled(source.CronSchedule!);
var context = TriggerContext.Scheduled(source.CronSchedule!, guidProvider: _guidProvider);
await DispatchAsync(source.SourceId, context, ct);
processed++;
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Triggers;
@@ -24,45 +25,73 @@ public sealed record TriggerContext
public Dictionary<string, string> Metadata { get; init; } = [];
/// <summary>Creates a context for a manual trigger.</summary>
public static TriggerContext Manual(string triggeredBy, string? correlationId = null) => new()
public static TriggerContext Manual(
string triggeredBy,
string? correlationId = null,
IGuidProvider? guidProvider = null)
{
Trigger = SbomSourceRunTrigger.Manual,
TriggerDetails = $"Triggered by {triggeredBy}",
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
Metadata = new() { ["triggeredBy"] = triggeredBy }
};
var provider = guidProvider ?? SystemGuidProvider.Instance;
return new TriggerContext
{
Trigger = SbomSourceRunTrigger.Manual,
TriggerDetails = $"Triggered by {triggeredBy}",
CorrelationId = correlationId ?? provider.NewGuid().ToString("N"),
Metadata = new() { ["triggeredBy"] = triggeredBy }
};
}
/// <summary>Creates a context for a scheduled trigger.</summary>
public static TriggerContext Scheduled(string cronExpression, string? correlationId = null) => new()
public static TriggerContext Scheduled(
string cronExpression,
string? correlationId = null,
IGuidProvider? guidProvider = null)
{
Trigger = SbomSourceRunTrigger.Scheduled,
TriggerDetails = $"Cron: {cronExpression}",
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N")
};
var provider = guidProvider ?? SystemGuidProvider.Instance;
return new TriggerContext
{
Trigger = SbomSourceRunTrigger.Scheduled,
TriggerDetails = $"Cron: {cronExpression}",
CorrelationId = correlationId ?? provider.NewGuid().ToString("N")
};
}
/// <summary>Creates a context for a webhook trigger.</summary>
public static TriggerContext Webhook(
string eventDetails,
JsonDocument payload,
string? correlationId = null) => new()
string? correlationId = null,
IGuidProvider? guidProvider = null)
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = eventDetails,
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
WebhookPayload = payload
};
var provider = guidProvider ?? SystemGuidProvider.Instance;
return new TriggerContext
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = eventDetails,
CorrelationId = correlationId ?? provider.NewGuid().ToString("N"),
WebhookPayload = payload
};
}
/// <summary>Creates a context for a push event trigger (registry/git push via webhook).</summary>
public static TriggerContext Push(
string eventDetails,
JsonDocument payload,
string? correlationId = null) => new()
string? correlationId = null,
IGuidProvider? guidProvider = null)
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = $"Push: {eventDetails}",
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
WebhookPayload = payload
};
var provider = guidProvider ?? SystemGuidProvider.Instance;
return new TriggerContext
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = $"Push: {eventDetails}",
CorrelationId = correlationId ?? provider.NewGuid().ToString("N"),
WebhookPayload = payload
};
}
}
/// <summary>