audit, advisories and doctors/setup work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
src/Scanner/StellaOps.Scanner.Analyzers.Native/TASKS.md
Normal file
15
src/Scanner/StellaOps.Scanner.Analyzers.Native/TASKS.md
Normal 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. |
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
10
src/Scanner/StellaOps.Scanner.Worker/TASKS.md
Normal file
10
src/Scanner/StellaOps.Scanner.Worker/TASKS.md
Normal 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. |
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
8
src/Scanner/__Libraries/StellaOps.Scanner.Emit/TASKS.md
Normal file
8
src/Scanner/__Libraries/StellaOps.Scanner.Emit/TASKS.md
Normal 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. |
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -203,14 +203,14 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
// For now, return a message that SSH will be validated on first scan
|
||||
return Task.FromResult(new ConnectionTestResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "SSH configuration accepted - connection will be validated on first scan",
|
||||
Success = false,
|
||||
Message = "SSH connection not validated - verify connectivity during the first scan",
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
["authMethod"] = config.AuthMethod.ToString(),
|
||||
["note"] = "Full SSH validation requires runtime execution"
|
||||
["note"] = "SSH validation is not performed by the connection tester"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Domain;
|
||||
|
||||
@@ -118,6 +119,7 @@ public sealed class SbomSource
|
||||
JsonDocument configuration,
|
||||
string createdBy,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
string? description = null,
|
||||
string? authRef = null,
|
||||
string? cronSchedule = null,
|
||||
@@ -126,7 +128,7 @@ public sealed class SbomSource
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var source = new SbomSource
|
||||
{
|
||||
SourceId = Guid.NewGuid(),
|
||||
SourceId = guidProvider.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = name,
|
||||
Description = description,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Domain;
|
||||
|
||||
#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary
|
||||
@@ -40,7 +42,7 @@ public sealed class SbomSourceRun
|
||||
if (CompletedAt.HasValue)
|
||||
return (long)(CompletedAt.Value - StartedAt).TotalMilliseconds;
|
||||
|
||||
var now = timeProvider?.GetUtcNow() ?? DateTimeOffset.UtcNow;
|
||||
var now = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||
return (long)(now - StartedAt).TotalMilliseconds;
|
||||
}
|
||||
|
||||
@@ -84,11 +86,12 @@ public sealed class SbomSourceRun
|
||||
SbomSourceRunTrigger trigger,
|
||||
string correlationId,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
string? triggerDetails = null)
|
||||
{
|
||||
return new SbomSourceRun
|
||||
{
|
||||
RunId = Guid.NewGuid(),
|
||||
RunId = guidProvider.NewGuid(),
|
||||
SourceId = sourceId,
|
||||
TenantId = tenantId,
|
||||
Trigger = trigger,
|
||||
|
||||
@@ -306,7 +306,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Repository, string? Tag) ParseReference(string reference)
|
||||
internal static (string Repository, string? Tag) ParseReference(string reference)
|
||||
{
|
||||
// Handle digest references
|
||||
if (reference.Contains('@'))
|
||||
@@ -316,18 +316,21 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
}
|
||||
|
||||
// Handle tag references
|
||||
if (reference.Contains(':'))
|
||||
var lastSlash = reference.LastIndexOf('/');
|
||||
var lastColon = reference.LastIndexOf(':');
|
||||
if (lastColon > -1 && lastColon > lastSlash)
|
||||
{
|
||||
var lastColon = reference.LastIndexOf(':');
|
||||
return (reference[..lastColon], reference[(lastColon + 1)..]);
|
||||
}
|
||||
|
||||
return (reference, null);
|
||||
}
|
||||
|
||||
private static string BuildFullReference(string registryUrl, string repository, string tag)
|
||||
internal static string BuildFullReference(string registryUrl, string repository, string tag)
|
||||
{
|
||||
var host = new Uri(registryUrl).Host;
|
||||
var uri = new Uri(registryUrl);
|
||||
var host = uri.Host;
|
||||
var authority = uri.Authority;
|
||||
|
||||
// Docker Hub special case
|
||||
if (host.Contains("docker.io") || host.Contains("docker.com"))
|
||||
@@ -339,6 +342,6 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
return $"{repository}:{tag}";
|
||||
}
|
||||
|
||||
return $"{host}/{repository}:{tag}";
|
||||
return $"{authority}/{repository}:{tag}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Handlers.Zastava;
|
||||
@@ -132,9 +133,9 @@ public sealed class ImageDiscoveryService : IImageDiscoveryService
|
||||
|
||||
return new SemVer
|
||||
{
|
||||
Major = int.Parse(match.Groups["major"].Value),
|
||||
Minor = int.Parse(match.Groups["minor"].Value),
|
||||
Patch = int.Parse(match.Groups["patch"].Value),
|
||||
Major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture),
|
||||
Minor = int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture),
|
||||
Patch = int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture),
|
||||
PreRelease = match.Groups["prerelease"].Success
|
||||
? match.Groups["prerelease"].Value
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Persistence;
|
||||
|
||||
internal static class CursorEncoding
|
||||
{
|
||||
public static int Decode(string cursor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cursor))
|
||||
{
|
||||
throw new ArgumentException("Cursor is required.", nameof(cursor));
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(cursor);
|
||||
var text = Encoding.UTF8.GetString(bytes);
|
||||
return int.Parse(text, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string Encode(int offset)
|
||||
{
|
||||
var text = offset.ToString(CultureInfo.InvariantCulture);
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ public interface ISbomSourceRepository
|
||||
/// </summary>
|
||||
Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a source by ID across all tenants.
|
||||
/// </summary>
|
||||
Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a source by name.
|
||||
/// </summary>
|
||||
|
||||
@@ -47,6 +47,21 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT * FROM {FullTable}
|
||||
WHERE source_id = @sourceId
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
"__system__",
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "sourceId", sourceId),
|
||||
MapSource,
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
@@ -113,8 +128,7 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
|
||||
if (!string.IsNullOrEmpty(request.Cursor))
|
||||
{
|
||||
// Cursor is base64 encoded offset
|
||||
var offset = int.Parse(
|
||||
Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
|
||||
var offset = CursorEncoding.Decode(request.Cursor);
|
||||
sb.Append($" OFFSET {offset}");
|
||||
}
|
||||
|
||||
@@ -136,9 +150,8 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
|
||||
{
|
||||
var currentOffset = string.IsNullOrEmpty(request.Cursor)
|
||||
? 0
|
||||
: int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
|
||||
nextCursor = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes((currentOffset + request.Limit).ToString()));
|
||||
: CursorEncoding.Decode(request.Cursor);
|
||||
nextCursor = CursorEncoding.Encode(currentOffset + request.Limit);
|
||||
items = items.Take(request.Limit).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -89,8 +89,7 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Cursor))
|
||||
{
|
||||
var offset = int.Parse(
|
||||
Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
|
||||
var offset = CursorEncoding.Decode(request.Cursor);
|
||||
sb.Append($" OFFSET {offset}");
|
||||
}
|
||||
|
||||
@@ -112,9 +111,8 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
|
||||
{
|
||||
var currentOffset = string.IsNullOrEmpty(request.Cursor)
|
||||
? 0
|
||||
: int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
|
||||
nextCursor = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes((currentOffset + request.Limit).ToString()));
|
||||
: CursorEncoding.Decode(request.Cursor);
|
||||
nextCursor = CursorEncoding.Encode(currentOffset + request.Limit);
|
||||
items = items.Take(request.Limit).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Sources.Configuration;
|
||||
using StellaOps.Scanner.Sources.Contracts;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
@@ -18,6 +19,7 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
private readonly ISourceConnectionTester _connectionTester;
|
||||
private readonly ILogger<SbomSourceService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public SbomSourceService(
|
||||
ISbomSourceRepository sourceRepository,
|
||||
@@ -25,7 +27,8 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
ISourceConfigValidator configValidator,
|
||||
ISourceConnectionTester connectionTester,
|
||||
ILogger<SbomSourceService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_sourceRepository = sourceRepository;
|
||||
_runRepository = runRepository;
|
||||
@@ -33,6 +36,7 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
_connectionTester = connectionTester;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
@@ -102,6 +106,7 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
request.Configuration,
|
||||
createdBy,
|
||||
_timeProvider,
|
||||
_guidProvider,
|
||||
request.Description,
|
||||
request.AuthRef,
|
||||
request.CronSchedule,
|
||||
@@ -267,6 +272,7 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
request.Configuration,
|
||||
"__test__",
|
||||
_timeProvider,
|
||||
_guidProvider,
|
||||
authRef: request.AuthRef);
|
||||
|
||||
return await _connectionTester.TestAsync(tempSource, request.TestCredentials, ct);
|
||||
@@ -342,8 +348,9 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
sourceId,
|
||||
tenantId,
|
||||
SbomSourceRunTrigger.Manual,
|
||||
Guid.NewGuid().ToString("N"),
|
||||
_guidProvider.NewGuid().ToString("N"),
|
||||
_timeProvider,
|
||||
_guidProvider,
|
||||
$"Triggered by {triggeredBy}");
|
||||
|
||||
await _runRepository.CreateAsync(run, ct);
|
||||
|
||||
@@ -24,4 +24,9 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>StellaOps.Scanner.Sources.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -63,7 +63,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
context.Trigger, sourceId, context.CorrelationId);
|
||||
|
||||
// 1. Get the source
|
||||
var source = await _sourceRepository.GetByIdAsync(null!, sourceId, ct);
|
||||
var source = await _sourceRepository.GetByIdAnyTenantAsync(sourceId, ct);
|
||||
if (source == null)
|
||||
{
|
||||
_logger.LogWarning("Source {SourceId} not found", sourceId);
|
||||
@@ -85,6 +85,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
context.Trigger,
|
||||
context.CorrelationId,
|
||||
_timeProvider,
|
||||
_guidProvider,
|
||||
context.TriggerDetails);
|
||||
failedRun.Fail(canTrigger.Error!, _timeProvider);
|
||||
await _runRepository.CreateAsync(failedRun, ct);
|
||||
@@ -104,6 +105,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
context.Trigger,
|
||||
context.CorrelationId,
|
||||
_timeProvider,
|
||||
_guidProvider,
|
||||
context.TriggerDetails);
|
||||
|
||||
await _runRepository.CreateAsync(run, ct);
|
||||
@@ -227,7 +229,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = TriggerContext.Scheduled(source.CronSchedule!);
|
||||
var context = TriggerContext.Scheduled(source.CronSchedule!, guidProvider: _guidProvider);
|
||||
await DispatchAsync(source.SourceId, context, ct);
|
||||
processed++;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.Sources.Triggers;
|
||||
@@ -24,45 +25,73 @@ public sealed record TriggerContext
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
|
||||
/// <summary>Creates a context for a manual trigger.</summary>
|
||||
public static TriggerContext Manual(string triggeredBy, string? correlationId = null) => new()
|
||||
public static TriggerContext Manual(
|
||||
string triggeredBy,
|
||||
string? correlationId = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Manual,
|
||||
TriggerDetails = $"Triggered by {triggeredBy}",
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
|
||||
Metadata = new() { ["triggeredBy"] = triggeredBy }
|
||||
};
|
||||
var provider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
|
||||
return new TriggerContext
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Manual,
|
||||
TriggerDetails = $"Triggered by {triggeredBy}",
|
||||
CorrelationId = correlationId ?? provider.NewGuid().ToString("N"),
|
||||
Metadata = new() { ["triggeredBy"] = triggeredBy }
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Creates a context for a scheduled trigger.</summary>
|
||||
public static TriggerContext Scheduled(string cronExpression, string? correlationId = null) => new()
|
||||
public static TriggerContext Scheduled(
|
||||
string cronExpression,
|
||||
string? correlationId = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Scheduled,
|
||||
TriggerDetails = $"Cron: {cronExpression}",
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N")
|
||||
};
|
||||
var provider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
|
||||
return new TriggerContext
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Scheduled,
|
||||
TriggerDetails = $"Cron: {cronExpression}",
|
||||
CorrelationId = correlationId ?? provider.NewGuid().ToString("N")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Creates a context for a webhook trigger.</summary>
|
||||
public static TriggerContext Webhook(
|
||||
string eventDetails,
|
||||
JsonDocument payload,
|
||||
string? correlationId = null) => new()
|
||||
string? correlationId = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Webhook,
|
||||
TriggerDetails = eventDetails,
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
|
||||
WebhookPayload = payload
|
||||
};
|
||||
var provider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
|
||||
return new TriggerContext
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Webhook,
|
||||
TriggerDetails = eventDetails,
|
||||
CorrelationId = correlationId ?? provider.NewGuid().ToString("N"),
|
||||
WebhookPayload = payload
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Creates a context for a push event trigger (registry/git push via webhook).</summary>
|
||||
public static TriggerContext Push(
|
||||
string eventDetails,
|
||||
JsonDocument payload,
|
||||
string? correlationId = null) => new()
|
||||
string? correlationId = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Webhook,
|
||||
TriggerDetails = $"Push: {eventDetails}",
|
||||
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
|
||||
WebhookPayload = payload
|
||||
};
|
||||
var provider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
|
||||
return new TriggerContext
|
||||
{
|
||||
Trigger = SbomSourceRunTrigger.Webhook,
|
||||
TriggerDetails = $"Push: {eventDetails}",
|
||||
CorrelationId = correlationId ?? provider.NewGuid().ToString("N"),
|
||||
WebhookPayload = payload
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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}"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Microsoft.Extensions.Time.Testing" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("""
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:True→False", 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:0→5", 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:none→warn", mismatches[0].AffectedArtifacts!);
|
||||
Assert.Contains("block_level:none->warn", mismatches[0].AffectedArtifacts!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
not an elf
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"fileHash": "ea5d1058e09e8cdc904dc089aa9a69dfa3bdfc93d0a0b02da6b9251b4ed1e871",
|
||||
"sections": {
|
||||
".data": {
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"size": 0
|
||||
},
|
||||
".text": {
|
||||
"sha256": "10a8e3dafed6c8938e72779d3652bca45f67d8cf7f662389ec01295cde46d254",
|
||||
"size": 8
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user