up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-26 07:47:08 +02:00
parent 56e2f64d07
commit 1c782897f7
184 changed files with 8991 additions and 649 deletions

View File

@@ -0,0 +1,92 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
namespace StellaOps.Scanner.Core.Entropy;
/// <summary>
/// Computes sliding-window Shannon entropy for byte buffers.
/// Offline-friendly and deterministic: no allocations beyond histogram buffer and result list.
/// </summary>
public static class EntropyCalculator
{
/// <summary>
/// Computes entropy windows over the supplied buffer.
/// </summary>
/// <param name="data">Input bytes.</param>
/// <param name="windowSize">Window length in bytes (default 4096).</param>
/// <param name="stride">Step between windows in bytes (default 1024).</param>
/// <returns>List of entropy windows (offset, length, entropy bits/byte).</returns>
public static IReadOnlyList<EntropyWindow> Compute(ReadOnlySpan<byte> data, int windowSize = 4096, int stride = 1024)
{
if (windowSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(windowSize), "Window size must be positive.");
}
if (stride <= 0)
{
throw new ArgumentOutOfRangeException(nameof(stride), "Stride must be positive.");
}
var results = new List<EntropyWindow>();
if (data.IsEmpty || data.Length < windowSize)
{
return results;
}
// Reuse histogram buffer; fixed length for byte values.
Span<int> histogram = stackalloc int[256];
var end = data.Length - windowSize;
// Seed histogram for first window.
for (var i = 0; i < windowSize; i++)
{
histogram[data[i]]++;
}
AppendEntropy(results, 0, windowSize, histogram, windowSize);
// Slide window with rolling histogram updates to avoid re-scanning the buffer.
for (var offset = stride; offset <= end; offset += stride)
{
var removeStart = offset - stride;
var removeEnd = removeStart + stride;
for (var i = removeStart; i < removeEnd; i++)
{
histogram[data[i]]--;
}
var addStart = offset + windowSize - stride;
var addEnd = offset + windowSize;
for (var i = addStart; i < addEnd; i++)
{
histogram[data[i]]++;
}
AppendEntropy(results, offset, windowSize, histogram, windowSize);
}
return results;
}
private static void AppendEntropy(ICollection<EntropyWindow> results, int offset, int length, ReadOnlySpan<int> histogram, int totalCount)
{
double entropy = 0;
for (var i = 0; i < 256; i++)
{
var count = histogram[i];
if (count == 0)
{
continue;
}
var p = (double)count / totalCount;
entropy -= p * Math.Log(p, 2);
}
results.Add(new EntropyWindow(offset, length, entropy));
}
}
public readonly record struct EntropyWindow(int Offset, int Length, double Entropy);

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Scanner.Core.Entropy;
/// <summary>
/// Builds per-file entropy reports and aggregates layer-level opaque ratios.
/// Keeps logic deterministic and offline-friendly.
/// </summary>
public sealed class EntropyReportBuilder
{
private readonly int _windowSize;
private readonly int _stride;
private readonly double _opaqueThreshold;
private readonly double _opaqueFileRatioFlag;
public EntropyReportBuilder(
int windowSize = 4096,
int stride = 1024,
double opaqueThreshold = 7.2,
double opaqueFileRatioFlag = 0.30)
{
if (windowSize <= 0) throw new ArgumentOutOfRangeException(nameof(windowSize));
if (stride <= 0) throw new ArgumentOutOfRangeException(nameof(stride));
if (opaqueThreshold <= 0) throw new ArgumentOutOfRangeException(nameof(opaqueThreshold));
if (opaqueFileRatioFlag < 0 || opaqueFileRatioFlag > 1) throw new ArgumentOutOfRangeException(nameof(opaqueFileRatioFlag));
_windowSize = windowSize;
_stride = stride;
_opaqueThreshold = opaqueThreshold;
_opaqueFileRatioFlag = opaqueFileRatioFlag;
}
/// <summary>
/// Builds a file-level entropy report.
/// </summary>
public EntropyFileReport BuildFile(string path, ReadOnlySpan<byte> data, IEnumerable<string>? flags = null)
{
ArgumentNullException.ThrowIfNull(path);
var windows = EntropyCalculator
.Compute(data, _windowSize, _stride)
.Select(w => new EntropyFileWindow(w.Offset, w.Length, w.Entropy))
.ToList();
var opaqueBytes = windows
.Where(w => w.Entropy >= _opaqueThreshold)
.Sum(w => (long)w.Length);
var size = data.Length;
var ratio = size == 0 ? 0d : (double)opaqueBytes / size;
var fileFlags = new List<string>();
if (flags is not null)
{
fileFlags.AddRange(flags.Where(f => !string.IsNullOrWhiteSpace(f)).Select(f => f.Trim()));
}
if (ratio >= _opaqueFileRatioFlag)
{
fileFlags.Add("opaque-high");
}
return new EntropyFileReport(
Path: path,
Size: size,
OpaqueBytes: opaqueBytes,
OpaqueRatio: ratio,
Flags: fileFlags,
Windows: windows);
}
/// <summary>
/// Aggregates layer-level opaque ratios and returns an image-level ratio.
/// </summary>
public (EntropyLayerSummary Layer, double ImageOpaqueRatio) BuildLayerSummary(
string layerDigest,
IEnumerable<EntropyFileReport> fileReports,
long layerTotalBytes,
double imageOpaqueBytes,
double imageTotalBytes)
{
ArgumentNullException.ThrowIfNull(fileReports);
ArgumentException.ThrowIfNullOrWhiteSpace(layerDigest);
var files = fileReports.ToList();
var opaqueBytes = files.Sum(f => f.OpaqueBytes);
var indicators = new List<string>();
if (files.Any(f => f.Flags.Contains("opaque-high", StringComparer.OrdinalIgnoreCase)))
{
indicators.Add("packed-like");
}
var layerRatio = layerTotalBytes <= 0 ? 0d : (double)opaqueBytes / layerTotalBytes;
var imageRatio = imageTotalBytes <= 0 ? 0d : imageOpaqueBytes / imageTotalBytes;
var summary = new EntropyLayerSummary(
LayerDigest: layerDigest,
OpaqueBytes: opaqueBytes,
TotalBytes: layerTotalBytes,
OpaqueRatio: layerRatio,
Indicators: indicators);
return (summary, imageRatio);
}
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Scanner.Core.Entropy;
public sealed record EntropyFileWindow(int Offset, int Length, double EntropyBits);
public sealed record EntropyFileReport(
string Path,
long Size,
long OpaqueBytes,
double OpaqueRatio,
IReadOnlyList<string> Flags,
IReadOnlyList<EntropyFileWindow> Windows);
public sealed record EntropyLayerSummary(
string LayerDigest,
long OpaqueBytes,
long TotalBytes,
double OpaqueRatio,
IReadOnlyList<string> Indicators);
public sealed record EntropyReport(
string ImageDigest,
string LayerDigest,
IReadOnlyList<EntropyFileReport> Files,
double ImageOpaqueRatio);

View File

@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Replay.Core;
namespace StellaOps.Scanner.Core.Replay;
/// <summary>
/// Assembles replay run metadata and bundle records from scanner artifacts.
/// </summary>
public sealed class RecordModeAssembler
{
private readonly TimeProvider _timeProvider;
public RecordModeAssembler(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ReplayRunRecord BuildRun(
string scanId,
ReplayManifest manifest,
string sbomDigest,
string findingsDigest,
string? vexDigest = null,
string? logDigest = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(sbomDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(findingsDigest);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var manifestHash = "sha256:" + manifest.ComputeCanonicalSha256();
return new ReplayRunRecord
{
Id = scanId,
ManifestHash = manifestHash,
Status = "pending",
CreatedAt = now,
UpdatedAt = now,
Outputs = new ReplayRunOutputs
{
Sbom = NormalizeDigest(sbomDigest),
Findings = NormalizeDigest(findingsDigest),
Vex = NormalizeOptionalDigest(vexDigest),
Log = NormalizeOptionalDigest(logDigest)
},
Signatures = new List<ReplaySignatureRecord>()
};
}
public IReadOnlyList<ReplayBundleRecord> BuildBundles(
ReplayBundleWriteResult inputBundle,
ReplayBundleWriteResult outputBundle,
IEnumerable<(ReplayBundleWriteResult Result, string Type)>? additionalBundles = null)
{
var now = _timeProvider.GetUtcNow().UtcDateTime;
var records = new List<ReplayBundleRecord>
{
ToBundleRecord(inputBundle, "input", now),
ToBundleRecord(outputBundle, "output", now)
};
if (additionalBundles != null)
{
records.AddRange(additionalBundles.Select(b => ToBundleRecord(b.Result, b.Type, now)));
}
return records;
}
private static ReplayBundleRecord ToBundleRecord(ReplayBundleWriteResult result, string type, DateTime createdAt)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentException.ThrowIfNullOrWhiteSpace(type);
return new ReplayBundleRecord
{
Id = result.ZstSha256,
Type = type.Trim().ToLowerInvariant(),
Size = result.ZstBytes,
Location = result.CasUri,
CreatedAt = createdAt
};
}
private static string NormalizeDigest(string digest)
{
var trimmed = digest.Trim().ToLowerInvariant();
return trimmed.StartsWith("sha256:", StringComparison.Ordinal) ? trimmed : $"sha256:{trimmed}";
}
private static string? NormalizeOptionalDigest(string? digest)
=> string.IsNullOrWhiteSpace(digest) ? null : NormalizeDigest(digest);
}

View File

@@ -14,5 +14,6 @@
<ItemGroup>
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
</ItemGroup>
</Project>
</Project>