up
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user