audit, advisories and doctors/setup work
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user