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

@@ -0,0 +1,452 @@
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.Analyzers.Native;
public sealed class ElfSectionHashExtractor : IElfSectionHashExtractor
{
private static readonly byte[] ElfMagic = [0x7F, 0x45, 0x4C, 0x46];
private readonly TimeProvider _timeProvider;
private readonly ElfSectionHashOptions _options;
private readonly ICryptoHash? _cryptoHash;
private readonly HashSet<string> _sectionAllowList;
private readonly bool _includeBlake3;
public ElfSectionHashExtractor(
TimeProvider timeProvider,
IOptions<ElfSectionHashOptions> options,
ICryptoHash? cryptoHash = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_cryptoHash = cryptoHash;
_sectionAllowList = _options.Sections
.Where(section => !string.IsNullOrWhiteSpace(section))
.Select(section => section.Trim())
.ToHashSet(StringComparer.Ordinal);
_includeBlake3 = _options.Algorithms.Any(alg =>
string.Equals(alg?.Trim(), "blake3", StringComparison.OrdinalIgnoreCase));
}
public async Task<ElfSectionHashSet?> ExtractAsync(
string elfPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(elfPath);
if (!_options.Enabled)
{
return null;
}
var bytes = await File.ReadAllBytesAsync(elfPath, cancellationToken).ConfigureAwait(false);
return await ExtractFromBytesAsync(bytes, elfPath, cancellationToken).ConfigureAwait(false);
}
public Task<ElfSectionHashSet?> ExtractFromBytesAsync(
ReadOnlyMemory<byte> elfBytes,
string virtualPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(virtualPath);
if (!_options.Enabled)
{
return Task.FromResult<ElfSectionHashSet?>(null);
}
var data = elfBytes.Span;
if (data.Length < 16 || !data[..4].SequenceEqual(ElfMagic))
{
return Task.FromResult<ElfSectionHashSet?>(null);
}
cancellationToken.ThrowIfCancellationRequested();
if (!TryReadHeader(data, out var header))
{
return Task.FromResult<ElfSectionHashSet?>(null);
}
var sections = ParseSections(data, header, cancellationToken);
if (sections.Count == 0)
{
return Task.FromResult<ElfSectionHashSet?>(null);
}
var sectionHashes = BuildSectionHashes(data, sections, cancellationToken);
var fileHash = ComputeSha256Hex(data);
var buildId = TryExtractBuildId(data, sections, header.IsLittleEndian);
var version = typeof(ElfSectionHashExtractor).Assembly.GetName().Version?.ToString() ?? "unknown";
var result = new ElfSectionHashSet
{
FilePath = virtualPath,
FileHash = fileHash,
BuildId = buildId,
Sections = sectionHashes,
ExtractedAt = _timeProvider.GetUtcNow(),
ExtractorVersion = version
};
return Task.FromResult<ElfSectionHashSet?>(result);
}
private ImmutableArray<ElfSectionHash> BuildSectionHashes(
ReadOnlySpan<byte> data,
List<ElfSectionHeader> sections,
CancellationToken cancellationToken)
{
var builder = new List<ElfSectionHash>(sections.Count);
foreach (var section in sections)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_sectionAllowList.Contains(section.Name))
{
continue;
}
if (section.Size < 0 || section.Size > _options.MaxSectionSizeBytes)
{
continue;
}
if (section.Offset < 0 || section.Offset > data.Length)
{
continue;
}
if (section.Size > 0 && section.Offset + section.Size > data.Length)
{
continue;
}
var sectionData = section.Size == 0
? ReadOnlySpan<byte>.Empty
: data.Slice((int)section.Offset, (int)section.Size);
var sha256 = ComputeSha256Hex(sectionData);
var blake3 = ComputeBlake3Hex(sectionData);
builder.Add(new ElfSectionHash
{
Name = section.Name,
Offset = section.Offset,
Size = section.Size,
Sha256 = sha256,
Blake3 = blake3,
SectionType = section.Type,
Flags = section.Flags
});
}
return builder
.OrderBy(section => section.Name, StringComparer.Ordinal)
.ToImmutableArray();
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> data)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(data, hash);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private string? ComputeBlake3Hex(ReadOnlySpan<byte> data)
{
if (!_includeBlake3 || _cryptoHash is null)
{
return null;
}
try
{
return _cryptoHash.ComputeHashHex(data, HashAlgorithms.Blake3_256);
}
catch (InvalidOperationException)
{
return null;
}
}
private static List<ElfSectionHeader> ParseSections(
ReadOnlySpan<byte> data,
ElfHeaderInfo header,
CancellationToken cancellationToken)
{
var sections = new List<ElfSectionHeader>(header.SectionHeaderCount);
var entrySize = header.SectionHeaderEntrySize;
var totalBytes = header.SectionHeaderOffset + (long)entrySize * header.SectionHeaderCount;
if (header.SectionHeaderOffset < 0 || totalBytes > data.Length)
{
return sections;
}
var stringTable = ReadStringTable(data, header, entrySize);
for (var i = 0; i < header.SectionHeaderCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var offset = header.SectionHeaderOffset + (long)entrySize * i;
if (offset + entrySize > data.Length)
{
break;
}
var headerSpan = data.Slice((int)offset, entrySize);
var nameIndex = ReadUInt32(headerSpan.Slice(0, 4), header.IsLittleEndian);
var type = (ElfSectionType)ReadUInt32(headerSpan.Slice(4, 4), header.IsLittleEndian);
ulong flags;
ulong sectionOffset;
ulong sectionSize;
if (header.Is64Bit)
{
flags = ReadUInt64(headerSpan.Slice(8, 8), header.IsLittleEndian);
sectionOffset = ReadUInt64(headerSpan.Slice(24, 8), header.IsLittleEndian);
sectionSize = ReadUInt64(headerSpan.Slice(32, 8), header.IsLittleEndian);
}
else
{
flags = ReadUInt32(headerSpan.Slice(8, 4), header.IsLittleEndian);
sectionOffset = ReadUInt32(headerSpan.Slice(16, 4), header.IsLittleEndian);
sectionSize = ReadUInt32(headerSpan.Slice(20, 4), header.IsLittleEndian);
}
var name = ReadString(stringTable, nameIndex);
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
sections.Add(new ElfSectionHeader(
nameIndex,
name,
type,
(ElfSectionFlags)flags,
(long)sectionOffset,
(long)sectionSize));
}
return sections;
}
private static byte[] ReadStringTable(ReadOnlySpan<byte> data, ElfHeaderInfo header, int entrySize)
{
if (header.SectionNameStringTableIndex < 0 || header.SectionNameStringTableIndex >= header.SectionHeaderCount)
{
return Array.Empty<byte>();
}
var stringHeaderOffset = header.SectionHeaderOffset + (long)entrySize * header.SectionNameStringTableIndex;
if (stringHeaderOffset + entrySize > data.Length)
{
return Array.Empty<byte>();
}
var headerSpan = data.Slice((int)stringHeaderOffset, entrySize);
ulong offset;
ulong size;
if (header.Is64Bit)
{
offset = ReadUInt64(headerSpan.Slice(24, 8), header.IsLittleEndian);
size = ReadUInt64(headerSpan.Slice(32, 8), header.IsLittleEndian);
}
else
{
offset = ReadUInt32(headerSpan.Slice(16, 4), header.IsLittleEndian);
size = ReadUInt32(headerSpan.Slice(20, 4), header.IsLittleEndian);
}
if (offset + size > (ulong)data.Length)
{
return Array.Empty<byte>();
}
return data.Slice((int)offset, (int)size).ToArray();
}
private static string ReadString(byte[] table, uint offset)
{
if (table.Length == 0 || offset >= table.Length)
{
return string.Empty;
}
var end = Array.IndexOf(table, (byte)0, (int)offset);
if (end < 0)
{
end = table.Length;
}
return Encoding.UTF8.GetString(table, (int)offset, end - (int)offset);
}
private static bool TryReadHeader(ReadOnlySpan<byte> data, out ElfHeaderInfo header)
{
header = default;
if (data.Length < 52)
{
return false;
}
var is64Bit = data[4] == 2;
var isLittleEndian = data[5] == 1;
if (!is64Bit && data[4] != 1)
{
return false;
}
if (!isLittleEndian && data[5] != 2)
{
return false;
}
long shoff;
int shentsize;
int shnum;
int shstrndx;
if (is64Bit)
{
if (data.Length < 64)
{
return false;
}
shoff = (long)ReadUInt64(data.Slice(40, 8), isLittleEndian);
shentsize = ReadUInt16(data.Slice(58, 2), isLittleEndian);
shnum = ReadUInt16(data.Slice(60, 2), isLittleEndian);
shstrndx = ReadUInt16(data.Slice(62, 2), isLittleEndian);
}
else
{
shoff = ReadUInt32(data.Slice(32, 4), isLittleEndian);
shentsize = ReadUInt16(data.Slice(46, 2), isLittleEndian);
shnum = ReadUInt16(data.Slice(48, 2), isLittleEndian);
shstrndx = ReadUInt16(data.Slice(50, 2), isLittleEndian);
}
if (shoff <= 0 || shentsize <= 0 || shnum <= 0)
{
return false;
}
header = new ElfHeaderInfo(is64Bit, isLittleEndian, shoff, shentsize, shnum, shstrndx);
return true;
}
private static string? TryExtractBuildId(
ReadOnlySpan<byte> data,
List<ElfSectionHeader> sections,
bool isLittleEndian)
{
var noteSection = sections.FirstOrDefault(section =>
string.Equals(section.Name, ".note.gnu.build-id", StringComparison.Ordinal));
if (noteSection is null || noteSection.Size <= 0)
{
return null;
}
if (noteSection.Offset < 0 || noteSection.Offset + noteSection.Size > data.Length)
{
return null;
}
var noteSpan = data.Slice((int)noteSection.Offset, (int)noteSection.Size);
return ParseGnuNote(noteSpan, isLittleEndian);
}
private static string? ParseGnuNote(ReadOnlySpan<byte> note, bool isLittleEndian)
{
var offset = 0;
while (offset + 12 <= note.Length)
{
var namesz = ReadUInt32(note.Slice(offset, 4), isLittleEndian);
var descsz = ReadUInt32(note.Slice(offset + 4, 4), isLittleEndian);
var type = ReadUInt32(note.Slice(offset + 8, 4), isLittleEndian);
var nameStart = offset + 12;
var namePadded = AlignTo4(namesz);
var descStart = nameStart + namePadded;
var descPadded = AlignTo4(descsz);
var next = descStart + descPadded;
if (next > note.Length)
{
break;
}
if (type == 3 && namesz >= 3)
{
var nameSpan = note.Slice(nameStart, (int)namesz);
if (nameSpan.Length >= 3 && nameSpan[0] == (byte)'G' && nameSpan[1] == (byte)'N' && nameSpan[2] == (byte)'U')
{
var desc = note.Slice(descStart, (int)descsz);
var hex = Convert.ToHexString(desc).ToLowerInvariant();
return "gnu-build-id:" + hex;
}
}
offset = next;
}
return null;
}
private static int AlignTo4(uint value) => (int)((value + 3) & ~3u);
private static ushort ReadUInt16(ReadOnlySpan<byte> span, bool littleEndian)
{
return littleEndian
? BinaryPrimitives.ReadUInt16LittleEndian(span)
: BinaryPrimitives.ReadUInt16BigEndian(span);
}
private static uint ReadUInt32(ReadOnlySpan<byte> span, bool littleEndian)
{
return littleEndian
? BinaryPrimitives.ReadUInt32LittleEndian(span)
: BinaryPrimitives.ReadUInt32BigEndian(span);
}
private static ulong ReadUInt64(ReadOnlySpan<byte> span, bool littleEndian)
{
return littleEndian
? BinaryPrimitives.ReadUInt64LittleEndian(span)
: BinaryPrimitives.ReadUInt64BigEndian(span);
}
private readonly record struct ElfHeaderInfo(
bool Is64Bit,
bool IsLittleEndian,
long SectionHeaderOffset,
int SectionHeaderEntrySize,
int SectionHeaderCount,
int SectionNameStringTableIndex);
private sealed record ElfSectionHeader(
uint NameIndex,
string Name,
ElfSectionType Type,
ElfSectionFlags Flags,
long Offset,
long Size);
}

View File

@@ -0,0 +1,96 @@
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Analyzers.Native;
/// <summary>
/// Options for ELF section hash extraction.
/// </summary>
public sealed class ElfSectionHashOptions
{
/// <summary>
/// Whether section hashing is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Section names to include (e.g., .text, .rodata, .data).
/// </summary>
public IList<string> Sections { get; } = new List<string>
{
".text",
".rodata",
".data",
".symtab",
".dynsym"
};
/// <summary>
/// Hash algorithms to compute (sha256 required, blake3 optional).
/// </summary>
public IList<string> Algorithms { get; } = new List<string>
{
"sha256"
};
/// <summary>
/// Maximum section size to hash (bytes).
/// </summary>
public long MaxSectionSizeBytes { get; set; } = 100 * 1024 * 1024;
}
/// <summary>
/// Validates <see cref="ElfSectionHashOptions"/>.
/// </summary>
public sealed class ElfSectionHashOptionsValidator : IValidateOptions<ElfSectionHashOptions>
{
private static readonly HashSet<string> AllowedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
"sha256",
"blake3"
};
public ValidateOptionsResult Validate(string? name, ElfSectionHashOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (!options.Enabled)
{
return ValidateOptionsResult.Success;
}
if (options.MaxSectionSizeBytes <= 0)
{
return ValidateOptionsResult.Fail("MaxSectionSizeBytes must be greater than zero.");
}
var sections = options.Sections
.Where(section => !string.IsNullOrWhiteSpace(section))
.Select(section => section.Trim())
.ToArray();
if (sections.Length == 0)
{
return ValidateOptionsResult.Fail("At least one section name must be configured.");
}
if (options.Algorithms.Count == 0 ||
!options.Algorithms.Any(alg => string.Equals(alg?.Trim(), "sha256", StringComparison.OrdinalIgnoreCase)))
{
return ValidateOptionsResult.Fail("Algorithms must include sha256.");
}
var invalid = options.Algorithms
.Where(alg => !string.IsNullOrWhiteSpace(alg))
.Select(alg => alg.Trim())
.Where(alg => !AllowedAlgorithms.Contains(alg))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (invalid.Length > 0)
{
return ValidateOptionsResult.Fail($"Unsupported algorithms: {string.Join(", ", invalid)}");
}
return ValidateOptionsResult.Success;
}
}

View File

@@ -0,0 +1,24 @@
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.Analyzers.Native;
public interface IElfSectionHashExtractor
{
/// <summary>
/// Extracts section hashes from an ELF binary.
/// </summary>
/// <param name="elfPath">Path to the ELF file.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Section hash set, or null if not a valid ELF.</returns>
Task<ElfSectionHashSet?> ExtractAsync(
string elfPath,
CancellationToken cancellationToken = default);
/// <summary>
/// Extracts section hashes from ELF bytes in memory.
/// </summary>
Task<ElfSectionHashSet?> ExtractFromBytesAsync(
ReadOnlyMemory<byte> elfBytes,
string virtualPath,
CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Determinism;
using StellaOps.Scanner.Analyzers.Native.Plugin;
using StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
@@ -71,6 +72,16 @@ public static class ServiceCollectionExtensions
// Register core services
services.TryAddSingleton<INativeAnalyzerPluginCatalog, NativeAnalyzerPluginCatalog>();
services.TryAddSingleton<INativeAnalyzer, NativeAnalyzer>();
services.TryAddSingleton<IValidateOptions<ElfSectionHashOptions>, ElfSectionHashOptionsValidator>();
var sectionOptionsBuilder = services.AddOptions<ElfSectionHashOptions>();
if (configuration != null)
{
sectionOptionsBuilder.Bind(configuration.GetSection($"{ConfigSectionName}:SectionHashes"));
}
sectionOptionsBuilder.ValidateOnStart();
services.TryAddSingleton<IElfSectionHashExtractor, ElfSectionHashExtractor>();
return services;
}

View File

@@ -14,8 +14,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.Contracts\\StellaOps.Scanner.Contracts.csproj" />
<ProjectReference Include="..\\__Libraries\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Determinism.Abstractions\\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,15 @@
# Scanner Native Analyzer Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260113_001_001_SCANNER_elf_section_hashes.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| ELF-SECTION-MODELS-0001 | DONE | Define section hash models in Scanner.Contracts. |
| ELF-SECTION-EXTRACTOR-0001 | DONE | Implement extractor and integrate with ELF parsing. |
| ELF-SECTION-CONFIG-0001 | DONE | Add options and validation. |
| ELF-SECTION-EVIDENCE-0001 | DONE | Emit section hashes as SBOM properties. |
| ELF-SECTION-DI-0001 | DONE | Register extractor in DI. |
| ELF-SECTION-TESTS-0001 | DONE | Add unit tests for section hashing. |
| ELF-SECTION-FIXTURES-0001 | DONE | Add ELF fixtures with golden hashes. |
| ELF-SECTION-DETERMINISM-0001 | DONE | Add determinism regression test. |

View File

@@ -113,7 +113,7 @@ internal static class WebhookEndpoints
var payloadBytes = Encoding.UTF8.GetBytes(payloadString);
// Get the source
var source = await sourceRepository.GetByIdAsync(null!, sourceId, ct);
var source = await sourceRepository.GetByIdAnyTenantAsync(sourceId, ct);
if (source == null)
{
return ProblemResultFactory.Create(

View File

@@ -54,7 +54,7 @@ public sealed class PolicyFidelityCalculator
// Compare overall outcome
if (a.Passed != b.Passed)
differences.Add($"outcome:{a.Passed}{b.Passed}");
differences.Add($"outcome:{a.Passed}->{b.Passed}");
// Compare reason codes (order-independent)
var aReasons = a.ReasonCodes.OrderBy(r => r, StringComparer.Ordinal).ToList();
@@ -65,11 +65,11 @@ public sealed class PolicyFidelityCalculator
// Compare violation count
if (a.ViolationCount != b.ViolationCount)
differences.Add($"violations:{a.ViolationCount}{b.ViolationCount}");
differences.Add($"violations:{a.ViolationCount}->{b.ViolationCount}");
// Compare block level
if (!string.Equals(a.BlockLevel, b.BlockLevel, StringComparison.Ordinal))
differences.Add($"block_level:{a.BlockLevel}{b.BlockLevel}");
differences.Add($"block_level:{a.BlockLevel}->{b.BlockLevel}");
return (differences.Count == 0, differences);
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Security.Cryptography;
namespace StellaOps.Scanner.Worker.Determinism;
@@ -21,6 +22,13 @@ public sealed class DeterministicRandomProvider : IDeterministicRandomProvider
public Random Create()
{
return _seed.HasValue ? new Random(_seed.Value) : Random.Shared;
if (_seed.HasValue)
{
return new Random(_seed.Value);
}
Span<byte> seedBytes = stackalloc byte[4];
RandomNumberGenerator.Fill(seedBytes);
return new Random(BitConverter.ToInt32(seedBytes));
}
}

View File

@@ -127,7 +127,7 @@ public sealed partial class ScannerWorkerHostedService : BackgroundService
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
processingException = null;
await lease.AbandonAsync("host-stopping", CancellationToken.None).ConfigureAwait(false);
await lease.AbandonAsync("host-stopping", stoppingToken).ConfigureAwait(false);
JobAbandoned(_logger, lease.JobId, lease.ScanId);
}
catch (Exception ex)
@@ -140,13 +140,13 @@ public sealed partial class ScannerWorkerHostedService : BackgroundService
var maxAttempts = options.Queue.MaxAttempts;
if (lease.Attempt >= maxAttempts)
{
await lease.PoisonAsync(reason, CancellationToken.None).ConfigureAwait(false);
await lease.PoisonAsync(reason, stoppingToken).ConfigureAwait(false);
_metrics.IncrementJobFailed(context, reason);
JobPoisoned(_logger, lease.JobId, lease.ScanId, lease.Attempt, maxAttempts, ex);
}
else
{
await lease.AbandonAsync(reason, CancellationToken.None).ConfigureAwait(false);
await lease.AbandonAsync(reason, stoppingToken).ConfigureAwait(false);
JobAbandonedWithError(_logger, lease.JobId, lease.ScanId, lease.Attempt, maxAttempts, ex);
}
}

View File

