audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -203,14 +203,14 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
// For now, return a message that SSH will be validated on first scan
return Task.FromResult(new ConnectionTestResult
{
Success = true,
Message = "SSH configuration accepted - connection will be validated on first scan",
Success = false,
Message = "SSH connection not validated - verify connectivity during the first scan",
TestedAt = _timeProvider.GetUtcNow(),
Details = new Dictionary<string, object>
{
["repositoryUrl"] = config.RepositoryUrl,
["authMethod"] = config.AuthMethod.ToString(),
["note"] = "Full SSH validation requires runtime execution"
["note"] = "SSH validation is not performed by the connection tester"
}
});
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using StellaOps.Determinism;
namespace StellaOps.Scanner.Sources.Domain;
@@ -118,6 +119,7 @@ public sealed class SbomSource
JsonDocument configuration,
string createdBy,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? description = null,
string? authRef = null,
string? cronSchedule = null,
@@ -126,7 +128,7 @@ public sealed class SbomSource
var now = timeProvider.GetUtcNow();
var source = new SbomSource
{
SourceId = Guid.NewGuid(),
SourceId = guidProvider.NewGuid(),
TenantId = tenantId,
Name = name,
Description = description,

View File

@@ -1,3 +1,5 @@
using StellaOps.Determinism;
namespace StellaOps.Scanner.Sources.Domain;
#pragma warning disable CA1062 // Validate arguments of public methods - TimeProvider validated at DI boundary
@@ -40,7 +42,7 @@ public sealed class SbomSourceRun
if (CompletedAt.HasValue)
return (long)(CompletedAt.Value - StartedAt).TotalMilliseconds;
var now = timeProvider?.GetUtcNow() ?? DateTimeOffset.UtcNow;
var now = (timeProvider ?? TimeProvider.System).GetUtcNow();
return (long)(now - StartedAt).TotalMilliseconds;
}
@@ -84,11 +86,12 @@ public sealed class SbomSourceRun
SbomSourceRunTrigger trigger,
string correlationId,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? triggerDetails = null)
{
return new SbomSourceRun
{
RunId = Guid.NewGuid(),
RunId = guidProvider.NewGuid(),
SourceId = sourceId,
TenantId = tenantId,
Trigger = trigger,

View File

@@ -306,7 +306,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
};
}
private static (string Repository, string? Tag) ParseReference(string reference)
internal static (string Repository, string? Tag) ParseReference(string reference)
{
// Handle digest references
if (reference.Contains('@'))
@@ -316,18 +316,21 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
}
// Handle tag references
if (reference.Contains(':'))
var lastSlash = reference.LastIndexOf('/');
var lastColon = reference.LastIndexOf(':');
if (lastColon > -1 && lastColon > lastSlash)
{
var lastColon = reference.LastIndexOf(':');
return (reference[..lastColon], reference[(lastColon + 1)..]);
}
return (reference, null);
}
private static string BuildFullReference(string registryUrl, string repository, string tag)
internal static string BuildFullReference(string registryUrl, string repository, string tag)
{
var host = new Uri(registryUrl).Host;
var uri = new Uri(registryUrl);
var host = uri.Host;
var authority = uri.Authority;
// Docker Hub special case
if (host.Contains("docker.io") || host.Contains("docker.com"))
@@ -339,6 +342,6 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
return $"{repository}:{tag}";
}
return $"{host}/{repository}:{tag}";
return $"{authority}/{repository}:{tag}";
}
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.RegularExpressions;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Handlers.Zastava;
@@ -132,9 +133,9 @@ public sealed class ImageDiscoveryService : IImageDiscoveryService
return new SemVer
{
Major = int.Parse(match.Groups["major"].Value),
Minor = int.Parse(match.Groups["minor"].Value),
Patch = int.Parse(match.Groups["patch"].Value),
Major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture),
Minor = int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture),
Patch = int.Parse(match.Groups["patch"].Value, CultureInfo.InvariantCulture),
PreRelease = match.Groups["prerelease"].Success
? match.Groups["prerelease"].Value
: null,

View File

