save progress

This commit is contained in:
StellaOps Bot
2026-01-04 14:54:52 +02:00
parent c49b03a254
commit 3098e84de4
132 changed files with 19783 additions and 31 deletions

View File

@@ -25,6 +25,12 @@ public sealed class ScannerWorkerMetrics
private readonly Counter<long> _surfacePayloadPersisted;
private readonly Histogram<double> _surfaceManifestPublishDurationMs;
// Secrets analysis metrics (Sprint: SPRINT_20251229_046_BE)
private readonly Counter<long> _secretsAnalysisCompleted;
private readonly Counter<long> _secretsAnalysisFailed;
private readonly Counter<long> _secretFindingsDetected;
private readonly Histogram<double> _secretsAnalysisDurationMs;
public ScannerWorkerMetrics()
{
_queueLatencyMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
@@ -80,6 +86,21 @@ public sealed class ScannerWorkerMetrics
"scanner_worker_surface_manifest_publish_duration_ms",
unit: "ms",
description: "Duration in milliseconds to persist and publish surface manifests.");
// Secrets analysis metrics (Sprint: SPRINT_20251229_046_BE)
_secretsAnalysisCompleted = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
"scanner_worker_secrets_analysis_completed_total",
description: "Number of successfully completed secrets analysis runs.");
_secretsAnalysisFailed = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
"scanner_worker_secrets_analysis_failed_total",
description: "Number of secrets analysis runs that failed.");
_secretFindingsDetected = ScannerWorkerInstrumentation.Meter.CreateCounter<long>(
"scanner_worker_secrets_findings_detected_total",
description: "Number of secret findings detected.");
_secretsAnalysisDurationMs = ScannerWorkerInstrumentation.Meter.CreateHistogram<double>(
"scanner_worker_secrets_analysis_duration_ms",
unit: "ms",
description: "Duration in milliseconds for secrets analysis.");
}
public void RecordQueueLatency(ScanJobContext context, TimeSpan latency)
@@ -343,4 +364,39 @@ public sealed class ScannerWorkerMetrics
// Native analysis metrics are tracked via counters/histograms
// This is a placeholder for when we add dedicated native analysis metrics
}
/// <summary>
/// Records successful secrets analysis completion.
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
/// </summary>
public void RecordSecretsAnalysisCompleted(
ScanJobContext context,
int findingCount,
int filesScanned,
TimeSpan duration,
TimeProvider timeProvider)
{
var tags = CreateTags(context, stage: ScanStageNames.ScanSecrets);
_secretsAnalysisCompleted.Add(1, tags);
if (findingCount > 0)
{
_secretFindingsDetected.Add(findingCount, tags);
}
if (duration > TimeSpan.Zero)
{
_secretsAnalysisDurationMs.Record(duration.TotalMilliseconds, tags);
}
}
/// <summary>
/// Records secrets analysis failure.
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
/// </summary>
public void RecordSecretsAnalysisFailed(ScanJobContext context, TimeProvider timeProvider)
{
var tags = CreateTags(context, stage: ScanStageNames.ScanSecrets);
_secretsAnalysisFailed.Add(1, tags);
}
}

View File

@@ -38,6 +38,12 @@ public sealed class ScannerWorkerOptions
public VerdictPushOptions VerdictPush { get; } = new();
/// <summary>
/// Options for secrets leak detection scanning.
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
/// </summary>
public SecretsOptions Secrets { get; } = new();
public sealed class QueueOptions
{
public int MaxAttempts { get; set; } = 5;
@@ -311,4 +317,43 @@ public sealed class ScannerWorkerOptions
/// </summary>
public bool AllowAnonymousFallback { get; set; } = true;
}
/// <summary>
/// Options for secrets leak detection scanning.
/// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
/// </summary>
public sealed class SecretsOptions
{
/// <summary>
/// Enable secrets leak detection scanning.
/// When disabled, the secrets scan stage will be skipped.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Path to the secrets ruleset bundle directory.
/// </summary>
public string RulesetPath { get; set; } = string.Empty;
/// <summary>
/// Maximum file size in bytes to scan for secrets.
/// Files larger than this will be skipped.
/// </summary>
public long MaxFileSizeBytes { get; set; } = 5 * 1024 * 1024; // 5 MB
/// <summary>
/// Maximum number of files to scan per job.
/// </summary>
public int MaxFilesPerJob { get; set; } = 10_000;
/// <summary>
/// Enable entropy-based secret detection.
/// </summary>
public bool EnableEntropyDetection { get; set; } = true;
/// <summary>
/// Minimum entropy threshold for high-entropy string detection.
/// </summary>
public double EntropyThreshold { get; set; } = 4.5;
}
}