@@ -37,7 +37,7 @@ internal sealed class ScannerStorageSurfaceSecretConfigurator : IConfigureOption
CasAccessSecret? secret = null;
try
{
using var handle = _secretProvider.GetAsync(request).AsTask().GetAwaiter().GetResult();
using var handle = _secretProvider.Get(request);
secret = SurfaceSecretParser.ParseCasAccessSecret(handle);
}
catch (SurfaceSecretNotFoundException)

View File

@@ -211,7 +211,7 @@ public class PoEOrchestrator
$"1. Build container image: {context.ImageDigest}",
$"2. Run scanner: stella scan --image {context.ImageDigest} --config {context.ConfigPath ?? "etc/scanner.yaml"}",
$"3. Extract reachability graph and resolve paths",
$"4. Resolve {subgraph.VulnId} {subgraph.ComponentRef} to vulnerable symbols",
$"4. Resolve {subgraph.VulnId} -> {subgraph.ComponentRef} to vulnerable symbols",
$"5. Compute paths from {subgraph.EntryRefs.Length} entry points to {subgraph.SinkRefs.Length} sinks"
};
}

View File

@@ -1,7 +1,7 @@
// -----------------------------------------------------------------------------
// BinaryFindingMapper.cs
// Sprint: SPRINT_20251226_014_BINIDX
// Task: SCANINT-08 Create BinaryFindingMapper to convert matches to findings
// Task: SCANINT-08 - Create BinaryFindingMapper to convert matches to findings
// -----------------------------------------------------------------------------
using System.Collections.Immutable;

View File

@@ -1,7 +1,7 @@
// -----------------------------------------------------------------------------
// BinaryLookupStageExecutor.cs
// Sprint: SPRINT_20251226_014_BINIDX
// Task: SCANINT-02 Create IBinaryLookupStep in scan pipeline
// Task: SCANINT-02 - Create IBinaryLookupStep in scan pipeline
// -----------------------------------------------------------------------------
using System.Collections.Immutable;

View File

@@ -175,8 +175,9 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
cancellationToken)
.ConfigureAwait(false);
result = cacheEntry.Result;
if (cacheEntry.IsHit)
var (cachedResult, isHit) = cacheEntry;
result = cachedResult;
if (isHit)
{
_metrics.RecordOsCacheHit(context, analyzer.AnalyzerId);
}
@@ -292,8 +293,9 @@ internal sealed class CompositeScanAnalyzerDispatcher : IScanAnalyzerDispatcher
token => engine.AnalyzeAsync(analyzerContext, token),
cancellationToken)
.ConfigureAwait(false);
var result = cacheEntry.Result;
if (cacheEntry.IsHit)
var (cachedResult, isHit) = cacheEntry;
var result = cachedResult;
if (isHit)
{
_metrics.RecordLanguageCacheHit(context, analyzer.Id);
}

View File

@@ -9,6 +9,7 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Analyzers.Native;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Native;
using StellaOps.Scanner.Worker.Diagnostics;
@@ -25,6 +26,7 @@ public sealed class NativeAnalyzerExecutor
{
private readonly NativeBinaryDiscovery _discovery;
private readonly INativeComponentEmitter _emitter;
private readonly IElfSectionHashExtractor _sectionHashExtractor;
private readonly NativeAnalyzerOptions _options;
private readonly ILogger<NativeAnalyzerExecutor> _logger;
private readonly ScannerWorkerMetrics _metrics;
@@ -32,12 +34,14 @@ public sealed class NativeAnalyzerExecutor
public NativeAnalyzerExecutor(
NativeBinaryDiscovery discovery,
INativeComponentEmitter emitter,
IElfSectionHashExtractor sectionHashExtractor,
IOptions<NativeAnalyzerOptions> options,
ILogger<NativeAnalyzerExecutor> logger,
ScannerWorkerMetrics metrics)
{
_discovery = discovery ?? throw new ArgumentNullException(nameof(discovery));
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
_sectionHashExtractor = sectionHashExtractor ?? throw new ArgumentNullException(nameof(sectionHashExtractor));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
@@ -148,20 +152,26 @@ public sealed class NativeAnalyzerExecutor
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.SingleBinaryTimeout);
return await Task.Run(() =>
{
// Read binary header to extract Build-ID and other metadata
var buildId = ExtractBuildId(binary);
var sectionHashes = binary.Format == BinaryFormat.Elf
? await _sectionHashExtractor.ExtractAsync(binary.AbsolutePath, cts.Token).ConfigureAwait(false)
: null;
return new NativeBinaryMetadata
{
Format = binary.Format.ToString().ToLowerInvariant(),
FilePath = binary.RelativePath,
BuildId = buildId,
Architecture = DetectArchitecture(binary),
Platform = DetectPlatform(binary)
};
}, cts.Token).ConfigureAwait(false);
cts.Token.ThrowIfCancellationRequested();
// Read binary header to extract Build-ID and other metadata
var buildId = ExtractBuildId(binary) ?? sectionHashes?.BuildId;
return new NativeBinaryMetadata
{
Format = binary.Format.ToString().ToLowerInvariant(),
FilePath = binary.RelativePath,
BuildId = buildId,
Architecture = DetectArchitecture(binary),
Platform = DetectPlatform(binary),
FileDigest = sectionHashes?.FileHash,
FileSize = binary.SizeBytes,
ElfSectionHashes = sectionHashes
};
}
catch (OperationCanceledException)
{

View File

@@ -38,7 +38,7 @@ public sealed class NativeBinaryDiscovery
/// <summary>
/// Discovers binaries in the specified root filesystem path.
/// </summary>
public async Task<IReadOnlyList<DiscoveredBinary>> DiscoverAsync(
public Task<IReadOnlyList<DiscoveredBinary>> DiscoverAsync(
string rootPath,
CancellationToken cancellationToken = default)
{
@@ -56,23 +56,20 @@ public sealed class NativeBinaryDiscovery
_options.BinaryExtensions.Select(e => e.StartsWith('.') ? e : "." + e),
StringComparer.OrdinalIgnoreCase);
await Task.Run(() =>
{
DiscoverRecursive(
rootPath,
rootPath,
discovered,
excludeSet,
extensionSet,
cancellationToken);
}, cancellationToken).ConfigureAwait(false);
DiscoverRecursive(
rootPath,
rootPath,
discovered,
excludeSet,
extensionSet,
cancellationToken);
_logger.LogInformation(
"Discovered {Count} native binaries in {RootPath}",
discovered.Count,
rootPath);
return discovered;
return Task.FromResult<IReadOnlyList<DiscoveredBinary>>(discovered);
}
private void DiscoverRecursive(

View File

@@ -150,18 +150,16 @@ public sealed class PoEGenerationStageExecutor : IScanStageExecutor
graphHash = casResult.GraphHash;
}
// Try to get build ID from surface manifest or other sources
string? buildId = null;
// TODO: Extract build ID from surface manifest or binary analysis
// Resolve build ID from metadata when available.
var metadata = context.Lease.Metadata;
var buildId = TryGetMetadataValue(metadata, "build.id", "buildId", "build-id", "scanner.build.id");
// Try to get image digest from scan job lease
string? imageDigest = null;
// TODO: Extract image digest from scan job
// Resolve image digest from scan job metadata.
var imageDigest = ResolveImageDigest(context);
// Try to get policy information
string? policyId = null;
string? policyDigest = null;
// TODO: Extract policy information from scan configuration
// Resolve policy identifiers from metadata.
var policyId = TryGetMetadataValue(metadata, "policy.id", "policyId", "scanner.policy.id");
var policyDigest = TryGetMetadataValue(metadata, "policy.digest", "policyDigest", "verdict.policy.digest");
// Get scanner version
var scannerVersion = typeof(PoEGenerationStageExecutor).Assembly.GetName().Version?.ToString() ?? "unknown";
@@ -180,6 +178,41 @@ public sealed class PoEGenerationStageExecutor : IScanStageExecutor
ConfigPath: configPath
);
}
private static string? ResolveImageDigest(ScanJobContext context)
{
var metadata = context.Lease.Metadata;
if (metadata.TryGetValue("image.digest", out var digest) && !string.IsNullOrWhiteSpace(digest))
{
return digest.Trim();
}
if (metadata.TryGetValue("imageDigest", out digest) && !string.IsNullOrWhiteSpace(digest))
{
return digest.Trim();
}
if (metadata.TryGetValue("scanner.image.digest", out digest) && !string.IsNullOrWhiteSpace(digest))
{
return digest.Trim();
}
return context.ImageDigest;
}
private static string? TryGetMetadataValue(IReadOnlyDictionary<string, string> metadata, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
}
/// <summary>

View File

@@ -3,11 +3,11 @@ using System.Buffers.Text;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestation;
using StellaOps.Cryptography;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Secrets;
@@ -71,12 +71,14 @@ internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner
public Task<DsseEnvelope> SignAsync(string payloadType, ReadOnlyMemory<byte> content, string suggestedKind, string merkleRoot, string? view, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_secretBytes is null)
{
return _deterministic.SignAsync(payloadType, content, suggestedKind, merkleRoot, view, cancellationToken);
}
var pae = BuildPae(payloadType, content.Span);
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, content.Span);
var signatureBytes = _cryptoHmac.ComputeHmacForPurpose(_secretBytes, pae, HmacPurpose.Signing);
var envelope = new
{
@@ -88,12 +90,7 @@ internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner
}
};
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = false
});
var bytes = Encoding.UTF8.GetBytes(json);
var bytes = DsseEnvelopeSerializer.Serialize(envelope);
var digest = $"sha256:{ComputeSha256Hex(content.Span)}";
var uri = $"cas://attestations/{suggestedKind}/{digest}.json";
@@ -134,7 +131,7 @@ internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner
Component: "scanner-worker",
SecretType: "attestation",
Name: "dsse-signing");
using var handle = provider.GetAsync(request, CancellationToken.None).GetAwaiter().GetResult();
using var handle = provider.Get(request);
var bytes = handle.AsBytes();
return bytes.IsEmpty ? null : bytes.Span.ToArray();
}
@@ -187,37 +184,6 @@ internal sealed class HmacDsseEnvelopeSigner : IDsseEnvelopeSigner
return null;
}
private static byte[] BuildPae(string payloadType, ReadOnlySpan<byte> payload)
{
const string prefix = "DSSEv1";
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var typeLen = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
var payloadLen = Encoding.UTF8.GetBytes(payload.Length.ToString());
var total = prefix.Length + 1 + typeLen.Length + 1 + typeBytes.Length + 1 + payloadLen.Length + 1 + payload.Length;
var buffer = new byte[total];
var offset = 0;
Encoding.UTF8.GetBytes(prefix, buffer.AsSpan(offset));
offset += prefix.Length;
buffer[offset++] = 0x20;
typeLen.CopyTo(buffer.AsSpan(offset));
offset += typeLen.Length;
buffer[offset++] = 0x20;
typeBytes.CopyTo(buffer.AsSpan(offset));
offset += typeBytes.Length;
buffer[offset++] = 0x20;
payloadLen.CopyTo(buffer.AsSpan(offset));
offset += payloadLen.Length;
buffer[offset++] = 0x20;
payload.CopyTo(buffer.AsSpan(offset));
return buffer;
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> data)
{
Span<byte> hash = stackalloc byte[32];

View File

@@ -1,7 +1,10 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Canonical.Json;
namespace StellaOps.Scanner.Worker.Processing.Surface;
@@ -12,6 +15,19 @@ internal interface IDsseEnvelopeSigner
Task<DsseEnvelope> SignAsync(string payloadType, ReadOnlyMemory<byte> content, string suggestedKind, string merkleRoot, string? view, CancellationToken cancellationToken);
}
internal static class DsseEnvelopeSerializer
{
private static readonly JsonSerializerOptions EnvelopeOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.Default
};
public static byte[] Serialize<T>(T value) => CanonJson.Canonicalize(value, EnvelopeOptions);
}
/// <summary>
/// Deterministic fallback signer that encodes sha256 hash as the signature. Replace with real Attestor/Signer when available.
/// </summary>
@@ -19,6 +35,8 @@ internal sealed class DeterministicDsseEnvelopeSigner : IDsseEnvelopeSigner
{
public Task<DsseEnvelope> SignAsync(string payloadType, ReadOnlyMemory<byte> content, string suggestedKind, string merkleRoot, string? view, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var signature = ComputeSha256Hex(content.Span);
var envelope = new
{
@@ -30,12 +48,7 @@ internal sealed class DeterministicDsseEnvelopeSigner : IDsseEnvelopeSigner
}
};
var json = JsonSerializer.Serialize(envelope, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = false
});
var bytes = Encoding.UTF8.GetBytes(json);
var bytes = DsseEnvelopeSerializer.Serialize(envelope);
var digest = $"sha256:{signature}";
var uri = $"cas://attestations/{suggestedKind}/{signature}.json";

View File

