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
367 lines
12 KiB
C#
367 lines
12 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.Homebrew;
|
|
|
|
/// <summary>
|
|
/// Analyzes Homebrew Cellar directories to extract installed formula information.
|
|
/// Scans /usr/local/Cellar (Intel) and /opt/homebrew/Cellar (Apple Silicon) directories.
|
|
/// </summary>
|
|
internal sealed class HomebrewPackageAnalyzer : OsPackageAnalyzerBase
|
|
{
|
|
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
|
|
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
|
|
|
|
/// <summary>
|
|
/// Default paths to scan for Homebrew Cellar directories.
|
|
/// </summary>
|
|
private static readonly string[] CellarPaths =
|
|
[
|
|
"usr/local/Cellar", // Intel Macs
|
|
"opt/homebrew/Cellar", // Apple Silicon Macs
|
|
];
|
|
|
|
/// <summary>
|
|
/// Maximum traversal depth within Cellar to prevent runaway scanning.
|
|
/// Formula structure: Cellar/{formula}/{version}/...
|
|
/// </summary>
|
|
private const int MaxTraversalDepth = 3;
|
|
|
|
/// <summary>
|
|
/// Maximum formula size in bytes (200MB default per design spec).
|
|
/// </summary>
|
|
private const long MaxFormulaSizeBytes = 200L * 1024L * 1024L;
|
|
|
|
private readonly HomebrewReceiptParser _parser = new();
|
|
|
|
public HomebrewPackageAnalyzer(ILogger<HomebrewPackageAnalyzer> logger)
|
|
: base(logger)
|
|
{
|
|
}
|
|
|
|
public override string AnalyzerId => "homebrew";
|
|
|
|
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
|
|
OSPackageAnalyzerContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var records = new List<OSPackageRecord>();
|
|
var warnings = new List<string>();
|
|
|
|
foreach (var cellarRelativePath in CellarPaths)
|
|
{
|
|
var cellarPath = Path.Combine(context.RootPath, cellarRelativePath);
|
|
if (!Directory.Exists(cellarPath))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Logger.LogInformation("Scanning Homebrew Cellar at {Path}", cellarPath);
|
|
|
|
try
|
|
{
|
|
DiscoverFormulas(cellarPath, records, warnings, cancellationToken);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to scan Homebrew Cellar at {Path}", cellarPath);
|
|
}
|
|
}
|
|
|
|
if (records.Count == 0)
|
|
{
|
|
Logger.LogInformation("No Homebrew formulas found; skipping analyzer.");
|
|
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
|
|
}
|
|
|
|
foreach (var warning in warnings)
|
|
{
|
|
Logger.LogWarning("Homebrew scan warning: {Warning}", warning);
|
|
}
|
|
|
|
Logger.LogInformation("Discovered {Count} Homebrew formulas", records.Count);
|
|
|
|
// Sort for deterministic output
|
|
records.Sort();
|
|
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
|
|
}
|
|
|
|
private void DiscoverFormulas(
|
|
string cellarPath,
|
|
List<OSPackageRecord> records,
|
|
List<string> warnings,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Enumerate formula directories (e.g., /usr/local/Cellar/openssl@3)
|
|
foreach (var formulaDir in EnumerateDirectoriesSafe(cellarPath))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var formulaName = Path.GetFileName(formulaDir);
|
|
if (string.IsNullOrWhiteSpace(formulaName) || formulaName.StartsWith('.'))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Enumerate version directories (e.g., /usr/local/Cellar/openssl@3/3.1.0)
|
|
foreach (var versionDir in EnumerateDirectoriesSafe(formulaDir))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var versionName = Path.GetFileName(versionDir);
|
|
if (string.IsNullOrWhiteSpace(versionName) || versionName.StartsWith('.'))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Check size guardrail
|
|
if (!CheckFormulaSizeGuardrail(versionDir, out var sizeWarning))
|
|
{
|
|
warnings.Add(sizeWarning!);
|
|
continue;
|
|
}
|
|
|
|
// Look for INSTALL_RECEIPT.json
|
|
var receiptPath = Path.Combine(versionDir, "INSTALL_RECEIPT.json");
|
|
if (File.Exists(receiptPath))
|
|
{
|
|
var record = ParseReceiptAndCreateRecord(receiptPath, formulaName, versionName, versionDir);
|
|
if (record is not null)
|
|
{
|
|
records.Add(record);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Fallback: create record from directory structure
|
|
var record = CreateRecordFromDirectory(formulaName, versionName, versionDir);
|
|
if (record is not null)
|
|
{
|
|
records.Add(record);
|
|
warnings.Add($"No INSTALL_RECEIPT.json for {formulaName}@{versionName}; using directory-based discovery.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private OSPackageRecord? ParseReceiptAndCreateRecord(
|
|
string receiptPath,
|
|
string formulaName,
|
|
string versionFromDir,
|
|
string versionDir)
|
|
{
|
|
var receipt = _parser.Parse(receiptPath);
|
|
if (receipt is null)
|
|
{
|
|
Logger.LogWarning("Failed to parse INSTALL_RECEIPT.json at {Path}", receiptPath);
|
|
return null;
|
|
}
|
|
|
|
// Use receipt version if available, fallback to directory name
|
|
var version = !string.IsNullOrWhiteSpace(receipt.Version) ? receipt.Version : versionFromDir;
|
|
|
|
// Build PURL per spec: pkg:brew/<tap>/<formula>@<version>?revision=<revision>
|
|
var purl = PackageUrlBuilder.BuildHomebrew(
|
|
receipt.Tap ?? "homebrew/core",
|
|
receipt.Name ?? formulaName,
|
|
version,
|
|
receipt.Revision);
|
|
|
|
var vendorMetadata = BuildVendorMetadata(receipt, versionDir);
|
|
var files = DiscoverFormulaFiles(versionDir);
|
|
|
|
return new OSPackageRecord(
|
|
AnalyzerId,
|
|
purl,
|
|
receipt.Name ?? formulaName,
|
|
version,
|
|
receipt.Architecture,
|
|
PackageEvidenceSource.HomebrewCellar,
|
|
epoch: null,
|
|
release: receipt.Revision > 0 ? receipt.Revision.ToString() : null,
|
|
sourcePackage: receipt.Tap,
|
|
license: receipt.License,
|
|
cveHints: null,
|
|
provides: null,
|
|
depends: receipt.RuntimeDependencies,
|
|
files: files,
|
|
vendorMetadata: vendorMetadata);
|
|
}
|
|
|
|
private OSPackageRecord? CreateRecordFromDirectory(
|
|
string formulaName,
|
|
string version,
|
|
string versionDir)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(formulaName) || string.IsNullOrWhiteSpace(version))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var purl = PackageUrlBuilder.BuildHomebrew("homebrew/core", formulaName, version, revision: 0);
|
|
var architecture = DetectArchitectureFromPath(versionDir);
|
|
var files = DiscoverFormulaFiles(versionDir);
|
|
|
|
var vendorMetadata = new Dictionary<string, string?>(StringComparer.Ordinal)
|
|
{
|
|
["brew:discovery_method"] = "directory",
|
|
["brew:install_path"] = versionDir,
|
|
};
|
|
|
|
return new OSPackageRecord(
|
|
AnalyzerId,
|
|
purl,
|
|
formulaName,
|
|
version,
|
|
architecture,
|
|
PackageEvidenceSource.HomebrewCellar,
|
|
epoch: null,
|
|
release: null,
|
|
sourcePackage: "homebrew/core",
|
|
license: null,
|
|
cveHints: null,
|
|
provides: null,
|
|
depends: null,
|
|
files: files,
|
|
vendorMetadata: vendorMetadata);
|
|
}
|
|
|
|
private static Dictionary<string, string?> BuildVendorMetadata(HomebrewReceipt receipt, string versionDir)
|
|
{
|
|
var metadata = new Dictionary<string, string?>(StringComparer.Ordinal)
|
|
{
|
|
["brew:tap"] = receipt.Tap,
|
|
["brew:poured_from_bottle"] = receipt.PouredFromBottle.ToString().ToLowerInvariant(),
|
|
["brew:installed_as_dependency"] = receipt.InstalledAsDependency.ToString().ToLowerInvariant(),
|
|
["brew:installed_on_request"] = receipt.InstalledOnRequest.ToString().ToLowerInvariant(),
|
|
["brew:install_path"] = versionDir,
|
|
};
|
|
|
|
if (receipt.InstallTime.HasValue)
|
|
{
|
|
var installTime = DateTimeOffset.FromUnixTimeSeconds(receipt.InstallTime.Value);
|
|
metadata["brew:install_time"] = installTime.ToString("o");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(receipt.Description))
|
|
{
|
|
metadata["description"] = receipt.Description;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(receipt.Homepage))
|
|
{
|
|
metadata["homepage"] = receipt.Homepage;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(receipt.SourceUrl))
|
|
{
|
|
metadata["brew:source_url"] = receipt.SourceUrl;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(receipt.SourceChecksum))
|
|
{
|
|
metadata["brew:source_checksum"] = receipt.SourceChecksum;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(receipt.BottleChecksum))
|
|
{
|
|
metadata["brew:bottle_checksum"] = receipt.BottleChecksum;
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
private List<OSPackageFileEvidence> DiscoverFormulaFiles(string versionDir)
|
|
{
|
|
var files = new List<OSPackageFileEvidence>();
|
|
|
|
try
|
|
{
|
|
// Only discover key files to avoid excessive enumeration
|
|
// Focus on bin/, lib/, include/, share/man directories
|
|
var keyDirs = new[] { "bin", "lib", "include", "sbin" };
|
|
|
|
foreach (var keyDir in keyDirs)
|
|
{
|
|
var keyPath = Path.Combine(versionDir, keyDir);
|
|
if (!Directory.Exists(keyPath))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var file in Directory.EnumerateFiles(keyPath, "*", SearchOption.TopDirectoryOnly))
|
|
{
|
|
var relativePath = Path.GetRelativePath(versionDir, file);
|
|
files.Add(new OSPackageFileEvidence(
|
|
relativePath,
|
|
layerDigest: null,
|
|
sha256: null,
|
|
sizeBytes: null,
|
|
isConfigFile: false));
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
// Ignore file enumeration errors
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
private static bool CheckFormulaSizeGuardrail(string versionDir, out string? warning)
|
|
{
|
|
warning = null;
|
|
|
|
try
|
|
{
|
|
long totalSize = 0;
|
|
foreach (var file in Directory.EnumerateFiles(versionDir, "*", SearchOption.AllDirectories))
|
|
{
|
|
var info = new FileInfo(file);
|
|
totalSize += info.Length;
|
|
|
|
if (totalSize > MaxFormulaSizeBytes)
|
|
{
|
|
warning = $"Formula at {versionDir} exceeds {MaxFormulaSizeBytes / (1024 * 1024)}MB size limit; skipping.";
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
// Allow if we can't determine size
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string DetectArchitectureFromPath(string path)
|
|
{
|
|
// /opt/homebrew is Apple Silicon (arm64)
|
|
// /usr/local is Intel (x86_64)
|
|
if (path.Contains("/opt/homebrew/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "arm64";
|
|
}
|
|
|
|
return "x86_64";
|
|
}
|
|
|
|
private static IEnumerable<string> EnumerateDirectoriesSafe(string path)
|
|
{
|
|
try
|
|
{
|
|
return Directory.EnumerateDirectories(path);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
return Array.Empty<string>();
|
|
}
|
|
}
|
|
}
|