Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -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

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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