@@ -5,9 +5,11 @@ using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Entropy;
@@ -23,10 +25,12 @@ namespace StellaOps.Scanner.Worker.Processing.Surface;
internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.Default
};
private static readonly JsonSerializerOptions ManifestSerializerOptions = new(JsonSerializerDefaults.Web)
@@ -92,8 +96,9 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
return;
}
var determinismPayloads = BuildDeterminismPayloads(context, payloads, out var merkleRoot);
if (determinismPayloads is not null && determinismPayloads.Count > 0)
var (determinismPayloads, merkleRoot) = await BuildDeterminismPayloadsAsync(context, payloads, cancellationToken)
.ConfigureAwait(false);
if (determinismPayloads.Count > 0)
{
payloads.AddRange(determinismPayloads);
}
@@ -191,13 +196,13 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
var fragments = context.Analysis.GetLayerFragments();
if (!fragments.IsDefaultOrEmpty && fragments.Length > 0)
{
var fragmentsJson = JsonSerializer.Serialize(fragments, JsonOptions);
var fragmentsBytes = SerializeCanonical(fragments);
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceLayerFragment,
ArtifactDocumentFormat.ComponentFragmentJson,
Kind: "layer.fragments",
MediaType: "application/json",
Content: Encoding.UTF8.GetBytes(fragmentsJson),
Content: fragmentsBytes,
View: "inventory"));
}
@@ -217,13 +222,13 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
if (context.Analysis.TryGet<EntropyReport>(ScanAnalysisKeys.EntropyReport, out var entropyReport) && entropyReport is not null)
{
var json = JsonSerializer.Serialize(entropyReport, JsonOptions);
var entropyBytes = SerializeCanonical(entropyReport);
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.ObservationJson,
Kind: "entropy.report",
MediaType: "application/json",
Content: Encoding.UTF8.GetBytes(json),
Content: entropyBytes,
View: "entropy",
Metadata: new Dictionary<string, string>
{
@@ -235,13 +240,13 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
if (context.Analysis.TryGet<EntropyLayerSummary>(ScanAnalysisKeys.EntropyLayerSummary, out var entropySummary) && entropySummary is not null)
{
var json = JsonSerializer.Serialize(entropySummary, JsonOptions);
var summaryBytes = SerializeCanonical(entropySummary);
payloads.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.ObservationJson,
Kind: "entropy.layer-summary",
MediaType: "application/json",
Content: Encoding.UTF8.GetBytes(json),
Content: summaryBytes,
View: "entropy",
Metadata: new Dictionary<string, string>
{
@@ -253,9 +258,11 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
return payloads;
}
private IReadOnlyList<SurfaceManifestPayload> BuildDeterminismPayloads(ScanJobContext context, IEnumerable<SurfaceManifestPayload> payloads, out string? merkleRoot)
private async Task<(IReadOnlyList<SurfaceManifestPayload> Payloads, string? MerkleRoot)> BuildDeterminismPayloadsAsync(
ScanJobContext context,
IEnumerable<SurfaceManifestPayload> payloads,
CancellationToken cancellationToken)
{
merkleRoot = null;
var pins = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (context.Lease.Metadata.TryGetValue("determinism.feed", out var feed) && !string.IsNullOrWhiteSpace(feed))
{
@@ -268,7 +275,6 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
}
var (artifactHashes, recipeBytes, recipeSha256) = BuildCompositionRecipe(payloads);
merkleRoot = recipeSha256;
var report = new
{
@@ -285,10 +291,10 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
var evidence = new Determinism.DeterminismEvidence(artifactHashes, recipeSha256);
context.Analysis.Set(ScanAnalysisKeys.DeterminismEvidence, evidence);
var payloadList = payloads.ToList();
var additions = new List<SurfaceManifestPayload>();
// Publish composition recipe as a manifest artifact for offline replay.
payloadList.Add(new SurfaceManifestPayload(
additions.Add(new SurfaceManifestPayload(
ArtifactDocumentType.CompositionRecipe,
ArtifactDocumentFormat.CompositionRecipeJson,
Kind: "composition.recipe",
@@ -301,14 +307,14 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
}));
// Attach DSSE envelope for the recipe (deterministic local signature = sha256 hash bytes).
var recipeDsse = _dsseSigner.SignAsync(
var recipeDsse = await _dsseSigner.SignAsync(
payloadType: "application/vnd.stellaops.composition.recipe+json",
content: recipeBytes,
suggestedKind: "composition.recipe.dsse",
merkleRoot: recipeSha256,
view: null,
cancellationToken: CancellationToken.None).Result;
payloadList.Add(new SurfaceManifestPayload(
cancellationToken: cancellationToken).ConfigureAwait(false);
additions.Add(new SurfaceManifestPayload(
ArtifactDocumentType.Attestation,
ArtifactDocumentFormat.DsseJson,
Kind: "composition.recipe.dsse",
@@ -321,17 +327,17 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
}));
// Attach DSSE envelope for layer fragments when present.
foreach (var fragmentPayload in payloadList.Where(p => p.Kind == "layer.fragments").ToArray())
foreach (var fragmentPayload in payloads.Where(p => p.Kind == "layer.fragments").ToArray())
{
var dsse = _dsseSigner.SignAsync(
var dsse = await _dsseSigner.SignAsync(
payloadType: fragmentPayload.MediaType,
content: fragmentPayload.Content,
suggestedKind: "layer.fragments.dsse",
merkleRoot: recipeSha256,
view: fragmentPayload.View,
cancellationToken: CancellationToken.None).Result;
cancellationToken: cancellationToken).ConfigureAwait(false);
payloadList.Add(new SurfaceManifestPayload(
additions.Add(new SurfaceManifestPayload(
ArtifactDocumentType.Attestation,
ArtifactDocumentFormat.DsseJson,
Kind: "layer.fragments.dsse",
@@ -345,16 +351,16 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
}));
}
var json = JsonSerializer.Serialize(report, JsonOptions);
payloadList.Add(new SurfaceManifestPayload(
var reportBytes = SerializeCanonical(report);
additions.Add(new SurfaceManifestPayload(
ArtifactDocumentType.SurfaceObservation,
ArtifactDocumentFormat.ObservationJson,
Kind: "determinism.json",
MediaType: "application/json",
Content: Encoding.UTF8.GetBytes(json),
Content: reportBytes,
View: "replay"));
return payloadList.Skip(payloads.Count()).ToList();
return (additions, recipeSha256);
}
private (Dictionary<string, string> Hashes, byte[] RecipeBytes, string RecipeSha256) BuildCompositionRecipe(IEnumerable<SurfaceManifestPayload> payloads)
@@ -373,8 +379,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
artifacts = map, // already sorted
};
var recipeJson = JsonSerializer.Serialize(recipe, JsonOptions);
var recipeBytes = Encoding.UTF8.GetBytes(recipeJson);
var recipeBytes = SerializeCanonical(recipe);
var merkleRoot = _hash.ComputeHashHex(recipeBytes, HashAlgorithms.Sha256);
return (new Dictionary<string, string>(map, StringComparer.OrdinalIgnoreCase), recipeBytes, merkleRoot);
@@ -402,13 +407,12 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
}
};
var json = JsonSerializer.Serialize(envelope, JsonOptions);
return new SurfaceManifestPayload(
ArtifactDocumentType.Attestation,
ArtifactDocumentFormat.DsseJson,
Kind: kind,
MediaType: mediaType,
Content: Encoding.UTF8.GetBytes(json),
Content: SerializeCanonical(envelope),
Metadata: new Dictionary<string, string>
{
["merkleRoot"] = merkleRoot,
@@ -670,6 +674,11 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
return normalized.Count == 0 ? null : normalized;
}
private static byte[] SerializeCanonical<T>(T value)
{
return CanonJson.Canonicalize(value, CanonicalJsonOptions);
}
private string ComputeDigest(ReadOnlySpan<byte> content)
{
var hex = _hash.ComputeHashHex(content, HashAlgorithms.Sha256);

View File

@@ -12,6 +12,7 @@ using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Gates;
using StellaOps.Scanner.Analyzers.OS.Plugin;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
using StellaOps.Scanner.Analyzers.Native;
using StellaOps.Scanner.Analyzers.Native.Index;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.Core.Contracts;
@@ -55,6 +56,8 @@ builder.Services.AddOptions<NativeAnalyzerOptions>()
.BindConfiguration(NativeAnalyzerOptions.SectionName)
.ValidateOnStart();
builder.Services.AddNativeAnalyzer(builder.Configuration);
builder.Services.AddSingleton<IValidateOptions<ScannerWorkerOptions>, ScannerWorkerOptionsValidator>();
var workerOptions = builder.Configuration.GetSection(ScannerWorkerOptions.SectionName).Get<ScannerWorkerOptions>() ?? new ScannerWorkerOptions();

View File

@@ -15,10 +15,12 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />

View File

@@ -0,0 +1,10 @@
# Scanner Worker Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260113_001_001_SCANNER_elf_section_hashes.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| ELF-SECTION-EVIDENCE-0001 | DONE | Populate section hashes into native metadata for SBOM emission. |
| ELF-SECTION-DI-0001 | DONE | Register section hash extractor options and services. |
| AUDIT-HOTLIST-SCANNER-WORKER-0001 | DONE | Apply audit hotlist findings for Scanner.Worker. |

View File

@@ -0,0 +1,98 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Contracts;
/// <summary>
/// ELF section type values from the specification.
/// </summary>
public enum ElfSectionType : uint
{
Null = 0,
ProgBits = 1,
SymTab = 2,
StrTab = 3,
Rela = 4,
Hash = 5,
Dynamic = 6,
Note = 7,
NoBits = 8,
Rel = 9,
ShLib = 10,
DynSym = 11,
InitArray = 14,
FiniArray = 15,
PreInitArray = 16,
Group = 17,
SymTabShndx = 18
}
/// <summary>
/// ELF section header flags.
/// </summary>
[Flags]
public enum ElfSectionFlags : ulong
{
None = 0,
Write = 0x1,
Alloc = 0x2,
ExecInstr = 0x4,
Merge = 0x10,
Strings = 0x20,
InfoLink = 0x40,
LinkOrder = 0x80,
OsNonConforming = 0x100,
Group = 0x200,
Tls = 0x400,
Compressed = 0x800
}
/// <summary>
/// Represents a cryptographic hash of an ELF section.
/// </summary>
public sealed record ElfSectionHash
{
/// <summary>Section name (e.g., ".text", ".rodata").</summary>
public required string Name { get; init; }
/// <summary>Section offset in file.</summary>
public required long Offset { get; init; }
/// <summary>Section size in bytes.</summary>
public required long Size { get; init; }
/// <summary>SHA-256 hash of section contents (lowercase hex).</summary>
public required string Sha256 { get; init; }
/// <summary>Optional BLAKE3-256 hash of section contents (lowercase hex).</summary>
public string? Blake3 { get; init; }
/// <summary>Section type from ELF header.</summary>
public required ElfSectionType SectionType { get; init; }
/// <summary>Section flags from ELF header.</summary>
public required ElfSectionFlags Flags { get; init; }
}
/// <summary>
/// Collection of section hashes for a single ELF binary.
/// </summary>
public sealed record ElfSectionHashSet
{
/// <summary>Path to the ELF binary.</summary>
public required string FilePath { get; init; }
/// <summary>SHA-256 hash of the entire file.</summary>
public required string FileHash { get; init; }
/// <summary>Build-ID from .note.gnu.build-id if present.</summary>
public string? BuildId { get; init; }
/// <summary>Section hashes, sorted by section name.</summary>
public required ImmutableArray<ElfSectionHash> Sections { get; init; }
/// <summary>Extraction timestamp (UTC ISO-8601).</summary>
public required DateTimeOffset ExtractedAt { get; init; }
/// <summary>Extractor version for reproducibility.</summary>
public required string ExtractorVersion { get; init; }
}

View File

@@ -0,0 +1,98 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Contracts;
/// <summary>
/// Result of inspecting an OCI image reference.
/// </summary>
public sealed record ImageInspectionResult
{
/// <summary>Original image reference provided.</summary>
public required string Reference { get; init; }
/// <summary>Resolved digest of the index or manifest.</summary>
public required string ResolvedDigest { get; init; }
/// <summary>Media type of the resolved artifact.</summary>
public required string MediaType { get; init; }
/// <summary>True if this is a multi-arch image index.</summary>
public required bool IsMultiArch { get; init; }
/// <summary>Platform manifests (1 for single-arch, N for multi-arch).</summary>
public required ImmutableArray<PlatformManifest> Platforms { get; init; }
/// <summary>Inspection timestamp (UTC).</summary>
public required DateTimeOffset InspectedAt { get; init; }
/// <summary>Inspector version for reproducibility.</summary>
public required string InspectorVersion { get; init; }
/// <summary>Registry that was queried.</summary>
public required string Registry { get; init; }
/// <summary>Repository name.</summary>
public required string Repository { get; init; }
/// <summary>Warnings encountered during inspection.</summary>
public ImmutableArray<string> Warnings { get; init; } = [];
}
/// <summary>
/// A platform-specific manifest within an image index.
/// </summary>
public sealed record PlatformManifest
{
/// <summary>Operating system (e.g., "linux", "windows").</summary>
public required string Os { get; init; }
/// <summary>CPU architecture (e.g., "amd64", "arm64").</summary>
public required string Architecture { get; init; }
/// <summary>Architecture variant (e.g., "v8" for arm64).</summary>
public string? Variant { get; init; }
/// <summary>OS version (mainly for Windows).</summary>
public string? OsVersion { get; init; }
/// <summary>Digest of this platform's manifest.</summary>
public required string ManifestDigest { get; init; }
/// <summary>Media type of the manifest.</summary>
public required string ManifestMediaType { get; init; }
/// <summary>Digest of the config blob.</summary>
public required string ConfigDigest { get; init; }
/// <summary>Ordered list of layers.</summary>
public required ImmutableArray<LayerInfo> Layers { get; init; }
/// <summary>Total size of all layers in bytes.</summary>
public required long TotalSize { get; init; }
/// <summary>Platform string (os/arch/variant).</summary>
public string PlatformString => Variant is null
? $"{Os}/{Architecture}"
: $"{Os}/{Architecture}/{Variant}";
}
/// <summary>
/// Information about a single layer.
/// </summary>
public sealed record LayerInfo
{
/// <summary>Layer order (0-indexed, application order).</summary>
public required int Order { get; init; }
/// <summary>Layer digest (sha256:...).</summary>
public required string Digest { get; init; }
/// <summary>Media type of the layer blob.</summary>
public required string MediaType { get; init; }
/// <summary>Compressed size in bytes.</summary>
public required long Size { get; init; }
/// <summary>Optional annotations from the manifest.</summary>
public ImmutableDictionary<string, string>? Annotations { get; init; }
}

View File

@@ -117,10 +117,12 @@ public sealed class SecretExceptionMatcher
var matcher = new Matcher();
matcher.AddInclude(globPattern);
// Normalize path separators
var normalizedPath = filePath.Replace('\\', '/');
// Normalize path separators to forward slashes
var normalizedPath = filePath.Replace('\\', '/').TrimStart('/');
// Match against the file name and relative path components
// For patterns like **/test/**, we need to match against the path
// Matcher.Match needs both a directory base and files, but we can
// work around this by matching the path itself
var result = matcher.Match(normalizedPath);
return result.HasMatches;
}

View File

@@ -76,6 +76,26 @@ public sealed record NativeComponentEmitResult(
AddList(properties, "stellaops:binary.imports", Metadata.Imports);
AddList(properties, "stellaops:binary.exports", Metadata.Exports);
if (Metadata.ElfSectionHashes is { Sections.Length: > 0 } sectionHashes)
{
foreach (var section in sectionHashes.Sections)
{
if (string.IsNullOrWhiteSpace(section.Name))
{
continue;
}
var keyPrefix = $"evidence:section:{section.Name}";
properties[$"{keyPrefix}:sha256"] = section.Sha256;
properties[$"{keyPrefix}:size"] = section.Size.ToString(CultureInfo.InvariantCulture);
if (!string.IsNullOrWhiteSpace(section.Blake3))
{
properties[$"{keyPrefix}:blake3"] = section.Blake3!;
}
}
}
if (LookupResult is not null)
{
AddIfNotEmpty(properties, "stellaops:binary.index.sourceDistro", LookupResult.SourceDistro);

View File

@@ -1,3 +1,5 @@
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.Emit.Native;
/// <summary>
@@ -58,4 +60,7 @@ public sealed record NativeBinaryMetadata
/// <summary>Exported symbols (for dependency analysis)</summary>
public IReadOnlyList<string>? Exports { get; init; }
/// <summary>ELF section hashes for evidence output.</summary>
public ElfSectionHashSet? ElfSectionHashes { get; init; }
}

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Contracts\StellaOps.Scanner.Contracts.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.EntryTrace\StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />

View File

@@ -0,0 +1,8 @@
# Scanner Emit Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260113_001_001_SCANNER_elf_section_hashes.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| ELF-SECTION-EVIDENCE-0001 | DONE | Add section hash properties to emitted components. |

View File

@@ -17,8 +17,8 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Diff\StellaOps.Scanner.Diff.csproj" />
<ProjectReference Include="..\..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Builders\StellaOps.BinaryIndex.Builders.csproj" />
<ProjectReference Include="..\..\..\..\Unknowns\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Builders\StellaOps.BinaryIndex.Builders.csproj" />
<ProjectReference Include="..\..\..\Unknowns\__Libraries\StellaOps.Unknowns.Core\StellaOps.Unknowns.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -136,7 +136,7 @@ public sealed class PathExplanationService : IPathExplanationService
var parts = pathId?.Split(':');
if (parts is not { Length: >= 2 })
{
return Task.FromResult<ExplainedPath?>(null);
return null;
}
var query = new PathExplanationQuery

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>

View File

@@ -0,0 +1,34 @@
namespace StellaOps.Scanner.Storage.Oci;
public interface IOciImageInspector
{
/// <summary>
/// Inspects an OCI image reference.
/// </summary>
/// <param name="reference">Image reference (e.g., "nginx:latest", "ghcr.io/org/app@sha256:...").</param>
/// <param name="options">Inspection options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Inspection result or null if not found.</returns>
Task<StellaOps.Scanner.Contracts.ImageInspectionResult?> InspectAsync(
string reference,
ImageInspectionOptions? options = null,
CancellationToken cancellationToken = default);
}
public sealed record ImageInspectionOptions
{
/// <summary>Resolve multi-arch index to platform manifests (default: true).</summary>
public bool ResolveIndex { get; init; } = true;
/// <summary>Include layer details (default: true).</summary>
public bool IncludeLayers { get; init; } = true;
/// <summary>Filter to specific platform (e.g., "linux/amd64").</summary>
public string? PlatformFilter { get; init; }
/// <summary>Maximum platforms to inspect (default: unlimited).</summary>
public int? MaxPlatforms { get; init; }
/// <summary>Request timeout.</summary>
public TimeSpan? Timeout { get; init; }
}

View File

@@ -0,0 +1,882 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.Storage.Oci;
public sealed class OciImageInspector : IOciImageInspector
{
public const string HttpClientName = "stellaops-oci-registry";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private static readonly string[] ManifestAccept =
[
OciMediaTypes.ImageIndex,
OciMediaTypes.DockerManifestList,
OciMediaTypes.ImageManifest,
OciMediaTypes.DockerManifest,
OciMediaTypes.ArtifactManifest
];
private readonly IHttpClientFactory _httpClientFactory;
private readonly OciRegistryOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<OciImageInspector> _logger;
private readonly ConcurrentDictionary<string, string> _tokenCache = new(StringComparer.OrdinalIgnoreCase);
public OciImageInspector(
IHttpClientFactory httpClientFactory,
OciRegistryOptions options,
ILogger<OciImageInspector> logger,
TimeProvider? timeProvider = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ImageInspectionResult?> InspectAsync(
string reference,
ImageInspectionOptions? options = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(reference))
{
return null;
}
options ??= new ImageInspectionOptions();
var parsedReference = OciImageReference.Parse(reference, _options.DefaultRegistry);
if (parsedReference is null)
{
_logger.LogWarning("Invalid OCI reference: {Reference}", reference);
return null;
}
using var timeoutCts = CreateTimeoutCts(options.Timeout, cancellationToken);
var effectiveToken = timeoutCts?.Token ?? cancellationToken;
var auth = OciRegistryAuthorization.FromOptions(parsedReference.Registry, _options.Auth);
var warnings = new List<string>();
var tagOrDigest = parsedReference.Digest ?? parsedReference.Tag ?? "latest";
var manifestFetch = await FetchManifestAsync(parsedReference, tagOrDigest, auth, warnings, effectiveToken)
.ConfigureAwait(false);
if (manifestFetch is null)
{
return null;
}
var resolvedDigest = ResolveDigest(parsedReference, manifestFetch.Digest, warnings);
var mediaType = NormalizeMediaType(manifestFetch.MediaType);
var kind = ResolveManifestKind(mediaType, manifestFetch.Json, warnings);
var platforms = kind == ManifestKind.Index
? await InspectIndexAsync(parsedReference, manifestFetch, options, auth, warnings, effectiveToken)
.ConfigureAwait(false)
: BuildSinglePlatform(await InspectManifestAsync(
parsedReference,
manifestFetch,
null,
options,
auth,
warnings,
effectiveToken)
.ConfigureAwait(false));
var orderedWarnings = warnings
.Where(warning => !string.IsNullOrWhiteSpace(warning))
.Distinct(StringComparer.Ordinal)
.OrderBy(warning => warning, StringComparer.Ordinal)
.ToImmutableArray();
return new ImageInspectionResult
{
Reference = reference,
ResolvedDigest = resolvedDigest,
MediaType = mediaType,
IsMultiArch = kind == ManifestKind.Index,
Platforms = platforms,
InspectedAt = _timeProvider.GetUtcNow(),
InspectorVersion = ResolveInspectorVersion(),
Registry = parsedReference.Registry,
Repository = parsedReference.Repository,
Warnings = orderedWarnings
};
}
private async Task<ImmutableArray<PlatformManifest>> InspectIndexAsync(
OciImageReference reference,
ManifestFetchResult manifest,
ImageInspectionOptions options,
OciRegistryAuthorization auth,
List<string> warnings,
CancellationToken cancellationToken)
{
var index = Deserialize<OciIndexDocument>(manifest.Json);
if (index?.Manifests is null)
{
warnings.Add("Index document did not include manifests.");
return ImmutableArray<PlatformManifest>.Empty;
}
var descriptors = index.Manifests
.Where(item => !string.IsNullOrWhiteSpace(item.Digest))
.Select(item => BuildPlatformDescriptor(item, warnings))
.ToList();
var platformFilter = ParsePlatformFilter(options.PlatformFilter, warnings);
if (platformFilter is not null)
{
descriptors = descriptors
.Where(descriptor => MatchesPlatform(descriptor, platformFilter))
.ToList();
}
descriptors = descriptors
.OrderBy(descriptor => descriptor.Os, StringComparer.Ordinal)
.ThenBy(descriptor => descriptor.Architecture, StringComparer.Ordinal)
.ThenBy(descriptor => descriptor.Variant ?? string.Empty, StringComparer.Ordinal)
.ToList();
if (options.MaxPlatforms is > 0 && descriptors.Count > options.MaxPlatforms.Value)
{
descriptors = descriptors.Take(options.MaxPlatforms.Value).ToList();
}
else if (options.MaxPlatforms is <= 0)
{
warnings.Add("MaxPlatforms must be greater than zero when specified.");
descriptors = [];
}
if (!options.ResolveIndex)
{
warnings.Add("Index resolution disabled; manifest details omitted.");
return descriptors
.Select(descriptor => new PlatformManifest
{
Os = descriptor.Os,
Architecture = descriptor.Architecture,
Variant = descriptor.Variant,
OsVersion = descriptor.OsVersion,
ManifestDigest = descriptor.Digest,
ManifestMediaType = descriptor.MediaType,
ConfigDigest = string.Empty,
Layers = ImmutableArray<LayerInfo>.Empty,
TotalSize = 0
})
.ToImmutableArray();
}
var results = new List<PlatformManifest>(descriptors.Count);
foreach (var descriptor in descriptors)
{
var platform = await InspectManifestAsync(
reference,
manifestOverride: null,
descriptor,
options,
auth,
warnings,
cancellationToken)
.ConfigureAwait(false);
if (platform is not null)
{
results.Add(platform);
}
}
return results.ToImmutableArray();
}
private async Task<PlatformManifest?> InspectManifestAsync(
OciImageReference reference,
ManifestFetchResult? manifestOverride,
PlatformDescriptor? descriptor,
ImageInspectionOptions options,
OciRegistryAuthorization auth,
List<string> warnings,
CancellationToken cancellationToken)
{
var manifestFetch = manifestOverride;
if (manifestFetch is null)
{
if (descriptor is null)
{
return null;
}
manifestFetch = await FetchManifestAsync(reference, descriptor.Digest, auth, warnings, cancellationToken)
.ConfigureAwait(false);
}
if (manifestFetch is null)
{
return null;
}
var document = Deserialize<OciManifestDocument>(manifestFetch.Json);
if (document?.Config is null)
{
warnings.Add($"Manifest {manifestFetch.Digest} missing config descriptor.");
return null;
}
var configDigest = document.Config.Digest ?? string.Empty;
if (string.IsNullOrWhiteSpace(configDigest))
{
warnings.Add($"Manifest {manifestFetch.Digest} missing config digest.");
}
var config = string.IsNullOrWhiteSpace(configDigest)
? null
: await FetchConfigAsync(reference, configDigest, auth, warnings, cancellationToken).ConfigureAwait(false);
var platform = ResolvePlatform(descriptor, config);
var layers = options.IncludeLayers
? BuildLayers(document.Layers, warnings)
: ImmutableArray<LayerInfo>.Empty;
var totalSize = layers.Sum(layer => layer.Size);
return new PlatformManifest
{
Os = platform.Os,
Architecture = platform.Architecture,
Variant = platform.Variant,
OsVersion = platform.OsVersion,
ManifestDigest = manifestFetch.Digest,
ManifestMediaType = NormalizeMediaType(manifestFetch.MediaType),
ConfigDigest = configDigest,
Layers = layers,
TotalSize = totalSize
};
}
private async Task<ManifestFetchResult?> FetchManifestAsync(
OciImageReference reference,
string tagOrDigest,
OciRegistryAuthorization auth,
List<string> warnings,
CancellationToken cancellationToken)
{
var headRequest = BuildManifestRequest(reference, tagOrDigest, HttpMethod.Head);
using var headResponse = await SendWithAuthAsync(reference, headRequest, auth, cancellationToken)
.ConfigureAwait(false);
if (headResponse.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
var headMediaType = headResponse.Content.Headers.ContentType?.MediaType;
var headDigest = TryGetDigest(headResponse);
if (!headResponse.IsSuccessStatusCode)
{
warnings.Add($"Manifest HEAD returned {headResponse.StatusCode}.");
}
var getRequest = BuildManifestRequest(reference, tagOrDigest, HttpMethod.Get);
using var getResponse = await SendWithAuthAsync(reference, getRequest, auth, cancellationToken)
.ConfigureAwait(false);
if (getResponse.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!getResponse.IsSuccessStatusCode)
{
warnings.Add($"Manifest GET returned {getResponse.StatusCode}.");
}
var json = await getResponse.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var mediaType = getResponse.Content.Headers.ContentType?.MediaType ?? headMediaType ?? string.Empty;
var digest = TryGetDigest(getResponse) ?? headDigest ?? string.Empty;
if (string.IsNullOrWhiteSpace(mediaType))
{
warnings.Add("Manifest media type missing; falling back to JSON sniffing.");
}
return new ManifestFetchResult(json, mediaType, digest);
}
private async Task<OciImageConfig?> FetchConfigAsync(
OciImageReference reference,
string digest,
OciRegistryAuthorization auth,
List<string> warnings,
CancellationToken cancellationToken)
{
var uri = BuildRegistryUri(reference, $"blobs/{digest}");
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
using var response = await SendWithAuthAsync(reference, request, auth, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
warnings.Add($"Config fetch failed for {digest}: {response.StatusCode}.");
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return Deserialize<OciImageConfig>(json);
}
private async Task<HttpResponseMessage> SendWithAuthAsync(
OciImageReference reference,
HttpRequestMessage request,
OciRegistryAuthorization auth,
CancellationToken cancellationToken)
{
auth.ApplyTo(request);
var client = _httpClientFactory.CreateClient(HttpClientName);
var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.Unauthorized)
{
return response;
}
var challenge = response.Headers.WwwAuthenticate.FirstOrDefault(header =>
header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase));
if (challenge is not null)
{
var token = await GetTokenAsync(reference, challenge, auth, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
response.Dispose();
var retry = CloneRequest(request);
retry.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await client.SendAsync(retry, cancellationToken).ConfigureAwait(false);
}
}
if (auth.AllowAnonymousFallback && auth.Mode != OciRegistryAuthMode.Anonymous)
{
response.Dispose();
var retry = CloneRequest(request);
retry.Headers.Authorization = null;
return await client.SendAsync(retry, cancellationToken).ConfigureAwait(false);
}
return response;
}
private async Task<string?> GetTokenAsync(
OciImageReference reference,
AuthenticationHeaderValue challenge,
OciRegistryAuthorization auth,
CancellationToken cancellationToken)
{
var parameters = ParseChallengeParameters(challenge.Parameter);
if (!parameters.TryGetValue("realm", out var realm) || string.IsNullOrWhiteSpace(realm))
{
return null;
}
var service = parameters.GetValueOrDefault("service");
var scope = parameters.GetValueOrDefault("scope") ?? $"repository:{reference.Repository}:pull";
var cacheKey = $"{realm}|{service}|{scope}";
if (_tokenCache.TryGetValue(cacheKey, out var cached))
{
return cached;
}
var tokenUri = BuildTokenUri(realm, service, scope);
using var request = new HttpRequestMessage(HttpMethod.Get, tokenUri);
var authHeader = BuildBasicAuthHeader(auth);
if (authHeader is not null)
{
request.Headers.Authorization = authHeader;
}
var client = _httpClientFactory.CreateClient(HttpClientName);
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("OCI token request failed: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("token", out var tokenElement) &&
!document.RootElement.TryGetProperty("access_token", out tokenElement))
{
return null;
}
var token = tokenElement.GetString();
if (!string.IsNullOrWhiteSpace(token))
{
_tokenCache.TryAdd(cacheKey, token);
}
return token;
}
private static AuthenticationHeaderValue? BuildBasicAuthHeader(OciRegistryAuthorization auth)
{
if (string.IsNullOrWhiteSpace(auth.Username) || auth.Password is null)
{
return null;
}
var token = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{auth.Username}:{auth.Password}"));
return new AuthenticationHeaderValue("Basic", token);
}
private static Dictionary<string, string> ParseChallengeParameters(string? parameter)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(parameter))
{
return result;
}
var parts = parameter.Split(',', StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var tokens = part.Split('=', 2, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length != 2)
{
continue;
}
var key = tokens[0].Trim();
var value = tokens[1].Trim().Trim('"');
if (!string.IsNullOrWhiteSpace(key))
{
result[key] = value;
}
}
return result;
}
private static Uri BuildTokenUri(string realm, string? service, string? scope)
{
var builder = new UriBuilder(realm);
var query = new List<string>();
if (!string.IsNullOrWhiteSpace(service))
{
query.Add($"service={Uri.EscapeDataString(service)}");
}
if (!string.IsNullOrWhiteSpace(scope))
{
query.Add($"scope={Uri.EscapeDataString(scope)}");
}
builder.Query = string.Join("&", query);
return builder.Uri;
}
private HttpRequestMessage BuildManifestRequest(
OciImageReference reference,
string tagOrDigest,
HttpMethod method)
{
var uri = BuildRegistryUri(reference, $"manifests/{tagOrDigest}");
var request = new HttpRequestMessage(method, uri);
foreach (var accept in ManifestAccept)
{
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
}
return request;
}
private Uri BuildRegistryUri(OciImageReference reference, string path)
{
var scheme = reference.Scheme;
if (_options.AllowInsecure)
{
scheme = "http";
}
return new Uri($"{scheme}://{reference.Registry}/v2/{reference.Repository}/{path}");
}
private static ManifestKind ResolveManifestKind(string mediaType, string json, List<string> warnings)
{
if (IsIndexMediaType(mediaType))
{
return ManifestKind.Index;
}
if (IsManifestMediaType(mediaType))
{
return ManifestKind.Manifest;
}
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("manifests", out var manifests) &&
manifests.ValueKind == JsonValueKind.Array)
{
return ManifestKind.Index;
}
if (document.RootElement.TryGetProperty("config", out _) &&
document.RootElement.TryGetProperty("layers", out _))
{
return ManifestKind.Manifest;
}
}
catch (JsonException)
{
warnings.Add("Unable to parse manifest JSON.");
return ManifestKind.Unknown;
}
warnings.Add($"Unknown manifest media type '{mediaType}'.");
return ManifestKind.Unknown;
}
private static ImmutableArray<LayerInfo> BuildLayers(
IReadOnlyList<OciDescriptor>? layers,
List<string> warnings)
{
if (layers is null || layers.Count == 0)
{
return ImmutableArray<LayerInfo>.Empty;
}
var builder = ImmutableArray.CreateBuilder<LayerInfo>(layers.Count);
for (var i = 0; i < layers.Count; i++)
{
var layer = layers[i];
if (string.IsNullOrWhiteSpace(layer.Digest))
{
warnings.Add($"Layer {i} missing digest.");
continue;
}
builder.Add(new LayerInfo
{
Order = i,
Digest = layer.Digest,
MediaType = layer.MediaType,
Size = layer.Size,
Annotations = NormalizeAnnotations(layer.Annotations)
});
}
return builder.ToImmutable();
}
private static ImmutableDictionary<string, string>? NormalizeAnnotations(
IReadOnlyDictionary<string, string>? annotations)
{
if (annotations is null || annotations.Count == 0)
{
return null;
}
return annotations
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
}
private static PlatformDescriptor BuildPlatformDescriptor(
OciIndexDescriptor descriptor,
List<string> warnings)
{
if (string.IsNullOrWhiteSpace(descriptor.MediaType))
{
warnings.Add($"Index manifest {descriptor.Digest} missing mediaType.");
}
var platform = descriptor.Platform;
return new PlatformDescriptor(
Digest: descriptor.Digest ?? string.Empty,
MediaType: descriptor.MediaType ?? string.Empty,
Os: platform?.Os ?? "unknown",
Architecture: platform?.Architecture ?? "unknown",
Variant: platform?.Variant,
OsVersion: platform?.OsVersion,
Annotations: descriptor.Annotations);
}
private static PlatformDescriptor? ParsePlatformFilter(string? filter, List<string> warnings)
{
if (string.IsNullOrWhiteSpace(filter))
{
return null;
}
var parts = filter.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2 || parts.Length > 3)
{
warnings.Add($"Invalid platform filter '{filter}'.");
return null;
}
return new PlatformDescriptor(
Digest: string.Empty,
MediaType: string.Empty,
Os: parts[0],
Architecture: parts[1],
Variant: parts.Length == 3 ? parts[2] : null,
OsVersion: null,
Annotations: null);
}
private static bool MatchesPlatform(PlatformDescriptor descriptor, PlatformDescriptor filter)
{
if (!string.Equals(descriptor.Os, filter.Os, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.Equals(descriptor.Architecture, filter.Architecture, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.IsNullOrWhiteSpace(filter.Variant))
{
return true;
}
return string.Equals(descriptor.Variant, filter.Variant, StringComparison.OrdinalIgnoreCase);
}
private static PlatformDescriptor ResolvePlatform(
PlatformDescriptor? descriptor,
OciImageConfig? config)
{
var os = config?.Os ?? descriptor?.Os ?? "unknown";
var arch = config?.Architecture ?? descriptor?.Architecture ?? "unknown";
var variant = config?.Variant ?? descriptor?.Variant;
var osVersion = config?.OsVersion ?? descriptor?.OsVersion;
return new PlatformDescriptor(
Digest: descriptor?.Digest ?? string.Empty,
MediaType: descriptor?.MediaType ?? string.Empty,
Os: os,
Architecture: arch,
Variant: variant,
OsVersion: osVersion,
Annotations: descriptor?.Annotations);
}
private static string ResolveDigest(
OciImageReference reference,
string digest,
List<string> warnings)
{
if (!string.IsNullOrWhiteSpace(digest))
{
return digest;
}
if (!string.IsNullOrWhiteSpace(reference.Digest))
{
return reference.Digest;
}
warnings.Add("Resolved digest missing from registry response.");
return string.Empty;
}
private static string NormalizeMediaType(string? mediaType)
{
if (string.IsNullOrWhiteSpace(mediaType))
{
return string.Empty;
}
var trimmed = mediaType.Trim();
var separator = trimmed.IndexOf(';');
return separator > 0 ? trimmed[..separator].Trim() : trimmed;
}
private static bool IsIndexMediaType(string mediaType)
=> mediaType.Equals(OciMediaTypes.ImageIndex, StringComparison.OrdinalIgnoreCase) ||
mediaType.Equals(OciMediaTypes.DockerManifestList, StringComparison.OrdinalIgnoreCase);
private static bool IsManifestMediaType(string mediaType)
=> mediaType.Equals(OciMediaTypes.ImageManifest, StringComparison.OrdinalIgnoreCase) ||
mediaType.Equals(OciMediaTypes.DockerManifest, StringComparison.OrdinalIgnoreCase) ||
mediaType.Equals(OciMediaTypes.ArtifactManifest, StringComparison.OrdinalIgnoreCase);
private static string? TryGetDigest(HttpResponseMessage response)
{
if (response.Headers.TryGetValues("Docker-Content-Digest", out var values))
{
return values.FirstOrDefault();
}
return null;
}
private static CancellationTokenSource? CreateTimeoutCts(
TimeSpan? timeout,
CancellationToken cancellationToken)
{
if (!timeout.HasValue)
{
return null;
}
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout.Value);
return cts;
}
private static string ResolveInspectorVersion()
{
var version = typeof(OciImageInspector).Assembly.GetName().Version?.ToString();
return string.IsNullOrWhiteSpace(version) ? "stellaops-scanner" : version;
}
private static HttpRequestMessage CloneRequest(HttpRequestMessage request)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
foreach (var header in request.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (request.Content is not null)
{
clone.Content = request.Content;
}
return clone;
}
private static T? Deserialize<T>(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch (JsonException)
{
return default;
}
}
private static ImmutableArray<PlatformManifest> BuildSinglePlatform(PlatformManifest? platform)
{
return platform is null
? ImmutableArray<PlatformManifest>.Empty
: ImmutableArray.Create(platform);
}
private sealed record ManifestFetchResult(string Json, string MediaType, string Digest);
private sealed record PlatformDescriptor(
string Digest,
string MediaType,
string Os,
string Architecture,
string? Variant,
string? OsVersion,
IReadOnlyDictionary<string, string>? Annotations);
private enum ManifestKind
{
Unknown = 0,
Manifest = 1,
Index = 2
}
private sealed record OciIndexDocument
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("manifests")]
public List<OciIndexDescriptor>? Manifests { get; init; }
}
private sealed record OciIndexDescriptor
{
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
[JsonPropertyName("size")]
public long Size { get; init; }
[JsonPropertyName("platform")]
public OciPlatform? Platform { get; init; }
[JsonPropertyName("annotations")]
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
private sealed record OciPlatform
{
[JsonPropertyName("os")]
public string? Os { get; init; }
[JsonPropertyName("architecture")]
public string? Architecture { get; init; }
[JsonPropertyName("variant")]
public string? Variant { get; init; }
[JsonPropertyName("os.version")]
public string? OsVersion { get; init; }
}
private sealed record OciManifestDocument
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("config")]
public OciDescriptor? Config { get; init; }
[JsonPropertyName("layers")]
public List<OciDescriptor>? Layers { get; init; }
}
private sealed record OciImageConfig
{
[JsonPropertyName("os")]
public string? Os { get; init; }
[JsonPropertyName("architecture")]
public string? Architecture { get; init; }
[JsonPropertyName("variant")]
public string? Variant { get; init; }
[JsonPropertyName("os.version")]
public string? OsVersion { get; init; }
}
}

View File

@@ -7,11 +7,27 @@ public static class OciMediaTypes
/// </summary>
public const string ImageManifest = "application/vnd.oci.image.manifest.v1+json";
/// <summary>
/// OCI 1.1 image index (multi-arch manifest list).
/// </summary>
public const string ImageIndex = "application/vnd.oci.image.index.v1+json";
/// <summary>
/// Docker manifest list (multi-arch).
/// </summary>
public const string DockerManifestList = "application/vnd.docker.distribution.manifest.list.v2+json";
/// <summary>
/// Docker image manifest.
/// </summary>
public const string DockerManifest = "application/vnd.docker.distribution.manifest.v2+json";
/// <summary>
/// Deprecated artifact manifest type (kept for compatibility, prefer ImageManifest).
/// </summary>
public const string ArtifactManifest = "application/vnd.oci.artifact.manifest.v1+json";
public const string ImageConfig = "application/vnd.oci.image.config.v1+json";
public const string EmptyConfig = "application/vnd.oci.empty.v1+json";
public const string OctetStream = "application/octet-stream";

View File

@@ -0,0 +1,65 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Storage.Oci;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddOciImageInspector(
this IServiceCollection services,
Action<OciRegistryOptions> configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.AddOptions<OciRegistryOptions>().Configure(configure);
RegisterInspectorServices(services);
return services;
}
public static IServiceCollection AddOciImageInspector(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddOptions<OciRegistryOptions>().Bind(configuration);
RegisterInspectorServices(services);
return services;
}
private static void RegisterInspectorServices(IServiceCollection services)
{
services.TryAddSingleton<TimeProvider>(TimeProvider.System);
services.AddHttpClient(OciImageInspector.HttpClientName)
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var options = sp.GetRequiredService<IOptions<OciRegistryOptions>>().Value;
if (!options.AllowInsecure)
{
return new HttpClientHandler();
}
return new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
});
services.TryAddSingleton<IOciImageInspector>(sp =>
{
var options = sp.GetRequiredService<IOptions<OciRegistryOptions>>().Value;
return new OciImageInspector(
sp.GetRequiredService<IHttpClientFactory>(),
options,
sp.GetRequiredService<ILogger<OciImageInspector>>(),
sp.GetRequiredService<TimeProvider>());
});
}
}

