Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -229,7 +229,7 @@ public sealed class FuncProofOciPublisher : IFuncProofOciPublisher
|
||||
[OciAnnotations.Title] = $"funcproof-{request.FuncProof.ProofId}",
|
||||
[FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId,
|
||||
[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId ?? string.Empty,
|
||||
[FuncProofOciAnnotations.FunctionCount] = request.FuncProof.Functions?.Count.ToString() ?? "0"
|
||||
[FuncProofOciAnnotations.FunctionCount] = request.FuncProof.Functions.Length.ToString()
|
||||
}
|
||||
});
|
||||
|
||||
@@ -277,9 +277,9 @@ public sealed class FuncProofOciPublisher : IFuncProofOciPublisher
|
||||
annotations[FuncProofOciAnnotations.FileSha256] = request.FuncProof.FileSha256;
|
||||
}
|
||||
|
||||
if (request.FuncProof.Metadata?.CreatedAt is not null)
|
||||
if (request.FuncProof.Meta?.BuildTime is not null)
|
||||
{
|
||||
annotations[OciAnnotations.Created] = request.FuncProof.Metadata.CreatedAt;
|
||||
annotations[OciAnnotations.Created] = request.FuncProof.Meta.BuildTime.Value.ToString("o");
|
||||
}
|
||||
|
||||
// Merge user-provided annotations
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOciAncestryExtractor.cs
|
||||
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-001)
|
||||
// Task: Implement IOciAncestryExtractor service interface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts ancestry information from OCI image manifests.
|
||||
/// Used for building SBOM lineage graphs.
|
||||
/// </summary>
|
||||
public interface IOciAncestryExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts ancestry information from an OCI image.
|
||||
/// </summary>
|
||||
/// <param name="imageRef">Reference to the OCI image.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Ancestry information including base image and layer digests.</returns>
|
||||
Task<OciAncestryResult> ExtractAsync(
|
||||
OciImageReference imageRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts ancestry from an already-fetched image config.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">Digest of the image manifest.</param>
|
||||
/// <param name="configJson">Raw image config JSON.</param>
|
||||
/// <returns>Ancestry information parsed from config.</returns>
|
||||
OciAncestryResult ExtractFromConfig(
|
||||
string imageDigest,
|
||||
string configJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of OCI ancestry extraction.
|
||||
/// </summary>
|
||||
public sealed record OciAncestryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether extraction succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ancestry data if extraction succeeded.
|
||||
/// </summary>
|
||||
public OciAncestry? Ancestry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if extraction failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static OciAncestryResult Succeeded(OciAncestry ancestry)
|
||||
=> new() { Success = true, Ancestry = ancestry };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static OciAncestryResult Failed(string error)
|
||||
=> new() { Success = false, Error = error };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI image ancestry information.
|
||||
/// </summary>
|
||||
public sealed record OciAncestry
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the image this ancestry belongs to.
|
||||
/// Format: "sha256:{hex}"
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the base/parent image, if determinable.
|
||||
/// Format: "sha256:{hex}"
|
||||
/// </summary>
|
||||
public string? BaseImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the base image (e.g., "docker.io/library/alpine:3.18").
|
||||
/// Extracted from image labels or history.
|
||||
/// </summary>
|
||||
public string? BaseImageRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of layer digests from bottom to top.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> LayerDigests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of layers inherited from base image.
|
||||
/// Used to determine which layers are from the build.
|
||||
/// </summary>
|
||||
public int InheritedLayerCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// History entries from image config.
|
||||
/// </summary>
|
||||
public IReadOnlyList<OciHistoryEntry>? History { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image creation timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image author if specified.
|
||||
/// </summary>
|
||||
public string? Author { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image labels.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry from OCI image config history.
|
||||
/// </summary>
|
||||
public sealed record OciHistoryEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Timestamp of this history entry.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Created { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Command that created this layer.
|
||||
/// </summary>
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is an empty layer (no filesystem changes).
|
||||
/// </summary>
|
||||
public bool EmptyLayer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OciAncestryExtractor.cs
|
||||
// Sprint: SPRINT_20251228_005_BE_sbom_lineage_graph_i (LIN-BE-001)
|
||||
// Task: Implement IOciAncestryExtractor service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts ancestry information from OCI image manifests and configs.
|
||||
/// </summary>
|
||||
public sealed class OciAncestryExtractor : IOciAncestryExtractor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Well-known label keys for base image reference.
|
||||
/// </summary>
|
||||
private static readonly string[] BaseImageLabelKeys =
|
||||
[
|
||||
"org.opencontainers.image.base.name",
|
||||
"org.opencontainers.image.base.digest",
|
||||
"io.buildah.base.image",
|
||||
"com.docker.official-images.bashbrew.arch"
|
||||
];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<OciAncestryResult> ExtractAsync(
|
||||
OciImageReference imageRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// In a full implementation, this would:
|
||||
// 1. Fetch the manifest from registry
|
||||
// 2. Fetch the config blob
|
||||
// 3. Parse and extract ancestry
|
||||
|
||||
// For now, return a placeholder indicating the method needs registry access
|
||||
return Task.FromResult(OciAncestryResult.Failed(
|
||||
"Registry fetch not implemented. Use ExtractFromConfig with pre-fetched config."));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public OciAncestryResult ExtractFromConfig(string imageDigest, string configJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = JsonSerializer.Deserialize<OciImageConfig>(configJson, JsonOptions);
|
||||
if (config is null)
|
||||
{
|
||||
return OciAncestryResult.Failed("Failed to parse image config JSON");
|
||||
}
|
||||
|
||||
// Extract layer digests from rootfs
|
||||
var layerDigests = config.RootFs?.DiffIds ?? [];
|
||||
|
||||
// Parse history entries
|
||||
var history = config.History?
|
||||
.Select(h => new OciHistoryEntry
|
||||
{
|
||||
Created = ParseTimestamp(h.Created),
|
||||
CreatedBy = h.CreatedBy,
|
||||
EmptyLayer = h.EmptyLayer,
|
||||
Comment = h.Comment
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Determine base image from labels or history
|
||||
var (baseImageRef, baseImageDigest, inheritedLayerCount) = DetermineBaseImage(config, layerDigests);
|
||||
|
||||
// Extract labels
|
||||
var labels = config.Config?.Labels;
|
||||
|
||||
var ancestry = new OciAncestry
|
||||
{
|
||||
ImageDigest = NormalizeDigest(imageDigest),
|
||||
BaseImageDigest = baseImageDigest,
|
||||
BaseImageRef = baseImageRef,
|
||||
LayerDigests = layerDigests.Select(NormalizeDigest).ToList(),
|
||||
InheritedLayerCount = inheritedLayerCount,
|
||||
History = history,
|
||||
CreatedAt = ParseTimestamp(config.Created),
|
||||
Author = config.Author,
|
||||
Labels = labels
|
||||
};
|
||||
|
||||
return OciAncestryResult.Succeeded(ancestry);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return OciAncestryResult.Failed($"JSON parsing error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return OciAncestryResult.Failed($"Extraction error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? baseRef, string? baseDigest, int inheritedCount) DetermineBaseImage(
|
||||
OciImageConfig config,
|
||||
IReadOnlyList<string> layerDigests)
|
||||
{
|
||||
string? baseRef = null;
|
||||
string? baseDigest = null;
|
||||
var inheritedCount = 0;
|
||||
|
||||
// Try to get base image from labels
|
||||
var labels = config.Config?.Labels;
|
||||
if (labels is not null)
|
||||
{
|
||||
if (labels.TryGetValue("org.opencontainers.image.base.name", out var baseName))
|
||||
{
|
||||
baseRef = baseName;
|
||||
}
|
||||
if (labels.TryGetValue("org.opencontainers.image.base.digest", out var digest))
|
||||
{
|
||||
baseDigest = NormalizeDigest(digest);
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze history to determine inherited layer count
|
||||
if (config.History is { Count: > 0 })
|
||||
{
|
||||
// Count non-empty layers in history
|
||||
var nonEmptyCount = 0;
|
||||
var foundFromInstruction = false;
|
||||
|
||||
foreach (var entry in config.History)
|
||||
{
|
||||
if (!entry.EmptyLayer)
|
||||
{
|
||||
nonEmptyCount++;
|
||||
}
|
||||
|
||||
// Look for FROM instruction in history
|
||||
if (entry.CreatedBy?.Contains("FROM ", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
foundFromInstruction = true;
|
||||
inheritedCount = nonEmptyCount - 1; // Layers before this FROM
|
||||
}
|
||||
}
|
||||
|
||||
// If no FROM found, try heuristics based on common base images
|
||||
if (!foundFromInstruction && layerDigests.Count > 0)
|
||||
{
|
||||
// Heuristic: first layer is typically from base image
|
||||
inheritedCount = Math.Min(1, layerDigests.Count);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract base image from first history entry
|
||||
if (baseRef is null && config.History is { Count: > 0 })
|
||||
{
|
||||
var firstEntry = config.History[0];
|
||||
baseRef = ExtractBaseImageFromCreatedBy(firstEntry.CreatedBy);
|
||||
}
|
||||
|
||||
return (baseRef, baseDigest, inheritedCount);
|
||||
}
|
||||
|
||||
private static string? ExtractBaseImageFromCreatedBy(string? createdBy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(createdBy))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look for patterns like:
|
||||
// - "FROM alpine:3.18"
|
||||
// - "/bin/sh -c #(nop) FROM mcr.microsoft.com/dotnet/runtime:8.0"
|
||||
var fromIndex = createdBy.IndexOf("FROM ", StringComparison.OrdinalIgnoreCase);
|
||||
if (fromIndex >= 0)
|
||||
{
|
||||
var imageStart = fromIndex + 5;
|
||||
var imageEnd = createdBy.IndexOfAny([' ', '\n', '\r'], imageStart);
|
||||
if (imageEnd < 0)
|
||||
{
|
||||
imageEnd = createdBy.Length;
|
||||
}
|
||||
|
||||
var imageRef = createdBy[imageStart..imageEnd].Trim();
|
||||
if (!string.IsNullOrEmpty(imageRef) && imageRef != "scratch")
|
||||
{
|
||||
return imageRef;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTimestamp(string? timestamp)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(timestamp))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(timestamp, out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
return digest;
|
||||
}
|
||||
|
||||
// Ensure digest has algorithm prefix
|
||||
if (!digest.Contains(':'))
|
||||
{
|
||||
return $"sha256:{digest}";
|
||||
}
|
||||
|
||||
return digest.ToLowerInvariant();
|
||||
}
|
||||
|
||||
#region Config DTOs
|
||||
|
||||
private sealed record OciImageConfig
|
||||
{
|
||||
[JsonPropertyName("created")]
|
||||
public string? Created { get; init; }
|
||||
|
||||
[JsonPropertyName("author")]
|
||||
public string? Author { get; init; }
|
||||
|
||||
[JsonPropertyName("architecture")]
|
||||
public string? Architecture { get; init; }
|
||||
|
||||
[JsonPropertyName("os")]
|
||||
public string? Os { get; init; }
|
||||
|
||||
[JsonPropertyName("config")]
|
||||
public OciImageConfigSection? Config { get; init; }
|
||||
|
||||
[JsonPropertyName("rootfs")]
|
||||
public OciRootFs? RootFs { get; init; }
|
||||
|
||||
[JsonPropertyName("history")]
|
||||
public IReadOnlyList<OciHistoryEntryDto>? History { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciImageConfigSection
|
||||
{
|
||||
[JsonPropertyName("Env")]
|
||||
public IReadOnlyList<string>? Env { get; init; }
|
||||
|
||||
[JsonPropertyName("Cmd")]
|
||||
public IReadOnlyList<string>? Cmd { get; init; }
|
||||
|
||||
[JsonPropertyName("Entrypoint")]
|
||||
public IReadOnlyList<string>? Entrypoint { get; init; }
|
||||
|
||||
[JsonPropertyName("WorkingDir")]
|
||||
public string? WorkingDir { get; init; }
|
||||
|
||||
[JsonPropertyName("Labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciRootFs
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("diff_ids")]
|
||||
public IReadOnlyList<string>? DiffIds { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OciHistoryEntryDto
|
||||
{
|
||||
[JsonPropertyName("created")]
|
||||
public string? Created { get; init; }
|
||||
|
||||
[JsonPropertyName("created_by")]
|
||||
public string? CreatedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("empty_layer")]
|
||||
public bool EmptyLayer { get; init; }
|
||||
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
@@ -6,7 +6,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
<!-- NOTE: Reachability reference intentionally removed to break circular dependency:
|
||||
Reachability -> SmartDiff -> Storage.Oci -> Reachability
|
||||
|
||||
Reference in New Issue
Block a user