up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 09:40:40 +02:00
parent 1c6730a1d2
commit 05da719048
206 changed files with 34741 additions and 1751 deletions

View File

@@ -0,0 +1,198 @@
using System.Buffers.Binary;
namespace StellaOps.Scanner.Analyzers.OS.Pkgutil;
/// <summary>
/// Parses macOS BOM (Bill of Materials) files to enumerate installed files.
/// BOM files are used by pkgutil to track which files were installed by a package.
/// </summary>
internal sealed class BomParser
{
/// <summary>
/// BOM file magic header: "BOMStore"
/// </summary>
private static ReadOnlySpan<byte> BomMagic => "BOMStore"u8;
/// <summary>
/// Extracts the list of installed file paths from a BOM file.
/// </summary>
/// <remarks>
/// BOM files have a complex binary format. This implementation extracts
/// the file paths from the BOM tree structure, focusing on the Paths tree.
/// </remarks>
public IReadOnlyList<BomFileEntry> Parse(string bomPath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bomPath);
if (!File.Exists(bomPath))
{
return Array.Empty<BomFileEntry>();
}
try
{
using var stream = File.OpenRead(bomPath);
return Parse(stream, cancellationToken);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return Array.Empty<BomFileEntry>();
}
}
/// <summary>
/// Extracts file paths from a BOM stream.
/// </summary>
public IReadOnlyList<BomFileEntry> Parse(Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
var results = new List<BomFileEntry>();
try
{
// Read header (512+ bytes)
var header = new byte[512];
if (stream.Read(header, 0, 512) < 512)
{
return results;
}
// Verify magic
if (!header.AsSpan(0, 8).SequenceEqual(BomMagic))
{
return results;
}
// BOM format is complex - we'll do a simplified extraction
// by scanning for null-terminated strings that look like paths
stream.Position = 0;
using var reader = new BinaryReader(stream);
var content = reader.ReadBytes((int)Math.Min(stream.Length, 10 * 1024 * 1024)); // Max 10MB
var paths = ExtractPaths(content, cancellationToken);
foreach (var path in paths)
{
results.Add(new BomFileEntry(path, IsDirectory: path.EndsWith('/')));
}
}
catch (Exception ex) when (ex is IOException or EndOfStreamException)
{
// Return partial results
}
return results;
}
/// <summary>
/// Finds the corresponding BOM file for a receipt plist.
/// </summary>
public string? FindBomForReceipt(string plistPath)
{
if (string.IsNullOrWhiteSpace(plistPath))
{
return null;
}
// BOM files are named with same base name as plist
// e.g., com.apple.pkg.Safari.plist -> com.apple.pkg.Safari.bom
var directory = Path.GetDirectoryName(plistPath);
var baseName = Path.GetFileNameWithoutExtension(plistPath);
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(baseName))
{
return null;
}
var bomPath = Path.Combine(directory, baseName + ".bom");
return File.Exists(bomPath) ? bomPath : null;
}
private static IEnumerable<string> ExtractPaths(byte[] content, CancellationToken cancellationToken)
{
var paths = new HashSet<string>(StringComparer.Ordinal);
// Scan for null-terminated strings that look like Unix paths
int start = -1;
for (int i = 0; i < content.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
byte b = content[i];
if (start == -1)
{
// Look for path start indicators
if (b == '/' || b == '.')
{
start = i;
}
}
else
{
if (b == 0) // Null terminator
{
var length = i - start;
if (length > 1 && length < 4096)
{
var potential = System.Text.Encoding.UTF8.GetString(content, start, length);
if (IsValidPath(potential))
{
paths.Add(potential);
}
}
start = -1;
}
else if (!IsValidPathChar(b))
{
start = -1;
}
}
}
return paths.OrderBy(p => p, StringComparer.Ordinal);
}
private static bool IsValidPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
// Must start with / or .
if (!path.StartsWith('/') && !path.StartsWith('.'))
{
return false;
}
// Must not contain control characters or obviously invalid sequences
foreach (char c in path)
{
if (c < 32 && c != '\t')
{
return false;
}
}
// Filter out common false positives
if (path.Contains("//") || path.EndsWith("/.") || path.Contains("/../"))
{
return false;
}
return true;
}
private static bool IsValidPathChar(byte b)
{
// Allow printable ASCII and common path characters
return b >= 32 && b < 127;
}
}
/// <summary>
/// Represents a file entry from a BOM file.
/// </summary>
internal sealed record BomFileEntry(string Path, bool IsDirectory);

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Plugin;
namespace StellaOps.Scanner.Analyzers.OS.Pkgutil;
/// <summary>
/// Plugin that registers the pkgutil package analyzer for macOS receipt discovery.
/// </summary>
public sealed class PkgutilAnalyzerPlugin : IOSAnalyzerPlugin
{
/// <inheritdoc />
public string Name => "StellaOps.Scanner.Analyzers.OS.Pkgutil";
/// <inheritdoc />
public bool IsAvailable(IServiceProvider services) => services is not null;
/// <inheritdoc />
public IOSPackageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new PkgutilPackageAnalyzer(loggerFactory.CreateLogger<PkgutilPackageAnalyzer>());
}
}