View File

@@ -7,7 +7,12 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
<!-- NOTE: Reachability reference intentionally removed to break circular dependency:
Reachability -> SmartDiff -> Storage.Oci -> Reachability
@@ -15,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Contracts\StellaOps.Scanner.Contracts.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Evidence\StellaOps.Scanner.Evidence.csproj" />
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>

View File

@@ -2,13 +2,13 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<OutputType>Library</OutputType>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Serilog" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="4.2.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,211 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Contracts;
using Xunit;
namespace StellaOps.Scanner.Analyzers.Native.Tests;
public sealed class ElfSectionHashExtractorTests
{
private static readonly TimeProvider FixedTimeProvider =
new FixedTimeProvider(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
[Fact]
public async Task ExtractAsync_ValidElf_ReturnsAllSections()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("standard-amd64.elf");
var golden = LoadGolden("standard-amd64.golden.json");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
result!.Sections.Should().HaveCount(5);
result.FileHash.Should().Be(golden.FileHash);
AssertSection(result.Sections, ".text", golden.Sections[".text"]);
AssertSection(result.Sections, ".rodata", golden.Sections[".rodata"]);
AssertSection(result.Sections, ".data", golden.Sections[".data"]);
AssertSection(result.Sections, ".symtab", golden.Sections[".symtab"]);
AssertSection(result.Sections, ".dynsym", golden.Sections[".dynsym"]);
}
[Fact]
public async Task ExtractAsync_StrippedElf_OmitsSymtab()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("stripped-amd64.elf");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
result!.Sections.Select(section => section.Name)
.Should().NotContain(".symtab");
result.Sections.Select(section => section.Name)
.Should().Contain(".dynsym");
}
[Fact]
public async Task ExtractAsync_InvalidElf_ReturnsNull()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("corrupt.bin");
var result = await extractor.ExtractAsync(fixture);
result.Should().BeNull();
}
[Fact]
public async Task ExtractAsync_EmptySection_ReturnsEmptyHash()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("minimal-amd64.elf");
var golden = LoadGolden("minimal-amd64.golden.json");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
AssertSection(result!.Sections, ".data", golden.Sections[".data"]);
}
[Fact]
public async Task ExtractFromBytesAsync_MatchesFileExtraction()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("standard-amd64.elf");
var bytes = await File.ReadAllBytesAsync(fixture);
var fromFile = await extractor.ExtractAsync(fixture);
var fromBytes = await extractor.ExtractFromBytesAsync(bytes, fixture);
fromFile.Should().NotBeNull();
fromBytes.Should().NotBeNull();
fromBytes!.FileHash.Should().Be(fromFile!.FileHash);
fromBytes.Sections.Should().Equal(fromFile.Sections);
}
[Fact]
public async Task ExtractAsync_LargeSection_RespectsLimit()
{
var options = new ElfSectionHashOptions
{
MaxSectionSizeBytes = 4
};
var extractor = new ElfSectionHashExtractor(
FixedTimeProvider,
Options.Create(options));
var fixture = GetFixturePath("standard-amd64.elf");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
result!.Sections.Select(section => section.Name)
.Should().NotContain(".text");
}
[Fact]
public async Task ExtractAsync_IncludesBlake3_WhenConfigured()
{
var options = new ElfSectionHashOptions();
options.Algorithms.Add("blake3");
var extractor = new ElfSectionHashExtractor(
FixedTimeProvider,
Options.Create(options),
DefaultCryptoHash.CreateForTests());
var fixture = GetFixturePath("standard-amd64.elf");
var result = await extractor.ExtractAsync(fixture);
result.Should().NotBeNull();
result!.Sections.Should().OnlyContain(section => !string.IsNullOrWhiteSpace(section.Blake3));
}
[Fact]
public async Task ExtractAsync_DeterministicAcrossRuns()
{
var extractor = CreateExtractor();
var fixture = GetFixturePath("standard-amd64.elf");
var first = await extractor.ExtractAsync(fixture);
var second = await extractor.ExtractAsync(fixture);
first.Should().NotBeNull();
second.Should().NotBeNull();
first!.FileHash.Should().Be(second!.FileHash);
first.Sections.Should().Equal(second.Sections);
first.ExtractedAt.Should().Be(second.ExtractedAt);
}
private static ElfSectionHashExtractor CreateExtractor()
{
var options = new ElfSectionHashOptions();
return new ElfSectionHashExtractor(FixedTimeProvider, Options.Create(options));
}
private static string GetFixturePath(string fileName)
{
var root = FindRepoRoot();
return Path.Combine(root, "src", "Scanner", "__Tests", "__Datasets", "elf-section-hashes", fileName);
}
private static string FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current != null)
{
if (Directory.Exists(Path.Combine(current.FullName, "docs")) &&
Directory.Exists(Path.Combine(current.FullName, "src")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Repo root not found for fixtures.");
}
private static GoldenResult LoadGolden(string fileName)
{
var path = GetFixturePath(fileName);
var json = File.ReadAllText(path);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var fileHash = root.GetProperty("fileHash").GetString() ?? string.Empty;
var sectionsElement = root.GetProperty("sections");
var sections = new Dictionary<string, GoldenSection>(StringComparer.Ordinal);
foreach (var section in sectionsElement.EnumerateObject())
{
var sectionValue = section.Value;
sections[section.Name] = new GoldenSection
{
Sha256 = sectionValue.GetProperty("sha256").GetString() ?? string.Empty,
Size = sectionValue.GetProperty("size").GetInt32()
};
}
return new GoldenResult(fileHash, sections);
}
private static void AssertSection(
IEnumerable<ElfSectionHash> sections,
string name,
GoldenSection expected)
{
var section = sections.Single(s => s.Name == name);
section.Sha256.Should().Be(expected.Sha256);
section.Size.Should().Be(expected.Size);
}
private sealed record GoldenResult(string FileHash, IReadOnlyDictionary<string, GoldenSection> Sections);
private sealed record GoldenSection
{
public required string Sha256 { get; init; }
public required int Size { get; init; }
}
}

View File

@@ -37,8 +37,8 @@ public sealed class SecretRevelationServiceTests
// Act
var result = _service.ApplyPolicy("AKIAIOSFODNN7EXAMPLE", context);
// Assert
Assert.StartsWith("AKIA", result);
// Assert - with 20 chars, reveals 2 prefix + 2 suffix with masking in between
Assert.StartsWith("AK", result);
Assert.EndsWith("LE", result);
Assert.Contains("*", result);
}
@@ -95,8 +95,9 @@ public sealed class SecretRevelationServiceTests
var result = _service.ApplyPolicy("short", context);
// Assert
// Should not reveal more than safe amount
Assert.Contains("*", result);
// Short values (< 8 chars after masking) return [SECRET: N chars] format
Assert.StartsWith("[SECRET:", result);
Assert.Contains("chars]", result);
}
[Fact]

