This commit is contained in:
@@ -115,6 +115,11 @@ internal sealed class PolicyExpressionEvaluator
|
||||
return rubyScope.Get(member.Member);
|
||||
}
|
||||
|
||||
if (raw is MacOsComponentScope macosScope)
|
||||
{
|
||||
return macosScope.Get(member.Member);
|
||||
}
|
||||
|
||||
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
|
||||
{
|
||||
return new EvaluationValue(value);
|
||||
@@ -155,6 +160,11 @@ internal sealed class PolicyExpressionEvaluator
|
||||
return rubyScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
}
|
||||
|
||||
if (targetRaw is MacOsComponentScope macosScope)
|
||||
{
|
||||
return macosScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
}
|
||||
|
||||
if (targetRaw is ComponentScope componentScope)
|
||||
{
|
||||
return componentScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
@@ -497,6 +507,14 @@ internal sealed class PolicyExpressionEvaluator
|
||||
locals["ruby"] = new RubyComponentScope(component);
|
||||
}
|
||||
|
||||
// Add macOS scope for brew packages, pkgutil receipts, and macOS bundles
|
||||
if (component.Type.Equals("brew", StringComparison.OrdinalIgnoreCase) ||
|
||||
component.Metadata.ContainsKey("macos:bundle_id") ||
|
||||
component.Metadata.ContainsKey("pkgutil:identifier"))
|
||||
{
|
||||
locals["macos"] = new MacOsComponentScope(component);
|
||||
}
|
||||
|
||||
var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals);
|
||||
if (evaluator.EvaluateBoolean(predicate, nestedScope))
|
||||
{
|
||||
@@ -865,4 +883,227 @@ internal sealed class PolicyExpressionEvaluator
|
||||
_ => EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SPL scope for macOS component predicates.
|
||||
/// Provides access to bundle signing, entitlements, sandboxing, and package receipt information.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// SPL predicates supported:
|
||||
/// - macos.signed == true
|
||||
/// - macos.sandboxed == true
|
||||
/// - macos.hardened_runtime == true
|
||||
/// - macos.team_id == "ABCD1234"
|
||||
/// - macos.bundle_id == "com.apple.Safari"
|
||||
/// - macos.entitlement("com.apple.security.network.client")
|
||||
/// - macos.entitlement_any(["com.apple.security.device.camera", "com.apple.security.device.microphone"])
|
||||
/// - macos.high_risk_entitlements == true
|
||||
/// - macos.pkg_receipt("com.apple.pkg.Safari")
|
||||
/// </example>
|
||||
private sealed class MacOsComponentScope
|
||||
{
|
||||
private readonly PolicyEvaluationComponent component;
|
||||
private readonly ImmutableHashSet<string> entitlementCategories;
|
||||
private readonly ImmutableHashSet<string> highRiskEntitlements;
|
||||
|
||||
public MacOsComponentScope(PolicyEvaluationComponent component)
|
||||
{
|
||||
this.component = component;
|
||||
entitlementCategories = ParseDelimitedSet(component.Metadata, "macos:capability_categories");
|
||||
highRiskEntitlements = ParseDelimitedSet(component.Metadata, "macos:high_risk_entitlements");
|
||||
}
|
||||
|
||||
public EvaluationValue Get(string member)
|
||||
{
|
||||
return member.ToLowerInvariant() switch
|
||||
{
|
||||
"signed" => new EvaluationValue(IsSigned()),
|
||||
"sandboxed" => new EvaluationValue(IsTruthy(GetMetadata("macos:sandboxed"))),
|
||||
"hardened_runtime" or "hardenedruntime" => new EvaluationValue(IsTruthy(GetMetadata("macos:hardened_runtime"))),
|
||||
"team_id" or "teamid" => new EvaluationValue(GetMetadata("macos:team_id")),
|
||||
"bundle_id" or "bundleid" => new EvaluationValue(GetMetadata("macos:bundle_id")),
|
||||
"bundle_type" or "bundletype" => new EvaluationValue(GetMetadata("macos:bundle_type")),
|
||||
"min_os_version" or "minosversion" => new EvaluationValue(GetMetadata("macos:min_os_version")),
|
||||
"high_risk_entitlements" or "highriskentitlements" => new EvaluationValue(!highRiskEntitlements.IsEmpty),
|
||||
"entitlement_categories" or "entitlementcategories" => new EvaluationValue(entitlementCategories.Select(c => (object?)c).ToImmutableArray()),
|
||||
"pkg_identifier" or "pkgidentifier" => new EvaluationValue(GetMetadata("pkgutil:identifier")),
|
||||
_ => component.Metadata.TryGetValue(member, out var value)
|
||||
? new EvaluationValue(value)
|
||||
: EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
switch (member.ToLowerInvariant())
|
||||
{
|
||||
case "entitlement":
|
||||
{
|
||||
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
return new EvaluationValue(HasEntitlement(name));
|
||||
}
|
||||
case "entitlement_any":
|
||||
{
|
||||
var entitlements = EvaluateAsStringSet(arguments, scope, evaluator);
|
||||
return new EvaluationValue(entitlements.Any(HasEntitlement));
|
||||
}
|
||||
case "category":
|
||||
{
|
||||
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
return new EvaluationValue(name is not null && entitlementCategories.Contains(name));
|
||||
}
|
||||
case "category_any":
|
||||
{
|
||||
var categories = EvaluateAsStringSet(arguments, scope, evaluator);
|
||||
return new EvaluationValue(categories.Any(c => entitlementCategories.Contains(c)));
|
||||
}
|
||||
case "signed":
|
||||
{
|
||||
if (arguments.Length == 0)
|
||||
{
|
||||
return new EvaluationValue(IsSigned());
|
||||
}
|
||||
|
||||
// Check for specific team ID or hardened runtime
|
||||
var teamId = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
var requireHardened = arguments.Length > 1 && evaluator.Evaluate(arguments[1], scope).AsBoolean();
|
||||
|
||||
var isSigned = IsSigned();
|
||||
if (!isSigned)
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(teamId))
|
||||
{
|
||||
var actualTeamId = GetMetadata("macos:team_id");
|
||||
if (!string.Equals(actualTeamId, teamId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
}
|
||||
|
||||
if (requireHardened && !IsTruthy(GetMetadata("macos:hardened_runtime")))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
return EvaluationValue.True;
|
||||
}
|
||||
case "pkg_receipt":
|
||||
{
|
||||
var identifier = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
var pkgId = GetMetadata("pkgutil:identifier");
|
||||
if (string.IsNullOrWhiteSpace(pkgId))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
if (arguments.Length > 1)
|
||||
{
|
||||
var version = evaluator.Evaluate(arguments[1], scope).AsString();
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
var pkgVersion = component.Version;
|
||||
return new EvaluationValue(
|
||||
string.Equals(pkgId, identifier, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(pkgVersion, version, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
return new EvaluationValue(string.Equals(pkgId, identifier, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
default:
|
||||
return EvaluationValue.Null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsSigned()
|
||||
{
|
||||
// Consider signed if team_id is present or code_resources_hash exists
|
||||
var teamId = GetMetadata("macos:team_id");
|
||||
var codeResourcesHash = GetMetadata("macos:code_resources_hash");
|
||||
return !string.IsNullOrWhiteSpace(teamId) || !string.IsNullOrWhiteSpace(codeResourcesHash);
|
||||
}
|
||||
|
||||
private bool HasEntitlement(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check high risk entitlements first
|
||||
if (highRiskEntitlements.Contains(name))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check capability categories for short names
|
||||
if (entitlementCategories.Contains(name))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string? GetMetadata(string key)
|
||||
{
|
||||
return component.Metadata.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
private static bool IsTruthy(string? value)
|
||||
{
|
||||
return value is not null
|
||||
&& (value.Equals("true", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("1", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("yes", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> ParseDelimitedSet(ImmutableDictionary<string, string> metadata, string key)
|
||||
{
|
||||
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return ImmutableHashSet<string>.Empty;
|
||||
}
|
||||
|
||||
return value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(static v => !string.IsNullOrWhiteSpace(v))
|
||||
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> EvaluateAsStringSet(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
var evaluated = evaluator.Evaluate(argument, scope).Raw;
|
||||
switch (evaluated)
|
||||
{
|
||||
case ImmutableArray<object?> array:
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is string text && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
builder.Add(text.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case string text when !string.IsNullOrWhiteSpace(text):
|
||||
builder.Add(text.Trim());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,4 +415,250 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
$"pkg:gem/{name}@{version}",
|
||||
metadataBuilder.ToImmutable());
|
||||
}
|
||||
|
||||
private static PolicyEvaluationComponent CreateMacOsComponent(
|
||||
string bundleId,
|
||||
string version,
|
||||
bool sandboxed = false,
|
||||
bool hardenedRuntime = false,
|
||||
string? teamId = null,
|
||||
string? codeResourcesHash = null,
|
||||
IEnumerable<string>? categories = null,
|
||||
IEnumerable<string>? highRiskEntitlements = null,
|
||||
string? pkgutilIdentifier = null)
|
||||
{
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
metadataBuilder["macos:bundle_id"] = bundleId;
|
||||
metadataBuilder["macos:sandboxed"] = sandboxed ? "true" : "false";
|
||||
metadataBuilder["macos:hardened_runtime"] = hardenedRuntime ? "true" : "false";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(teamId))
|
||||
{
|
||||
metadataBuilder["macos:team_id"] = teamId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(codeResourcesHash))
|
||||
{
|
||||
metadataBuilder["macos:code_resources_hash"] = codeResourcesHash;
|
||||
}
|
||||
|
||||
if (categories is not null && categories.Any())
|
||||
{
|
||||
metadataBuilder["macos:capability_categories"] = string.Join(",", categories);
|
||||
}
|
||||
|
||||
if (highRiskEntitlements is not null && highRiskEntitlements.Any())
|
||||
{
|
||||
metadataBuilder["macos:high_risk_entitlements"] = string.Join(",", highRiskEntitlements);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pkgutilIdentifier))
|
||||
{
|
||||
metadataBuilder["pkgutil:identifier"] = pkgutilIdentifier;
|
||||
}
|
||||
|
||||
return new PolicyEvaluationComponent(
|
||||
bundleId.Split('.').Last(),
|
||||
version,
|
||||
"macos-bundle",
|
||||
$"pkg:generic/macos-app/{bundleId}@{version}",
|
||||
metadataBuilder.ToImmutable());
|
||||
}
|
||||
|
||||
#region macOS Policy Predicate Tests
|
||||
|
||||
private static readonly string MacOsPolicy = """
|
||||
policy "macOS Security Policy" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
description = "Enforce macOS bundle security requirements."
|
||||
tags = ["macos","security"]
|
||||
}
|
||||
|
||||
rule block_unsigned_apps priority 3 {
|
||||
when sbom.any_component(not macos.signed)
|
||||
then status := "blocked"
|
||||
because "Unsigned macOS applications are not permitted."
|
||||
}
|
||||
|
||||
rule warn_high_risk_entitlements priority 4 {
|
||||
when sbom.any_component(macos.high_risk_entitlements)
|
||||
then warn message "Application requests high-risk entitlements."
|
||||
because "High-risk entitlements require review."
|
||||
}
|
||||
|
||||
rule require_hardened_runtime priority 5 {
|
||||
when sbom.any_component(macos.signed and not macos.hardened_runtime)
|
||||
then warn message "Application does not use hardened runtime."
|
||||
because "Hardened runtime is recommended for security."
|
||||
}
|
||||
|
||||
rule block_unsandboxed_apps priority 2 {
|
||||
when sbom.any_component(not macos.sandboxed)
|
||||
then warn message "Application is not sandboxed."
|
||||
because "App sandbox provides security isolation."
|
||||
}
|
||||
|
||||
rule block_camera_microphone priority 1 {
|
||||
when sbom.any_component(macos.category_any(["camera", "microphone"]))
|
||||
then warn message "Application accesses camera or microphone."
|
||||
because "Camera/microphone access requires review."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_UnsignedAppBlocked()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
Assert.True(document.Success);
|
||||
var ir = Assert.IsType<PolicyIrDocument>(document.Document);
|
||||
|
||||
// Unsigned but sandboxed app to avoid matching the unsandboxed rule first
|
||||
var component = CreateMacOsComponent(
|
||||
bundleId: "com.unknown.UnsignedApp",
|
||||
version: "1.0.0",
|
||||
sandboxed: true, // Sandboxed to avoid matching block_unsandboxed_apps first
|
||||
hardenedRuntime: false,
|
||||
teamId: null,
|
||||
codeResourcesHash: null);
|
||||
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string>.Empty,
|
||||
ImmutableArray.Create(component))
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(ir, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("block_unsigned_apps", result.RuleName);
|
||||
Assert.Equal("blocked", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_SignedAppPasses()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
Assert.True(document.Success);
|
||||
var ir = Assert.IsType<PolicyIrDocument>(document.Document);
|
||||
|
||||
var component = CreateMacOsComponent(
|
||||
bundleId: "com.apple.Safari",
|
||||
version: "17.1",
|
||||
sandboxed: true,
|
||||
hardenedRuntime: true,
|
||||
teamId: "APPLE123",
|
||||
codeResourcesHash: "sha256:abc123");
|
||||
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string>.Empty,
|
||||
ImmutableArray.Create(component))
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(ir, context);
|
||||
|
||||
// No blocking rules should match for a properly signed and sandboxed app
|
||||
Assert.False(result.Matched && result.Status == "blocked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_HighRiskEntitlementsWarns()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
Assert.True(document.Success);
|
||||
var ir = Assert.IsType<PolicyIrDocument>(document.Document);
|
||||
|
||||
// App with high-risk entitlements but no camera/microphone categories
|
||||
// to avoid matching block_camera_microphone rule first
|
||||
var component = CreateMacOsComponent(
|
||||
bundleId: "com.example.AutomationApp",
|
||||
version: "2.0.0",
|
||||
sandboxed: true,
|
||||
hardenedRuntime: true,
|
||||
teamId: "TEAM123",
|
||||
codeResourcesHash: "sha256:def456",
|
||||
categories: new[] { "network", "automation" },
|
||||
highRiskEntitlements: new[] { "com.apple.security.automation.apple-events" });
|
||||
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string>.Empty,
|
||||
ImmutableArray.Create(component))
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(ir, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("warn_high_risk_entitlements", result.RuleName);
|
||||
Assert.Equal("warned", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_CategoryMatchesCameraAccess()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
Assert.True(document.Success);
|
||||
var ir = Assert.IsType<PolicyIrDocument>(document.Document);
|
||||
|
||||
var component = CreateMacOsComponent(
|
||||
bundleId: "com.example.VideoChat",
|
||||
version: "1.5.0",
|
||||
sandboxed: true,
|
||||
hardenedRuntime: true,
|
||||
teamId: "TEAM456",
|
||||
codeResourcesHash: "sha256:ghi789",
|
||||
categories: new[] { "camera", "microphone", "network" });
|
||||
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string>.Empty,
|
||||
ImmutableArray.Create(component))
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(ir, context);
|
||||
|
||||
// Should match camera/microphone warning rule
|
||||
Assert.True(result.Matched);
|
||||
// Either high_risk or camera_microphone rule should match
|
||||
Assert.True(
|
||||
result.RuleName == "block_camera_microphone" ||
|
||||
result.RuleName == "warn_high_risk_entitlements" ||
|
||||
result.Status == "warned");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MacOs_HardenedRuntimeWarnsWhenMissing()
|
||||
{
|
||||
var document = compiler.Compile(MacOsPolicy);
|
||||
Assert.True(document.Success);
|
||||
var ir = Assert.IsType<PolicyIrDocument>(document.Document);
|
||||
|
||||
var component = CreateMacOsComponent(
|
||||
bundleId: "com.example.LegacyApp",
|
||||
version: "3.0.0",
|
||||
sandboxed: true,
|
||||
hardenedRuntime: false,
|
||||
teamId: "TEAM789",
|
||||
codeResourcesHash: "sha256:jkl012");
|
||||
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string>.Empty,
|
||||
ImmutableArray.Create(component))
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(ir, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("require_hardened_runtime", result.RuleName);
|
||||
Assert.Equal("warned", result.Status);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user