audit, advisories and doctors/setup work
This commit is contained in:
@@ -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"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user