View File

@@ -56,7 +56,7 @@ public sealed class SecurityCardGeneratorTests
CreateSnapshot("target"));
// Assert
Assert.Single(cards);
Assert.Single(cards, CancellationToken.None);
Assert.Equal(ChangeCategory.Security, cards[0].Category);
Assert.Equal(95, cards[0].Priority); // Critical = 95
Assert.Equal("CVE-2024-1234", cards[0].Cves![0]);
@@ -93,7 +93,7 @@ public sealed class SecurityCardGeneratorTests
CreateSnapshot("target"));
// Assert
Assert.Single(cards);
Assert.Single(cards, CancellationToken.None);
Assert.Equal(90, cards[0].Priority); // High (80) + KEV boost (10) = 90
Assert.Contains("actively exploited (KEV)", cards[0].Why.Text);
}
@@ -129,7 +129,7 @@ public sealed class SecurityCardGeneratorTests
CreateSnapshot("target"));
// Assert
Assert.Single(cards);
Assert.Single(cards, CancellationToken.None);
Assert.Equal(85, cards[0].Priority); // High (80) + reachable boost (5) = 85
Assert.Contains("reachable from entry points", cards[0].Why.Text);
}
@@ -157,7 +157,7 @@ public sealed class SecurityCardGeneratorTests
// Arrange
var changes = new List<MaterialRiskChange>
{
new()
new(, CancellationToken.None)
{
ChangeId = "fixed-change-id",
RuleId = "cve-new",
@@ -175,7 +175,7 @@ public sealed class SecurityCardGeneratorTests
// Act
var cards1 = await _generator.GenerateCardsAsync(CreateSnapshot("base"), CreateSnapshot("target"));
var cards2 = await _generator.GenerateCardsAsync(CreateSnapshot("base"), CreateSnapshot("target"));
var cards2 = await _generator.GenerateCardsAsync(CreateSnapshot("base", CancellationToken.None), CreateSnapshot("target"));
// Assert
Assert.Equal(cards1[0].CardId, cards2[0].CardId);
@@ -189,3 +189,4 @@ public sealed class SecurityCardGeneratorTests
SbomDigest = $"sha256:sbom-{id}"
};
}

View File

@@ -12,7 +12,7 @@
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="Microsoft.Extensions.Time.Testing" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,55 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.ConnectionTesters;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Services;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.ConnectionTesters;
public class GitConnectionTesterTests
{
[Fact]
public async Task TestAsync_WithSshUrl_ReturnsNotValidated()
{
var config = JsonDocument.Parse("""
{
"provider": "GitHub",
"repositoryUrl": "git@github.com:stellaops/sample.git",
"branches": { "include": ["main"] },
"triggers": { "onPush": true, "onPullRequest": false, "onTag": false },
"scanOptions": { "analyzers": ["sbom"] },
"authMethod": "Ssh"
}
""");
var guidProvider = new SequentialGuidProvider();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
var source = SbomSource.Create(
tenantId: "tenant-1",
name: "git-source",
sourceType: SbomSourceType.Git,
configuration: config,
createdBy: "tester",
timeProvider: timeProvider,
guidProvider: guidProvider);
var httpClientFactory = new Mock<IHttpClientFactory>();
var credentialResolver = new Mock<ICredentialResolver>();
var logger = new Mock<ILogger<GitConnectionTester>>();
var tester = new GitConnectionTester(
httpClientFactory.Object,
credentialResolver.Object,
logger.Object,
timeProvider);
var result = await tester.TestAsync(source, overrideCredentials: null, ct: default);
result.Success.Should().BeFalse();
result.Message.Should().Contain("SSH");
result.Details.Should().ContainKey("note");
}
}

View File

