finish secrets finding work and audit remarks work save

This commit is contained in:
StellaOps Bot
2026-01-04 21:48:13 +02:00
parent 75611a505f
commit 8862e112c4
157 changed files with 11702 additions and 416 deletions

View File

@@ -15,6 +15,7 @@ namespace StellaOps.Scanner.Sources.ConnectionTesters;
public sealed class CliConnectionTester : ISourceTypeConnectionTester
{
private readonly ICredentialResolver _credentialResolver;
private readonly TimeProvider _timeProvider;
private readonly ILogger<CliConnectionTester> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
@@ -27,10 +28,12 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester
public CliConnectionTester(
ICredentialResolver credentialResolver,
ILogger<CliConnectionTester> logger)
ILogger<CliConnectionTester> logger,
TimeProvider? timeProvider = null)
{
_credentialResolver = credentialResolver;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ConnectionTestResult> TestAsync(
@@ -45,7 +48,7 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester
{
Success = false,
Message = "Invalid configuration format",
TestedAt = DateTimeOffset.UtcNow
TestedAt = _timeProvider.GetUtcNow()
};
}
@@ -103,7 +106,7 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester
{
Success = false,
Message = $"Configuration issues: {string.Join("; ", validationIssues)}",
TestedAt = DateTimeOffset.UtcNow,
TestedAt = _timeProvider.GetUtcNow(),
Details = details
};
}
@@ -112,7 +115,7 @@ public sealed class CliConnectionTester : ISourceTypeConnectionTester
{
Success = true,
Message = "CLI source configuration is valid - ready to receive SBOMs",
TestedAt = DateTimeOffset.UtcNow,
TestedAt = _timeProvider.GetUtcNow(),
Details = details
};
}

View File

@@ -298,23 +298,23 @@ public sealed record ConnectionTestResult
public required bool Success { get; init; }
public string? Message { get; init; }
public string? ErrorCode { get; init; }
public DateTimeOffset TestedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset TestedAt { get; init; }
public List<ConnectionTestCheck> Checks { get; init; } = [];
public Dictionary<string, object>? Details { get; init; }
public static ConnectionTestResult Succeeded(string? message = null) => new()
public static ConnectionTestResult Succeeded(TimeProvider timeProvider, string? message = null) => new()
{
Success = true,
Message = message ?? "Connection successful",
TestedAt = DateTimeOffset.UtcNow
TestedAt = timeProvider.GetUtcNow()
};
public static ConnectionTestResult Failed(string message, string? errorCode = null) => new()
public static ConnectionTestResult Failed(TimeProvider timeProvider, string message, string? errorCode = null) => new()
{
Success = false,
Message = message,
ErrorCode = errorCode,
TestedAt = DateTimeOffset.UtcNow
TestedAt = timeProvider.GetUtcNow()
};
}

View File

