save progress
This commit is contained in:
166
src/__Libraries/StellaOps.Facet/BuiltInFacets.cs
Normal file
166
src/__Libraries/StellaOps.Facet/BuiltInFacets.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
// <copyright file="BuiltInFacets.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in facet definitions for common image components.
|
||||
/// </summary>
|
||||
public static class BuiltInFacets
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all built-in facet definitions.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<IFacet> All { get; } = new IFacet[]
|
||||
{
|
||||
// OS Package Managers (priority 10)
|
||||
new FacetDefinition(
|
||||
"os-packages-dpkg",
|
||||
"Debian Packages",
|
||||
FacetCategory.OsPackages,
|
||||
["/var/lib/dpkg/status", "/var/lib/dpkg/info/**"],
|
||||
priority: 10),
|
||||
new FacetDefinition(
|
||||
"os-packages-rpm",
|
||||
"RPM Packages",
|
||||
FacetCategory.OsPackages,
|
||||
["/var/lib/rpm/**", "/usr/lib/sysimage/rpm/**"],
|
||||
priority: 10),
|
||||
new FacetDefinition(
|
||||
"os-packages-apk",
|
||||
"Alpine Packages",
|
||||
FacetCategory.OsPackages,
|
||||
["/lib/apk/db/**"],
|
||||
priority: 10),
|
||||
new FacetDefinition(
|
||||
"os-packages-pacman",
|
||||
"Arch Packages",
|
||||
FacetCategory.OsPackages,
|
||||
["/var/lib/pacman/**"],
|
||||
priority: 10),
|
||||
|
||||
// Language Interpreters (priority 15 - before lang deps)
|
||||
new FacetDefinition(
|
||||
"interpreters-python",
|
||||
"Python Interpreters",
|
||||
FacetCategory.Interpreters,
|
||||
["/usr/bin/python*", "/usr/local/bin/python*"],
|
||||
priority: 15),
|
||||
new FacetDefinition(
|
||||
"interpreters-node",
|
||||
"Node.js Interpreters",
|
||||
FacetCategory.Interpreters,
|
||||
["/usr/bin/node*", "/usr/local/bin/node*"],
|
||||
priority: 15),
|
||||
new FacetDefinition(
|
||||
"interpreters-ruby",
|
||||
"Ruby Interpreters",
|
||||
FacetCategory.Interpreters,
|
||||
["/usr/bin/ruby*", "/usr/local/bin/ruby*"],
|
||||
priority: 15),
|
||||
new FacetDefinition(
|
||||
"interpreters-perl",
|
||||
"Perl Interpreters",
|
||||
FacetCategory.Interpreters,
|
||||
["/usr/bin/perl*", "/usr/local/bin/perl*"],
|
||||
priority: 15),
|
||||
|
||||
// Language Dependencies (priority 20)
|
||||
new FacetDefinition(
|
||||
"lang-deps-npm",
|
||||
"NPM Packages",
|
||||
FacetCategory.LanguageDependencies,
|
||||
["**/node_modules/**/package.json", "**/package-lock.json"],
|
||||
priority: 20),
|
||||
new FacetDefinition(
|
||||
"lang-deps-pip",
|
||||
"Python Packages",
|
||||
FacetCategory.LanguageDependencies,
|
||||
["**/site-packages/**/*.dist-info/METADATA", "**/requirements.txt"],
|
||||
priority: 20),
|
||||
new FacetDefinition(
|
||||
"lang-deps-nuget",
|
||||
"NuGet Packages",
|
||||
FacetCategory.LanguageDependencies,
|
||||
["**/*.deps.json", "**/.nuget/**"],
|
||||
priority: 20),
|
||||
new FacetDefinition(
|
||||
"lang-deps-maven",
|
||||
"Maven Packages",
|
||||
FacetCategory.LanguageDependencies,
|
||||
["**/.m2/repository/**/*.pom"],
|
||||
priority: 20),
|
||||
new FacetDefinition(
|
||||
"lang-deps-cargo",
|
||||
"Cargo Packages",
|
||||
FacetCategory.LanguageDependencies,
|
||||
["**/.cargo/registry/**", "**/Cargo.lock"],
|
||||
priority: 20),
|
||||
new FacetDefinition(
|
||||
"lang-deps-go",
|
||||
"Go Modules",
|
||||
FacetCategory.LanguageDependencies,
|
||||
["**/go.sum", "**/go/pkg/mod/**"],
|
||||
priority: 20),
|
||||
new FacetDefinition(
|
||||
"lang-deps-gem",
|
||||
"Ruby Gems",
|
||||
FacetCategory.LanguageDependencies,
|
||||
["**/gems/**/*.gemspec", "**/Gemfile.lock"],
|
||||
priority: 20),
|
||||
|
||||
// Certificates (priority 25)
|
||||
new FacetDefinition(
|
||||
"certs-system",
|
||||
"System Certificates",
|
||||
FacetCategory.Certificates,
|
||||
["/etc/ssl/certs/**", "/etc/pki/**", "/usr/share/ca-certificates/**"],
|
||||
priority: 25),
|
||||
|
||||
// Binaries (priority 30)
|
||||
new FacetDefinition(
|
||||
"binaries-usr",
|
||||
"System Binaries",
|
||||
FacetCategory.Binaries,
|
||||
["/usr/bin/*", "/usr/sbin/*", "/bin/*", "/sbin/*"],
|
||||
priority: 30),
|
||||
new FacetDefinition(
|
||||
"binaries-lib",
|
||||
"Shared Libraries",
|
||||
FacetCategory.Binaries,
|
||||
["/usr/lib/**/*.so*", "/lib/**/*.so*", "/usr/lib64/**/*.so*", "/lib64/**/*.so*"],
|
||||
priority: 30),
|
||||
|
||||
// Configuration (priority 40)
|
||||
new FacetDefinition(
|
||||
"config-etc",
|
||||
"System Configuration",
|
||||
FacetCategory.Configuration,
|
||||
["/etc/**/*.conf", "/etc/**/*.cfg", "/etc/**/*.yaml", "/etc/**/*.yml", "/etc/**/*.json"],
|
||||
priority: 40),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets a facet by its ID.
|
||||
/// </summary>
|
||||
/// <param name="facetId">The facet identifier.</param>
|
||||
/// <returns>The facet or null if not found.</returns>
|
||||
public static IFacet? GetById(string facetId)
|
||||
=> All.FirstOrDefault(f => f.FacetId == facetId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all facets in a category.
|
||||
/// </summary>
|
||||
/// <param name="category">The category to filter by.</param>
|
||||
/// <returns>Facets in the category.</returns>
|
||||
public static IEnumerable<IFacet> GetByCategory(FacetCategory category)
|
||||
=> All.Where(f => f.Category == category);
|
||||
|
||||
/// <summary>
|
||||
/// Gets facets sorted by priority (lowest first).
|
||||
/// </summary>
|
||||
/// <returns>Priority-sorted facets.</returns>
|
||||
public static IEnumerable<IFacet> GetByPriority()
|
||||
=> All.OrderBy(f => f.Priority);
|
||||
}
|
||||
53
src/__Libraries/StellaOps.Facet/DefaultCryptoHash.cs
Normal file
53
src/__Libraries/StellaOps.Facet/DefaultCryptoHash.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
// <copyright file="DefaultCryptoHash.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ICryptoHash"/> using .NET built-in algorithms.
|
||||
/// </summary>
|
||||
public sealed class DefaultCryptoHash : ICryptoHash
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the singleton instance.
|
||||
/// </summary>
|
||||
public static DefaultCryptoHash Instance { get; } = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public byte[] ComputeHash(byte[] data, string algorithm)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(algorithm);
|
||||
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
"SHA256" => SHA256.HashData(data),
|
||||
"SHA384" => SHA384.HashData(data),
|
||||
"SHA512" => SHA512.HashData(data),
|
||||
"SHA1" => SHA1.HashData(data),
|
||||
"MD5" => MD5.HashData(data),
|
||||
_ => throw new NotSupportedException($"Hash algorithm '{algorithm}' is not supported")
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<byte[]> ComputeHashAsync(
|
||||
Stream stream,
|
||||
string algorithm,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(algorithm);
|
||||
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
"SHA256" => await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false),
|
||||
"SHA384" => await SHA384.HashDataAsync(stream, ct).ConfigureAwait(false),
|
||||
"SHA512" => await SHA512.HashDataAsync(stream, ct).ConfigureAwait(false),
|
||||
_ => throw new NotSupportedException($"Hash algorithm '{algorithm}' is not supported for async")
|
||||
};
|
||||
}
|
||||
}
|
||||
46
src/__Libraries/StellaOps.Facet/FacetCategory.cs
Normal file
46
src/__Libraries/StellaOps.Facet/FacetCategory.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
// <copyright file="FacetCategory.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Categories for grouping facets.
|
||||
/// </summary>
|
||||
public enum FacetCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// OS-level package managers (dpkg, rpm, apk, pacman).
|
||||
/// </summary>
|
||||
OsPackages,
|
||||
|
||||
/// <summary>
|
||||
/// Language-specific dependencies (npm, pip, nuget, maven, cargo, go).
|
||||
/// </summary>
|
||||
LanguageDependencies,
|
||||
|
||||
/// <summary>
|
||||
/// Executable binaries and shared libraries.
|
||||
/// </summary>
|
||||
Binaries,
|
||||
|
||||
/// <summary>
|
||||
/// Configuration files (etc, conf, yaml, json).
|
||||
/// </summary>
|
||||
Configuration,
|
||||
|
||||
/// <summary>
|
||||
/// SSL/TLS certificates and trust anchors.
|
||||
/// </summary>
|
||||
Certificates,
|
||||
|
||||
/// <summary>
|
||||
/// Language interpreters (python, node, ruby, perl).
|
||||
/// </summary>
|
||||
Interpreters,
|
||||
|
||||
/// <summary>
|
||||
/// User-defined custom facets.
|
||||
/// </summary>
|
||||
Custom
|
||||
}
|
||||
91
src/__Libraries/StellaOps.Facet/FacetClassifier.cs
Normal file
91
src/__Libraries/StellaOps.Facet/FacetClassifier.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
// <copyright file="FacetClassifier.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies files into facets based on selectors.
|
||||
/// </summary>
|
||||
public sealed class FacetClassifier
|
||||
{
|
||||
private readonly List<(IFacet Facet, GlobMatcher Matcher)> _facetMatchers;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetClassifier"/> class.
|
||||
/// </summary>
|
||||
/// <param name="facets">Facets to classify against (will be sorted by priority).</param>
|
||||
public FacetClassifier(IEnumerable<IFacet> facets)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(facets);
|
||||
|
||||
// Sort by priority (lowest first = highest priority)
|
||||
_facetMatchers = facets
|
||||
.OrderBy(f => f.Priority)
|
||||
.Select(f => (f, GlobMatcher.ForFacet(f)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a classifier using built-in facets.
|
||||
/// </summary>
|
||||
public static FacetClassifier Default { get; } = new(BuiltInFacets.All);
|
||||
|
||||
/// <summary>
|
||||
/// Classify a file path to a facet.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path to classify.</param>
|
||||
/// <returns>The matching facet or null if no match.</returns>
|
||||
public IFacet? Classify(string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
// First matching facet wins (ordered by priority)
|
||||
foreach (var (facet, matcher) in _facetMatchers)
|
||||
{
|
||||
if (matcher.IsMatch(path))
|
||||
{
|
||||
return facet;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify a file and return the facet ID.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path to classify.</param>
|
||||
/// <returns>The facet ID or null if no match.</returns>
|
||||
public string? ClassifyToId(string path)
|
||||
=> Classify(path)?.FacetId;
|
||||
|
||||
/// <summary>
|
||||
/// Classify multiple files efficiently.
|
||||
/// </summary>
|
||||
/// <param name="paths">The file paths to classify.</param>
|
||||
/// <returns>Dictionary from facet ID to matched paths.</returns>
|
||||
public Dictionary<string, List<string>> ClassifyMany(IEnumerable<string> paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
var result = new Dictionary<string, List<string>>();
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var facet = Classify(path);
|
||||
if (facet is not null)
|
||||
{
|
||||
if (!result.TryGetValue(facet.FacetId, out var list))
|
||||
{
|
||||
list = [];
|
||||
result[facet.FacetId] = list;
|
||||
}
|
||||
|
||||
list.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
55
src/__Libraries/StellaOps.Facet/FacetDefinition.cs
Normal file
55
src/__Libraries/StellaOps.Facet/FacetDefinition.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
// <copyright file="FacetDefinition.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Standard implementation of <see cref="IFacet"/> for defining facets.
|
||||
/// </summary>
|
||||
internal sealed class FacetDefinition : IFacet
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string FacetId { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public FacetCategory Category { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<string> Selectors { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int Priority { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetDefinition"/> class.
|
||||
/// </summary>
|
||||
/// <param name="facetId">Unique identifier for the facet.</param>
|
||||
/// <param name="name">Human-readable name.</param>
|
||||
/// <param name="category">Facet category.</param>
|
||||
/// <param name="selectors">Glob patterns or paths for file matching.</param>
|
||||
/// <param name="priority">Priority for conflict resolution (lower = higher priority).</param>
|
||||
public FacetDefinition(
|
||||
string facetId,
|
||||
string name,
|
||||
FacetCategory category,
|
||||
string[] selectors,
|
||||
int priority)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(facetId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentNullException.ThrowIfNull(selectors);
|
||||
|
||||
FacetId = facetId;
|
||||
Name = name;
|
||||
Category = category;
|
||||
Selectors = selectors;
|
||||
Priority = priority;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => $"{FacetId} ({Name})";
|
||||
}
|
||||
132
src/__Libraries/StellaOps.Facet/FacetDrift.cs
Normal file
132
src/__Libraries/StellaOps.Facet/FacetDrift.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
// <copyright file="FacetDrift.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Drift detection result for a single facet.
|
||||
/// </summary>
|
||||
public sealed record FacetDrift
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the facet this drift applies to.
|
||||
/// </summary>
|
||||
public required string FacetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the files added since baseline.
|
||||
/// </summary>
|
||||
public required ImmutableArray<FacetFileEntry> Added { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the files removed since baseline.
|
||||
/// </summary>
|
||||
public required ImmutableArray<FacetFileEntry> Removed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the files modified since baseline.
|
||||
/// </summary>
|
||||
public required ImmutableArray<FacetFileModification> Modified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the drift score (0-100, higher = more drift).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The drift score weighs additions, removals, and modifications
|
||||
/// to produce a single measure of change magnitude.
|
||||
/// </remarks>
|
||||
public required decimal DriftScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the quota evaluation result.
|
||||
/// </summary>
|
||||
public required QuotaVerdict QuotaVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of files in baseline facet seal.
|
||||
/// </summary>
|
||||
public required int BaselineFileCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of changes (added + removed + modified).
|
||||
/// </summary>
|
||||
public int TotalChanges => Added.Length + Removed.Length + Modified.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the churn percentage = (changes / baseline count) * 100.
|
||||
/// </summary>
|
||||
public decimal ChurnPercent => BaselineFileCount > 0
|
||||
? TotalChanges / (decimal)BaselineFileCount * 100
|
||||
: Added.Length > 0 ? 100m : 0m;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this facet has any drift.
|
||||
/// </summary>
|
||||
public bool HasDrift => TotalChanges > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a no-drift instance for a facet.
|
||||
/// </summary>
|
||||
public static FacetDrift NoDrift(string facetId, int baselineFileCount) => new()
|
||||
{
|
||||
FacetId = facetId,
|
||||
Added = [],
|
||||
Removed = [],
|
||||
Modified = [],
|
||||
DriftScore = 0m,
|
||||
QuotaVerdict = QuotaVerdict.Ok,
|
||||
BaselineFileCount = baselineFileCount
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated drift report for all facets in an image.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftReport
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the image digest analyzed.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the baseline seal used for comparison.
|
||||
/// </summary>
|
||||
public required string BaselineSealId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the analysis was performed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AnalyzedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the per-facet drift results.
|
||||
/// </summary>
|
||||
public required ImmutableArray<FacetDrift> FacetDrifts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the overall verdict (worst of all facets).
|
||||
/// </summary>
|
||||
public required QuotaVerdict OverallVerdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total files changed across all facets.
|
||||
/// </summary>
|
||||
public int TotalChangedFiles => FacetDrifts.Sum(d => d.TotalChanges);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the facets with any drift.
|
||||
/// </summary>
|
||||
public IEnumerable<FacetDrift> DriftedFacets => FacetDrifts.Where(d => d.HasDrift);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the facets with quota violations.
|
||||
/// </summary>
|
||||
public IEnumerable<FacetDrift> QuotaViolations =>
|
||||
FacetDrifts.Where(d => d.QuotaVerdict is QuotaVerdict.Warning
|
||||
or QuotaVerdict.Blocked
|
||||
or QuotaVerdict.RequiresVex);
|
||||
}
|
||||
353
src/__Libraries/StellaOps.Facet/FacetDriftDetector.cs
Normal file
353
src/__Libraries/StellaOps.Facet/FacetDriftDetector.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
// <copyright file="FacetDriftDetector.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using DotNet.Globbing;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IFacetDriftDetector"/>.
|
||||
/// </summary>
|
||||
public sealed class FacetDriftDetector : IFacetDriftDetector
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetDriftDetector"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
public FacetDriftDetector(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<FacetDriftReport> DetectDriftAsync(
|
||||
FacetSeal baseline,
|
||||
FacetExtractionResult current,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseline);
|
||||
ArgumentNullException.ThrowIfNull(current);
|
||||
|
||||
var drifts = new List<FacetDrift>();
|
||||
|
||||
// Build lookup for current facets
|
||||
var currentFacetLookup = current.Facets.ToDictionary(f => f.FacetId);
|
||||
|
||||
// Process each baseline facet
|
||||
foreach (var baselineFacet in baseline.Facets)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (currentFacetLookup.TryGetValue(baselineFacet.FacetId, out var currentFacet))
|
||||
{
|
||||
// Both have this facet - compute drift
|
||||
var drift = ComputeFacetDrift(
|
||||
baselineFacet,
|
||||
currentFacet,
|
||||
baseline.GetQuota(baselineFacet.FacetId));
|
||||
|
||||
drifts.Add(drift);
|
||||
currentFacetLookup.Remove(baselineFacet.FacetId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Facet was removed entirely - all files are "removed"
|
||||
var drift = CreateRemovedFacetDrift(baselineFacet, baseline.GetQuota(baselineFacet.FacetId));
|
||||
drifts.Add(drift);
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining current facets are new
|
||||
foreach (var newFacet in currentFacetLookup.Values)
|
||||
{
|
||||
var drift = CreateNewFacetDrift(newFacet);
|
||||
drifts.Add(drift);
|
||||
}
|
||||
|
||||
var overallVerdict = ComputeOverallVerdict(drifts);
|
||||
|
||||
var report = new FacetDriftReport
|
||||
{
|
||||
ImageDigest = baseline.ImageDigest,
|
||||
BaselineSealId = baseline.CombinedMerkleRoot,
|
||||
AnalyzedAt = _timeProvider.GetUtcNow(),
|
||||
FacetDrifts = [.. drifts],
|
||||
OverallVerdict = overallVerdict
|
||||
};
|
||||
|
||||
return Task.FromResult(report);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<FacetDriftReport> DetectDriftAsync(
|
||||
FacetSeal baseline,
|
||||
FacetSeal current,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseline);
|
||||
ArgumentNullException.ThrowIfNull(current);
|
||||
|
||||
var drifts = new List<FacetDrift>();
|
||||
|
||||
// Build lookup for current facets
|
||||
var currentFacetLookup = current.Facets.ToDictionary(f => f.FacetId);
|
||||
|
||||
// Process each baseline facet
|
||||
foreach (var baselineFacet in baseline.Facets)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (currentFacetLookup.TryGetValue(baselineFacet.FacetId, out var currentFacet))
|
||||
{
|
||||
// Both have this facet - compute drift
|
||||
var drift = ComputeFacetDrift(
|
||||
baselineFacet,
|
||||
currentFacet,
|
||||
baseline.GetQuota(baselineFacet.FacetId));
|
||||
|
||||
drifts.Add(drift);
|
||||
currentFacetLookup.Remove(baselineFacet.FacetId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Facet was removed entirely
|
||||
var drift = CreateRemovedFacetDrift(baselineFacet, baseline.GetQuota(baselineFacet.FacetId));
|
||||
drifts.Add(drift);
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining current facets are new
|
||||
foreach (var newFacet in currentFacetLookup.Values)
|
||||
{
|
||||
var drift = CreateNewFacetDrift(newFacet);
|
||||
drifts.Add(drift);
|
||||
}
|
||||
|
||||
var overallVerdict = ComputeOverallVerdict(drifts);
|
||||
|
||||
var report = new FacetDriftReport
|
||||
{
|
||||
ImageDigest = current.ImageDigest,
|
||||
BaselineSealId = baseline.CombinedMerkleRoot,
|
||||
AnalyzedAt = _timeProvider.GetUtcNow(),
|
||||
FacetDrifts = [.. drifts],
|
||||
OverallVerdict = overallVerdict
|
||||
};
|
||||
|
||||
return Task.FromResult(report);
|
||||
}
|
||||
|
||||
private static FacetDrift ComputeFacetDrift(
|
||||
FacetEntry baseline,
|
||||
FacetEntry current,
|
||||
FacetQuota quota)
|
||||
{
|
||||
// Quick check: if Merkle roots match, no drift
|
||||
if (baseline.MerkleRoot == current.MerkleRoot)
|
||||
{
|
||||
return FacetDrift.NoDrift(baseline.FacetId, baseline.FileCount);
|
||||
}
|
||||
|
||||
// Need file-level comparison
|
||||
if (baseline.Files is null || current.Files is null)
|
||||
{
|
||||
// Can't compute detailed drift without file entries
|
||||
// Fall back to root-level drift indication
|
||||
return new FacetDrift
|
||||
{
|
||||
FacetId = baseline.FacetId,
|
||||
Added = [],
|
||||
Removed = [],
|
||||
Modified = [],
|
||||
DriftScore = 100m, // Max drift since we can't compute details
|
||||
QuotaVerdict = quota.Action switch
|
||||
{
|
||||
QuotaExceededAction.Block => QuotaVerdict.Blocked,
|
||||
QuotaExceededAction.RequireVex => QuotaVerdict.RequiresVex,
|
||||
_ => QuotaVerdict.Warning
|
||||
},
|
||||
BaselineFileCount = baseline.FileCount
|
||||
};
|
||||
}
|
||||
|
||||
// Build allowlist globs
|
||||
var allowlistGlobs = quota.AllowlistGlobs
|
||||
.Select(p => Glob.Parse(p))
|
||||
.ToList();
|
||||
|
||||
bool IsAllowlisted(string path) => allowlistGlobs.Any(g => g.IsMatch(path));
|
||||
|
||||
// Build file dictionaries
|
||||
var baselineFiles = baseline.Files.Value.ToDictionary(f => f.Path);
|
||||
var currentFiles = current.Files.Value.ToDictionary(f => f.Path);
|
||||
|
||||
var added = new List<FacetFileEntry>();
|
||||
var removed = new List<FacetFileEntry>();
|
||||
var modified = new List<FacetFileModification>();
|
||||
|
||||
// Find additions and modifications
|
||||
foreach (var (path, currentFile) in currentFiles)
|
||||
{
|
||||
if (IsAllowlisted(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (baselineFiles.TryGetValue(path, out var baselineFile))
|
||||
{
|
||||
// File exists in both - check for modification
|
||||
if (baselineFile.Digest != currentFile.Digest)
|
||||
{
|
||||
modified.Add(new FacetFileModification(
|
||||
path,
|
||||
baselineFile.Digest,
|
||||
currentFile.Digest,
|
||||
baselineFile.SizeBytes,
|
||||
currentFile.SizeBytes));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// File is new
|
||||
added.Add(currentFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Find removals
|
||||
foreach (var (path, baselineFile) in baselineFiles)
|
||||
{
|
||||
if (IsAllowlisted(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentFiles.ContainsKey(path))
|
||||
{
|
||||
removed.Add(baselineFile);
|
||||
}
|
||||
}
|
||||
|
||||
var totalChanges = added.Count + removed.Count + modified.Count;
|
||||
var driftScore = ComputeDriftScore(
|
||||
added.Count,
|
||||
removed.Count,
|
||||
modified.Count,
|
||||
baseline.FileCount);
|
||||
|
||||
var churnPercent = baseline.FileCount > 0
|
||||
? totalChanges / (decimal)baseline.FileCount * 100
|
||||
: added.Count > 0 ? 100m : 0m;
|
||||
|
||||
var verdict = EvaluateQuota(quota, churnPercent, totalChanges);
|
||||
|
||||
return new FacetDrift
|
||||
{
|
||||
FacetId = baseline.FacetId,
|
||||
Added = [.. added],
|
||||
Removed = [.. removed],
|
||||
Modified = [.. modified],
|
||||
DriftScore = driftScore,
|
||||
QuotaVerdict = verdict,
|
||||
BaselineFileCount = baseline.FileCount
|
||||
};
|
||||
}
|
||||
|
||||
private static FacetDrift CreateRemovedFacetDrift(FacetEntry baseline, FacetQuota quota)
|
||||
{
|
||||
var removedFiles = baseline.Files?.ToImmutableArray() ?? [];
|
||||
var verdict = quota.Action switch
|
||||
{
|
||||
QuotaExceededAction.Block => QuotaVerdict.Blocked,
|
||||
QuotaExceededAction.RequireVex => QuotaVerdict.RequiresVex,
|
||||
_ => QuotaVerdict.Warning
|
||||
};
|
||||
|
||||
return new FacetDrift
|
||||
{
|
||||
FacetId = baseline.FacetId,
|
||||
Added = [],
|
||||
Removed = removedFiles,
|
||||
Modified = [],
|
||||
DriftScore = 100m,
|
||||
QuotaVerdict = verdict,
|
||||
BaselineFileCount = baseline.FileCount
|
||||
};
|
||||
}
|
||||
|
||||
private static FacetDrift CreateNewFacetDrift(FacetEntry newFacet)
|
||||
{
|
||||
var addedFiles = newFacet.Files?.ToImmutableArray() ?? [];
|
||||
|
||||
return new FacetDrift
|
||||
{
|
||||
FacetId = newFacet.FacetId,
|
||||
Added = addedFiles,
|
||||
Removed = [],
|
||||
Modified = [],
|
||||
DriftScore = 100m, // All new = max drift from baseline perspective
|
||||
QuotaVerdict = QuotaVerdict.Warning, // New facets get warning by default
|
||||
BaselineFileCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal ComputeDriftScore(
|
||||
int added,
|
||||
int removed,
|
||||
int modified,
|
||||
int baselineCount)
|
||||
{
|
||||
if (baselineCount == 0)
|
||||
{
|
||||
return added > 0 ? 100m : 0m;
|
||||
}
|
||||
|
||||
// Weighted score: additions=1.0, removals=1.0, modifications=0.5
|
||||
var weightedChanges = added + removed + (modified * 0.5m);
|
||||
var score = weightedChanges / baselineCount * 100;
|
||||
|
||||
return Math.Min(100m, score);
|
||||
}
|
||||
|
||||
private static QuotaVerdict EvaluateQuota(FacetQuota quota, decimal churnPercent, int totalChanges)
|
||||
{
|
||||
var exceeds = churnPercent > quota.MaxChurnPercent ||
|
||||
totalChanges > quota.MaxChangedFiles;
|
||||
|
||||
if (!exceeds)
|
||||
{
|
||||
return QuotaVerdict.Ok;
|
||||
}
|
||||
|
||||
return quota.Action switch
|
||||
{
|
||||
QuotaExceededAction.Block => QuotaVerdict.Blocked,
|
||||
QuotaExceededAction.RequireVex => QuotaVerdict.RequiresVex,
|
||||
_ => QuotaVerdict.Warning
|
||||
};
|
||||
}
|
||||
|
||||
private static QuotaVerdict ComputeOverallVerdict(List<FacetDrift> drifts)
|
||||
{
|
||||
// Return worst verdict
|
||||
if (drifts.Any(d => d.QuotaVerdict == QuotaVerdict.Blocked))
|
||||
{
|
||||
return QuotaVerdict.Blocked;
|
||||
}
|
||||
|
||||
if (drifts.Any(d => d.QuotaVerdict == QuotaVerdict.RequiresVex))
|
||||
{
|
||||
return QuotaVerdict.RequiresVex;
|
||||
}
|
||||
|
||||
if (drifts.Any(d => d.QuotaVerdict == QuotaVerdict.Warning))
|
||||
{
|
||||
return QuotaVerdict.Warning;
|
||||
}
|
||||
|
||||
return QuotaVerdict.Ok;
|
||||
}
|
||||
}
|
||||
59
src/__Libraries/StellaOps.Facet/FacetEntry.cs
Normal file
59
src/__Libraries/StellaOps.Facet/FacetEntry.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
// <copyright file="FacetEntry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// A sealed facet entry within a <see cref="FacetSeal"/>.
|
||||
/// </summary>
|
||||
public sealed record FacetEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the facet identifier (e.g., "os-packages-dpkg", "lang-deps-npm").
|
||||
/// </summary>
|
||||
public required string FacetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the category for grouping.
|
||||
/// </summary>
|
||||
public required FacetCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selectors used to identify files in this facet.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> Selectors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Merkle root of all files in this facet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format: "sha256:{hex}" computed from sorted file entries.
|
||||
/// </remarks>
|
||||
public required string MerkleRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of files in this facet.
|
||||
/// </summary>
|
||||
public required int FileCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total bytes across all files.
|
||||
/// </summary>
|
||||
public required long TotalBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional individual file entries (for detailed audit).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// May be null for compact seals that only store Merkle roots.
|
||||
/// </remarks>
|
||||
public ImmutableArray<FacetFileEntry>? Files { get; init; }
|
||||
}
|
||||
78
src/__Libraries/StellaOps.Facet/FacetExtractionOptions.cs
Normal file
78
src/__Libraries/StellaOps.Facet/FacetExtractionOptions.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
// <copyright file="FacetExtractionOptions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Options for facet extraction operations.
|
||||
/// </summary>
|
||||
public sealed record FacetExtractionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the facets to extract. If empty, all built-in facets are used.
|
||||
/// </summary>
|
||||
public ImmutableArray<IFacet> Facets { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include individual file entries in the result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When false, only Merkle roots are computed (more compact).
|
||||
/// When true, all file details are preserved for audit.
|
||||
/// </remarks>
|
||||
public bool IncludeFileDetails { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to compute Merkle proofs for each file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Enabling proofs allows individual file verification against the facet root.
|
||||
/// </remarks>
|
||||
public bool ComputeMerkleProofs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets glob patterns for files to exclude from extraction.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ExcludePatterns { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash algorithm to use (default: SHA256).
|
||||
/// </summary>
|
||||
public string HashAlgorithm { get; init; } = "SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to follow symlinks.
|
||||
/// </summary>
|
||||
public bool FollowSymlinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum file size to hash (larger files are skipped with placeholder).
|
||||
/// </summary>
|
||||
public long MaxFileSizeBytes { get; init; } = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default options.
|
||||
/// </summary>
|
||||
public static FacetExtractionOptions Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets options for compact sealing (no file details, just roots).
|
||||
/// </summary>
|
||||
public static FacetExtractionOptions Compact { get; } = new()
|
||||
{
|
||||
IncludeFileDetails = false,
|
||||
ComputeMerkleProofs = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets options for full audit (all details and proofs).
|
||||
/// </summary>
|
||||
public static FacetExtractionOptions FullAudit { get; } = new()
|
||||
{
|
||||
IncludeFileDetails = true,
|
||||
ComputeMerkleProofs = true
|
||||
};
|
||||
}
|
||||
86
src/__Libraries/StellaOps.Facet/FacetExtractionResult.cs
Normal file
86
src/__Libraries/StellaOps.Facet/FacetExtractionResult.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
// <copyright file="FacetExtractionResult.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Result of facet extraction from an image.
|
||||
/// </summary>
|
||||
public sealed record FacetExtractionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the extracted facet entries.
|
||||
/// </summary>
|
||||
public required ImmutableArray<FacetEntry> Facets { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets files that didn't match any facet selector.
|
||||
/// </summary>
|
||||
public required ImmutableArray<FacetFileEntry> UnmatchedFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets files that were skipped (too large, unreadable, etc.).
|
||||
/// </summary>
|
||||
public required ImmutableArray<SkippedFile> SkippedFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the combined Merkle root of all facets.
|
||||
/// </summary>
|
||||
public required string CombinedMerkleRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets extraction statistics.
|
||||
/// </summary>
|
||||
public required FacetExtractionStats Stats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets extraction warnings (non-fatal issues).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Warnings { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file that was skipped during extraction.
|
||||
/// </summary>
|
||||
/// <param name="Path">The file path.</param>
|
||||
/// <param name="Reason">Why the file was skipped.</param>
|
||||
public sealed record SkippedFile(string Path, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Statistics from facet extraction.
|
||||
/// </summary>
|
||||
public sealed record FacetExtractionStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the total files processed.
|
||||
/// </summary>
|
||||
public required int TotalFilesProcessed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total bytes across all files.
|
||||
/// </summary>
|
||||
public required long TotalBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of files matched to facets.
|
||||
/// </summary>
|
||||
public required int FilesMatched { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of files not matching any facet.
|
||||
/// </summary>
|
||||
public required int FilesUnmatched { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of files skipped.
|
||||
/// </summary>
|
||||
public required int FilesSkipped { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the extraction duration.
|
||||
/// </summary>
|
||||
public required TimeSpan Duration { get; init; }
|
||||
}
|
||||
18
src/__Libraries/StellaOps.Facet/FacetFileEntry.cs
Normal file
18
src/__Libraries/StellaOps.Facet/FacetFileEntry.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
// <copyright file="FacetFileEntry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single file within a facet.
|
||||
/// </summary>
|
||||
/// <param name="Path">The file path within the image.</param>
|
||||
/// <param name="Digest">Content hash in "algorithm:hex" format (e.g., "sha256:abc...").</param>
|
||||
/// <param name="SizeBytes">File size in bytes.</param>
|
||||
/// <param name="ModifiedAt">Last modification timestamp, if available.</param>
|
||||
public sealed record FacetFileEntry(
|
||||
string Path,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
DateTimeOffset? ModifiedAt);
|
||||
26
src/__Libraries/StellaOps.Facet/FacetFileModification.cs
Normal file
26
src/__Libraries/StellaOps.Facet/FacetFileModification.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
// <copyright file="FacetFileModification.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a modified file between baseline and current state.
|
||||
/// </summary>
|
||||
/// <param name="Path">The file path within the image.</param>
|
||||
/// <param name="PreviousDigest">Content hash from baseline.</param>
|
||||
/// <param name="CurrentDigest">Content hash from current state.</param>
|
||||
/// <param name="PreviousSizeBytes">File size in baseline.</param>
|
||||
/// <param name="CurrentSizeBytes">File size in current state.</param>
|
||||
public sealed record FacetFileModification(
|
||||
string Path,
|
||||
string PreviousDigest,
|
||||
string CurrentDigest,
|
||||
long PreviousSizeBytes,
|
||||
long CurrentSizeBytes)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the size change in bytes (positive = growth, negative = shrinkage).
|
||||
/// </summary>
|
||||
public long SizeDelta => CurrentSizeBytes - PreviousSizeBytes;
|
||||
}
|
||||
194
src/__Libraries/StellaOps.Facet/FacetMerkleTree.cs
Normal file
194
src/__Libraries/StellaOps.Facet/FacetMerkleTree.cs
Normal file
@@ -0,0 +1,194 @@
|
||||
// <copyright file="FacetMerkleTree.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Computes deterministic Merkle roots for facet file sets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Leaf nodes are computed from: path | digest | size (sorted by path).
|
||||
/// Internal nodes are computed by concatenating and hashing child pairs.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FacetMerkleTree
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly string _algorithm;
|
||||
|
||||
/// <summary>
|
||||
/// Empty tree root constant (SHA-256 of empty string).
|
||||
/// </summary>
|
||||
public const string EmptyTreeRoot = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetMerkleTree"/> class.
|
||||
/// </summary>
|
||||
/// <param name="cryptoHash">Cryptographic hash implementation.</param>
|
||||
/// <param name="algorithm">Hash algorithm to use (default: SHA256).</param>
|
||||
public FacetMerkleTree(ICryptoHash? cryptoHash = null, string algorithm = "SHA256")
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? DefaultCryptoHash.Instance;
|
||||
_algorithm = algorithm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute Merkle root from file entries.
|
||||
/// </summary>
|
||||
/// <param name="files">Files to include in the tree.</param>
|
||||
/// <returns>Merkle root in "sha256:{hex}" format.</returns>
|
||||
public string ComputeRoot(IEnumerable<FacetFileEntry> files)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(files);
|
||||
|
||||
// Sort files by path for determinism (ordinal comparison)
|
||||
var sortedFiles = files
|
||||
.OrderBy(f => f.Path, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (sortedFiles.Count == 0)
|
||||
{
|
||||
return EmptyTreeRoot;
|
||||
}
|
||||
|
||||
// Build leaf nodes
|
||||
var leaves = sortedFiles
|
||||
.Select(ComputeLeafHash)
|
||||
.ToList();
|
||||
|
||||
// Build tree and return root
|
||||
return ComputeMerkleRootFromNodes(leaves);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute combined root from multiple facet entries.
|
||||
/// </summary>
|
||||
/// <param name="facets">Facet entries with Merkle roots.</param>
|
||||
/// <returns>Combined Merkle root.</returns>
|
||||
public string ComputeCombinedRoot(IEnumerable<FacetEntry> facets)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(facets);
|
||||
|
||||
var facetRoots = facets
|
||||
.OrderBy(f => f.FacetId, StringComparer.Ordinal)
|
||||
.Select(f => HexToBytes(StripAlgorithmPrefix(f.MerkleRoot)))
|
||||
.ToList();
|
||||
|
||||
if (facetRoots.Count == 0)
|
||||
{
|
||||
return EmptyTreeRoot;
|
||||
}
|
||||
|
||||
return ComputeMerkleRootFromNodes(facetRoots);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a file is included in a Merkle root.
|
||||
/// </summary>
|
||||
/// <param name="file">The file to verify.</param>
|
||||
/// <param name="proof">The Merkle proof (sibling hashes).</param>
|
||||
/// <param name="expectedRoot">The expected Merkle root.</param>
|
||||
/// <returns>True if the proof is valid.</returns>
|
||||
public bool VerifyProof(FacetFileEntry file, IReadOnlyList<byte[]> proof, string expectedRoot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(file);
|
||||
ArgumentNullException.ThrowIfNull(proof);
|
||||
|
||||
var currentHash = ComputeLeafHash(file);
|
||||
|
||||
foreach (var sibling in proof)
|
||||
{
|
||||
// Determine ordering: smaller hash comes first
|
||||
var comparison = CompareHashes(currentHash, sibling);
|
||||
currentHash = comparison <= 0
|
||||
? HashPair(currentHash, sibling)
|
||||
: HashPair(sibling, currentHash);
|
||||
}
|
||||
|
||||
var computedRoot = FormatRoot(currentHash);
|
||||
return string.Equals(computedRoot, expectedRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private byte[] ComputeLeafHash(FacetFileEntry file)
|
||||
{
|
||||
// Canonical leaf format: "path|digest|size"
|
||||
// Using InvariantCulture for size formatting
|
||||
var canonical = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{file.Path}|{file.Digest}|{file.SizeBytes}");
|
||||
|
||||
return _cryptoHash.ComputeHash(Encoding.UTF8.GetBytes(canonical), _algorithm);
|
||||
}
|
||||
|
||||
private string ComputeMerkleRootFromNodes(List<byte[]> nodes)
|
||||
{
|
||||
while (nodes.Count > 1)
|
||||
{
|
||||
var nextLevel = new List<byte[]>();
|
||||
|
||||
for (var i = 0; i < nodes.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < nodes.Count)
|
||||
{
|
||||
// Hash pair of nodes
|
||||
nextLevel.Add(HashPair(nodes[i], nodes[i + 1]));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd node: promote as-is (or optionally hash with itself)
|
||||
nextLevel.Add(nodes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
nodes = nextLevel;
|
||||
}
|
||||
|
||||
return FormatRoot(nodes[0]);
|
||||
}
|
||||
|
||||
private byte[] HashPair(byte[] left, byte[] right)
|
||||
{
|
||||
var combined = new byte[left.Length + right.Length];
|
||||
left.CopyTo(combined, 0);
|
||||
right.CopyTo(combined, left.Length);
|
||||
return _cryptoHash.ComputeHash(combined, _algorithm);
|
||||
}
|
||||
|
||||
private static int CompareHashes(byte[] a, byte[] b)
|
||||
{
|
||||
var minLength = Math.Min(a.Length, b.Length);
|
||||
for (var i = 0; i < minLength; i++)
|
||||
{
|
||||
var cmp = a[i].CompareTo(b[i]);
|
||||
if (cmp != 0)
|
||||
{
|
||||
return cmp;
|
||||
}
|
||||
}
|
||||
|
||||
return a.Length.CompareTo(b.Length);
|
||||
}
|
||||
|
||||
private string FormatRoot(byte[] hash)
|
||||
{
|
||||
var algPrefix = _algorithm.ToLowerInvariant();
|
||||
var hex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return $"{algPrefix}:{hex}";
|
||||
}
|
||||
|
||||
private static string StripAlgorithmPrefix(string digest)
|
||||
{
|
||||
var colonIndex = digest.IndexOf(':', StringComparison.Ordinal);
|
||||
return colonIndex >= 0 ? digest[(colonIndex + 1)..] : digest;
|
||||
}
|
||||
|
||||
private static byte[] HexToBytes(string hex)
|
||||
{
|
||||
return Convert.FromHexString(hex);
|
||||
}
|
||||
}
|
||||
65
src/__Libraries/StellaOps.Facet/FacetQuota.cs
Normal file
65
src/__Libraries/StellaOps.Facet/FacetQuota.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
// <copyright file="FacetQuota.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Quota configuration for a facet, defining acceptable drift thresholds.
|
||||
/// </summary>
|
||||
public sealed record FacetQuota
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or initializes the maximum allowed churn percentage (0-100).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Churn = (added + removed + modified files) / baseline file count * 100.
|
||||
/// </remarks>
|
||||
public decimal MaxChurnPercent { get; init; } = 10m;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or initializes the maximum number of changed files before alert.
|
||||
/// </summary>
|
||||
public int MaxChangedFiles { get; init; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or initializes the glob patterns for files exempt from quota enforcement.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Files matching these patterns are excluded from drift calculations.
|
||||
/// Useful for expected changes like logs, timestamps, or cache files.
|
||||
/// </remarks>
|
||||
public ImmutableArray<string> AllowlistGlobs { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or initializes the action when quota is exceeded.
|
||||
/// </summary>
|
||||
public QuotaExceededAction Action { get; init; } = QuotaExceededAction.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default quota configuration.
|
||||
/// </summary>
|
||||
public static FacetQuota Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a strict quota suitable for high-security binaries.
|
||||
/// </summary>
|
||||
public static FacetQuota Strict { get; } = new()
|
||||
{
|
||||
MaxChurnPercent = 5m,
|
||||
MaxChangedFiles = 10,
|
||||
Action = QuotaExceededAction.Block
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a permissive quota suitable for frequently-updated dependencies.
|
||||
/// </summary>
|
||||
public static FacetQuota Permissive { get; } = new()
|
||||
{
|
||||
MaxChurnPercent = 25m,
|
||||
MaxChangedFiles = 200,
|
||||
Action = QuotaExceededAction.Warn
|
||||
};
|
||||
}
|
||||
114
src/__Libraries/StellaOps.Facet/FacetSeal.cs
Normal file
114
src/__Libraries/StellaOps.Facet/FacetSeal.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
// <copyright file="FacetSeal.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Sealed manifest of facets for an image at a point in time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A FacetSeal captures the cryptographic state of all facets in an image,
|
||||
/// enabling drift detection and quota enforcement on subsequent scans.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The seal can be optionally signed with DSSE for authenticity verification.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record FacetSeal
|
||||
{
|
||||
/// <summary>
|
||||
/// Current schema version.
|
||||
/// </summary>
|
||||
public const string CurrentSchemaVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the schema version for forward compatibility.
|
||||
/// </summary>
|
||||
public string SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the image digest this seal applies to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format: "sha256:{hex}" or "sha512:{hex}".
|
||||
/// </remarks>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the seal was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional build attestation reference (in-toto provenance).
|
||||
/// </summary>
|
||||
public string? BuildAttestationRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the individual facet seals.
|
||||
/// </summary>
|
||||
public required ImmutableArray<FacetEntry> Facets { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the quota configuration per facet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keys are facet IDs. Facets without explicit quotas use default values.
|
||||
/// </remarks>
|
||||
public ImmutableDictionary<string, FacetQuota>? Quotas { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the combined Merkle root of all facet roots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Computed from facet Merkle roots in sorted order by FacetId.
|
||||
/// Enables single-value integrity verification.
|
||||
/// </remarks>
|
||||
public required string CombinedMerkleRoot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional DSSE signature over canonical form.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Base64-encoded DSSE envelope when the seal is signed.
|
||||
/// </remarks>
|
||||
public string? Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the signing key identifier, if signed.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this seal is signed.
|
||||
/// </summary>
|
||||
public bool IsSigned => !string.IsNullOrEmpty(Signature);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the quota for a specific facet, or default if not configured.
|
||||
/// </summary>
|
||||
/// <param name="facetId">The facet identifier.</param>
|
||||
/// <returns>The configured quota or <see cref="FacetQuota.Default"/>.</returns>
|
||||
public FacetQuota GetQuota(string facetId)
|
||||
{
|
||||
if (Quotas is not null &&
|
||||
Quotas.TryGetValue(facetId, out var quota))
|
||||
{
|
||||
return quota;
|
||||
}
|
||||
|
||||
return FacetQuota.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a facet entry by ID.
|
||||
/// </summary>
|
||||
/// <param name="facetId">The facet identifier.</param>
|
||||
/// <returns>The facet entry or null if not found.</returns>
|
||||
public FacetEntry? GetFacet(string facetId)
|
||||
=> Facets.FirstOrDefault(f => f.FacetId == facetId);
|
||||
}
|
||||
121
src/__Libraries/StellaOps.Facet/FacetSealer.cs
Normal file
121
src/__Libraries/StellaOps.Facet/FacetSealer.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
// <copyright file="FacetSealer.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Creates <see cref="FacetSeal"/> instances from extraction results.
|
||||
/// </summary>
|
||||
public sealed class FacetSealer
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly FacetMerkleTree _merkleTree;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetSealer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <param name="cryptoHash">Hash implementation.</param>
|
||||
/// <param name="algorithm">Hash algorithm.</param>
|
||||
public FacetSealer(
|
||||
TimeProvider? timeProvider = null,
|
||||
ICryptoHash? cryptoHash = null,
|
||||
string algorithm = "SHA256")
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_merkleTree = new FacetMerkleTree(cryptoHash, algorithm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a seal from extraction result.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The image digest this seal applies to.</param>
|
||||
/// <param name="extraction">The extraction result.</param>
|
||||
/// <param name="quotas">Optional per-facet quota configuration.</param>
|
||||
/// <param name="buildAttestationRef">Optional build attestation reference.</param>
|
||||
/// <returns>The created seal.</returns>
|
||||
public FacetSeal CreateSeal(
|
||||
string imageDigest,
|
||||
FacetExtractionResult extraction,
|
||||
ImmutableDictionary<string, FacetQuota>? quotas = null,
|
||||
string? buildAttestationRef = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
ArgumentNullException.ThrowIfNull(extraction);
|
||||
|
||||
var combinedRoot = _merkleTree.ComputeCombinedRoot(extraction.Facets);
|
||||
|
||||
return new FacetSeal
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
BuildAttestationRef = buildAttestationRef,
|
||||
Facets = extraction.Facets,
|
||||
Quotas = quotas,
|
||||
CombinedMerkleRoot = combinedRoot
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a seal from facet entries directly.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The image digest.</param>
|
||||
/// <param name="facets">The facet entries.</param>
|
||||
/// <param name="quotas">Optional quotas.</param>
|
||||
/// <param name="buildAttestationRef">Optional attestation ref.</param>
|
||||
/// <returns>The created seal.</returns>
|
||||
public FacetSeal CreateSeal(
|
||||
string imageDigest,
|
||||
ImmutableArray<FacetEntry> facets,
|
||||
ImmutableDictionary<string, FacetQuota>? quotas = null,
|
||||
string? buildAttestationRef = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
|
||||
var combinedRoot = _merkleTree.ComputeCombinedRoot(facets);
|
||||
|
||||
return new FacetSeal
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
BuildAttestationRef = buildAttestationRef,
|
||||
Facets = facets,
|
||||
Quotas = quotas,
|
||||
CombinedMerkleRoot = combinedRoot
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a facet entry from file entries.
|
||||
/// </summary>
|
||||
/// <param name="facet">The facet definition.</param>
|
||||
/// <param name="files">Files belonging to this facet.</param>
|
||||
/// <param name="includeFileDetails">Whether to include individual file entries.</param>
|
||||
/// <returns>The facet entry.</returns>
|
||||
public FacetEntry CreateFacetEntry(
|
||||
IFacet facet,
|
||||
IReadOnlyList<FacetFileEntry> files,
|
||||
bool includeFileDetails = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(facet);
|
||||
ArgumentNullException.ThrowIfNull(files);
|
||||
|
||||
var merkleRoot = _merkleTree.ComputeRoot(files);
|
||||
var totalBytes = files.Sum(f => f.SizeBytes);
|
||||
|
||||
return new FacetEntry
|
||||
{
|
||||
FacetId = facet.FacetId,
|
||||
Name = facet.Name,
|
||||
Category = facet.Category,
|
||||
Selectors = [.. facet.Selectors],
|
||||
MerkleRoot = merkleRoot,
|
||||
FileCount = files.Count,
|
||||
TotalBytes = totalBytes,
|
||||
Files = includeFileDetails ? [.. files] : null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// <copyright file="FacetServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering facet services with dependency injection.
|
||||
/// </summary>
|
||||
public static class FacetServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add facet services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFacetServices(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Register crypto hash
|
||||
services.TryAddSingleton<ICryptoHash>(DefaultCryptoHash.Instance);
|
||||
|
||||
// Register Merkle tree
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var crypto = sp.GetService<ICryptoHash>() ?? DefaultCryptoHash.Instance;
|
||||
return new FacetMerkleTree(crypto);
|
||||
});
|
||||
|
||||
// Register classifier with built-in facets
|
||||
services.TryAddSingleton(_ => FacetClassifier.Default);
|
||||
|
||||
// Register sealer
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var crypto = sp.GetService<ICryptoHash>() ?? DefaultCryptoHash.Instance;
|
||||
return new FacetSealer(timeProvider, crypto);
|
||||
});
|
||||
|
||||
// Register drift detector
|
||||
services.TryAddSingleton<IFacetDriftDetector>(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
return new FacetDriftDetector(timeProvider);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add facet services with custom configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Configuration action.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFacetServices(
|
||||
this IServiceCollection services,
|
||||
Action<FacetServiceOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
var options = new FacetServiceOptions();
|
||||
configure(options);
|
||||
|
||||
// Register crypto hash
|
||||
if (options.CryptoHash is not null)
|
||||
{
|
||||
services.AddSingleton(options.CryptoHash);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<ICryptoHash>(DefaultCryptoHash.Instance);
|
||||
}
|
||||
|
||||
// Register custom facets if provided
|
||||
if (options.CustomFacets is { Count: > 0 })
|
||||
{
|
||||
var allFacets = BuiltInFacets.All.Concat(options.CustomFacets).ToList();
|
||||
services.AddSingleton(new FacetClassifier(allFacets));
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton(_ => FacetClassifier.Default);
|
||||
}
|
||||
|
||||
// Register Merkle tree with algorithm
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var crypto = sp.GetService<ICryptoHash>() ?? DefaultCryptoHash.Instance;
|
||||
return new FacetMerkleTree(crypto, options.HashAlgorithm);
|
||||
});
|
||||
|
||||
// Register sealer
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var crypto = sp.GetService<ICryptoHash>() ?? DefaultCryptoHash.Instance;
|
||||
return new FacetSealer(timeProvider, crypto, options.HashAlgorithm);
|
||||
});
|
||||
|
||||
// Register drift detector
|
||||
services.TryAddSingleton<IFacetDriftDetector>(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
return new FacetDriftDetector(timeProvider);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for facet services.
|
||||
/// </summary>
|
||||
public sealed class FacetServiceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the hash algorithm (default: SHA256).
|
||||
/// </summary>
|
||||
public string HashAlgorithm { get; set; } = "SHA256";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom facet definitions to add to built-ins.
|
||||
/// </summary>
|
||||
public List<IFacet>? CustomFacets { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a custom crypto hash implementation.
|
||||
/// </summary>
|
||||
public ICryptoHash? CryptoHash { get; set; }
|
||||
}
|
||||
70
src/__Libraries/StellaOps.Facet/GlobMatcher.cs
Normal file
70
src/__Libraries/StellaOps.Facet/GlobMatcher.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
// <copyright file="GlobMatcher.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using DotNet.Globbing;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Utility for matching file paths against glob patterns.
|
||||
/// </summary>
|
||||
public sealed class GlobMatcher
|
||||
{
|
||||
private readonly List<Glob> _globs;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GlobMatcher"/> class.
|
||||
/// </summary>
|
||||
/// <param name="patterns">Glob patterns to match against.</param>
|
||||
public GlobMatcher(IEnumerable<string> patterns)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(patterns);
|
||||
|
||||
_globs = patterns
|
||||
.Select(p => Glob.Parse(NormalizePattern(p)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a path matches any of the patterns.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to check (Unix-style).</param>
|
||||
/// <returns>True if any pattern matches.</returns>
|
||||
public bool IsMatch(string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
var normalizedPath = NormalizePath(path);
|
||||
return _globs.Any(g => g.IsMatch(normalizedPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a matcher for a single facet.
|
||||
/// </summary>
|
||||
/// <param name="facet">The facet to create a matcher for.</param>
|
||||
/// <returns>A GlobMatcher for the facet's selectors.</returns>
|
||||
public static GlobMatcher ForFacet(IFacet facet)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(facet);
|
||||
return new GlobMatcher(facet.Selectors);
|
||||
}
|
||||
|
||||
private static string NormalizePattern(string pattern)
|
||||
{
|
||||
// Ensure patterns use forward slashes
|
||||
return pattern.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
// Ensure paths use forward slashes and are rooted
|
||||
var normalized = path.Replace('\\', '/');
|
||||
if (!normalized.StartsWith('/'))
|
||||
{
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
32
src/__Libraries/StellaOps.Facet/ICryptoHash.cs
Normal file
32
src/__Libraries/StellaOps.Facet/ICryptoHash.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
// <copyright file="ICryptoHash.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for cryptographic hash operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface allows the facet library to be used with different
|
||||
/// cryptographic implementations (e.g., built-in .NET, BouncyCastle, HSM).
|
||||
/// </remarks>
|
||||
public interface ICryptoHash
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute hash of the given data.
|
||||
/// </summary>
|
||||
/// <param name="data">Data to hash.</param>
|
||||
/// <param name="algorithm">Algorithm name (e.g., "SHA256", "SHA512").</param>
|
||||
/// <returns>Hash bytes.</returns>
|
||||
byte[] ComputeHash(byte[] data, string algorithm);
|
||||
|
||||
/// <summary>
|
||||
/// Compute hash of a stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">Stream to hash.</param>
|
||||
/// <param name="algorithm">Algorithm name.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Hash bytes.</returns>
|
||||
Task<byte[]> ComputeHashAsync(Stream stream, string algorithm, CancellationToken ct = default);
|
||||
}
|
||||
60
src/__Libraries/StellaOps.Facet/IFacet.cs
Normal file
60
src/__Libraries/StellaOps.Facet/IFacet.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
// <copyright file="IFacet.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a trackable slice of an image.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A facet defines a logical grouping of files within a container image
|
||||
/// that can be tracked independently for sealing and drift detection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Examples of facets: OS packages, language dependencies, binaries, config files.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IFacet
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this facet type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format: "{category}-{specifics}" e.g., "os-packages-dpkg", "lang-deps-npm".
|
||||
/// </remarks>
|
||||
string FacetId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable name.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the facet category for grouping.
|
||||
/// </summary>
|
||||
FacetCategory Category { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the glob patterns or path selectors for files in this facet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Selectors support:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Glob patterns: "**/*.json", "/usr/bin/*"</description></item>
|
||||
/// <item><description>Exact paths: "/var/lib/dpkg/status"</description></item>
|
||||
/// <item><description>Directory patterns: "/etc/**"</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
IReadOnlyList<string> Selectors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the priority for conflict resolution when files match multiple facets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lower values = higher priority. A file matching multiple facets
|
||||
/// will be assigned to the facet with the lowest priority value.
|
||||
/// </remarks>
|
||||
int Priority { get; }
|
||||
}
|
||||
35
src/__Libraries/StellaOps.Facet/IFacetDriftDetector.cs
Normal file
35
src/__Libraries/StellaOps.Facet/IFacetDriftDetector.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
// <copyright file="IFacetDriftDetector.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Detects drift between a baseline seal and current state.
|
||||
/// </summary>
|
||||
public interface IFacetDriftDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Compare current extraction result against a baseline seal.
|
||||
/// </summary>
|
||||
/// <param name="baseline">The baseline facet seal.</param>
|
||||
/// <param name="current">The current extraction result.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Drift report with per-facet analysis.</returns>
|
||||
Task<FacetDriftReport> DetectDriftAsync(
|
||||
FacetSeal baseline,
|
||||
FacetExtractionResult current,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Compare two seals.
|
||||
/// </summary>
|
||||
/// <param name="baseline">The baseline seal.</param>
|
||||
/// <param name="current">The current seal.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Drift report with per-facet analysis.</returns>
|
||||
Task<FacetDriftReport> DetectDriftAsync(
|
||||
FacetSeal baseline,
|
||||
FacetSeal current,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
47
src/__Libraries/StellaOps.Facet/IFacetExtractor.cs
Normal file
47
src/__Libraries/StellaOps.Facet/IFacetExtractor.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
// <copyright file="IFacetExtractor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts facet information from container images.
|
||||
/// </summary>
|
||||
public interface IFacetExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extract facets from a local directory (unpacked image).
|
||||
/// </summary>
|
||||
/// <param name="rootPath">Path to the unpacked image root.</param>
|
||||
/// <param name="options">Extraction options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Extraction result with all facet entries.</returns>
|
||||
Task<FacetExtractionResult> ExtractFromDirectoryAsync(
|
||||
string rootPath,
|
||||
FacetExtractionOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extract facets from a tar archive.
|
||||
/// </summary>
|
||||
/// <param name="tarStream">Stream containing the tar archive.</param>
|
||||
/// <param name="options">Extraction options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Extraction result with all facet entries.</returns>
|
||||
Task<FacetExtractionResult> ExtractFromTarAsync(
|
||||
Stream tarStream,
|
||||
FacetExtractionOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extract facets from an OCI image layer.
|
||||
/// </summary>
|
||||
/// <param name="layerStream">Stream containing the layer (tar.gz).</param>
|
||||
/// <param name="options">Extraction options.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Extraction result with all facet entries.</returns>
|
||||
Task<FacetExtractionResult> ExtractFromOciLayerAsync(
|
||||
Stream layerStream,
|
||||
FacetExtractionOptions? options = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
52
src/__Libraries/StellaOps.Facet/QuotaExceededAction.cs
Normal file
52
src/__Libraries/StellaOps.Facet/QuotaExceededAction.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
// <copyright file="QuotaExceededAction.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Action to take when a facet quota is exceeded.
|
||||
/// </summary>
|
||||
public enum QuotaExceededAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Emit a warning but allow the operation to continue.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// Block the operation (fail deployment/admission).
|
||||
/// </summary>
|
||||
Block,
|
||||
|
||||
/// <summary>
|
||||
/// Require a VEX statement to authorize the drift.
|
||||
/// </summary>
|
||||
RequireVex
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evaluating a facet's drift against its quota.
|
||||
/// </summary>
|
||||
public enum QuotaVerdict
|
||||
{
|
||||
/// <summary>
|
||||
/// Drift is within acceptable limits.
|
||||
/// </summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>
|
||||
/// Drift exceeds threshold but action is Warn.
|
||||
/// </summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>
|
||||
/// Drift exceeds threshold and action is Block.
|
||||
/// </summary>
|
||||
Blocked,
|
||||
|
||||
/// <summary>
|
||||
/// Drift requires VEX authorization.
|
||||
/// </summary>
|
||||
RequiresVex
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// <copyright file="FacetSealJsonConverter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Facet.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization options for facet seals.
|
||||
/// </summary>
|
||||
public static class FacetJsonOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default JSON serializer options for facet seals.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions Default { get; } = CreateOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Gets options for compact serialization (no indentation).
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions Compact { get; } = CreateOptions(writeIndented: false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets options for pretty-printed serialization.
|
||||
/// </summary>
|
||||
public static JsonSerializerOptions Pretty { get; } = CreateOptions(writeIndented: true);
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented = false)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = writeIndented,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
options.Converters.Add(new ImmutableArrayConverterFactory());
|
||||
options.Converters.Add(new ImmutableDictionaryConverterFactory());
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converter factory for ImmutableArray{T}.
|
||||
/// </summary>
|
||||
internal sealed class ImmutableArrayConverterFactory : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert)
|
||||
{
|
||||
return typeToConvert.IsGenericType &&
|
||||
typeToConvert.GetGenericTypeDefinition() == typeof(ImmutableArray<>);
|
||||
}
|
||||
|
||||
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var elementType = typeToConvert.GetGenericArguments()[0];
|
||||
var converterType = typeof(ImmutableArrayConverter<>).MakeGenericType(elementType);
|
||||
return (JsonConverter)Activator.CreateInstance(converterType)!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converter for ImmutableArray{T}.
|
||||
/// </summary>
|
||||
internal sealed class ImmutableArrayConverter<T> : JsonConverter<ImmutableArray<T>>
|
||||
{
|
||||
public override ImmutableArray<T> Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var list = JsonSerializer.Deserialize<List<T>>(ref reader, options);
|
||||
return list is null ? [] : [.. list];
|
||||
}
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
ImmutableArray<T> value,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
JsonSerializer.Serialize(writer, value.AsEnumerable(), options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converter factory for ImmutableDictionary{TKey,TValue}.
|
||||
/// </summary>
|
||||
internal sealed class ImmutableDictionaryConverterFactory : JsonConverterFactory
|
||||
{
|
||||
public override bool CanConvert(Type typeToConvert)
|
||||
{
|
||||
return typeToConvert.IsGenericType &&
|
||||
typeToConvert.GetGenericTypeDefinition() == typeof(ImmutableDictionary<,>);
|
||||
}
|
||||
|
||||
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var keyType = typeToConvert.GetGenericArguments()[0];
|
||||
var valueType = typeToConvert.GetGenericArguments()[1];
|
||||
var converterType = typeof(ImmutableDictionaryConverter<,>).MakeGenericType(keyType, valueType);
|
||||
return (JsonConverter)Activator.CreateInstance(converterType)!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converter for ImmutableDictionary{TKey,TValue}.
|
||||
/// </summary>
|
||||
internal sealed class ImmutableDictionaryConverter<TKey, TValue> : JsonConverter<ImmutableDictionary<TKey, TValue>>
|
||||
where TKey : notnull
|
||||
{
|
||||
public override ImmutableDictionary<TKey, TValue>? Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options);
|
||||
return dict?.ToImmutableDictionary();
|
||||
}
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
ImmutableDictionary<TKey, TValue> value,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
JsonSerializer.Serialize(writer, value.AsEnumerable().ToDictionary(kv => kv.Key, kv => kv.Value), options);
|
||||
}
|
||||
}
|
||||
18
src/__Libraries/StellaOps.Facet/StellaOps.Facet.csproj
Normal file
18
src/__Libraries/StellaOps.Facet/StellaOps.Facet.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Facet abstraction layer for per-facet sealing and drift tracking in container images.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="DotNet.Glob" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user