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
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:
@@ -0,0 +1,366 @@
|
||||
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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user