Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.OS.Homebrew/HomebrewPackageAnalyzer.cs
StellaOps Bot 05da719048
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
up
2025-11-28 09:41:08 +02:00

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>();
}
}
}