View File

@@ -23,6 +23,9 @@ public static class ScanStageNames
// Sprint: SPRINT_20251226_014_BINIDX - Binary Vulnerability Lookup
public const string BinaryLookup = "binary-lookup";
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
public const string ScanSecrets = "scan-secrets";
public static readonly IReadOnlyList<string> Ordered = new[]
{
IngestReplay,
@@ -30,6 +33,7 @@ public static class ScanStageNames
PullLayers,
BuildFilesystem,
ExecuteAnalyzers,
ScanSecrets,
BinaryLookup,
EpssEnrichment,
ComposeArtifacts,

View File

@@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Analyzers.Secrets;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Processing.Secrets;
/// <summary>
/// Stage executor that scans filesystem for hardcoded secrets and credentials.
/// </summary>
internal sealed class SecretsAnalyzerStageExecutor : IScanStageExecutor
{
private static readonly string[] RootFsMetadataKeys =
{
"filesystem.rootfs",
"rootfs.path",
"scanner.rootfs",
};
private readonly ISecretsAnalyzer _secretsAnalyzer;
private readonly ScannerWorkerMetrics _metrics;
private readonly TimeProvider _timeProvider;
private readonly IOptions<ScannerWorkerOptions> _options;
private readonly ILogger<SecretsAnalyzerStageExecutor> _logger;
public SecretsAnalyzerStageExecutor(
ISecretsAnalyzer secretsAnalyzer,
ScannerWorkerMetrics metrics,
TimeProvider timeProvider,
IOptions<ScannerWorkerOptions> options,
ILogger<SecretsAnalyzerStageExecutor> logger)
{
_secretsAnalyzer = secretsAnalyzer ?? throw new ArgumentNullException(nameof(secretsAnalyzer));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.ScanSecrets;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var secretsOptions = _options.Value.Secrets;
if (!secretsOptions.Enabled)
{
_logger.LogDebug("Secrets scanning is disabled; skipping stage.");
return;
}
// Get file entries from analyzer stage
if (!context.Analysis.TryGet<IReadOnlyList<ScanFileEntry>>(ScanAnalysisKeys.FileEntries, out var files) || files is null)
{
_logger.LogDebug("No file entries available; skipping secrets scan.");
return;
}
var rootfsPath = ResolveRootfsPath(context.Lease.Metadata);
if (string.IsNullOrWhiteSpace(rootfsPath))
{
_logger.LogWarning("No rootfs path found in job metadata; skipping secrets scan for job {JobId}.", context.JobId);
return;
}
var startTime = _timeProvider.GetTimestamp();
var allFindings = new List<SecretFinding>();
try
{
// Filter to text-like files only
var textFiles = files
.Where(f => ShouldScanFile(f))
.ToList();
_logger.LogInformation(
"Scanning {FileCount} files for secrets in job {JobId}.",
textFiles.Count,
context.JobId);
foreach (var file in textFiles)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var filePath = Path.Combine(rootfsPath, file.Path.TrimStart('/'));
if (!File.Exists(filePath))
{
continue;
}
var content = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
if (content.Length == 0 || content.Length > secretsOptions.MaxFileSizeBytes)
{
continue;
}
var findings = await _secretsAnalyzer.AnalyzeAsync(
content,
file.Path,
cancellationToken).ConfigureAwait(false);
if (findings.Count > 0)
{
allFindings.AddRange(findings);
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogDebug(ex, "Error scanning file {Path} for secrets: {Message}", file.Path, ex.Message);
}
}
var elapsed = _timeProvider.GetElapsedTime(startTime);
// Store findings in analysis store
var report = new SecretsAnalysisReport
{
JobId = context.JobId,
ScanId = context.ScanId,
Findings = allFindings.ToImmutableArray(),
FilesScanned = textFiles.Count,
RulesetVersion = _secretsAnalyzer.RulesetVersion,
AnalyzedAtUtc = _timeProvider.GetUtcNow(),
ElapsedMilliseconds = elapsed.TotalMilliseconds
};
context.Analysis.Set(ScanAnalysisKeys.SecretFindings, report);
context.Analysis.Set(ScanAnalysisKeys.SecretRulesetVersion, _secretsAnalyzer.RulesetVersion);
_metrics.RecordSecretsAnalysisCompleted(
context,
allFindings.Count,
textFiles.Count,
elapsed,
_timeProvider);
_logger.LogInformation(
"Secrets scan completed for job {JobId}: {FindingCount} findings in {FileCount} files ({ElapsedMs:F0}ms).",
context.JobId,
allFindings.Count,
textFiles.Count,
elapsed.TotalMilliseconds);
}
catch (OperationCanceledException)
{
_logger.LogDebug("Secrets scan cancelled for job {JobId}.", context.JobId);
throw;
}
catch (Exception ex)
{
_metrics.RecordSecretsAnalysisFailed(context, _timeProvider);
_logger.LogError(ex, "Secrets scan failed for job {JobId}: {Message}", context.JobId, ex.Message);
}
}
private static bool ShouldScanFile(ScanFileEntry file)
{
if (file is null || file.SizeBytes == 0)
{
return false;
}
// Skip binary files
if (file.Kind is "elf" or "pe" or "mach-o" or "blob")
{
return false;
}
// Skip very large files
if (file.SizeBytes > 10 * 1024 * 1024)
{
return false;
}
var ext = Path.GetExtension(file.Path).ToLowerInvariant();
// Include common text/config file extensions
return ext is ".json" or ".yaml" or ".yml" or ".xml" or ".properties" or ".conf" or ".config"
or ".env" or ".ini" or ".toml" or ".cfg"
or ".js" or ".ts" or ".jsx" or ".tsx" or ".mjs" or ".cjs"
or ".py" or ".rb" or ".php" or ".go" or ".java" or ".cs" or ".rs" or ".swift" or ".kt"
or ".sh" or ".bash" or ".zsh" or ".ps1" or ".bat" or ".cmd"
or ".sql" or ".graphql" or ".gql"
or ".tf" or ".tfvars" or ".hcl"
or ".dockerfile" or ".dockerignore"
or ".gitignore" or ".npmrc" or ".yarnrc" or ".pypirc"
or ".pem" or ".key" or ".crt" or ".cer"
or ".md" or ".txt" or ".log"
|| string.IsNullOrEmpty(ext);
}
private static string? ResolveRootfsPath(IReadOnlyDictionary<string, string> metadata)
{
if (metadata is null)
{
return null;
}
foreach (var key in RootFsMetadataKeys)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
}
/// <summary>
/// Report of secrets analysis for a scan job.
/// </summary>
public sealed record SecretsAnalysisReport
{
public required string JobId { get; init; }
public required string ScanId { get; init; }
public required ImmutableArray<SecretFinding> Findings { get; init; }
public required int FilesScanned { get; init; }
public required string RulesetVersion { get; init; }
public required DateTimeOffset AnalyzedAtUtc { get; init; }
public required double ElapsedMilliseconds { get; init; }
}

View File

@@ -26,7 +26,9 @@ using StellaOps.Scanner.Worker.Hosting;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Processing.Entropy;
using StellaOps.Scanner.Worker.Processing.Secrets;
using StellaOps.Scanner.Worker.Determinism;
using StellaOps.Scanner.Analyzers.Secrets;
using StellaOps.Scanner.Worker.Extensions;
using StellaOps.Scanner.Worker.Processing.Surface;
using StellaOps.Scanner.Storage.Extensions;
@@ -167,6 +169,18 @@ builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityBuild
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityPublishStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, EntropyStageExecutor>();
// Secrets Leak Detection (Sprint: SPRINT_20251229_046_BE)
if (workerOptions.Secrets.Enabled)
{
builder.Services.AddSecretsAnalyzer(options =>
{
options.RulesetPath = workerOptions.Secrets.RulesetPath;
options.EnableEntropyDetection = workerOptions.Secrets.EnableEntropyDetection;
options.EntropyThreshold = workerOptions.Secrets.EntropyThreshold;
});
builder.Services.AddSingleton<IScanStageExecutor, SecretsAnalyzerStageExecutor>();
}
// Proof of Exposure (Sprint: SPRINT_3500_0001_0001_proof_of_exposure_mvp)
builder.Services.AddOptions<StellaOps.Scanner.Core.Configuration.PoEConfiguration>()
.BindConfiguration("PoE")

View File

@@ -27,6 +27,7 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Emit/StellaOps.Scanner.Emit.csproj" />