@@ -0,0 +1,25 @@
using System.Globalization;
using System.Text;
namespace StellaOps.Scanner.Sources.Persistence;
internal static class CursorEncoding
{
public static int Decode(string cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
{
throw new ArgumentException("Cursor is required.", nameof(cursor));
}
var bytes = Convert.FromBase64String(cursor);
var text = Encoding.UTF8.GetString(bytes);
return int.Parse(text, CultureInfo.InvariantCulture);
}
public static string Encode(int offset)
{
var text = offset.ToString(CultureInfo.InvariantCulture);
return Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
}
}

View File

@@ -13,6 +13,11 @@ public interface ISbomSourceRepository
/// </summary>
Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Get a source by ID across all tenants.
/// </summary>
Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default);
/// <summary>
/// Get a source by name.
/// </summary>

View File

@@ -47,6 +47,21 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
ct);
}
public async Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default)
{
const string sql = $"""
SELECT * FROM {FullTable}
WHERE source_id = @sourceId
""";
return await QuerySingleOrDefaultAsync(
"__system__",
sql,
cmd => AddParameter(cmd, "sourceId", sourceId),
MapSource,
ct);
}
public async Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
{
const string sql = $"""
@@ -113,8 +128,7 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
if (!string.IsNullOrEmpty(request.Cursor))
{
// Cursor is base64 encoded offset
var offset = int.Parse(
Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
var offset = CursorEncoding.Decode(request.Cursor);
sb.Append($" OFFSET {offset}");
}
@@ -136,9 +150,8 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
{
var currentOffset = string.IsNullOrEmpty(request.Cursor)
? 0
: int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
nextCursor = Convert.ToBase64String(
Encoding.UTF8.GetBytes((currentOffset + request.Limit).ToString()));
: CursorEncoding.Decode(request.Cursor);
nextCursor = CursorEncoding.Encode(currentOffset + request.Limit);
items = items.Take(request.Limit).ToList();
}

View File

@@ -89,8 +89,7 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
if (!string.IsNullOrEmpty(request.Cursor))
{
var offset = int.Parse(
Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
var offset = CursorEncoding.Decode(request.Cursor);
sb.Append($" OFFSET {offset}");
}
@@ -112,9 +111,8 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
{
var currentOffset = string.IsNullOrEmpty(request.Cursor)
? 0
: int.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor)));
nextCursor = Convert.ToBase64String(
Encoding.UTF8.GetBytes((currentOffset + request.Limit).ToString()));
: CursorEncoding.Decode(request.Cursor);
nextCursor = CursorEncoding.Encode(currentOffset + request.Limit);
items = items.Take(request.Limit).ToList();
}

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Configuration;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
@@ -18,6 +19,7 @@ public sealed class SbomSourceService : ISbomSourceService
private readonly ISourceConnectionTester _connectionTester;
private readonly ILogger<SbomSourceService> _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public SbomSourceService(
ISbomSourceRepository sourceRepository,
@@ -25,7 +27,8 @@ public sealed class SbomSourceService : ISbomSourceService
ISourceConfigValidator configValidator,
ISourceConnectionTester connectionTester,
ILogger<SbomSourceService> logger,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null)
{
_sourceRepository = sourceRepository;
_runRepository = runRepository;
@@ -33,6 +36,7 @@ public sealed class SbomSourceService : ISbomSourceService
_connectionTester = connectionTester;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
public async Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
@@ -102,6 +106,7 @@ public sealed class SbomSourceService : ISbomSourceService
request.Configuration,
createdBy,
_timeProvider,
_guidProvider,
request.Description,
request.AuthRef,
request.CronSchedule,
@@ -267,6 +272,7 @@ public sealed class SbomSourceService : ISbomSourceService
request.Configuration,
"__test__",
_timeProvider,
_guidProvider,
authRef: request.AuthRef);
return await _connectionTester.TestAsync(tempSource, request.TestCredentials, ct);
@@ -342,8 +348,9 @@ public sealed class SbomSourceService : ISbomSourceService
sourceId,
tenantId,
SbomSourceRunTrigger.Manual,
Guid.NewGuid().ToString("N"),
_guidProvider.NewGuid().ToString("N"),
_timeProvider,
_guidProvider,
$"Triggered by {triggeredBy}");
await _runRepository.CreateAsync(run, ct);

View File

@@ -24,4 +24,9 @@
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>StellaOps.Scanner.Sources.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -63,7 +63,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
context.Trigger, sourceId, context.CorrelationId);
// 1. Get the source
var source = await _sourceRepository.GetByIdAsync(null!, sourceId, ct);
var source = await _sourceRepository.GetByIdAnyTenantAsync(sourceId, ct);
if (source == null)
{
_logger.LogWarning("Source {SourceId} not found", sourceId);
@@ -85,6 +85,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
context.Trigger,
context.CorrelationId,
_timeProvider,
_guidProvider,
context.TriggerDetails);
failedRun.Fail(canTrigger.Error!, _timeProvider);
await _runRepository.CreateAsync(failedRun, ct);
@@ -104,6 +105,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
context.Trigger,
context.CorrelationId,
_timeProvider,
_guidProvider,
context.TriggerDetails);
await _runRepository.CreateAsync(run, ct);
@@ -227,7 +229,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
{
try
{
var context = TriggerContext.Scheduled(source.CronSchedule!);
var context = TriggerContext.Scheduled(source.CronSchedule!, guidProvider: _guidProvider);
await DispatchAsync(source.SourceId, context, ct);
processed++;
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Domain;
namespace StellaOps.Scanner.Sources.Triggers;
@@ -24,45 +25,73 @@ public sealed record TriggerContext
public Dictionary<string, string> Metadata { get; init; } = [];
/// <summary>Creates a context for a manual trigger.</summary>
public static TriggerContext Manual(string triggeredBy, string? correlationId = null) => new()
public static TriggerContext Manual(
string triggeredBy,
string? correlationId = null,
IGuidProvider? guidProvider = null)
{
Trigger = SbomSourceRunTrigger.Manual,
TriggerDetails = $"Triggered by {triggeredBy}",
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
Metadata = new() { ["triggeredBy"] = triggeredBy }
};
var provider = guidProvider ?? SystemGuidProvider.Instance;
return new TriggerContext
{
Trigger = SbomSourceRunTrigger.Manual,
TriggerDetails = $"Triggered by {triggeredBy}",
CorrelationId = correlationId ?? provider.NewGuid().ToString("N"),
Metadata = new() { ["triggeredBy"] = triggeredBy }
};
}
/// <summary>Creates a context for a scheduled trigger.</summary>
public static TriggerContext Scheduled(string cronExpression, string? correlationId = null) => new()
public static TriggerContext Scheduled(
string cronExpression,
string? correlationId = null,
IGuidProvider? guidProvider = null)
{
Trigger = SbomSourceRunTrigger.Scheduled,
TriggerDetails = $"Cron: {cronExpression}",
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N")
};
var provider = guidProvider ?? SystemGuidProvider.Instance;
return new TriggerContext
{
Trigger = SbomSourceRunTrigger.Scheduled,
TriggerDetails = $"Cron: {cronExpression}",
CorrelationId = correlationId ?? provider.NewGuid().ToString("N")
};
}
/// <summary>Creates a context for a webhook trigger.</summary>
public static TriggerContext Webhook(
string eventDetails,
JsonDocument payload,
string? correlationId = null) => new()
string? correlationId = null,
IGuidProvider? guidProvider = null)
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = eventDetails,
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
WebhookPayload = payload
};
var provider = guidProvider ?? SystemGuidProvider.Instance;
return new TriggerContext
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = eventDetails,
CorrelationId = correlationId ?? provider.NewGuid().ToString("N"),
WebhookPayload = payload
};
}
/// <summary>Creates a context for a push event trigger (registry/git push via webhook).</summary>
public static TriggerContext Push(
string eventDetails,
JsonDocument payload,
string? correlationId = null) => new()
string? correlationId = null,
IGuidProvider? guidProvider = null)
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = $"Push: {eventDetails}",
CorrelationId = correlationId ?? Guid.NewGuid().ToString("N"),
WebhookPayload = payload
};
var provider = guidProvider ?? SystemGuidProvider.Instance;
return new TriggerContext
{
Trigger = SbomSourceRunTrigger.Webhook,
TriggerDetails = $"Push: {eventDetails}",
CorrelationId = correlationId ?? provider.NewGuid().ToString("N"),
WebhookPayload = payload
};
}
}
/// <summary>

View File

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

View File

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

View File

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

View File

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

View File

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