@@ -1,5 +1,7 @@
using System.Globalization;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Domain;
using Xunit;
@@ -7,14 +9,16 @@ namespace StellaOps.Scanner.Sources.Tests.Domain;
public class SbomSourceRunTests
{
private static readonly FakeTimeProvider TimeProvider = new(DateTimeOffset.Parse("2026-01-01T00:00:00Z"));
private static readonly FakeTimeProvider TimeProvider = new(
DateTimeOffset.Parse("2026-01-01T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind));
[Fact]
public void Create_WithValidInputs_CreatesRunInRunningStatus()
{
// Arrange
var sourceId = Guid.NewGuid();
var correlationId = Guid.NewGuid().ToString("N");
var guidProvider = new SequentialGuidProvider();
var sourceId = new Guid("11111111-1111-1111-1111-111111111111");
var correlationId = new Guid("22222222-2222-2222-2222-222222222222").ToString("N");
// Act
var run = SbomSourceRun.Create(
@@ -23,10 +27,11 @@ public class SbomSourceRunTests
trigger: SbomSourceRunTrigger.Manual,
correlationId: correlationId,
timeProvider: TimeProvider,
guidProvider: guidProvider,
triggerDetails: "Triggered by user");
// Assert
run.RunId.Should().NotBeEmpty();
run.RunId.Should().Be(new Guid("00000000-0000-0000-0000-000000000001"));
run.SourceId.Should().Be(sourceId);
run.TenantId.Should().Be("tenant-1");
run.Trigger.Should().Be(SbomSourceRunTrigger.Manual);
@@ -41,7 +46,7 @@ public class SbomSourceRunTests
public void SetDiscoveredItems_UpdatesDiscoveryCount()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
// Act
run.SetDiscoveredItems(10);
@@ -54,13 +59,14 @@ public class SbomSourceRunTests
public void RecordItemSuccess_IncrementsCounts()
{
// Arrange
var run = CreateTestRun();
var guidProvider = new SequentialGuidProvider();
var run = CreateTestRun(guidProvider);
run.SetDiscoveredItems(5);
// Act
var scanJobId = Guid.NewGuid();
var scanJobId = guidProvider.NewGuid();
run.RecordItemSuccess(scanJobId);
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
// Assert
run.ItemsScanned.Should().Be(2);
@@ -72,7 +78,7 @@ public class SbomSourceRunTests
public void RecordItemFailure_IncrementsCounts()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
run.SetDiscoveredItems(5);
// Act
@@ -89,7 +95,7 @@ public class SbomSourceRunTests
public void RecordItemSkipped_IncrementsCounts()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
run.SetDiscoveredItems(5);
// Act
@@ -104,11 +110,12 @@ public class SbomSourceRunTests
public void Complete_SetsSuccessStatusAndCompletedAt()
{
// Arrange
var run = CreateTestRun();
var guidProvider = new SequentialGuidProvider();
var run = CreateTestRun(guidProvider);
run.SetDiscoveredItems(3);
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
// Act
run.Complete(TimeProvider);
@@ -123,7 +130,7 @@ public class SbomSourceRunTests
public void Fail_SetsFailedStatusAndErrorMessage()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
// Act
run.Fail("Connection timeout", TimeProvider, "Stack trace here");
@@ -139,7 +146,7 @@ public class SbomSourceRunTests
public void Cancel_SetsCancelledStatus()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
// Act
run.Cancel("User requested cancellation", TimeProvider);
@@ -154,15 +161,16 @@ public class SbomSourceRunTests
public void MixedResults_TracksAllCountsCorrectly()
{
// Arrange
var run = CreateTestRun();
var guidProvider = new SequentialGuidProvider();
var run = CreateTestRun(guidProvider);
run.SetDiscoveredItems(10);
// Act
run.RecordItemSuccess(Guid.NewGuid()); // 1 success
run.RecordItemSuccess(Guid.NewGuid()); // 2 successes
run.RecordItemSuccess(guidProvider.NewGuid()); // 1 success
run.RecordItemSuccess(guidProvider.NewGuid()); // 2 successes
run.RecordItemFailure(); // 1 failure
run.RecordItemSkipped(); // 1 skipped
run.RecordItemSuccess(Guid.NewGuid()); // 3 successes
run.RecordItemSuccess(guidProvider.NewGuid()); // 3 successes
run.RecordItemFailure(); // 2 failures
// Assert
@@ -183,12 +191,14 @@ public class SbomSourceRunTests
string details)
{
// Arrange & Act
var guidProvider = new SequentialGuidProvider();
var run = SbomSourceRun.Create(
sourceId: Guid.NewGuid(),
sourceId: new Guid("33333333-3333-3333-3333-333333333333"),
tenantId: "tenant-1",
trigger: trigger,
correlationId: Guid.NewGuid().ToString("N"),
correlationId: new Guid("44444444-4444-4444-4444-444444444444").ToString("N"),
timeProvider: TimeProvider,
guidProvider: guidProvider,
triggerDetails: details);
// Assert
@@ -200,9 +210,10 @@ public class SbomSourceRunTests
public void Complete_WithMixedResults_SetsPartialSuccessStatus()
{
// Arrange
var run = CreateTestRun();
var guidProvider = new SequentialGuidProvider();
var run = CreateTestRun(guidProvider);
run.SetDiscoveredItems(3);
run.RecordItemSuccess(Guid.NewGuid());
run.RecordItemSuccess(guidProvider.NewGuid());
run.RecordItemFailure();
// Act
@@ -216,7 +227,7 @@ public class SbomSourceRunTests
public void Complete_WithNoSuccesses_SetsSkippedStatus()
{
// Arrange
var run = CreateTestRun();
var run = CreateTestRun(new SequentialGuidProvider());
run.SetDiscoveredItems(0);
// Act
@@ -226,13 +237,14 @@ public class SbomSourceRunTests
run.Status.Should().Be(SbomSourceRunStatus.Skipped);
}
private static SbomSourceRun CreateTestRun()
private static SbomSourceRun CreateTestRun(IGuidProvider guidProvider)
{
return SbomSourceRun.Create(
sourceId: Guid.NewGuid(),
sourceId: new Guid("11111111-1111-1111-1111-111111111111"),
tenantId: "tenant-1",
trigger: SbomSourceRunTrigger.Manual,
correlationId: Guid.NewGuid().ToString("N"),
timeProvider: TimeProvider);
correlationId: "corr-1",
timeProvider: TimeProvider,
guidProvider: guidProvider);
}
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Domain;
using Xunit;
@@ -9,10 +10,12 @@ namespace StellaOps.Scanner.Sources.Tests.Domain;
public class SbomSourceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly SequentialGuidProvider _guidProvider;
public SbomSourceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 10, 0, 0, TimeSpan.Zero));
_guidProvider = new SequentialGuidProvider();
}
private static readonly JsonDocument SampleConfig = JsonDocument.Parse("""
@@ -32,10 +35,11 @@ public class SbomSourceTests
sourceType: SbomSourceType.Zastava,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
// Assert
source.SourceId.Should().NotBeEmpty();
source.SourceId.Should().Be(new Guid("00000000-0000-0000-0000-000000000001"));
source.TenantId.Should().Be("tenant-1");
source.Name.Should().Be("test-source");
source.SourceType.Should().Be(SbomSourceType.Zastava);
@@ -56,6 +60,7 @@ public class SbomSourceTests
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider,
guidProvider: _guidProvider,
cronSchedule: "0 * * * *"); // Every hour
// Assert
@@ -74,7 +79,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Zastava,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
// Assert
source.WebhookEndpoint.Should().NotBeNullOrEmpty();
@@ -91,7 +97,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
// Act
source.Activate("activator", _timeProvider);
@@ -111,7 +118,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.Activate("activator", _timeProvider);
// Act
@@ -134,7 +142,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.Activate("activator", _timeProvider);
source.Pause("Maintenance", null, "operator", _timeProvider);
@@ -157,7 +166,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.Activate("activator", _timeProvider);
// Simulate some failures
@@ -185,7 +195,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.Activate("activator", _timeProvider);
// Act - fail multiple times
@@ -210,7 +221,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
source.MaxScansPerHour = 10;
source.Activate("activator", _timeProvider);
@@ -231,7 +243,8 @@ public class SbomSourceTests
sourceType: SbomSourceType.Docker,
configuration: SampleConfig,
createdBy: "user-1",
timeProvider: _timeProvider);
timeProvider: _timeProvider,
guidProvider: _guidProvider);
var newConfig = JsonDocument.Parse("""
{

View File

@@ -0,0 +1,41 @@
using FluentAssertions;
using StellaOps.Scanner.Sources.Handlers.Docker;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Handlers.Docker;
public class DockerSourceHandlerTests
{
[Fact]
public void ParseReference_KeepsRegistryPort_WhenNoTagProvided()
{
var reference = "registry.example.com:5000/repo/app";
var result = DockerSourceHandler.ParseReference(reference);
result.Repository.Should().Be("registry.example.com:5000/repo/app");
result.Tag.Should().BeNull();
}
[Fact]
public void ParseReference_ParsesTag_WhenAfterSlash()
{
var reference = "registry.example.com:5000/repo/app:1.2.3";
var result = DockerSourceHandler.ParseReference(reference);
result.Repository.Should().Be("registry.example.com:5000/repo/app");
result.Tag.Should().Be("1.2.3");
}
[Fact]
public void BuildFullReference_IncludesRegistryPort()
{
var reference = DockerSourceHandler.BuildFullReference(
"https://registry.example.com:5000",
"repo/app",
"1.2.3");
reference.Should().Be("registry.example.com:5000/repo/app:1.2.3");
}
}

View File

@@ -0,0 +1,43 @@
using System.Globalization;
using System.Text;
using FluentAssertions;
using StellaOps.Scanner.Sources.Persistence;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Persistence;
public class CursorEncodingTests
{
[Fact]
public void Encode_UsesInvariantDigits()
{
var originalCulture = CultureInfo.CurrentCulture;
var originalUiCulture = CultureInfo.CurrentUICulture;
try
{
var testCulture = new CultureInfo("ar-SA");
CultureInfo.CurrentCulture = testCulture;
CultureInfo.CurrentUICulture = testCulture;
var encoded = CursorEncoding.Encode(123);
var expected = Convert.ToBase64String(Encoding.UTF8.GetBytes("123"));
encoded.Should().Be(expected);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
CultureInfo.CurrentUICulture = originalUiCulture;
}
}
[Fact]
public void Decode_RoundTripsEncodedValue()
{
var encoded = CursorEncoding.Encode(456);
var decoded = CursorEncoding.Decode(encoded);
decoded.Should().Be(456);
}
}

View File

@@ -6,10 +6,12 @@
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />

View File

@@ -0,0 +1,21 @@
using FluentAssertions;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Triggers;
using Xunit;
namespace StellaOps.Scanner.Sources.Tests.Triggers;
public class TriggerContextTests
{
[Fact]
public void Manual_UsesGuidProvider_WhenCorrelationIdNotProvided()
{
var expected = new SequentialGuidProvider().NewGuid().ToString("N");
var provider = new SequentialGuidProvider();
var context = TriggerContext.Manual("tester", guidProvider: provider);
context.CorrelationId.Should().Be(expected);
context.Metadata.Should().ContainKey("triggeredBy").WhoseValue.Should().Be("tester");
}
}

View File

@@ -0,0 +1,165 @@
using System.Net.Http.Headers;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Storage.Oci.Tests;
/// <summary>
/// Integration tests for OciImageInspector using a local OCI registry container.
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class OciImageInspectorIntegrationTests : IAsyncLifetime
{
private IContainer? _registryContainer;
private string _registryHost = string.Empty;
private HttpClient? _httpClient;
public async ValueTask InitializeAsync()
{
_registryContainer = new ContainerBuilder()
.WithImage("registry:2")
.WithPortBinding(5000, true)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilHttpRequestIsSucceeded(r => r.ForPath("/v2/").ForPort(5000)))
.Build();
await _registryContainer.StartAsync();
var port = _registryContainer.GetMappedPublicPort(5000);
_registryHost = $"localhost:{port}";
_httpClient = new HttpClient();
}
public async ValueTask DisposeAsync()
{
_httpClient?.Dispose();
if (_registryContainer is not null)
{
await _registryContainer.DisposeAsync();
}
}
[Fact]
public async Task InspectAsync_LocalRegistry_ReturnsManifest()
{
var published = await PushBaseImageAsync();
var inspector = new OciImageInspector(
new TestHttpClientFactory(_httpClient!),
new OciRegistryOptions { DefaultRegistry = _registryHost, AllowInsecure = true },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero)));
var result = await inspector.InspectAsync($"http://{_registryHost}/test/app:latest");
Assert.NotNull(result);
Assert.False(result!.IsMultiArch);
Assert.Equal(published.ManifestDigest, result.ResolvedDigest);
Assert.Single(result.Platforms);
Assert.Equal(published.ConfigDigest, result.Platforms[0].ConfigDigest);
Assert.Single(result.Platforms[0].Layers);
Assert.Equal(published.LayerDigest, result.Platforms[0].Layers[0].Digest);
}
private async Task<PublishedImage> PushBaseImageAsync()
{
var config = """{"created":"2026-01-13T00:00:00Z","architecture":"amd64","os":"linux"}"""u8.ToArray();
var configDigest = ComputeSha256Digest(config);
await PushBlobAsync("test/app", configDigest, config);
var layer = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var layerDigest = ComputeSha256Digest(layer);
await PushBlobAsync("test/app", layerDigest, layer);
var manifest = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": {
"mediaType": "{{OciMediaTypes.ImageConfig}}",
"digest": "{{configDigest}}",
"size": {{config.Length}}
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "{{layerDigest}}",
"size": {{layer.Length}}
}
]
}
""";
var manifestBytes = System.Text.Encoding.UTF8.GetBytes(manifest);
var manifestDigest = ComputeSha256Digest(manifestBytes);
var manifestUrl = $"http://{_registryHost}/v2/test/app/manifests/latest";
var request = new HttpRequestMessage(HttpMethod.Put, manifestUrl)
{
Content = new ByteArrayContent(manifestBytes)
};
request.Content.Headers.ContentType = new MediaTypeHeaderValue(OciMediaTypes.ImageManifest);
var response = await _httpClient!.SendAsync(request);
response.EnsureSuccessStatusCode();
return new PublishedImage(manifestDigest, configDigest, layerDigest);
}
private async Task PushBlobAsync(string repository, string digest, byte[] content)
{
var initiateUrl = $"http://{_registryHost}/v2/{repository}/blobs/uploads/";
var initiateRequest = new HttpRequestMessage(HttpMethod.Post, initiateUrl);
var initiateResponse = await _httpClient!.SendAsync(initiateRequest);
initiateResponse.EnsureSuccessStatusCode();
var uploadLocation = initiateResponse.Headers.Location?.ToString();
Assert.NotNull(uploadLocation);
var separator = uploadLocation.Contains('?') ? "&" : "?";
var uploadUrl = $"{uploadLocation}{separator}digest={Uri.EscapeDataString(digest)}";
if (!uploadUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
uploadUrl = $"http://{_registryHost}{uploadUrl}";
}
var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl)
{
Content = new ByteArrayContent(content)
};
uploadRequest.Content.Headers.ContentType = new MediaTypeHeaderValue(OciMediaTypes.OctetStream);
var uploadResponse = await _httpClient!.SendAsync(uploadRequest);
uploadResponse.EnsureSuccessStatusCode();
}
private static string ComputeSha256Digest(byte[] content)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(content);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private sealed record PublishedImage(string ManifestDigest, string ConfigDigest, string LayerDigest);
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public TestHttpClientFactory(HttpClient client) => _client = client;
public HttpClient CreateClient(string name) => _client;
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,529 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.Storage.Oci.Tests;
public sealed class OciImageInspectorTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 1, 13, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_SingleManifest_ReturnsPlatform()
{
var manifestDigest = "sha256:manifest123";
var configDigest = "sha256:config123";
var manifestJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": {
"mediaType": "{{OciMediaTypes.ImageConfig}}",
"digest": "{{configDigest}}",
"size": 64
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:layer-one",
"size": 11
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:layer-two",
"size": 22,
"annotations": {
"org.opencontainers.image.title": "layer-two"
}
}
]
}
""";
var configJson = """
{
"os": "linux",
"architecture": "amd64"
}
""";
var handler = new ScenarioHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJson, OciMediaTypes.ImageManifest, manifestDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configDigest}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJson, OciMediaTypes.ImageConfig, null);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/demo/app:1.0");
Assert.NotNull(result);
Assert.False(result!.IsMultiArch);
Assert.Equal("registry.example", result.Registry);
Assert.Equal("demo/app", result.Repository);
Assert.Equal(manifestDigest, result.ResolvedDigest);
Assert.Equal(FixedNow, result.InspectedAt);
Assert.Single(result.Platforms);
var platform = result.Platforms[0];
Assert.Equal("linux", platform.Os);
Assert.Equal("amd64", platform.Architecture);
Assert.Equal(manifestDigest, platform.ManifestDigest);
Assert.Equal(configDigest, platform.ConfigDigest);
Assert.Equal(2, platform.Layers.Length);
Assert.Equal(0, platform.Layers[0].Order);
Assert.Equal(1, platform.Layers[1].Order);
Assert.Equal(platform.Layers.Sum(layer => layer.Size), platform.TotalSize);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_MultiArchIndex_ReturnsSortedPlatforms()
{
var indexDigest = "sha256:index123";
var manifestA = "sha256:manifest-a";
var manifestB = "sha256:manifest-b";
var configA = "sha256:config-a";
var configB = "sha256:config-b";
var indexJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageIndex}}",
"manifests": [
{
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"digest": "{{manifestB}}",
"size": 100,
"platform": { "os": "linux", "architecture": "arm64", "variant": "v8" }
},
{
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"digest": "{{manifestA}}",
"size": 90,
"platform": { "os": "linux", "architecture": "amd64" }
}
]
}
""";
var manifestJsonA = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": { "mediaType": "{{OciMediaTypes.ImageConfig}}", "digest": "{{configA}}", "size": 10 },
"layers": []
}
""";
var manifestJsonB = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": { "mediaType": "{{OciMediaTypes.ImageConfig}}", "digest": "{{configB}}", "size": 10 },
"layers": []
}
""";
var configJsonA = """{ "os": "linux", "architecture": "amd64" }""";
var configJsonB = """{ "os": "linux", "architecture": "arm64", "variant": "v8" }""";
var handler = new ScenarioHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/latest", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageIndex, indexDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/latest", StringComparison.Ordinal))
{
return CreateJsonResponse(indexJson, OciMediaTypes.ImageIndex, indexDigest);
}
if (request.Method == HttpMethod.Head && path.EndsWith($"/manifests/{manifestA}", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestA);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/manifests/{manifestA}", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJsonA, OciMediaTypes.ImageManifest, manifestA);
}
if (request.Method == HttpMethod.Head && path.EndsWith($"/manifests/{manifestB}", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestB);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/manifests/{manifestB}", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJsonB, OciMediaTypes.ImageManifest, manifestB);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configA}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJsonA, OciMediaTypes.ImageConfig, null);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configB}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJsonB, OciMediaTypes.ImageConfig, null);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/demo/app:latest");
Assert.NotNull(result);
Assert.True(result!.IsMultiArch);
Assert.Equal(2, result.Platforms.Length);
Assert.Equal("amd64", result.Platforms[0].Architecture);
Assert.Equal("arm64", result.Platforms[1].Architecture);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_PlatformFilter_ReturnsSingleMatch()
{
var indexDigest = "sha256:index-filter";
var manifestA = "sha256:manifest-a";
var manifestB = "sha256:manifest-b";
var configA = "sha256:config-a";
var configB = "sha256:config-b";
var indexJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.DockerManifestList}}",
"manifests": [
{
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"digest": "{{manifestB}}",
"size": 100,
"platform": { "os": "linux", "architecture": "arm64", "variant": "v8" }
},
{
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"digest": "{{manifestA}}",
"size": 90,
"platform": { "os": "linux", "architecture": "amd64" }
}
]
}
""";
var manifestJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": { "mediaType": "{{OciMediaTypes.ImageConfig}}", "digest": "{{configA}}", "size": 10 },
"layers": []
}
""";
var configJson = """{ "os": "linux", "architecture": "amd64" }""";
var handler = new ScenarioHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/latest", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.DockerManifestList, indexDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/latest", StringComparison.Ordinal))
{
return CreateJsonResponse(indexJson, OciMediaTypes.DockerManifestList, indexDigest);
}
if (request.Method == HttpMethod.Head && path.EndsWith($"/manifests/{manifestA}", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestA);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/manifests/{manifestA}", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJson, OciMediaTypes.ImageManifest, manifestA);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configA}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJson, OciMediaTypes.ImageConfig, null);
}
if (request.Method == HttpMethod.Head && path.EndsWith($"/manifests/{manifestB}", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestB);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/manifests/{manifestB}", StringComparison.Ordinal))
{
return CreateJsonResponse(manifestJson, OciMediaTypes.ImageManifest, manifestB);
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configB}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJson, OciMediaTypes.ImageConfig, null);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var options = new ImageInspectionOptions
{
PlatformFilter = "linux/amd64"
};
var result = await inspector.InspectAsync("registry.example/demo/app:latest", options);
Assert.NotNull(result);
Assert.Single(result!.Platforms);
Assert.Equal("amd64", result.Platforms[0].Architecture);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_NotFound_ReturnsNull()
{
var handler = new ScenarioHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/missing/app:latest");
Assert.Null(result);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_AuthChallenge_RequestsToken()
{
var manifestDigest = "sha256:manifest-auth";
var configDigest = "sha256:config-auth";
var manifestJson = $$"""
{
"schemaVersion": 2,
"mediaType": "{{OciMediaTypes.ImageManifest}}",
"config": { "mediaType": "{{OciMediaTypes.ImageConfig}}", "digest": "{{configDigest}}", "size": 5 },
"layers": []
}
""";
var configJson = """{ "os": "linux", "architecture": "amd64" }""";
var sawBasic = false;
var sawBearer = false;
var sawTokenRequest = false;
var handler = new ScenarioHandler(request =>
{
var uri = request.RequestUri;
var path = uri?.AbsolutePath ?? string.Empty;
if (uri is not null && uri.Host.Equals("auth.local", StringComparison.OrdinalIgnoreCase))
{
sawTokenRequest = true;
return CreateJsonResponse("""{ "token": "token-123" }""", "application/json", null);
}
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
var auth = request.Headers.Authorization;
if (auth is not null && auth.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase))
{
sawBearer = true;
return CreateJsonResponse(manifestJson, OciMediaTypes.ImageManifest, manifestDigest);
}
if (auth is not null && auth.Scheme.Equals("Basic", StringComparison.OrdinalIgnoreCase))
{
sawBasic = true;
}
var unauthorized = new HttpResponseMessage(HttpStatusCode.Unauthorized);
unauthorized.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue(
"Bearer",
"realm=\"http://auth.local/token\",service=\"registry\",scope=\"repository:demo/app:pull\""));
return unauthorized;
}
if (request.Method == HttpMethod.Get && path.EndsWith($"/blobs/{configDigest}", StringComparison.Ordinal))
{
return CreateJsonResponse(configJson, OciMediaTypes.ImageConfig, null);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions
{
DefaultRegistry = "registry.example",
Auth = new OciRegistryAuthOptions
{
Username = "user",
Password = "pass",
AllowAnonymousFallback = false
}
},
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/demo/app:1.0");
Assert.NotNull(result);
Assert.True(sawBasic);
Assert.True(sawTokenRequest);
Assert.True(sawBearer);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task InspectAsync_InvalidManifest_ReturnsWarning()
{
var manifestDigest = "sha256:manifest-bad";
var handler = new ScenarioHandler(request =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
if (request.Method == HttpMethod.Head && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateHeadResponse(HttpStatusCode.OK, OciMediaTypes.ImageManifest, manifestDigest);
}
if (request.Method == HttpMethod.Get && path.EndsWith("/manifests/1.0", StringComparison.Ordinal))
{
return CreateJsonResponse("not-json", OciMediaTypes.ImageManifest, manifestDigest);
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var inspector = new OciImageInspector(
new TestHttpClientFactory(handler),
new OciRegistryOptions { DefaultRegistry = "registry.example" },
NullLogger<OciImageInspector>.Instance,
new FixedTimeProvider(FixedNow));
var result = await inspector.InspectAsync("registry.example/demo/app:1.0");
Assert.NotNull(result);
Assert.Empty(result!.Platforms);
Assert.Contains(result.Warnings, warning => warning.Contains("missing config", StringComparison.OrdinalIgnoreCase));
}
private static HttpResponseMessage CreateHeadResponse(HttpStatusCode statusCode, string? mediaType, string? digest)
{
var response = new HttpResponseMessage(statusCode);
if (!string.IsNullOrWhiteSpace(mediaType))
{
response.Content = new ByteArrayContent(Array.Empty<byte>());
response.Content.Headers.ContentType = new MediaTypeHeaderValue(mediaType);
}
if (!string.IsNullOrWhiteSpace(digest))
{
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", digest);
}
return response;
}
private static HttpResponseMessage CreateJsonResponse(string json, string mediaType, string? digest)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, mediaType)
};
if (!string.IsNullOrWhiteSpace(digest))
{
response.Headers.TryAddWithoutValidation("Docker-Content-Digest", digest);
}
return response;
}
private sealed class ScenarioHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public ScenarioHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
_responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_responder(request));
}
}
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public TestHttpClientFactory(HttpMessageHandler handler)
{
_client = new HttpClient(handler);
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -16,6 +16,9 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class BinaryVulnerabilityAnalyzerTests
{
private static readonly Guid FixedScanId = new("11111111-1111-1111-1111-111111111111");
private static readonly Guid AlternateScanId = new("22222222-2222-2222-2222-222222222222");
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task AnalyzeLayerAsync_WithNoBinaryPaths_ReturnsEmptyResult()
@@ -32,7 +35,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
var context = new BinaryLayerContext
{
ScanId = Guid.NewGuid(),
ScanId = FixedScanId,
LayerDigest = "sha256:test",
BinaryPaths = Array.Empty<string>(),
OpenFile = _ => null
@@ -51,7 +54,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
public async Task AnalyzeLayerAsync_WithBinaryPaths_ExtractsIdentitiesAndLooksUpVulnerabilities()
{
// Arrange
var scanId = Guid.NewGuid();
var scanId = AlternateScanId;
var layerDigest = "sha256:abc123";
var buildId = "0123456789abcdef0123456789abcdef01234567";
@@ -163,7 +166,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
var context = new BinaryLayerContext
{
ScanId = Guid.NewGuid(),
ScanId = FixedScanId,
LayerDigest = "sha256:test",
BinaryPaths = ["/usr/lib/bad.so", "/usr/lib/good.so"],
OpenFile = _ => new MemoryStream([0x7F, 0x45, 0x4C, 0x46])
@@ -194,7 +197,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
var context = new BinaryLayerContext
{
ScanId = Guid.NewGuid(),
ScanId = FixedScanId,
LayerDigest = "sha256:test",
BinaryPaths = ["/usr/lib/missing.so"],
OpenFile = _ => null // All files fail to open
@@ -215,7 +218,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
// Arrange
var finding = new BinaryVulnerabilityFinding
{
ScanId = Guid.NewGuid(),
ScanId = FixedScanId,
LayerDigest = "sha256:test",
BinaryKey = "testkey",
CveId = "CVE-2024-5678",
@@ -240,7 +243,7 @@ public sealed class BinaryVulnerabilityAnalyzerTests
public void BinaryAnalysisResult_Empty_ReturnsValidEmptyResult()
{
// Arrange
var scanId = Guid.NewGuid();
var scanId = FixedScanId;
var layerDigest = "sha256:empty";
// Act

View File

@@ -11,6 +11,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
using StellaOps.Scanner.Analyzers.Native.Index;
@@ -35,12 +36,16 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class CompositeScanAnalyzerDispatcherTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_RunsLanguageAnalyzers_StoresResults()
{
using var workspace = new TempDirectory();
using var cacheRoot = new TempDirectory();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.test");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", "unit-test-bucket");
@@ -70,7 +75,7 @@ public sealed class CompositeScanAnalyzerDispatcherTests
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
serviceCollection.AddSingleton(TimeProvider.System);
serviceCollection.AddSingleton<TimeProvider>(timeProvider);
serviceCollection.AddSurfaceEnvironment(options => options.ComponentName = "Scanner.Worker");
serviceCollection.AddSurfaceValidation();
serviceCollection.AddSingleton<ISurfaceValidatorRunner, NoopSurfaceValidatorRunner>();
@@ -121,14 +126,14 @@ public sealed class CompositeScanAnalyzerDispatcherTests
metrics,
new TestCryptoHash());
var lease = new TestJobLease(metadata);
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var lease = new TestJobLease(metadata, timeProvider, jobId: "job-lang-1", scanId: "scan-lang-1");
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(context, TestContext.Current.CancellationToken);
// Re-run with a new context to exercise cache reuse.
var leaseSecond = new TestJobLease(metadata);
var contextSecond = new ScanJobContext(leaseSecond, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var leaseSecond = new TestJobLease(metadata, timeProvider, jobId: "job-lang-2", scanId: "scan-lang-2");
var contextSecond = new ScanJobContext(leaseSecond, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(contextSecond, TestContext.Current.CancellationToken);
meterListener.RecordObservableInstruments();
@@ -161,6 +166,8 @@ public sealed class CompositeScanAnalyzerDispatcherTests
{
using var rootfs = new TempDirectory();
using var cacheRoot = new TempDirectory();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.test");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", "unit-test-bucket");
@@ -194,7 +201,7 @@ public sealed class CompositeScanAnalyzerDispatcherTests
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
serviceCollection.AddSingleton(TimeProvider.System);
serviceCollection.AddSingleton<TimeProvider>(timeProvider);
serviceCollection.AddSurfaceEnvironment(options => options.ComponentName = "Scanner.Worker");
serviceCollection.AddSurfaceValidation();
serviceCollection.AddSingleton<ISurfaceValidatorRunner, NoopSurfaceValidatorRunner>();
@@ -245,13 +252,13 @@ public sealed class CompositeScanAnalyzerDispatcherTests
metrics,
new TestCryptoHash());
var lease = new TestJobLease(metadata);
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var lease = new TestJobLease(metadata, timeProvider, jobId: "job-os-1", scanId: "scan-os-1");
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(context, TestContext.Current.CancellationToken);
var leaseSecond = new TestJobLease(metadata);
var contextSecond = new ScanJobContext(leaseSecond, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var leaseSecond = new TestJobLease(metadata, timeProvider, jobId: "job-os-2", scanId: "scan-os-2");
var contextSecond = new ScanJobContext(leaseSecond, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(contextSecond, TestContext.Current.CancellationToken);
Assert.Equal(1, analyzer.InvocationCount);
@@ -281,6 +288,8 @@ public sealed class CompositeScanAnalyzerDispatcherTests
public async Task ExecuteAsync_RunsNativeAnalyzer_AppendsFileComponents()
{
using var rootfs = new TempDirectory();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
@@ -300,7 +309,7 @@ public sealed class CompositeScanAnalyzerDispatcherTests
var serviceCollection = new ServiceCollection();
serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
serviceCollection.AddSingleton(TimeProvider.System);
serviceCollection.AddSingleton<TimeProvider>(timeProvider);
serviceCollection.AddSingleton<ScannerWorkerMetrics>();
serviceCollection.AddSingleton<IOptions<StellaOps.Scanner.Worker.Options.NativeAnalyzerOptions>>(
Microsoft.Extensions.Options.Options.Create(new StellaOps.Scanner.Worker.Options.NativeAnalyzerOptions
@@ -334,8 +343,8 @@ public sealed class CompositeScanAnalyzerDispatcherTests
metrics,
new TestCryptoHash());
var lease = new TestJobLease(metadata);
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var lease = new TestJobLease(metadata, timeProvider, jobId: "job-native-1", scanId: "scan-native-1");
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
await dispatcher.ExecuteAsync(context, TestContext.Current.CancellationToken);
@@ -460,14 +469,15 @@ public sealed class CompositeScanAnalyzerDispatcherTests
{
private readonly Dictionary<string, string> _metadata;
public TestJobLease(Dictionary<string, string> metadata)
public TestJobLease(Dictionary<string, string> metadata, TimeProvider timeProvider, string jobId, string scanId)
{
_metadata = metadata;
JobId = Guid.NewGuid().ToString("n");
ScanId = $"scan-{Guid.NewGuid():n}";
JobId = jobId;
ScanId = scanId;
Attempt = 1;
EnqueuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-1);
LeasedAtUtc = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now.AddSeconds(-1);
LeasedAtUtc = now;
LeaseDuration = TimeSpan.FromMinutes(5);
}
@@ -498,9 +508,12 @@ public sealed class CompositeScanAnalyzerDispatcherTests
private sealed class TempDirectory : IDisposable
{
private static int _counter;
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():n}");
var suffix = Interlocked.Increment(ref _counter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-test-{suffix}");
Directory.CreateDirectory(Path);
}

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Scanner.Worker.Tests.Determinism;
public sealed class FidelityMetricsIntegrationTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void DeterminismReport_WithFidelityMetrics_IncludesAllThreeTiers()
{
@@ -108,7 +110,7 @@ public sealed class FidelityMetricsIntegrationTests
IdenticalOutputs = (int)(totalReplays * bitwiseFidelity),
SemanticMatches = (int)(totalReplays * semanticFidelity),
PolicyMatches = (int)(totalReplays * policyFidelity),
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
}

View File

@@ -6,6 +6,7 @@ namespace StellaOps.Scanner.Worker.Tests.Determinism;
public sealed class FidelityMetricsServiceTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private readonly FidelityMetricsService _service = new();
[Fact]
@@ -97,7 +98,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 10,
SemanticMatches = 10,
PolicyMatches = 10,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;
@@ -120,7 +121,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 9,
SemanticMatches = 10,
PolicyMatches = 10,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;
@@ -143,7 +144,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 8,
SemanticMatches = 10,
PolicyMatches = 10,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;
@@ -165,7 +166,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 9,
SemanticMatches = 10,
PolicyMatches = 10,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;
@@ -188,7 +189,7 @@ public sealed class FidelityMetricsServiceTests
IdenticalOutputs = 9,
SemanticMatches = 8,
PolicyMatches = 7,
ComputedAt = DateTimeOffset.UtcNow
ComputedAt = FixedNow
};
var thresholds = FidelityThresholds.Default;

View File

@@ -59,7 +59,7 @@ public sealed class PolicyFidelityCalculatorTests
Assert.Equal(0, matchCount);
Assert.Single(mismatches);
Assert.Equal(FidelityMismatchType.PolicyDrift, mismatches[0].Type);
Assert.Contains("outcome:TrueFalse", mismatches[0].AffectedArtifacts!);
Assert.Contains("outcome:True->False", mismatches[0].AffectedArtifacts!);
}
[Fact]
@@ -101,7 +101,7 @@ public sealed class PolicyFidelityCalculatorTests
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Contains("violations:05", mismatches[0].AffectedArtifacts!);
Assert.Contains("violations:0->5", mismatches[0].AffectedArtifacts!);
}
[Fact]
@@ -122,7 +122,7 @@ public sealed class PolicyFidelityCalculatorTests
var (score, matchCount, mismatches) = _calculator.Calculate(baseline, replays);
Assert.Equal(0.0, score);
Assert.Contains("block_level:nonewarn", mismatches[0].AffectedArtifacts!);
Assert.Contains("block_level:none->warn", mismatches[0].AffectedArtifacts!);
}
[Fact]

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Entropy;
using StellaOps.Scanner.Worker.Processing;
@@ -15,45 +16,64 @@ namespace StellaOps.Scanner.Worker.Tests;
public class EntropyStageExecutorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_WritesEntropyReportAndSummary()
{
// Arrange: create a temp file with random bytes to yield high entropy.
var tmp = Path.GetTempFileName();
var rng = new Random(1234);
var bytes = new byte[64 * 1024];
rng.NextBytes(bytes);
File.WriteAllBytes(tmp, bytes);
var fileEntries = new List<ScanFileEntry>
var tmp = Path.Combine(Path.GetTempPath(), "stellaops-tests", "entropy-stage", "entropy.bin");
Directory.CreateDirectory(Path.GetDirectoryName(tmp)!);
if (File.Exists(tmp))
{
new ScanFileEntry(tmp, SizeBytes: bytes.LongLength, Kind: "blob", Metadata: new Dictionary<string, string>())
};
File.Delete(tmp);
}
try
{
var rng = new Random(1234);
var bytes = new byte[64 * 1024];
rng.NextBytes(bytes);
File.WriteAllBytes(tmp, bytes);
var lease = new StubLease("job-1", "scan-1", imageDigest: "sha256:test", layerDigest: "sha256:layer");
var context = new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, TestContext.Current.CancellationToken);
context.Analysis.Set(ScanAnalysisKeys.FileEntries, (IReadOnlyList<ScanFileEntry>)fileEntries);
var fileEntries = new List<ScanFileEntry>
{
new ScanFileEntry(tmp, SizeBytes: bytes.LongLength, Kind: "blob", Metadata: new Dictionary<string, string>())
};
var executor = new EntropyStageExecutor(NullLogger<EntropyStageExecutor>.Instance);
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new StubLease(timeProvider, "job-1", "scan-1", imageDigest: "sha256:test", layerDigest: "sha256:layer");
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
context.Analysis.Set(ScanAnalysisKeys.FileEntries, (IReadOnlyList<ScanFileEntry>)fileEntries);
// Act
await executor.ExecuteAsync(context, TestContext.Current.CancellationToken);
var executor = new EntropyStageExecutor(NullLogger<EntropyStageExecutor>.Instance);
// Assert
Assert.True(context.Analysis.TryGet<EntropyReport>(ScanAnalysisKeys.EntropyReport, out var report));
Assert.NotNull(report);
Assert.Equal("sha256:layer", report!.LayerDigest);
Assert.NotEmpty(report.Files);
// Act
await executor.ExecuteAsync(context, TestContext.Current.CancellationToken);
Assert.True(context.Analysis.TryGet<EntropyLayerSummary>(ScanAnalysisKeys.EntropyLayerSummary, out var summary));
Assert.NotNull(summary);
Assert.Equal("sha256:layer", summary!.LayerDigest);
// Assert
Assert.True(context.Analysis.TryGet<EntropyReport>(ScanAnalysisKeys.EntropyReport, out var report));
Assert.NotNull(report);
Assert.Equal("sha256:layer", report!.LayerDigest);
Assert.NotEmpty(report.Files);
Assert.True(context.Analysis.TryGet<EntropyLayerSummary>(ScanAnalysisKeys.EntropyLayerSummary, out var summary));
Assert.NotNull(summary);
Assert.Equal("sha256:layer", summary!.LayerDigest);
}
finally
{
if (File.Exists(tmp))
{
File.Delete(tmp);
}
}
}
private sealed class StubLease : IScanJobLease
{
public StubLease(string jobId, string scanId, string imageDigest, string layerDigest)
public StubLease(TimeProvider timeProvider, string jobId, string scanId, string imageDigest, string layerDigest)
{
JobId = jobId;
ScanId = scanId;
@@ -62,13 +82,16 @@ public class EntropyStageExecutorTests
["image.digest"] = imageDigest,
["layerDigest"] = layerDigest
};
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now;
LeasedAtUtc = now;
}
public string JobId { get; }
public string ScanId { get; }
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc => DateTimeOffset.UtcNow;
public DateTimeOffset LeasedAtUtc => DateTimeOffset.UtcNow;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata { get; }

View File

@@ -10,6 +10,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.EntryTrace.Runtime;
@@ -28,11 +29,15 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class EntryTraceExecutionServiceTests : IDisposable
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private static int _instanceCounter;
private static int _tempCounter;
private readonly string _tempRoot;
public EntryTraceExecutionServiceTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{Guid.NewGuid():n}");
var suffix = Interlocked.Increment(ref _instanceCounter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{suffix}");
Directory.CreateDirectory(_tempRoot);
}
@@ -207,15 +212,21 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
hash);
}
private static ScanJobContext CreateContext(IReadOnlyDictionary<string, string> metadata)
private static ScanJobContext CreateContext(
IReadOnlyDictionary<string, string> metadata,
string jobId = "job-entrytrace",
string scanId = "scan-entrytrace")
{
var lease = new TestLease(metadata);
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, TestContext.Current.CancellationToken);
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new TestLease(metadata, timeProvider, jobId, scanId);
return new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
}
private Dictionary<string, string> CreateMetadata(params string[] environmentEntries)
{
var configPath = Path.Combine(_tempRoot, $"config-{Guid.NewGuid():n}.json");
var suffix = Interlocked.Increment(ref _tempCounter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
var configPath = Path.Combine(_tempRoot, $"config-{suffix}.json");
var env = environmentEntries.Length == 0
? new[] { "PATH=/bin:/usr/bin" }
: environmentEntries;
@@ -232,7 +243,7 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
}
""");
var rootDirectory = Path.Combine(_tempRoot, $"root-{Guid.NewGuid():n}");
var rootDirectory = Path.Combine(_tempRoot, $"root-{suffix}");
Directory.CreateDirectory(rootDirectory);
File.WriteAllText(Path.Combine(rootDirectory, "entrypoint.sh"), "#!/bin/sh\necho hello\n");
@@ -320,16 +331,19 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public TestLease(IReadOnlyDictionary<string, string> metadata)
public TestLease(IReadOnlyDictionary<string, string> metadata, TimeProvider timeProvider, string jobId, string scanId)
{
_metadata = metadata;
EnqueuedAtUtc = DateTimeOffset.UtcNow;
LeasedAtUtc = EnqueuedAtUtc;
JobId = jobId;
ScanId = scanId;
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now;
LeasedAtUtc = now;
}
public string JobId { get; } = $"job-{Guid.NewGuid():n}";
public string JobId { get; }
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public string ScanId { get; }
public int Attempt => 1;
@@ -368,7 +382,7 @@ public sealed class EntryTraceExecutionServiceTests : IDisposable
new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, AllowInline: true),
"tenant",
new SurfaceTlsConfiguration(null, null, null))
{ CreatedAtUtc = DateTimeOffset.UtcNow };
{ CreatedAtUtc = FixedNow };
RawVariables = new Dictionary<string, string>();
}

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
@@ -51,7 +52,7 @@ public sealed class EpssEnrichmentJobTests
score: 0.70,
percentile: 0.995,
modelDate: modelDate,
capturedAt: DateTimeOffset.Parse("2027-01-16T00:07:00Z"),
capturedAt: new DateTimeOffset(2027, 1, 16, 0, 7, 0, TimeSpan.Zero),
source: "test",
fromCache: false)
},
@@ -88,7 +89,7 @@ public sealed class EpssEnrichmentJobTests
CriticalPercentile = 0.995,
MediumPercentile = 0.90,
}),
TimeProvider.System,
CreateTimeProvider(),
NullLogger<EpssEnrichmentJob>.Instance);
await job.EnrichAsync();
@@ -98,4 +99,11 @@ public sealed class EpssEnrichmentJobTests
Assert.Equal(EpssPriorityBand.Medium.ToString(), published[0].oldBand);
Assert.Equal(EpssPriorityBand.Critical.ToString(), published[0].newBand);
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(new DateTimeOffset(2027, 1, 16, 0, 8, 0, TimeSpan.Zero));
return timeProvider;
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Npgsql;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage;
@@ -60,11 +61,15 @@ public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
var observedCveRepository = new PostgresObservedCveRepository(_dataSource);
var day1 = new DateOnly(2027, 1, 15);
var run1 = await epssRepository.BeginImportAsync(day1, "bundle://day1.csv.gz", DateTimeOffset.Parse("2027-01-15T00:05:00Z"), "sha256:day1");
var run1 = await epssRepository.BeginImportAsync(
day1,
"bundle://day1.csv.gz",
new DateTimeOffset(2027, 1, 15, 0, 5, 0, TimeSpan.Zero),
"sha256:day1");
var write1 = await epssRepository.WriteSnapshotAsync(
run1.ImportRunId,
day1,
DateTimeOffset.Parse("2027-01-15T00:06:00Z"),
new DateTimeOffset(2027, 1, 15, 0, 6, 0, TimeSpan.Zero),
ToAsync(new[]
{
new EpssScoreRow("CVE-2024-0001", 0.40, 0.90),
@@ -73,11 +78,15 @@ public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
await epssRepository.MarkImportSucceededAsync(run1.ImportRunId, write1.RowCount, "sha256:decompressed1", "v2027.01.15", day1);
var day2 = new DateOnly(2027, 1, 16);
var run2 = await epssRepository.BeginImportAsync(day2, "bundle://day2.csv.gz", DateTimeOffset.Parse("2027-01-16T00:05:00Z"), "sha256:day2");
var run2 = await epssRepository.BeginImportAsync(
day2,
"bundle://day2.csv.gz",
new DateTimeOffset(2027, 1, 16, 0, 5, 0, TimeSpan.Zero),
"sha256:day2");
var write2 = await epssRepository.WriteSnapshotAsync(
run2.ImportRunId,
day2,
DateTimeOffset.Parse("2027-01-16T00:06:00Z"),
new DateTimeOffset(2027, 1, 16, 0, 6, 0, TimeSpan.Zero),
ToAsync(new[]
{
new EpssScoreRow("CVE-2024-0001", 0.55, 0.95),
@@ -107,7 +116,7 @@ public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
Enabled = true,
BatchSize = 500
}),
TimeProvider.System,
CreateTimeProvider(),
NullLogger<EpssSignalJob>.Instance);
await job.GenerateSignalsAsync();
@@ -167,6 +176,13 @@ public sealed class EpssSignalFlowIntegrationTests : IAsyncLifetime
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(new DateTimeOffset(2027, 1, 16, 0, 8, 0, TimeSpan.Zero));
return timeProvider;
}
private sealed class RecordingEpssSignalPublisher : IEpssSignalPublisher
{
public List<EpssSignal> Published { get; } = new();

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
@@ -26,7 +27,7 @@ public sealed class EpssSignalJobTests
ImportRunId: Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
ModelDate: modelDate,
SourceUri: "bundle://test.csv.gz",
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
RetrievedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 5, 0, TimeSpan.Zero),
FileSha256: "sha256:test",
DecompressedSha256: "sha256:decompressed",
RowCount: 3,
@@ -34,7 +35,7 @@ public sealed class EpssSignalJobTests
PublishedDate: modelDate,
Status: "SUCCEEDED",
Error: null,
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")));
CreatedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 6, 0, TimeSpan.Zero)));
var changes = new List<EpssChangeRecord>
{
@@ -123,7 +124,7 @@ public sealed class EpssSignalJobTests
Enabled = true,
BatchSize = 500
}),
TimeProvider.System,
CreateTimeProvider(),
NullLogger<EpssSignalJob>.Instance);
await job.GenerateSignalsAsync();
@@ -159,7 +160,7 @@ public sealed class EpssSignalJobTests
ImportRunId: Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
ModelDate: modelDate,
SourceUri: "bundle://test.csv.gz",
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
RetrievedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 5, 0, TimeSpan.Zero),
FileSha256: "sha256:test",
DecompressedSha256: "sha256:decompressed",
RowCount: 1,
@@ -167,12 +168,12 @@ public sealed class EpssSignalJobTests
PublishedDate: modelDate,
Status: "SUCCEEDED",
Error: null,
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")))
CreatedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 6, 0, TimeSpan.Zero)))
.ReturnsAsync(new EpssImportRun(
ImportRunId: Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
ModelDate: modelDate,
SourceUri: "bundle://test.csv.gz",
RetrievedAtUtc: DateTimeOffset.Parse("2027-01-16T00:05:00Z"),
RetrievedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 5, 0, TimeSpan.Zero),
FileSha256: "sha256:test",
DecompressedSha256: "sha256:decompressed",
RowCount: 1,
@@ -180,7 +181,7 @@ public sealed class EpssSignalJobTests
PublishedDate: modelDate,
Status: "SUCCEEDED",
Error: null,
CreatedAtUtc: DateTimeOffset.Parse("2027-01-16T00:06:00Z")));
CreatedAtUtc: new DateTimeOffset(2027, 1, 16, 0, 6, 0, TimeSpan.Zero)));
var changes = new List<EpssChangeRecord>
{
@@ -262,7 +263,7 @@ public sealed class EpssSignalJobTests
Enabled = true,
BatchSize = 500
}),
TimeProvider.System,
CreateTimeProvider(),
NullLogger<EpssSignalJob>.Instance);
await job.GenerateSignalsAsync(); // establishes _lastModelVersion
@@ -291,4 +292,11 @@ public sealed class EpssSignalJobTests
public Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default) => Task.FromResult(_latestModelDate);
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default) => Task.FromResult(true);
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(new DateTimeOffset(2027, 1, 16, 0, 8, 0, TimeSpan.Zero));
return timeProvider;
}
}