@@ -2,6 +2,8 @@ using System.Text.Json;
namespace StellaOps.Scanner.Sources.Domain;
#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary
/// <summary>
/// Represents a configured SBOM ingestion source.
/// Sources can be registry webhooks (Zastava), direct Docker image scans,
@@ -115,12 +117,13 @@ public sealed class SbomSource
SbomSourceType sourceType,
JsonDocument configuration,
string createdBy,
TimeProvider timeProvider,
string? description = null,
string? authRef = null,
string? cronSchedule = null,
string? cronTimezone = null)
{
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var source = new SbomSource
{
SourceId = Guid.NewGuid(),
@@ -148,7 +151,7 @@ public sealed class SbomSource
// Calculate next scheduled run
if (!string.IsNullOrEmpty(cronSchedule))
{
source.CalculateNextScheduledRun();
source.CalculateNextScheduledRun(timeProvider);
}
return source;
@@ -161,37 +164,38 @@ public sealed class SbomSource
/// <summary>
/// Activate the source (after successful validation).
/// </summary>
public void Activate(string updatedBy)
public void Activate(string updatedBy, TimeProvider timeProvider)
{
if (Status == SbomSourceStatus.Disabled)
throw new InvalidOperationException("Cannot activate a disabled source. Enable it first.");
Status = SbomSourceStatus.Active;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = updatedBy;
}
/// <summary>
/// Pause the source with a reason.
/// </summary>
public void Pause(string reason, string? ticket, string pausedBy)
public void Pause(string reason, string? ticket, string pausedBy, TimeProvider timeProvider)
{
if (Paused) return;
var now = timeProvider.GetUtcNow();
Paused = true;
PauseReason = reason;
PauseTicket = ticket;
PausedAt = DateTimeOffset.UtcNow;
PausedAt = now;
PausedBy = pausedBy;
Status = SbomSourceStatus.Paused;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = now;
UpdatedBy = pausedBy;
}
/// <summary>
/// Resume a paused source.
/// </summary>
public void Resume(string resumedBy)
public void Resume(string resumedBy, TimeProvider timeProvider)
{
if (!Paused) return;
@@ -201,30 +205,30 @@ public sealed class SbomSource
PausedAt = null;
PausedBy = null;
Status = ConsecutiveFailures > 0 ? SbomSourceStatus.Error : SbomSourceStatus.Active;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = resumedBy;
}
/// <summary>
/// Disable the source administratively.
/// </summary>
public void Disable(string disabledBy)
public void Disable(string disabledBy, TimeProvider timeProvider)
{
Status = SbomSourceStatus.Disabled;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = disabledBy;
}
/// <summary>
/// Enable a disabled source.
/// </summary>
public void Enable(string enabledBy)
public void Enable(string enabledBy, TimeProvider timeProvider)
{
if (Status != SbomSourceStatus.Disabled)
throw new InvalidOperationException("Source is not disabled.");
Status = SbomSourceStatus.Pending;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = enabledBy;
}
@@ -235,7 +239,7 @@ public sealed class SbomSource
/// <summary>
/// Record a successful run.
/// </summary>
public void RecordSuccessfulRun(DateTimeOffset runAt)
public void RecordSuccessfulRun(DateTimeOffset runAt, TimeProvider timeProvider)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.Succeeded;
@@ -247,14 +251,14 @@ public sealed class SbomSource
Status = SbomSourceStatus.Active;
}
IncrementHourScans();
CalculateNextScheduledRun();
IncrementHourScans(timeProvider);
CalculateNextScheduledRun(timeProvider);
}
/// <summary>
/// Record a failed run.
/// </summary>
public void RecordFailedRun(DateTimeOffset runAt, string error)
public void RecordFailedRun(DateTimeOffset runAt, string error, TimeProvider timeProvider)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.Failed;
@@ -266,22 +270,22 @@ public sealed class SbomSource
Status = SbomSourceStatus.Error;
}
IncrementHourScans();
CalculateNextScheduledRun();
IncrementHourScans(timeProvider);
CalculateNextScheduledRun(timeProvider);
}
/// <summary>
/// Record a partial success run.
/// </summary>
public void RecordPartialRun(DateTimeOffset runAt, string? warning = null)
public void RecordPartialRun(DateTimeOffset runAt, TimeProvider timeProvider, string? warning = null)
{
LastRunAt = runAt;
LastRunStatus = SbomSourceRunStatus.PartialSuccess;
LastRunError = warning;
// Don't reset consecutive failures for partial success
IncrementHourScans();
CalculateNextScheduledRun();
IncrementHourScans(timeProvider);
CalculateNextScheduledRun(timeProvider);
}
// -------------------------------------------------------------------------
@@ -291,12 +295,12 @@ public sealed class SbomSource
/// <summary>
/// Check if the source is rate limited.
/// </summary>
public bool IsRateLimited()
public bool IsRateLimited(TimeProvider timeProvider)
{
if (!MaxScansPerHour.HasValue) return false;
// Check if we're in a new hour window
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1))
{
return false; // New window, not rate limited
@@ -305,9 +309,9 @@ public sealed class SbomSource
return CurrentHourScans >= MaxScansPerHour.Value;
}
private void IncrementHourScans()
private void IncrementHourScans(TimeProvider timeProvider)
{
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
if (!HourWindowStart.HasValue || now - HourWindowStart.Value >= TimeSpan.FromHours(1))
{
@@ -343,14 +347,14 @@ public sealed class SbomSource
/// <summary>
/// Regenerate webhook secret (for rotation).
/// </summary>
public void RotateWebhookSecret(string updatedBy)
public void RotateWebhookSecret(string updatedBy, TimeProvider timeProvider)
{
if (WebhookEndpoint == null)
throw new InvalidOperationException("Source does not have a webhook endpoint.");
// The actual secret rotation happens in the credential store
// This just updates the audit trail
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = updatedBy;
}
@@ -361,7 +365,7 @@ public sealed class SbomSource
/// <summary>
/// Calculate the next scheduled run time.
/// </summary>
public void CalculateNextScheduledRun()
public void CalculateNextScheduledRun(TimeProvider timeProvider)
{
if (string.IsNullOrEmpty(CronSchedule))
{
@@ -373,7 +377,7 @@ public sealed class SbomSource
{
var cron = Cronos.CronExpression.Parse(CronSchedule);
var timezone = TimeZoneInfo.FindSystemTimeZoneById(CronTimezone ?? "UTC");
NextScheduledRun = cron.GetNextOccurrence(DateTimeOffset.UtcNow, timezone);
NextScheduledRun = cron.GetNextOccurrence(timeProvider.GetUtcNow(), timezone);
}
catch
{
@@ -397,10 +401,10 @@ public sealed class SbomSource
/// <summary>
/// Update the configuration.
/// </summary>
public void UpdateConfiguration(JsonDocument newConfiguration, string updatedBy)
public void UpdateConfiguration(JsonDocument newConfiguration, string updatedBy, TimeProvider timeProvider)
{
Configuration = newConfiguration;
UpdatedAt = DateTimeOffset.UtcNow;
UpdatedAt = timeProvider.GetUtcNow();
UpdatedBy = updatedBy;
}
}

View File

@@ -1,5 +1,7 @@
namespace StellaOps.Scanner.Sources.Domain;
#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary
/// <summary>
/// Represents a single execution run of an SBOM source.
/// Tracks status, timing, item counts, and any errors.
@@ -30,10 +32,17 @@ public sealed class SbomSourceRun
/// <summary>When the run completed (if finished).</summary>
public DateTimeOffset? CompletedAt { get; private set; }
/// <summary>Duration in milliseconds.</summary>
public long DurationMs => CompletedAt.HasValue
? (long)(CompletedAt.Value - StartedAt).TotalMilliseconds
: (long)(DateTimeOffset.UtcNow - StartedAt).TotalMilliseconds;
/// <summary>
/// Duration in milliseconds. Pass a TimeProvider to get the live duration for in-progress runs.
/// </summary>
public long GetDurationMs(TimeProvider? timeProvider = null)
{
if (CompletedAt.HasValue)
return (long)(CompletedAt.Value - StartedAt).TotalMilliseconds;
var now = timeProvider?.GetUtcNow() ?? DateTimeOffset.UtcNow;
return (long)(now - StartedAt).TotalMilliseconds;
}
/// <summary>Number of items discovered to scan.</summary>
public int ItemsDiscovered { get; private set; }
@@ -74,6 +83,7 @@ public sealed class SbomSourceRun
string tenantId,
SbomSourceRunTrigger trigger,
string correlationId,
TimeProvider timeProvider,
string? triggerDetails = null)
{
return new SbomSourceRun
@@ -84,7 +94,7 @@ public sealed class SbomSourceRun
Trigger = trigger,
TriggerDetails = triggerDetails,
Status = SbomSourceRunStatus.Running,
StartedAt = DateTimeOffset.UtcNow,
StartedAt = timeProvider.GetUtcNow(),
CorrelationId = correlationId
};
}
@@ -135,7 +145,7 @@ public sealed class SbomSourceRun
/// <summary>
/// Complete the run successfully.
/// </summary>
public void Complete()
public void Complete(TimeProvider timeProvider)
{
Status = ItemsFailed > 0
? SbomSourceRunStatus.PartialSuccess
@@ -143,27 +153,27 @@ public sealed class SbomSourceRun
? SbomSourceRunStatus.Succeeded
: SbomSourceRunStatus.Skipped;
CompletedAt = DateTimeOffset.UtcNow;
CompletedAt = timeProvider.GetUtcNow();
}
/// <summary>
/// Fail the run with an error.
/// </summary>
public void Fail(string message, string? stackTrace = null)
public void Fail(string message, TimeProvider timeProvider, string? stackTrace = null)
{
Status = SbomSourceRunStatus.Failed;
ErrorMessage = message;
ErrorStackTrace = stackTrace;
CompletedAt = DateTimeOffset.UtcNow;
CompletedAt = timeProvider.GetUtcNow();
}
/// <summary>
/// Cancel the run.
/// </summary>
public void Cancel(string reason)
public void Cancel(string reason, TimeProvider timeProvider)
{
Status = SbomSourceRunStatus.Cancelled;
ErrorMessage = reason;
CompletedAt = DateTimeOffset.UtcNow;
CompletedAt = timeProvider.GetUtcNow();
}
}

View File

@@ -106,7 +106,7 @@ public sealed record WebhookPayloadInfo
public string? Actor { get; init; }
/// <summary>Timestamp of the event.</summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset Timestamp { get; init; }
/// <summary>Additional metadata from the payload.</summary>
public Dictionary<string, string> Metadata { get; init; } = [];

View File

@@ -101,6 +101,7 @@ public sealed class SbomSourceService : ISbomSourceService
request.SourceType,
request.Configuration,
createdBy,
_timeProvider,
request.Description,
request.AuthRef,
request.CronSchedule,
@@ -158,7 +159,7 @@ public sealed class SbomSourceService : ISbomSourceService
throw new ArgumentException($"Invalid configuration: {string.Join(", ", validationResult.Errors)}");
}
source.UpdateConfiguration(request.Configuration, updatedBy);
source.UpdateConfiguration(request.Configuration, updatedBy, _timeProvider);
}
// Validate cron schedule if provided
@@ -177,7 +178,7 @@ public sealed class SbomSourceService : ISbomSourceService
}
source.CronSchedule = request.CronSchedule;
source.CalculateNextScheduledRun();
source.CalculateNextScheduledRun(_timeProvider);
}
// Update simple fields via reflection (maintaining encapsulation)
@@ -199,7 +200,7 @@ public sealed class SbomSourceService : ISbomSourceService
if (request.CronTimezone != null)
{
source.CronTimezone = request.CronTimezone;
source.CalculateNextScheduledRun();
source.CalculateNextScheduledRun(_timeProvider);
}
if (request.MaxScansPerHour.HasValue)
@@ -265,6 +266,7 @@ public sealed class SbomSourceService : ISbomSourceService
request.SourceType,
request.Configuration,
"__test__",
_timeProvider,
authRef: request.AuthRef);
return await _connectionTester.TestAsync(tempSource, request.TestCredentials, ct);
@@ -280,7 +282,7 @@ public sealed class SbomSourceService : ISbomSourceService
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Pause(request.Reason, request.Ticket, pausedBy);
source.Pause(request.Reason, request.Ticket, pausedBy, _timeProvider);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
@@ -299,7 +301,7 @@ public sealed class SbomSourceService : ISbomSourceService
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Resume(resumedBy);
source.Resume(resumedBy, _timeProvider);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(
@@ -330,7 +332,7 @@ public sealed class SbomSourceService : ISbomSourceService
throw new InvalidOperationException($"Source is paused: {source.PauseReason}");
}
if (source.IsRateLimited() && request?.Force != true)
if (source.IsRateLimited(_timeProvider) && request?.Force != true)
{
throw new InvalidOperationException("Source is rate limited. Use force=true to override.");
}
@@ -341,6 +343,7 @@ public sealed class SbomSourceService : ISbomSourceService
tenantId,
SbomSourceRunTrigger.Manual,
Guid.NewGuid().ToString("N"),
_timeProvider,
$"Triggered by {triggeredBy}");
await _runRepository.CreateAsync(run, ct);
@@ -407,7 +410,7 @@ public sealed class SbomSourceService : ISbomSourceService
var source = await _sourceRepository.GetByIdAsync(tenantId, sourceId, ct)
?? throw new KeyNotFoundException($"Source {sourceId} not found");
source.Activate(activatedBy);
source.Activate(activatedBy, _timeProvider);
await _sourceRepository.UpdateAsync(source, ct);
_logger.LogInformation(