up
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Enriches OS package file evidence with layer attribution and stable hashes/sizes.
|
||||
/// </summary>
|
||||
public sealed class OsFileEvidenceFactory
|
||||
{
|
||||
private readonly string _rootPath;
|
||||
private readonly ImmutableArray<(string? Digest, string Path)> _layerDirectories;
|
||||
private readonly string? _defaultLayerDigest;
|
||||
|
||||
private OsFileEvidenceFactory(string rootPath, ImmutableArray<(string? Digest, string Path)> layerDirectories, string? defaultLayerDigest)
|
||||
{
|
||||
_rootPath = rootPath;
|
||||
_layerDirectories = layerDirectories;
|
||||
_defaultLayerDigest = NormalizeDigest(defaultLayerDigest);
|
||||
}
|
||||
|
||||
public static OsFileEvidenceFactory Create(string rootPath, IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
var layerDirectories = ParseLayerEntries(metadata, ScanMetadataKeys.LayerDirectories);
|
||||
metadata.TryGetValue(ScanMetadataKeys.CurrentLayerDigest, out var defaultLayerDigest);
|
||||
|
||||
return new OsFileEvidenceFactory(rootPath, layerDirectories, defaultLayerDigest);
|
||||
}
|
||||
|
||||
public OSPackageFileEvidence Create(string path, bool isConfigFile, IDictionary<string, string>? digests = null)
|
||||
{
|
||||
var digestMap = digests is null
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, string>(digests, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var layerDigest = ResolveLayerDigest(path) ?? _defaultLayerDigest;
|
||||
string? sha256 = null;
|
||||
long? size = null;
|
||||
|
||||
var fullPath = CombineWithRoot(path);
|
||||
if (fullPath is not null && File.Exists(fullPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(fullPath);
|
||||
size = info.Length;
|
||||
|
||||
if (info.Length > 0 && !digestMap.TryGetValue("sha256", out sha256))
|
||||
{
|
||||
sha256 = ComputeSha256(fullPath);
|
||||
digestMap["sha256"] = sha256;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort: ignore IO failures and fall back to existing metadata
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore permission issues
|
||||
}
|
||||
}
|
||||
|
||||
return new OSPackageFileEvidence(
|
||||
path,
|
||||
layerDigest: layerDigest,
|
||||
sha256: sha256,
|
||||
sizeBytes: size,
|
||||
isConfigFile: isConfigFile,
|
||||
digests: digestMap);
|
||||
}
|
||||
|
||||
private string? CombineWithRoot(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = path.TrimStart('/', '\\');
|
||||
var combined = Path.Combine(_rootPath, trimmed);
|
||||
return Path.GetFullPath(combined);
|
||||
}
|
||||
|
||||
private string? ResolveLayerDigest(string path)
|
||||
{
|
||||
if (_layerDirectories.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var relative = path.TrimStart('/', '\\');
|
||||
foreach (var (digest, layerPath) in _layerDirectories)
|
||||
{
|
||||
string? layerDigest = NormalizeDigest(digest);
|
||||
string candidate;
|
||||
try
|
||||
{
|
||||
candidate = Path.GetFullPath(Path.Combine(layerPath, relative));
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return layerDigest ?? ComputeDirectoryDigest(layerPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeDirectoryDigest(string path)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(Path.GetFullPath(path));
|
||||
var hash = SHA256.HashData(payload);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static ImmutableArray<(string? Digest, string Path)> ParseLayerEntries(
|
||||
IReadOnlyDictionary<string, string> metadata,
|
||||
string metadataKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(metadataKey) ||
|
||||
!metadata.TryGetValue(metadataKey, out var rawValue) ||
|
||||
string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return ImmutableArray<(string?, string)>.Empty;
|
||||
}
|
||||
|
||||
rawValue = rawValue.Trim();
|
||||
IEnumerable<string> tokens;
|
||||
|
||||
if (rawValue.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<string[]>(rawValue);
|
||||
tokens = parsed ?? Array.Empty<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
tokens = SplitLayerString(rawValue);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
tokens = SplitLayerString(rawValue);
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<(string?, string)>();
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
var entry = token.Trim();
|
||||
if (entry.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = entry.IndexOf('=');
|
||||
string? digest = null;
|
||||
var pathPart = entry;
|
||||
|
||||
if (separator >= 0)
|
||||
{
|
||||
digest = entry[..separator].Trim();
|
||||
pathPart = entry[(separator + 1)..].Trim();
|
||||
}
|
||||
|
||||
if (pathPart.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add((NormalizeDigest(digest), pathPart));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitLayerString(string raw)
|
||||
=> raw.Split(new[] { '\n', '\r', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
private static string? NormalizeDigest(string? digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = digest.Trim();
|
||||
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
var parts = trimmed.Split(':', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length == 2
|
||||
? $"{parts[0].ToLowerInvariant()}:{parts[1].ToLowerInvariant()}"
|
||||
: trimmed.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Globalization;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Mapping;
|
||||
@@ -144,6 +145,11 @@ public static class OsComponentMapper
|
||||
|
||||
properties[$"digest.{digest.Key}.{NormalizePathKey(file.Path)}"] = digest.Value.Trim();
|
||||
}
|
||||
|
||||
if (file.SizeBytes.HasValue)
|
||||
{
|
||||
properties[$"size.{NormalizePathKey(file.Path)}"] = file.SizeBytes.Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<string>? licenses = null;
|
||||
|
||||
Reference in New Issue
Block a user