View File

@@ -1,11 +1,11 @@
using System;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestation;
using StellaOps.Cryptography;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Processing.Surface;
@@ -86,42 +86,11 @@ public sealed class HmacDsseEnvelopeSignerTests
{
var secret = Convert.FromBase64String(base64Secret);
using var hmac = new System.Security.Cryptography.HMACSHA256(secret);
var pae = BuildPae(payloadType, payload);
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payload);
var signature = hmac.ComputeHash(pae);
return Base64UrlEncode(signature);
}
private static byte[] BuildPae(string payloadType, byte[] payload)
{
const string prefix = "DSSEv1";
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var typeLen = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
var payloadLen = Encoding.UTF8.GetBytes(payload.Length.ToString());
var total = prefix.Length + 1 + typeLen.Length + 1 + typeBytes.Length + 1 + payloadLen.Length + 1 + payload.Length;
var buffer = new byte[total];
var offset = 0;
Encoding.UTF8.GetBytes(prefix, buffer.AsSpan(offset));
offset += prefix.Length;
buffer[offset++] = 0x20;
typeLen.CopyTo(buffer.AsSpan(offset));
offset += typeLen.Length;
buffer[offset++] = 0x20;
typeBytes.CopyTo(buffer.AsSpan(offset));
offset += typeBytes.Length;
buffer[offset++] = 0x20;
payloadLen.CopyTo(buffer.AsSpan(offset));
offset += payloadLen.Length;
buffer[offset++] = 0x20;
payload.CopyTo(buffer.AsSpan(offset));
return buffer;
}
private static string Base64UrlEncode(ReadOnlySpan<byte> data)
{
var base64 = Convert.ToBase64String(data);

View File

@@ -1,7 +1,7 @@
// -----------------------------------------------------------------------------
// WorkerEndToEndJobTests.cs
// Sprint: SPRINT_5100_0009_0001 - Scanner Module Test Implementation
// Task: SCANNER-5100-020 - Add end-to-end job test: enqueue worker runs stored evidence exists events emitted
// Task: SCANNER-5100-020 - Add end-to-end job test: enqueue -> worker runs -> stored evidence exists -> events emitted
// Description: Tests the complete job lifecycle from enqueue to evidence storage
// -----------------------------------------------------------------------------
@@ -28,7 +28,7 @@ namespace StellaOps.Scanner.Worker.Tests.Integration;
/// <summary>
/// End-to-end integration tests for Scanner Worker job lifecycle.
/// Validates: job acquisition processing storage event emission.
/// Validates: job acquisition -> processing -> storage -> event emission.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "WK1")]
@@ -422,7 +422,7 @@ public sealed class WorkerEndToEndJobTests
{
Type = "scan.completed",
ScanId = context.Lease.ScanId,
Timestamp = DateTimeOffset.UtcNow
Timestamp = context.TimeProvider.GetUtcNow()
});
}
}

View File

