save progress

This commit is contained in:
StellaOps Bot
2026-01-06 09:42:02 +02:00
parent 94d68bee8b
commit 37e11918e0
443 changed files with 85863 additions and 897 deletions

View 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);
}

View 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")
};
}
}

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

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

View 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})";
}

View 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);
}

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

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

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

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

View 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);

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

View 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);
}
}

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

View 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);
}

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

View File

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

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

View 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);
}

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

View 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);
}

View 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);
}

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

View File

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

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