View File

@@ -0,0 +1,229 @@
using System.Collections.ObjectModel;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Analyzers.OS.Abstractions;
using StellaOps.Scanner.Analyzers.OS.Analyzers;
using StellaOps.Scanner.Analyzers.OS.Helpers;
namespace StellaOps.Scanner.Analyzers.OS.Pkgutil;
/// <summary>
/// Analyzes macOS pkgutil receipts to extract installed package information.
/// Parses receipt plists from /var/db/receipts/ and optionally enumerates
/// installed files from corresponding BOM files.
/// </summary>
internal sealed class PkgutilPackageAnalyzer : OsPackageAnalyzerBase
{
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
private readonly PkgutilReceiptParser _receiptParser = new();
private readonly BomParser _bomParser = new();
/// <summary>
/// Maximum number of files to enumerate from BOM per package.
/// </summary>
private const int MaxFilesPerPackage = 1000;
public PkgutilPackageAnalyzer(ILogger<PkgutilPackageAnalyzer> logger)
: base(logger)
{
}
public override string AnalyzerId => "pkgutil";
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
OSPackageAnalyzerContext context,
CancellationToken cancellationToken)
{
var receiptsPath = Path.Combine(context.RootPath, "var", "db", "receipts");
if (!Directory.Exists(receiptsPath))
{
Logger.LogInformation("pkgutil receipts directory not found at {Path}; skipping analyzer.", receiptsPath);
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
}
var receipts = _receiptParser.DiscoverReceipts(context.RootPath, cancellationToken);
if (receipts.Count == 0)
{
Logger.LogInformation("No pkgutil receipts found; skipping analyzer.");
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
}
Logger.LogInformation("Discovered {Count} pkgutil receipts", receipts.Count);
var records = new List<OSPackageRecord>(receipts.Count);
foreach (var receipt in receipts)
{
cancellationToken.ThrowIfCancellationRequested();
var record = CreateRecordFromReceipt(receipt, cancellationToken);
if (record is not null)
{
records.Add(record);
}
}
Logger.LogInformation("Created {Count} package records from pkgutil receipts", records.Count);
// Sort for deterministic output
records.Sort();
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
}
private OSPackageRecord? CreateRecordFromReceipt(
PkgutilReceipt receipt,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(receipt.Identifier) ||
string.IsNullOrWhiteSpace(receipt.Version))
{
return null;
}
// Build PURL
var purl = PackageUrlBuilder.BuildPkgutil(receipt.Identifier, receipt.Version);
// Determine architecture from identifier or install path heuristics
var architecture = DetectArchitecture(receipt);
// Extract files from BOM if available
var files = new List<OSPackageFileEvidence>();
if (!string.IsNullOrWhiteSpace(receipt.SourcePath))
{
var bomPath = _bomParser.FindBomForReceipt(receipt.SourcePath);
if (bomPath is not null)
{
var bomEntries = _bomParser.Parse(bomPath, cancellationToken);
var count = 0;
foreach (var entry in bomEntries)
{
if (count >= MaxFilesPerPackage)
{
break;
}
if (!entry.IsDirectory)
{
files.Add(new OSPackageFileEvidence(
entry.Path,
layerDigest: null,
sha256: null,
sizeBytes: null,
isConfigFile: IsConfigPath(entry.Path)));
count++;
}
}
}
}
// Build vendor metadata
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["pkgutil:identifier"] = receipt.Identifier,
["pkgutil:volume"] = receipt.VolumePath,
};
if (receipt.InstallDate.HasValue)
{
vendorMetadata["pkgutil:install_date"] = receipt.InstallDate.Value.ToString("o");
}
if (!string.IsNullOrWhiteSpace(receipt.InstallPrefixPath))
{
vendorMetadata["pkgutil:install_prefix"] = receipt.InstallPrefixPath;
}
if (!string.IsNullOrWhiteSpace(receipt.InstallProcessName))
{
vendorMetadata["pkgutil:installer"] = receipt.InstallProcessName;
}
// Extract package name from identifier (last component typically)
var name = ExtractNameFromIdentifier(receipt.Identifier);
return new OSPackageRecord(
AnalyzerId,
purl,
name,
receipt.Version,
architecture,
PackageEvidenceSource.PkgutilReceipt,
epoch: null,
release: null,
sourcePackage: ExtractVendorFromIdentifier(receipt.Identifier),
license: null,
cveHints: null,
provides: null,
depends: null,
files: files,
vendorMetadata: vendorMetadata);
}
private static string ExtractNameFromIdentifier(string identifier)
{
// Identifier format is typically: com.vendor.product or com.apple.pkg.Safari
var parts = identifier.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
return identifier;
}
// Return the last meaningful part
var last = parts[^1];
// Skip common suffixes
if (parts.Length > 1 &&
(last.Equals("pkg", StringComparison.OrdinalIgnoreCase) ||
last.Equals("app", StringComparison.OrdinalIgnoreCase)))
{
return parts[^2];
}
return last;
}
private static string? ExtractVendorFromIdentifier(string identifier)
{
// Extract vendor from identifier (e.g., "com.apple.pkg.Safari" -> "apple")
var parts = identifier.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
return parts[1];
}
return null;
}
private static string DetectArchitecture(PkgutilReceipt receipt)
{
// Check install path for architecture hints
var prefix = receipt.InstallPrefixPath ?? receipt.VolumePath;
if (!string.IsNullOrWhiteSpace(prefix))
{
if (prefix.Contains("/arm64/", StringComparison.OrdinalIgnoreCase) ||
prefix.Contains("/aarch64/", StringComparison.OrdinalIgnoreCase))
{
return "arm64";
}
if (prefix.Contains("/x86_64/", StringComparison.OrdinalIgnoreCase) ||
prefix.Contains("/amd64/", StringComparison.OrdinalIgnoreCase))
{
return "x86_64";
}
}
// Default to universal (noarch) for macOS packages
return "universal";
}
private static bool IsConfigPath(string path)
{
// Common macOS configuration paths
return path.Contains("/Preferences/", StringComparison.OrdinalIgnoreCase) ||
path.Contains("/etc/", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".plist", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".conf", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".cfg", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,144 @@
using Claunia.PropertyList;
namespace StellaOps.Scanner.Analyzers.OS.Pkgutil;
/// <summary>
/// Parses macOS pkgutil receipt .plist files from /var/db/receipts/.
/// </summary>
internal sealed class PkgutilReceiptParser
{
/// <summary>
/// Parses a pkgutil receipt plist file.
/// </summary>
public PkgutilReceipt? Parse(string plistPath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(plistPath);
if (!File.Exists(plistPath))
{
return null;
}
try
{
using var stream = File.OpenRead(plistPath);
return Parse(stream, plistPath, cancellationToken);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or FormatException)
{
return null;
}
}
/// <summary>
/// Parses a pkgutil receipt plist from a stream.
/// </summary>
public PkgutilReceipt? Parse(Stream stream, string? sourcePath = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
try
{
var plist = PropertyListParser.Parse(stream);
if (plist is not NSDictionary root)
{
return null;
}
var identifier = GetString(root, "PackageIdentifier") ?? GetString(root, "packageIdentifier");
if (string.IsNullOrWhiteSpace(identifier))
{
return null;
}
var version = GetString(root, "PackageVersion") ?? GetString(root, "packageVersion") ?? "0.0.0";
var installDate = GetDate(root, "InstallDate");
var installPrefixPath = GetString(root, "InstallPrefixPath");
var volumePath = GetString(root, "VolumePath") ?? "/";
var installProcessName = GetString(root, "InstallProcessName");
return new PkgutilReceipt(
Identifier: identifier.Trim(),
Version: version.Trim(),
InstallDate: installDate,
InstallPrefixPath: installPrefixPath?.Trim(),
VolumePath: volumePath.Trim(),
InstallProcessName: installProcessName?.Trim(),
SourcePath: sourcePath);
}
catch (Exception ex) when (ex is FormatException or InvalidOperationException or ArgumentException)
{
return null;
}
}
/// <summary>
/// Discovers and parses all receipt plist files in the receipts directory.
/// </summary>
public IReadOnlyList<PkgutilReceipt> DiscoverReceipts(
string rootPath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
var receiptsPath = Path.Combine(rootPath, "var", "db", "receipts");
if (!Directory.Exists(receiptsPath))
{
return Array.Empty<PkgutilReceipt>();
}
var results = new List<PkgutilReceipt>();
try
{
foreach (var plistFile in Directory.EnumerateFiles(receiptsPath, "*.plist"))
{
cancellationToken.ThrowIfCancellationRequested();
var receipt = Parse(plistFile, cancellationToken);
if (receipt is not null)
{
results.Add(receipt);
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Partial results are acceptable
}
return results;
}
private static string? GetString(NSDictionary dict, string key)
{
if (dict.TryGetValue(key, out var value) && value is NSString nsString)
{
return nsString.Content;
}
return null;
}
private static DateTimeOffset? GetDate(NSDictionary dict, string key)
{
if (dict.TryGetValue(key, out var value) && value is NSDate nsDate)
{
return new DateTimeOffset(nsDate.Date, TimeSpan.Zero);
}
return null;
}
}
/// <summary>
/// Represents parsed macOS pkgutil receipt metadata.
/// </summary>
internal sealed record PkgutilReceipt(
string Identifier,
string Version,
DateTimeOffset? InstallDate,
string? InstallPrefixPath,
string VolumePath,
string? InstallProcessName,
string? SourcePath);

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.Pkgutil.Tests")]

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="plist-cil" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" />
</ItemGroup>
</Project>