@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Worker.Options;
using StellaOps.Scanner.Worker.Determinism;
using StellaOps.Scanner.Worker.Processing;
@@ -15,6 +16,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class LeaseHeartbeatServiceTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RunAsync_RespectsSafetyFactorBudget()
@@ -32,11 +35,13 @@ public sealed class LeaseHeartbeatServiceTests
var optionsMonitor = new StaticOptionsMonitor<ScannerWorkerOptions>(options);
using var cts = new CancellationTokenSource();
var scheduler = new RecordingDelayScheduler(cts);
var lease = new TestJobLease(TimeSpan.FromSeconds(90));
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new TestJobLease(timeProvider, "job-lease-1", "scan-lease-1", TimeSpan.FromSeconds(90));
var randomProvider = new DeterministicRandomProvider(seed: 1337);
var service = new LeaseHeartbeatService(
TimeProvider.System,
timeProvider,
scheduler,
optionsMonitor,
randomProvider,
@@ -71,16 +76,19 @@ public sealed class LeaseHeartbeatServiceTests
private sealed class TestJobLease : IScanJobLease
{
public TestJobLease(TimeSpan leaseDuration)
public TestJobLease(TimeProvider timeProvider, string jobId, string scanId, TimeSpan leaseDuration)
{
LeaseDuration = leaseDuration;
EnqueuedAtUtc = DateTimeOffset.UtcNow - leaseDuration;
LeasedAtUtc = DateTimeOffset.UtcNow;
JobId = jobId;
ScanId = scanId;
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now - leaseDuration;
LeasedAtUtc = now;
}
public string JobId { get; } = Guid.NewGuid().ToString("n");
public string JobId { get; }
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public string ScanId { get; }
public int Attempt { get; } = 1;

View File

@@ -16,6 +16,13 @@ namespace StellaOps.Scanner.Worker.Tests.Metrics;
public sealed class ScanCompletionMetricsIntegrationTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private static readonly Guid ScanIdPrimary = new("11111111-1111-1111-1111-111111111111");
private static readonly Guid TenantIdPrimary = new("22222222-2222-2222-2222-222222222222");
private static readonly Guid ScanIdSecondary = new("33333333-3333-3333-3333-333333333333");
private static readonly Guid ScanIdTertiary = new("44444444-4444-4444-4444-444444444444");
private static readonly Guid ScanIdQuaternary = new("55555555-5555-5555-5555-555555555555");
[Fact]
public async Task CaptureAsync_PersistsMetricsOnScanCompletion()
{
@@ -40,8 +47,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
var context = new ScanCompletionContext
{
ScanId = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
ScanId = ScanIdPrimary,
TenantId = TenantIdPrimary,
ArtifactDigest = "sha256:abc123",
ArtifactType = "oci_image",
FindingsSha256 = "sha256:def456",
@@ -53,15 +60,15 @@ public sealed class ScanCompletionMetricsIntegrationTests
new PhaseCompletionInfo
{
PhaseName = "pull",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-10),
FinishedAt = DateTimeOffset.UtcNow.AddSeconds(-5),
StartedAt = FixedNow.AddSeconds(-10),
FinishedAt = FixedNow.AddSeconds(-5),
Success = true
},
new PhaseCompletionInfo
{
PhaseName = "analyze",
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-5),
FinishedAt = DateTimeOffset.UtcNow,
StartedAt = FixedNow.AddSeconds(-5),
FinishedAt = FixedNow,
Success = true
}
}
@@ -97,8 +104,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
var context = new ScanCompletionContext
{
ScanId = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
ScanId = ScanIdSecondary,
TenantId = TenantIdPrimary,
ArtifactDigest = "sha256:abc123",
ArtifactType = "oci_image",
FindingsSha256 = "sha256:def456"
@@ -130,8 +137,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
var context = new ScanCompletionContext
{
ScanId = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
ScanId = ScanIdTertiary,
TenantId = TenantIdPrimary,
ArtifactDigest = "sha256:abc123",
ArtifactType = "oci_image",
FindingsSha256 = "sha256:findings",
@@ -172,8 +179,8 @@ public sealed class ScanCompletionMetricsIntegrationTests
var context = new ScanCompletionContext
{
ScanId = Guid.NewGuid(),
TenantId = Guid.NewGuid(),
ScanId = ScanIdQuaternary,
TenantId = TenantIdPrimary,
ArtifactDigest = "sha256:abc123",
ArtifactType = "oci_image",
FindingsSha256 = "sha256:findings",

View File

@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Attestor;
using StellaOps.Scanner.Core.Configuration;
@@ -22,6 +23,9 @@ namespace StellaOps.Scanner.Worker.Tests.PoE;
public class PoEGenerationStageExecutorTests : IDisposable
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private static int _tempCounter;
private readonly string _tempCasRoot;
private readonly Mock<IReachabilityResolver> _resolverMock;
private readonly Mock<IProofEmitter> _emitterMock;
@@ -32,7 +36,8 @@ public class PoEGenerationStageExecutorTests : IDisposable
public PoEGenerationStageExecutorTests()
{
_tempCasRoot = Path.Combine(Path.GetTempPath(), $"poe-stage-test-{Guid.NewGuid()}");
var suffix = Interlocked.Increment(ref _tempCounter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
_tempCasRoot = Path.Combine(Path.GetTempPath(), $"poe-stage-test-{suffix}");
Directory.CreateDirectory(_tempCasRoot);
_resolverMock = new Mock<IReachabilityResolver>();
@@ -249,9 +254,14 @@ public class PoEGenerationStageExecutorTests : IDisposable
It.IsAny<CancellationToken>()))
.ReturnsAsync(poeBytes);
var hashCounter = 0;
_emitterMock
.Setup(x => x.ComputePoEHash(poeBytes))
.Returns((byte[] data) => $"blake3:{Guid.NewGuid():N}");
.Returns(() =>
{
hashCounter++;
return $"blake3:{hashCounter:D2}";
});
_emitterMock
.Setup(x => x.SignPoEAsync(poeBytes, It.IsAny<string>(), It.IsAny<CancellationToken>()))
@@ -325,10 +335,12 @@ public class PoEGenerationStageExecutorTests : IDisposable
leaseMock.Setup(l => l.JobId).Returns("job-123");
leaseMock.Setup(l => l.ScanId).Returns("scan-abc123");
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
return new ScanJobContext(
leaseMock.Object,
TimeProvider.System,
DateTimeOffset.UtcNow,
timeProvider,
timeProvider.GetUtcNow(),
TestContext.Current.CancellationToken
);
}

View File

@@ -21,6 +21,8 @@ namespace StellaOps.Scanner.Worker.Tests.PoE;
/// </summary>
public class PoEOrchestratorDirectTests : IDisposable
{
private static int _tempCounter;
private readonly ITestOutputHelper _output;
private readonly string _tempCasRoot;
private readonly Mock<IReachabilityResolver> _resolverMock;
@@ -31,7 +33,8 @@ public class PoEOrchestratorDirectTests : IDisposable
public PoEOrchestratorDirectTests(ITestOutputHelper output)
{
_output = output;
_tempCasRoot = Path.Combine(Path.GetTempPath(), $"poe-direct-test-{Guid.NewGuid()}");
var suffix = Interlocked.Increment(ref _tempCounter).ToString("D4", System.Globalization.CultureInfo.InvariantCulture);
_tempCasRoot = Path.Combine(Path.GetTempPath(), $"poe-direct-test-{suffix}");
Directory.CreateDirectory(_tempCasRoot);
_resolverMock = new Mock<IReachabilityResolver>();

View File

@@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Queue;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Hosting;
@@ -21,6 +22,10 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class RedisWorkerSmokeTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private static readonly string RunId =
Environment.GetEnvironmentVariable("STELLAOPS_REDIS_SMOKE_RUN_ID") ?? "default";
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Worker_CompletesJob_ViaRedisQueue()
@@ -32,8 +37,8 @@ public sealed class RedisWorkerSmokeTests
}
var redisConnection = Environment.GetEnvironmentVariable("STELLAOPS_REDIS_CONNECTION") ?? "localhost:6379";
var streamName = $"scanner:jobs:{Guid.NewGuid():n}";
var consumerGroup = $"worker-smoke-{Guid.NewGuid():n}";
var streamName = $"scanner:jobs:{RunId}";
var consumerGroup = $"worker-smoke-{RunId}";
var configuration = BuildQueueConfiguration(redisConnection, streamName, consumerGroup);
var queueOptions = new ScannerQueueOptions();
@@ -59,7 +64,9 @@ public sealed class RedisWorkerSmokeTests
builder.SetMinimumLevel(LogLevel.Debug);
builder.AddConsole();
});
services.AddSingleton(TimeProvider.System);
var timeProvider = CreateTimeProvider();
services.AddSingleton(timeProvider);
services.AddSingleton<TimeProvider>(timeProvider);
services.AddScannerQueue(configuration, "scanner:queue");
services.AddSingleton<IScanJobSource, QueueBackedScanJobSource>();
services.AddSingleton<QueueBackedScanJobSourceDependencies>();
@@ -77,8 +84,8 @@ public sealed class RedisWorkerSmokeTests
using var provider = services.BuildServiceProvider();
var queue = provider.GetRequiredService<IScanQueue>();
var jobId = $"job-{Guid.NewGuid():n}";
var scanId = $"scan-{Guid.NewGuid():n}";
var jobId = $"job-{RunId}";
var scanId = $"scan-{RunId}";
await queue.EnqueueAsync(new ScanQueueMessage(jobId, Encoding.UTF8.GetBytes("smoke"))
{
Attributes = new Dictionary<string, string>(StringComparer.Ordinal)
@@ -139,7 +146,7 @@ public sealed class RedisWorkerSmokeTests
private readonly ScannerQueueOptions _queueOptions;
private readonly QueueBackedScanJobSourceDependencies _deps;
private readonly TimeProvider _timeProvider;
private readonly string _consumerName = $"worker-smoke-{Guid.NewGuid():n}";
private readonly string _consumerName = $"worker-smoke-{RunId}";
public QueueBackedScanJobSource(
IScanQueue queue,
@@ -150,7 +157,7 @@ public sealed class RedisWorkerSmokeTests
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
_deps = deps ?? throw new ArgumentNullException(nameof(deps));
_timeProvider = timeProvider ?? TimeProvider.System;
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
@@ -245,4 +252,11 @@ public sealed class RedisWorkerSmokeTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
return timeProvider;
}
}

View File

@@ -7,6 +7,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Surface.Env;
using StellaOps.Scanner.Surface.Secrets;
@@ -20,6 +21,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class RegistrySecretStageExecutorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_WithSecret_StoresCredentialsAndEmitsMetrics()
@@ -41,7 +44,8 @@ public sealed class RegistrySecretStageExecutorTests
var provider = new StubSecretProvider(secretJson);
var environment = new StubSurfaceEnvironment("tenant-eu");
var metrics = new ScannerWorkerMetrics();
var timeProvider = TimeProvider.System;
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var executor = new RegistrySecretStageExecutor(
provider,
environment,
@@ -53,7 +57,7 @@ public sealed class RegistrySecretStageExecutorTests
{
["surface.registry.secret"] = "primary"
};
var lease = new StubLease("job-1", "scan-1", metadata);
var lease = new StubLease(timeProvider, "job-1", "scan-1", metadata);
using var contextCancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), contextCancellation.Token);
@@ -81,15 +85,17 @@ public sealed class RegistrySecretStageExecutorTests
var provider = new MissingSecretProvider();
var environment = new StubSurfaceEnvironment("tenant-eu");
var metrics = new ScannerWorkerMetrics();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var executor = new RegistrySecretStageExecutor(
provider,
environment,
metrics,
TimeProvider.System,
timeProvider,
NullLogger<RegistrySecretStageExecutor>.Instance);
var lease = new StubLease("job-2", "scan-2", new Dictionary<string, string>());
var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
var lease = new StubLease(timeProvider, "job-2", "scan-2", new Dictionary<string, string>());
var context = new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
var measurements = new List<(long Value, KeyValuePair<string, object?>[] Tags)>();
using var listener = CreateCounterListener("scanner_worker_registry_secret_requests_total", measurements);
@@ -183,7 +189,7 @@ public sealed class RegistrySecretStageExecutorTests
tenant,
new SurfaceTlsConfiguration(null, null, null))
{
CreatedAtUtc = DateTimeOffset.UtcNow
CreatedAtUtc = FixedNow
};
RawVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -198,13 +204,14 @@ public sealed class RegistrySecretStageExecutorTests
{
private readonly IReadOnlyDictionary<string, string> _metadata;
public StubLease(string jobId, string scanId, IReadOnlyDictionary<string, string> metadata)
public StubLease(TimeProvider timeProvider, string jobId, string scanId, IReadOnlyDictionary<string, string> metadata)
{
JobId = jobId;
ScanId = scanId;
_metadata = metadata;
EnqueuedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1);
LeasedAtUtc = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now.AddMinutes(-1);
LeasedAtUtc = now;
}
public string JobId { get; }

View File

@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Processing.Replay;
@@ -45,20 +46,31 @@ public sealed class ReplaySealedBundleStageExecutorTests
internal static class TestContexts
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
public static ScanJobContext Create(out Dictionary<string, string> metadata)
{
var lease = new TestScanJobLease();
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new TestScanJobLease(timeProvider);
metadata = lease.MutableMetadata;
return new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), TestContext.Current.CancellationToken);
return new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
}
private sealed class TestScanJobLease : IScanJobLease
{
private readonly DateTimeOffset _now;
public TestScanJobLease(TimeProvider timeProvider)
{
_now = timeProvider.GetUtcNow();
}
public string JobId => "job-1";
public string ScanId => "scan-1";
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc => DateTimeOffset.UtcNow;
public DateTimeOffset LeasedAtUtc => DateTimeOffset.UtcNow;
public DateTimeOffset EnqueuedAtUtc => _now;
public DateTimeOffset LeasedAtUtc => _now;
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public Dictionary<string, string> MutableMetadata { get; } = new();
public IReadOnlyDictionary<string, string> Metadata => MutableMetadata;

View File

@@ -16,6 +16,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class ScannerStorageSurfaceSecretConfiguratorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Configure_WithCasAccessSecret_AppliesSettings()
@@ -85,7 +87,7 @@ public sealed class ScannerStorageSurfaceSecretConfiguratorTests
tenant,
new SurfaceTlsConfiguration(null, null, null))
{
CreatedAtUtc = DateTimeOffset.UtcNow
CreatedAtUtc = FixedNow
};
RawVariables = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -5,16 +5,18 @@
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Queue/StellaOps.Scanner.Queue.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
</Project>
</Project>

View File

@@ -11,11 +11,13 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class SurfaceCacheOptionsConfiguratorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Configure_UsesSurfaceEnvironmentCacheRoot()
{
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", "surface-cache-options"));
var settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"surface-cache",
@@ -27,7 +29,7 @@ public sealed class SurfaceCacheOptionsConfiguratorTests
new SurfaceSecretsConfiguration("file", "tenant-a", "/etc/secrets", null, null, false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()))
{ CreatedAtUtc = DateTimeOffset.UtcNow };
{ CreatedAtUtc = FixedNow };
var environment = new StubSurfaceEnvironment(settings);
var configurator = new SurfaceCacheOptionsConfigurator(environment);

View File

@@ -12,6 +12,7 @@ using System.Threading.Tasks;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Ruby;
using StellaOps.Scanner.Core.Contracts;
@@ -33,6 +34,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class SurfaceManifestStageExecutorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ExecuteAsync_WhenNoPayloads_SkipsPublishAndRecordsSkipMetric()
@@ -261,8 +264,10 @@ public sealed class SurfaceManifestStageExecutorTests
private static ScanJobContext CreateContext(Dictionary<string, string>? metadata = null)
{
var lease = new FakeJobLease(metadata);
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, TestContext.Current.CancellationToken);
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
var lease = new FakeJobLease(timeProvider, metadata);
return new ScanJobContext(lease, timeProvider, timeProvider.GetUtcNow(), TestContext.Current.CancellationToken);
}
private static void PopulateAnalysis(ScanJobContext context)
@@ -418,7 +423,7 @@ public sealed class SurfaceManifestStageExecutorTests
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { analyzer });
var analyzerContext = new LanguageAnalyzerContext(
fixturePath,
TimeProvider.System,
CreateTimeProvider(),
usageHints: null,
services: null,
analysisStore: context.Analysis);
@@ -690,7 +695,7 @@ public sealed class SurfaceManifestStageExecutorTests
Tenant = _tenant,
ImageDigest = request.ImageDigest,
ScanId = request.ScanId,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = FixedNow,
Source = new SurfaceManifestSource
{
Component = request.Component,
@@ -740,7 +745,7 @@ public sealed class SurfaceManifestStageExecutorTests
Secrets: new SurfaceSecretsConfiguration("none", tenant, null, null, null, false),
Tenant: tenant,
Tls: new SurfaceTlsConfiguration(null, null, null))
{ CreatedAtUtc = DateTimeOffset.UtcNow };
{ CreatedAtUtc = FixedNow };
}
public SurfaceEnvironmentSettings Settings { get; }
@@ -785,13 +790,18 @@ public sealed class SurfaceManifestStageExecutorTests
{
private readonly Dictionary<string, string> _metadata;
public FakeJobLease(Dictionary<string, string>? extraMetadata = null)
public FakeJobLease(TimeProvider timeProvider, Dictionary<string, string>? extraMetadata = null)
{
_metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["queue"] = "tests",
["job.kind"] = "unit"
};
JobId = "job-surface-manifest";
ScanId = "scan-surface-manifest";
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now.AddMinutes(-1);
LeasedAtUtc = now;
if (extraMetadata is not null)
{
@@ -802,15 +812,15 @@ public sealed class SurfaceManifestStageExecutorTests
}
}
public string JobId { get; } = Guid.NewGuid().ToString("n");
public string JobId { get; }
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public string ScanId { get; }
public int Attempt { get; } = 1;
public DateTimeOffset EnqueuedAtUtc { get; } = DateTimeOffset.UtcNow.AddMinutes(-1);
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration { get; } = TimeSpan.FromMinutes(5);
@@ -826,4 +836,11 @@ public sealed class SurfaceManifestStageExecutorTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
return timeProvider;
}
}

View File

@@ -12,11 +12,13 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class SurfaceManifestStoreOptionsConfiguratorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Configure_UsesSurfaceEnvironmentEndpointAndBucket()
{
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", "surface-manifest-store"));
var settings = new SurfaceEnvironmentSettings(
new Uri("https://surface.example"),
"surface-bucket",
@@ -28,7 +30,7 @@ public sealed class SurfaceManifestStoreOptionsConfiguratorTests
new SurfaceSecretsConfiguration("file", "tenant-a", "/etc/secrets", null, null, false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, new X509Certificate2Collection()))
{ CreatedAtUtc = DateTimeOffset.UtcNow };
{ CreatedAtUtc = FixedNow };
var environment = new StubSurfaceEnvironment(settings);
var cacheOptions = Microsoft.Extensions.Options.Options.Create(new SurfaceCacheOptions { RootDirectory = cacheRoot.FullName });

View File

@@ -8,6 +8,7 @@ using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Gate;
@@ -25,6 +26,7 @@ namespace StellaOps.Scanner.Worker.Tests;
[Trait("Category", TestCategories.Unit)]
public sealed class VexGateStageExecutorTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
private readonly Mock<IVexGateService> _mockGateService;
private readonly Mock<IScanMetricsCollector> _mockMetrics;
private readonly ILogger<VexGateStageExecutor> _logger;
@@ -49,8 +51,8 @@ public sealed class VexGateStageExecutorTests
Dictionary<string, object>? analysisData = null,
TimeProvider? timeProvider = null)
{
var tp = timeProvider ?? TimeProvider.System;
var lease = new TestJobLease();
var tp = timeProvider ?? CreateTimeProvider();
var lease = new TestJobLease(tp);
var context = new ScanJobContext(lease, tp, tp.GetUtcNow(), TestContext.Current.CancellationToken);
if (analysisData is not null)
@@ -108,7 +110,7 @@ public sealed class VexGateStageExecutorTests
ConfidenceScore = 0.9,
BackportHints = []
},
EvaluatedAt = DateTimeOffset.UtcNow
EvaluatedAt = FixedNow
}
};
}
@@ -549,11 +551,20 @@ public sealed class VexGateStageExecutorTests
/// </summary>
private sealed class TestJobLease : IScanJobLease
{
public string JobId { get; } = $"job-{Guid.NewGuid():N}";
public string ScanId { get; } = $"scan-{Guid.NewGuid():N}";
public TestJobLease(TimeProvider timeProvider)
{
JobId = "job-vex-gate";
ScanId = "scan-vex-gate";
var now = timeProvider.GetUtcNow();
EnqueuedAtUtc = now.AddMinutes(-1);
LeasedAtUtc = now;
}
public string JobId { get; }
public string ScanId { get; }
public int Attempt => 1;
public DateTimeOffset EnqueuedAtUtc { get; } = DateTimeOffset.UtcNow.AddMinutes(-1);
public DateTimeOffset LeasedAtUtc { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset EnqueuedAtUtc { get; }
public DateTimeOffset LeasedAtUtc { get; }
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
public IReadOnlyDictionary<string, string> Metadata { get; } = new Dictionary<string, string>
{
@@ -568,5 +579,12 @@ public sealed class VexGateStageExecutorTests
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static TimeProvider CreateTimeProvider()
{
var timeProvider = new FakeTimeProvider();
timeProvider.SetUtcNow(FixedNow);
return timeProvider;
}
#endregion
}

View File

@@ -29,6 +29,8 @@ namespace StellaOps.Scanner.Worker.Tests;
public sealed class WorkerBasicScanScenarioTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 12, 24, 12, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DelayAsync_CompletesAfterTimeAdvance()
@@ -44,7 +46,7 @@ public sealed class WorkerBasicScanScenarioTests
public async Task Worker_CompletesJob_RecordsTelemetry_And_Heartbeats()
{
var fakeTime = new FakeTimeProvider();
fakeTime.SetUtcNow(DateTimeOffset.UtcNow);
fakeTime.SetUtcNow(FixedNow);
var options = new ScannerWorkerOptions
{
@@ -114,11 +116,11 @@ public sealed class WorkerBasicScanScenarioTests
}
catch (TimeoutException ex)
{
var stageLogs = string.Join(Environment.NewLine, testLoggerProvider
var stageLogs = string.Join("\n", testLoggerProvider
.GetEntriesForCategory(typeof(ScanProgressReporter).FullName!)
.Select(entry => entry.ToFormattedString()));
throw new TimeoutException($"Worker did not complete within timeout. Logs:{Environment.NewLine}{stageLogs}", ex);
throw new TimeoutException($"Worker did not complete within timeout. Logs:\n{stageLogs}", ex);
}
await worker.StopAsync(TestContext.Current.CancellationToken);
@@ -193,13 +195,15 @@ public sealed class WorkerBasicScanScenarioTests
public TestJobLease(FakeTimeProvider timeProvider)
{
_timeProvider = timeProvider;
JobId = "job-basic";
ScanId = "scan-basic";
EnqueuedAtUtc = _timeProvider.GetUtcNow() - TimeSpan.FromSeconds(5);
LeasedAtUtc = _timeProvider.GetUtcNow();
}
public string JobId { get; } = Guid.NewGuid().ToString("n");
public string JobId { get; }
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
public string ScanId { get; }
public int Attempt { get; } = 1;

View File

@@ -0,0 +1,9 @@
# ELF Section Hash Fixtures
These fixtures are synthetic ELF binaries with known section contents.
They are used to validate deterministic section hashing.
- standard-amd64.elf: includes .text, .rodata, .data, .symtab, .dynsym
- stripped-amd64.elf: omits .symtab
- minimal-amd64.elf: .data is empty (hash of empty)
- corrupt.bin: non-ELF input

View File

@@ -0,0 +1 @@
not an elf

View File

@@ -0,0 +1,13 @@
{
"fileHash": "ea5d1058e09e8cdc904dc089aa9a69dfa3bdfc93d0a0b02da6b9251b4ed1e871",
"sections": {
".data": {
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
".text": {
"sha256": "10a8e3dafed6c8938e72779d3652bca45f67d8cf7f662389ec01295cde46d254",
"size": 8
}
}
}

View File

@@ -0,0 +1,25 @@
{
"fileHash": "e8b34dc1fbf85d99d7a94918d8982d042a3a9bf297edb6c89740eb72e8910758",
"sections": {
".symtab": {
"sha256": "044a28a44a08c9e5ab42ac90e2bbefcd498f4682d066467b449ab44e3b0c8e48",
"size": 17
},
".rodata": {
"sha256": "9d11c1c7eac0cd03133b258e45e2010003ff0c554d6f0a3c6b9518129386a9d1",
"size": 6
},
".text": {
"sha256": "10a8e3dafed6c8938e72779d3652bca45f67d8cf7f662389ec01295cde46d254",
"size": 8
},
".dynsym": {
"sha256": "f21e7fa140a6a132d85b0b2fa47a9b353adeca0a9e9a229fb521990597ad3431",
"size": 17
},
".data": {
"sha256": "c97c29c7a71b392b437ee03fd17f09bb10b75e879466fc0eb757b2c4a78ac938",
"size": 4
}
}
}

View File

@@ -0,0 +1,21 @@
{
"fileHash": "6bd7e3f22f8bc923d626991ac89422b740e3aee0b2f25c8104f640769c511d5a",
"sections": {
".rodata": {
"sha256": "9d11c1c7eac0cd03133b258e45e2010003ff0c554d6f0a3c6b9518129386a9d1",
"size": 6
},
".text": {
"sha256": "10a8e3dafed6c8938e72779d3652bca45f67d8cf7f662389ec01295cde46d254",
"size": 8
},
".dynsym": {
"sha256": "f21e7fa140a6a132d85b0b2fa47a9b353adeca0a9e9a229fb521990597ad3431",
"size": 17
},
".data": {
"sha256": "c97c29c7a71b392b437ee03fd17f09bb10b75e879466fc0eb757b2c4a78ac938",
"size": 4
}
}
}