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,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.Homebrew;
/// <summary>
/// Plugin that registers the Homebrew package analyzer for macOS Cellar discovery.
/// </summary>
public sealed class HomebrewAnalyzerPlugin : IOSAnalyzerPlugin
{
/// <inheritdoc />
public string Name => "StellaOps.Scanner.Analyzers.OS.Homebrew";
/// <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 HomebrewPackageAnalyzer(loggerFactory.CreateLogger<HomebrewPackageAnalyzer>());
}
}

View File

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

View File

@@ -0,0 +1,237 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Analyzers.OS.Homebrew;
/// <summary>
/// Parses Homebrew INSTALL_RECEIPT.json files to extract formula metadata.
/// </summary>
internal sealed class HomebrewReceiptParser
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>
/// Parses a Homebrew INSTALL_RECEIPT.json stream.
/// </summary>
public HomebrewReceipt? Parse(Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
try
{
var rawReceipt = JsonSerializer.Deserialize<RawHomebrewReceipt>(stream, SerializerOptions);
if (rawReceipt is null || string.IsNullOrWhiteSpace(rawReceipt.Name))
{
return null;
}
return new HomebrewReceipt(
Name: rawReceipt.Name.Trim(),
Version: rawReceipt.Versions?.Stable?.Trim() ?? rawReceipt.Version?.Trim() ?? string.Empty,
Revision: rawReceipt.Revision ?? 0,
Tap: rawReceipt.TappedFrom?.Trim() ?? rawReceipt.Tap?.Trim() ?? "homebrew/core",
PouredFromBottle: rawReceipt.PouredFromBottle ?? false,
InstallTime: rawReceipt.InstallTime ?? rawReceipt.Time,
InstalledAsDependency: rawReceipt.InstalledAsDependency ?? false,
InstalledOnRequest: rawReceipt.InstalledOnRequest ?? true,
RuntimeDependencies: ExtractDependencies(rawReceipt.RuntimeDependencies),
BuildDependencies: ExtractDependencies(rawReceipt.BuildDependencies),
SourceUrl: rawReceipt.Source?.Url?.Trim(),
SourceChecksum: rawReceipt.Source?.Checksum?.Trim(),
BottleChecksum: rawReceipt.BottleChecksum?.Trim(),
Description: rawReceipt.Description?.Trim(),
Homepage: rawReceipt.Homepage?.Trim(),
License: rawReceipt.License?.Trim(),
Architecture: NormalizeArchitecture(rawReceipt.TabJson?.Arch ?? rawReceipt.Arch),
TabPath: rawReceipt.TabPath?.Trim());
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Parses a Homebrew INSTALL_RECEIPT.json from a file path.
/// </summary>
public HomebrewReceipt? Parse(string receiptPath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(receiptPath);
if (!File.Exists(receiptPath))
{
return null;
}
using var stream = File.OpenRead(receiptPath);
return Parse(stream, cancellationToken);
}
private static IReadOnlyList<string> ExtractDependencies(RawDependency[]? dependencies)
{
if (dependencies is null or { Length: 0 })
{
return Array.Empty<string>();
}
var result = new List<string>(dependencies.Length);
foreach (var dep in dependencies)
{
var name = dep.FullName?.Trim() ?? dep.Name?.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
result.Add(name);
}
}
result.Sort(StringComparer.Ordinal);
return result;
}
private static string NormalizeArchitecture(string? arch)
{
if (string.IsNullOrWhiteSpace(arch))
{
return "x86_64"; // Default for Intel Macs
}
var normalized = arch.Trim().ToLowerInvariant();
return normalized switch
{
"arm64" or "aarch64" => "arm64",
"x86_64" or "amd64" or "x64" => "x86_64",
_ => normalized,
};
}
// Raw JSON models for deserialization
private sealed class RawHomebrewReceipt
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("versions")]
public RawVersions? Versions { get; set; }
[JsonPropertyName("revision")]
public int? Revision { get; set; }
[JsonPropertyName("tap")]
public string? Tap { get; set; }
[JsonPropertyName("tapped_from")]
public string? TappedFrom { get; set; }
[JsonPropertyName("poured_from_bottle")]
public bool? PouredFromBottle { get; set; }
[JsonPropertyName("time")]
public long? Time { get; set; }
[JsonPropertyName("install_time")]
public long? InstallTime { get; set; }
[JsonPropertyName("installed_as_dependency")]
public bool? InstalledAsDependency { get; set; }
[JsonPropertyName("installed_on_request")]
public bool? InstalledOnRequest { get; set; }
[JsonPropertyName("runtime_dependencies")]
public RawDependency[]? RuntimeDependencies { get; set; }
[JsonPropertyName("build_dependencies")]
public RawDependency[]? BuildDependencies { get; set; }
[JsonPropertyName("source")]
public RawSource? Source { get; set; }
[JsonPropertyName("bottle_checksum")]
public string? BottleChecksum { get; set; }
[JsonPropertyName("desc")]
public string? Description { get; set; }
[JsonPropertyName("homepage")]
public string? Homepage { get; set; }
[JsonPropertyName("license")]
public string? License { get; set; }
[JsonPropertyName("arch")]
public string? Arch { get; set; }
[JsonPropertyName("HOMEBREW_INSTALL_PATH")]
public string? TabPath { get; set; }
[JsonPropertyName("tab")]
public RawTab? TabJson { get; set; }
}
private sealed class RawVersions
{
[JsonPropertyName("stable")]
public string? Stable { get; set; }
[JsonPropertyName("head")]
public string? Head { get; set; }
}
private sealed class RawDependency
{
[JsonPropertyName("full_name")]
public string? FullName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
}
private sealed class RawSource
{
[JsonPropertyName("url")]
public string? Url { get; set; }
[JsonPropertyName("checksum")]
public string? Checksum { get; set; }
}
private sealed class RawTab
{
[JsonPropertyName("arch")]
public string? Arch { get; set; }
}
}
/// <summary>
/// Represents parsed Homebrew formula receipt metadata.
/// </summary>
internal sealed record HomebrewReceipt(
string Name,
string Version,
int Revision,
string Tap,
bool PouredFromBottle,
long? InstallTime,
bool InstalledAsDependency,
bool InstalledOnRequest,
IReadOnlyList<string> RuntimeDependencies,
IReadOnlyList<string> BuildDependencies,
string? SourceUrl,
string? SourceChecksum,
string? BottleChecksum,
string? Description,
string? Homepage,
string? License,
string Architecture,
string? TabPath);

View File

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

View File

@@ -0,0 +1,15 @@
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" />
</ItemGroup>
</Project>