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,230 @@
|
||||
using Claunia.PropertyList;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.MacOsBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Parses macOS entitlements from embedded plist files or code signature data.
|
||||
/// </summary>
|
||||
internal sealed class EntitlementsParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Well-known entitlement categories for capability classification.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> EntitlementCategories = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Network
|
||||
["com.apple.security.network.client"] = "network",
|
||||
["com.apple.security.network.server"] = "network",
|
||||
|
||||
// File System
|
||||
["com.apple.security.files.user-selected.read-only"] = "filesystem",
|
||||
["com.apple.security.files.user-selected.read-write"] = "filesystem",
|
||||
["com.apple.security.files.downloads.read-only"] = "filesystem",
|
||||
["com.apple.security.files.downloads.read-write"] = "filesystem",
|
||||
["com.apple.security.files.all"] = "filesystem",
|
||||
|
||||
// Hardware
|
||||
["com.apple.security.device.camera"] = "camera",
|
||||
["com.apple.security.device.microphone"] = "microphone",
|
||||
["com.apple.security.device.usb"] = "hardware",
|
||||
["com.apple.security.device.bluetooth"] = "hardware",
|
||||
["com.apple.security.device.serial"] = "hardware",
|
||||
|
||||
// Privacy
|
||||
["com.apple.security.personal-information.addressbook"] = "privacy",
|
||||
["com.apple.security.personal-information.calendars"] = "privacy",
|
||||
["com.apple.security.personal-information.location"] = "privacy",
|
||||
["com.apple.security.personal-information.photos-library"] = "privacy",
|
||||
|
||||
// System
|
||||
["com.apple.security.automation.apple-events"] = "automation",
|
||||
["com.apple.security.scripting-targets"] = "automation",
|
||||
["com.apple.security.cs.allow-jit"] = "code-execution",
|
||||
["com.apple.security.cs.allow-unsigned-executable-memory"] = "code-execution",
|
||||
["com.apple.security.cs.disable-library-validation"] = "code-execution",
|
||||
["com.apple.security.cs.allow-dyld-environment-variables"] = "code-execution",
|
||||
["com.apple.security.get-task-allow"] = "debugging",
|
||||
|
||||
// App Sandbox
|
||||
["com.apple.security.app-sandbox"] = "sandbox",
|
||||
["com.apple.security.inherit"] = "sandbox",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// High-risk entitlements that warrant policy attention.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> HighRiskEntitlements = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"com.apple.security.device.camera",
|
||||
"com.apple.security.device.microphone",
|
||||
"com.apple.security.cs.allow-unsigned-executable-memory",
|
||||
"com.apple.security.cs.disable-library-validation",
|
||||
"com.apple.security.get-task-allow",
|
||||
"com.apple.security.files.all",
|
||||
"com.apple.security.automation.apple-events",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses entitlements from a plist file (e.g., embedded entitlements or xcent file).
|
||||
/// </summary>
|
||||
public BundleEntitlements Parse(string entitlementsPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entitlementsPath);
|
||||
|
||||
if (!File.Exists(entitlementsPath))
|
||||
{
|
||||
return BundleEntitlements.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(entitlementsPath);
|
||||
return Parse(stream, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or FormatException)
|
||||
{
|
||||
return BundleEntitlements.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses entitlements from a stream.
|
||||
/// </summary>
|
||||
public BundleEntitlements Parse(Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
try
|
||||
{
|
||||
var plist = PropertyListParser.Parse(stream);
|
||||
if (plist is not NSDictionary root)
|
||||
{
|
||||
return BundleEntitlements.Empty;
|
||||
}
|
||||
|
||||
var entitlements = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
var categories = new HashSet<string>(StringComparer.Ordinal);
|
||||
var highRisk = new List<string>();
|
||||
|
||||
foreach (var kvp in root)
|
||||
{
|
||||
var key = kvp.Key;
|
||||
var value = ConvertValue(kvp.Value);
|
||||
entitlements[key] = value;
|
||||
|
||||
// Classify the entitlement
|
||||
if (EntitlementCategories.TryGetValue(key, out var category))
|
||||
{
|
||||
categories.Add(category);
|
||||
}
|
||||
|
||||
// Check if high risk
|
||||
if (HighRiskEntitlements.Contains(key) && IsTrueValue(value))
|
||||
{
|
||||
highRisk.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return new BundleEntitlements(
|
||||
Entitlements: entitlements,
|
||||
Categories: categories.OrderBy(c => c, StringComparer.Ordinal).ToList(),
|
||||
HighRiskEntitlements: highRisk.OrderBy(e => e, StringComparer.Ordinal).ToList(),
|
||||
IsSandboxed: entitlements.TryGetValue("com.apple.security.app-sandbox", out var sandbox) && IsTrueValue(sandbox),
|
||||
HasHardenedRuntime: entitlements.TryGetValue("com.apple.security.cs.allow-unsigned-executable-memory", out var hr) && !IsTrueValue(hr));
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidOperationException or ArgumentException)
|
||||
{
|
||||
return BundleEntitlements.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers entitlements file within an app bundle.
|
||||
/// </summary>
|
||||
public string? FindEntitlementsFile(string bundlePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look for xcent files first (highest priority for actual entitlements)
|
||||
var codeSignPath = Path.Combine(bundlePath, "Contents", "_CodeSignature");
|
||||
if (Directory.Exists(codeSignPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var xcentFiles = Directory.GetFiles(codeSignPath, "*.xcent");
|
||||
if (xcentFiles.Length > 0)
|
||||
{
|
||||
return xcentFiles[0];
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Check other common entitlements locations
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(bundlePath, "Contents", "embedded.provisionprofile"),
|
||||
Path.Combine(bundlePath, "embedded.mobileprovision"),
|
||||
};
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object? ConvertValue(NSObject? obj)
|
||||
{
|
||||
return obj switch
|
||||
{
|
||||
NSString s => s.Content,
|
||||
NSNumber n => n.ToObject(),
|
||||
NSData d => Convert.ToBase64String(d.Bytes),
|
||||
NSArray a => a.Select(ConvertValue).ToArray(),
|
||||
NSDictionary d => d.ToDictionary(kvp => kvp.Key, kvp => ConvertValue(kvp.Value)),
|
||||
_ => obj?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsTrueValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
bool b => b,
|
||||
int i => i != 0,
|
||||
string s => s.Equals("true", StringComparison.OrdinalIgnoreCase) || s == "1",
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents parsed macOS bundle entitlements.
|
||||
/// </summary>
|
||||
internal sealed record BundleEntitlements(
|
||||
IReadOnlyDictionary<string, object?> Entitlements,
|
||||
IReadOnlyList<string> Categories,
|
||||
IReadOnlyList<string> HighRiskEntitlements,
|
||||
bool IsSandboxed,
|
||||
bool HasHardenedRuntime)
|
||||
{
|
||||
public static BundleEntitlements Empty { get; } = new(
|
||||
new Dictionary<string, object?>(),
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<string>(),
|
||||
IsSandboxed: false,
|
||||
HasHardenedRuntime: false);
|
||||
|
||||
public bool HasEntitlement(string key) => Entitlements.ContainsKey(key);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Claunia.PropertyList;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.MacOsBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Parses macOS application bundle Info.plist files.
|
||||
/// </summary>
|
||||
internal sealed class InfoPlistParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses an Info.plist file from the given path.
|
||||
/// </summary>
|
||||
public BundleInfo? Parse(string plistPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(plistPath);
|
||||
|
||||
if (!File.Exists(plistPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(plistPath);
|
||||
return Parse(stream, plistPath, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an Info.plist from a stream.
|
||||
/// </summary>
|
||||
public BundleInfo? Parse(Stream stream, string? sourcePath = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
try
|
||||
{
|
||||
var plist = PropertyListParser.Parse(stream);
|
||||
if (plist is not NSDictionary root)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bundleId = GetString(root, "CFBundleIdentifier");
|
||||
if (string.IsNullOrWhiteSpace(bundleId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BundleInfo(
|
||||
BundleIdentifier: bundleId.Trim(),
|
||||
BundleName: GetString(root, "CFBundleName")?.Trim() ?? ExtractNameFromBundleId(bundleId),
|
||||
BundleDisplayName: GetString(root, "CFBundleDisplayName")?.Trim(),
|
||||
Version: GetString(root, "CFBundleVersion")?.Trim() ?? "0",
|
||||
ShortVersion: GetString(root, "CFBundleShortVersionString")?.Trim(),
|
||||
MinimumSystemVersion: GetString(root, "LSMinimumSystemVersion")?.Trim(),
|
||||
Executable: GetString(root, "CFBundleExecutable")?.Trim(),
|
||||
BundlePackageType: GetString(root, "CFBundlePackageType")?.Trim() ?? "APPL",
|
||||
SupportedPlatforms: GetStringArray(root, "CFBundleSupportedPlatforms"),
|
||||
RequiredCapabilities: GetStringArray(root, "UIRequiredDeviceCapabilities"),
|
||||
SourcePath: sourcePath);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidOperationException or ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetString(NSDictionary dict, string key)
|
||||
{
|
||||
if (dict.TryGetValue(key, out var value) && value is NSString nsString)
|
||||
{
|
||||
return nsString.Content;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetStringArray(NSDictionary dict, string key)
|
||||
{
|
||||
if (!dict.TryGetValue(key, out var value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var result = new List<string>();
|
||||
|
||||
if (value is NSArray array)
|
||||
{
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is NSString nsString && !string.IsNullOrWhiteSpace(nsString.Content))
|
||||
{
|
||||
result.Add(nsString.Content.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (value is NSString singleString && !string.IsNullOrWhiteSpace(singleString.Content))
|
||||
{
|
||||
result.Add(singleString.Content.Trim());
|
||||
}
|
||||
|
||||
result.Sort(StringComparer.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ExtractNameFromBundleId(string bundleId)
|
||||
{
|
||||
var parts = bundleId.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length > 0 ? parts[^1] : bundleId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents parsed macOS bundle Info.plist metadata.
|
||||
/// </summary>
|
||||
internal sealed record BundleInfo(
|
||||
string BundleIdentifier,
|
||||
string BundleName,
|
||||
string? BundleDisplayName,
|
||||
string Version,
|
||||
string? ShortVersion,
|
||||
string? MinimumSystemVersion,
|
||||
string? Executable,
|
||||
string BundlePackageType,
|
||||
IReadOnlyList<string> SupportedPlatforms,
|
||||
IReadOnlyList<string> RequiredCapabilities,
|
||||
string? SourcePath);
|
||||
@@ -0,0 +1,368 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Security.Cryptography;
|
||||
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.MacOsBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes macOS application bundles (.app) to extract metadata and capability information.
|
||||
/// Scans /Applications, /System/Applications, and user application directories.
|
||||
/// </summary>
|
||||
internal sealed class MacOsBundleAnalyzer : OsPackageAnalyzerBase
|
||||
{
|
||||
private static readonly IReadOnlyList<OSPackageRecord> EmptyPackages =
|
||||
new ReadOnlyCollection<OSPackageRecord>(Array.Empty<OSPackageRecord>());
|
||||
|
||||
/// <summary>
|
||||
/// Standard paths to scan for application bundles.
|
||||
/// </summary>
|
||||
private static readonly string[] ApplicationPaths =
|
||||
[
|
||||
"Applications",
|
||||
"System/Applications",
|
||||
"Library/Application Support",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Maximum traversal depth within application directories.
|
||||
/// </summary>
|
||||
private const int MaxTraversalDepth = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bundle size to process (500MB).
|
||||
/// </summary>
|
||||
private const long MaxBundleSizeBytes = 500L * 1024L * 1024L;
|
||||
|
||||
private readonly InfoPlistParser _infoPlistParser = new();
|
||||
private readonly EntitlementsParser _entitlementsParser = new();
|
||||
|
||||
public MacOsBundleAnalyzer(ILogger<MacOsBundleAnalyzer> logger)
|
||||
: base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
public override string AnalyzerId => "macos-bundle";
|
||||
|
||||
protected override ValueTask<IReadOnlyList<OSPackageRecord>> ExecuteCoreAsync(
|
||||
OSPackageAnalyzerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var records = new List<OSPackageRecord>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Scan standard application paths
|
||||
foreach (var appPath in ApplicationPaths)
|
||||
{
|
||||
var fullPath = Path.Combine(context.RootPath, appPath);
|
||||
if (!Directory.Exists(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.LogInformation("Scanning for application bundles in {Path}", fullPath);
|
||||
|
||||
try
|
||||
{
|
||||
DiscoverBundles(fullPath, records, warnings, 0, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to scan application path {Path}", fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Scan user directories
|
||||
var usersPath = Path.Combine(context.RootPath, "Users");
|
||||
if (Directory.Exists(usersPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var userDir in Directory.EnumerateDirectories(usersPath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var userAppsPath = Path.Combine(userDir, "Applications");
|
||||
if (Directory.Exists(userAppsPath))
|
||||
{
|
||||
DiscoverBundles(userAppsPath, records, warnings, 0, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Logger.LogDebug(ex, "Could not enumerate user directories");
|
||||
}
|
||||
}
|
||||
|
||||
if (records.Count == 0)
|
||||
{
|
||||
Logger.LogInformation("No application bundles found; skipping analyzer.");
|
||||
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(EmptyPackages);
|
||||
}
|
||||
|
||||
foreach (var warning in warnings.Take(10)) // Limit warning output
|
||||
{
|
||||
Logger.LogWarning("Bundle scan warning: {Warning}", warning);
|
||||
}
|
||||
|
||||
Logger.LogInformation("Discovered {Count} application bundles", records.Count);
|
||||
|
||||
// Sort for deterministic output
|
||||
records.Sort();
|
||||
return ValueTask.FromResult<IReadOnlyList<OSPackageRecord>>(records);
|
||||
}
|
||||
|
||||
private void DiscoverBundles(
|
||||
string searchPath,
|
||||
List<OSPackageRecord> records,
|
||||
List<string> warnings,
|
||||
int depth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (depth > MaxTraversalDepth)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<string> entries;
|
||||
try
|
||||
{
|
||||
entries = Directory.EnumerateDirectories(searchPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var name = Path.GetFileName(entry);
|
||||
if (string.IsNullOrWhiteSpace(name) || name.StartsWith('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is an app bundle
|
||||
if (name.EndsWith(".app", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var record = AnalyzeBundle(entry, warnings, cancellationToken);
|
||||
if (record is not null)
|
||||
{
|
||||
records.Add(record);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Recurse into subdirectories (e.g., for nested apps)
|
||||
DiscoverBundles(entry, records, warnings, depth + 1, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private OSPackageRecord? AnalyzeBundle(
|
||||
string bundlePath,
|
||||
List<string> warnings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Find and parse Info.plist
|
||||
var infoPlistPath = Path.Combine(bundlePath, "Contents", "Info.plist");
|
||||
if (!File.Exists(infoPlistPath))
|
||||
{
|
||||
// Try iOS-style location
|
||||
infoPlistPath = Path.Combine(bundlePath, "Info.plist");
|
||||
}
|
||||
|
||||
if (!File.Exists(infoPlistPath))
|
||||
{
|
||||
warnings.Add($"No Info.plist found in {bundlePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var bundleInfo = _infoPlistParser.Parse(infoPlistPath, cancellationToken);
|
||||
if (bundleInfo is null)
|
||||
{
|
||||
warnings.Add($"Failed to parse Info.plist in {bundlePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse entitlements if available
|
||||
var entitlementsPath = _entitlementsParser.FindEntitlementsFile(bundlePath);
|
||||
var entitlements = entitlementsPath is not null
|
||||
? _entitlementsParser.Parse(entitlementsPath, cancellationToken)
|
||||
: BundleEntitlements.Empty;
|
||||
|
||||
// Compute CodeResources hash if available
|
||||
var codeResourcesHash = ComputeCodeResourcesHash(bundlePath);
|
||||
|
||||
// Determine version (prefer short version, fallback to bundle version)
|
||||
var version = !string.IsNullOrWhiteSpace(bundleInfo.ShortVersion)
|
||||
? bundleInfo.ShortVersion
|
||||
: bundleInfo.Version;
|
||||
|
||||
// Build PURL
|
||||
var purl = PackageUrlBuilder.BuildMacOsBundle(bundleInfo.BundleIdentifier, version);
|
||||
|
||||
// Build vendor metadata
|
||||
var vendorMetadata = BuildVendorMetadata(bundleInfo, entitlements, codeResourcesHash, bundlePath);
|
||||
|
||||
// Discover key files
|
||||
var files = DiscoverBundleFiles(bundlePath, bundleInfo);
|
||||
|
||||
// Extract display name
|
||||
var displayName = bundleInfo.BundleDisplayName ?? bundleInfo.BundleName;
|
||||
|
||||
return new OSPackageRecord(
|
||||
AnalyzerId,
|
||||
purl,
|
||||
displayName,
|
||||
version,
|
||||
DetermineArchitecture(bundlePath),
|
||||
PackageEvidenceSource.MacOsBundle,
|
||||
epoch: null,
|
||||
release: bundleInfo.Version != version ? bundleInfo.Version : null,
|
||||
sourcePackage: ExtractVendorFromBundleId(bundleInfo.BundleIdentifier),
|
||||
license: null,
|
||||
cveHints: null,
|
||||
provides: null,
|
||||
depends: null,
|
||||
files: files,
|
||||
vendorMetadata: vendorMetadata);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> BuildVendorMetadata(
|
||||
BundleInfo bundleInfo,
|
||||
BundleEntitlements entitlements,
|
||||
string? codeResourcesHash,
|
||||
string bundlePath)
|
||||
{
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["macos:bundle_id"] = bundleInfo.BundleIdentifier,
|
||||
["macos:bundle_type"] = bundleInfo.BundlePackageType,
|
||||
["macos:bundle_path"] = bundlePath,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bundleInfo.MinimumSystemVersion))
|
||||
{
|
||||
metadata["macos:min_os_version"] = bundleInfo.MinimumSystemVersion;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bundleInfo.Executable))
|
||||
{
|
||||
metadata["macos:executable"] = bundleInfo.Executable;
|
||||
}
|
||||
|
||||
if (bundleInfo.SupportedPlatforms.Count > 0)
|
||||
{
|
||||
metadata["macos:platforms"] = string.Join(",", bundleInfo.SupportedPlatforms);
|
||||
}
|
||||
|
||||
// Entitlements metadata
|
||||
metadata["macos:sandboxed"] = entitlements.IsSandboxed.ToString().ToLowerInvariant();
|
||||
metadata["macos:hardened_runtime"] = entitlements.HasHardenedRuntime.ToString().ToLowerInvariant();
|
||||
|
||||
if (entitlements.Categories.Count > 0)
|
||||
{
|
||||
metadata["macos:capability_categories"] = string.Join(",", entitlements.Categories);
|
||||
}
|
||||
|
||||
if (entitlements.HighRiskEntitlements.Count > 0)
|
||||
{
|
||||
metadata["macos:high_risk_entitlements"] = string.Join(",", entitlements.HighRiskEntitlements);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(codeResourcesHash))
|
||||
{
|
||||
metadata["macos:code_resources_hash"] = codeResourcesHash;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static string? ComputeCodeResourcesHash(string bundlePath)
|
||||
{
|
||||
var codeResourcesPath = Path.Combine(bundlePath, "Contents", "_CodeSignature", "CodeResources");
|
||||
if (!File.Exists(codeResourcesPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(codeResourcesPath);
|
||||
var hash = SHA256.HashData(stream);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<OSPackageFileEvidence> DiscoverBundleFiles(string bundlePath, BundleInfo bundleInfo)
|
||||
{
|
||||
var files = new List<OSPackageFileEvidence>();
|
||||
|
||||
// Add key bundle files
|
||||
var contentsPath = Path.Combine(bundlePath, "Contents");
|
||||
|
||||
// Executable
|
||||
if (!string.IsNullOrWhiteSpace(bundleInfo.Executable))
|
||||
{
|
||||
var execPath = Path.Combine(contentsPath, "MacOS", bundleInfo.Executable);
|
||||
if (File.Exists(execPath))
|
||||
{
|
||||
files.Add(new OSPackageFileEvidence(
|
||||
$"Contents/MacOS/{bundleInfo.Executable}",
|
||||
layerDigest: null,
|
||||
sha256: null,
|
||||
sizeBytes: null,
|
||||
isConfigFile: false));
|
||||
}
|
||||
}
|
||||
|
||||
// Info.plist
|
||||
var infoPlistRelative = "Contents/Info.plist";
|
||||
if (File.Exists(Path.Combine(bundlePath, infoPlistRelative)))
|
||||
{
|
||||
files.Add(new OSPackageFileEvidence(
|
||||
infoPlistRelative,
|
||||
layerDigest: null,
|
||||
sha256: null,
|
||||
sizeBytes: null,
|
||||
isConfigFile: true));
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static string DetermineArchitecture(string bundlePath)
|
||||
{
|
||||
// Check for universal binary indicators
|
||||
var macosPath = Path.Combine(bundlePath, "Contents", "MacOS");
|
||||
if (Directory.Exists(macosPath))
|
||||
{
|
||||
// Look for architecture-specific subdirectories or lipo info
|
||||
// For now, default to universal
|
||||
return "universal";
|
||||
}
|
||||
|
||||
return "universal";
|
||||
}
|
||||
|
||||
private static string? ExtractVendorFromBundleId(string bundleId)
|
||||
{
|
||||
var parts = bundleId.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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.MacOsBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin that registers the macOS bundle analyzer for application bundle discovery.
|
||||
/// </summary>
|
||||
public sealed class MacOsBundleAnalyzerPlugin : IOSAnalyzerPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "StellaOps.Scanner.Analyzers.OS.MacOsBundle";
|
||||
|
||||
/// <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 MacOsBundleAnalyzer(loggerFactory.CreateLogger<MacOsBundleAnalyzer>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Analyzers.OS.MacOsBundle.Tests")]
|
||||
@@ -0,0 +1,16 @@
|
||||
<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" />
|
||||
<PackageReference Include="plist-cil" Version="2.2.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.OS\StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user