Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
228 lines
7.8 KiB
C#
228 lines
7.8 KiB
C#
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<ExecutionResult> 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(ExecutionResult.FromPackages(EmptyPackages));
|
|
}
|
|
|
|
var receipts = _receiptParser.DiscoverReceipts(context.RootPath, cancellationToken);
|
|
if (receipts.Count == 0)
|
|
{
|
|
Logger.LogInformation("No pkgutil receipts found; skipping analyzer.");
|
|
return ValueTask.FromResult(ExecutionResult.FromPackages(EmptyPackages));
|
|
}
|
|
|
|
Logger.LogInformation("Discovered {Count} pkgutil receipts", receipts.Count);
|
|
|
|
var evidenceFactory = OsFileEvidenceFactory.Create(context.RootPath, context.Metadata);
|
|
|
|
var records = new List<OSPackageRecord>(receipts.Count);
|
|
foreach (var receipt in receipts)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var record = CreateRecordFromReceipt(receipt, evidenceFactory, 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(ExecutionResult.FromPackages(records));
|
|
}
|
|
|
|
private OSPackageRecord? CreateRecordFromReceipt(
|
|
PkgutilReceipt receipt,
|
|
OsFileEvidenceFactory evidenceFactory,
|
|
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(evidenceFactory.Create(entry.Path, 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);
|
|
}
|
|
}
|