feat: Add Promotion-Time Attestations for Stella Ops

- Introduced a new document for promotion-time attestations, detailing the purpose, predicate schema, producer workflow, verification flow, APIs, and security considerations.
- Implemented the `stella.ops/promotion@v1` predicate schema to capture promotion evidence including image digest, SBOM/VEX artifacts, and Rekor proof.
- Defined producer responsibilities and workflows for CLI orchestration, signer responsibilities, and Export Center integration.
- Added verification steps for auditors to validate promotion attestations offline.

feat: Create Symbol Manifest v1 Specification

- Developed a specification for Symbol Manifest v1 to provide a deterministic format for publishing debug symbols and source maps.
- Defined the manifest structure, including schema, entries, source maps, toolchain, and provenance.
- Outlined upload and verification processes, resolve APIs, runtime proxy, caching, and offline bundle generation.
- Included security considerations and related tasks for implementation.

chore: Add Ruby Analyzer with Git Sources

- Created a Gemfile and Gemfile.lock for Ruby analyzer with dependencies on git-gem, httparty, and path-gem.
- Implemented main application logic to utilize the defined gems and output their versions.
- Added expected JSON output for the Ruby analyzer to validate the integration of the new gems and their functionalities.
- Developed internal observation classes for Ruby packages, runtime edges, and capabilities, including serialization logic for observations.

test: Add tests for Ruby Analyzer

- Created test fixtures for Ruby analyzer, including Gemfile, Gemfile.lock, main application, and expected JSON output.
- Ensured that the tests validate the correct integration and functionality of the Ruby analyzer with the specified gems.
This commit is contained in:
master
2025-11-11 15:30:22 +02:00
parent 56c687253f
commit c2c6b58b41
56 changed files with 2305 additions and 198 deletions

View File

@@ -574,12 +574,12 @@ internal sealed class PolicyParser
PolicyExpression expr = new PolicyIdentifierExpression(identifier.Text, identifier.Span);
while (true)
{
if (Match(TokenKind.Dot))
{
var member = Consume(TokenKind.Identifier, "Expected identifier after '.'.", "expression.member");
expr = new PolicyMemberAccessExpression(expr, member.Text, new SourceSpan(expr.Span.Start, member.Span.End));
continue;
}
if (Match(TokenKind.Dot))
{
var member = ConsumeIdentifier("Expected identifier after '.'.", "expression.member");
expr = new PolicyMemberAccessExpression(expr, member.Text, new SourceSpan(expr.Span.Start, member.Span.End));
continue;
}
if (Match(TokenKind.LeftParen))
{
@@ -609,12 +609,26 @@ internal sealed class PolicyParser
break;
}
return expr;
}
private bool Match(TokenKind kind)
{
if (Check(kind))
return expr;
}
private DslToken ConsumeIdentifier(string message, string path)
{
if (Check(TokenKind.Identifier) || IsKeywordIdentifier(Current.Kind))
{
return Advance();
}
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.UnexpectedToken, message, path));
return Advance();
}
private static bool IsKeywordIdentifier(TokenKind kind) =>
kind == TokenKind.KeywordSource;
private bool Match(TokenKind kind)
{
if (Check(kind))
{
Advance();
return true;

View File

@@ -11,13 +11,13 @@ internal sealed record PolicyEvaluationRequest(
PolicyIrDocument Document,
PolicyEvaluationContext Context);
internal sealed record PolicyEvaluationContext(
PolicyEvaluationSeverity Severity,
PolicyEvaluationEnvironment Environment,
PolicyEvaluationAdvisory Advisory,
PolicyEvaluationVexEvidence Vex,
PolicyEvaluationSbom Sbom,
PolicyEvaluationExceptions Exceptions);
internal sealed record PolicyEvaluationContext(
PolicyEvaluationSeverity Severity,
PolicyEvaluationEnvironment Environment,
PolicyEvaluationAdvisory Advisory,
PolicyEvaluationVexEvidence Vex,
PolicyEvaluationSbom Sbom,
PolicyEvaluationExceptions Exceptions);
internal sealed record PolicyEvaluationSeverity(string Normalized, decimal? Score = null);
@@ -43,10 +43,28 @@ internal sealed record PolicyEvaluationVexStatement(
string StatementId,
DateTimeOffset? Timestamp = null);
internal sealed record PolicyEvaluationSbom(ImmutableHashSet<string> Tags)
{
public bool HasTag(string tag) => Tags.Contains(tag);
}
internal sealed record PolicyEvaluationSbom(
ImmutableHashSet<string> Tags,
ImmutableArray<PolicyEvaluationComponent> Components)
{
public PolicyEvaluationSbom(ImmutableHashSet<string> Tags)
: this(Tags, ImmutableArray<PolicyEvaluationComponent>.Empty)
{
}
public static readonly PolicyEvaluationSbom Empty = new(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableArray<PolicyEvaluationComponent>.Empty);
public bool HasTag(string tag) => Tags.Contains(tag);
}
internal sealed record PolicyEvaluationComponent(
string Name,
string Version,
string Type,
string? Purl,
ImmutableDictionary<string, string> Metadata);
internal sealed record PolicyEvaluationResult(
bool Matched,

View File

@@ -98,10 +98,20 @@ internal sealed class PolicyExpressionEvaluator
return sbom.Get(member.Member);
}
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
{
return new EvaluationValue(value);
}
if (raw is ComponentScope componentScope)
{
return componentScope.Get(member.Member);
}
if (raw is RubyComponentScope rubyScope)
{
return rubyScope.Get(member.Member);
}
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
{
return new EvaluationValue(value);
}
if (raw is PolicyEvaluationVexStatement stmt)
{
@@ -129,47 +139,51 @@ internal sealed class PolicyExpressionEvaluator
}
}
if (invocation.Target is PolicyMemberAccessExpression member && member.Target is PolicyIdentifierExpression root)
{
if (root.Name == "vex")
{
var vex = Evaluate(member.Target, scope);
if (vex.Raw is VexScope vexScope)
{
return member.Member switch
{
"any" => new EvaluationValue(vexScope.Any(invocation.Arguments, scope)),
"latest" => new EvaluationValue(vexScope.Latest()),
_ => EvaluationValue.Null,
};
}
}
if (root.Name == "sbom")
{
var sbom = Evaluate(member.Target, scope);
if (sbom.Raw is SbomScope sbomScope)
{
return member.Member switch
{
"has_tag" => sbomScope.HasTag(invocation.Arguments, scope, this),
_ => EvaluationValue.Null,
};
}
}
if (root.Name == "advisory")
{
var advisory = Evaluate(member.Target, scope);
if (advisory.Raw is AdvisoryScope advisoryScope)
{
return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
}
}
return EvaluationValue.Null;
}
if (invocation.Target is PolicyMemberAccessExpression member)
{
var targetValue = Evaluate(member.Target, scope);
var targetRaw = targetValue.Raw;
if (targetRaw is RubyComponentScope rubyScope)
{
return rubyScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
if (targetRaw is ComponentScope componentScope)
{
return componentScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
if (member.Target is PolicyIdentifierExpression root)
{
if (root.Name == "vex" && targetRaw is VexScope vexScope)
{
return member.Member switch
{
"any" => new EvaluationValue(vexScope.Any(invocation.Arguments, scope)),
"latest" => new EvaluationValue(vexScope.Latest()),
_ => EvaluationValue.Null,
};
}
if (root.Name == "sbom" && targetRaw is SbomScope sbomScope)
{
return member.Member switch
{
"has_tag" => sbomScope.HasTag(invocation.Arguments, scope, this),
"any_component" => sbomScope.AnyComponent(invocation.Arguments, scope, this),
_ => EvaluationValue.Null,
};
}
if (root.Name == "advisory" && targetRaw is AdvisoryScope advisoryScope)
{
return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this);
}
}
}
return EvaluationValue.Null;
}
private EvaluationValue EvaluateIndexer(PolicyIndexerExpression indexer, EvaluationScope scope)
{
@@ -428,31 +442,322 @@ internal sealed class PolicyExpressionEvaluator
this.sbom = sbom;
}
public EvaluationValue Get(string member)
{
if (member.Equals("tags", StringComparison.OrdinalIgnoreCase))
{
return new EvaluationValue(sbom.Tags.ToImmutableArray<object?>());
}
return EvaluationValue.Null;
}
public EvaluationValue HasTag(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
var tag = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
if (string.IsNullOrWhiteSpace(tag))
{
return EvaluationValue.False;
}
return new EvaluationValue(sbom.HasTag(tag!));
}
}
private sealed class VexScope
{
private readonly PolicyExpressionEvaluator evaluator;
public EvaluationValue Get(string member)
{
if (member.Equals("tags", StringComparison.OrdinalIgnoreCase))
{
return new EvaluationValue(sbom.Tags.ToImmutableArray<object?>());
}
if (member.Equals("components", StringComparison.OrdinalIgnoreCase))
{
return new EvaluationValue(sbom.Components
.Select(component => (object?)new ComponentScope(component))
.ToImmutableArray());
}
return EvaluationValue.Null;
}
public EvaluationValue HasTag(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
var tag = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
if (string.IsNullOrWhiteSpace(tag))
{
return EvaluationValue.False;
}
return new EvaluationValue(sbom.HasTag(tag!));
}
public EvaluationValue AnyComponent(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
if (arguments.Length == 0 || sbom.Components.IsDefaultOrEmpty)
{
return EvaluationValue.False;
}
var predicate = arguments[0];
foreach (var component in sbom.Components)
{
var locals = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["component"] = new ComponentScope(component),
};
if (component.Type.Equals("gem", StringComparison.OrdinalIgnoreCase))
{
locals["ruby"] = new RubyComponentScope(component);
}
var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals);
if (evaluator.EvaluateBoolean(predicate, nestedScope))
{
return EvaluationValue.True;
}
}
return EvaluationValue.False;
}
}
private sealed class ComponentScope
{
private readonly PolicyEvaluationComponent component;
public ComponentScope(PolicyEvaluationComponent component)
{
this.component = component;
}
public EvaluationValue Get(string member)
{
return member.ToLowerInvariant() switch
{
"name" => new EvaluationValue(component.Name),
"version" => new EvaluationValue(component.Version),
"type" => new EvaluationValue(component.Type),
"purl" => new EvaluationValue(component.Purl),
"metadata" => new EvaluationValue(component.Metadata),
_ => component.Metadata.TryGetValue(member, out var value)
? new EvaluationValue(value)
: EvaluationValue.Null,
};
}
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
{
if (member.Equals("has_metadata", StringComparison.OrdinalIgnoreCase))
{
var key = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
if (string.IsNullOrWhiteSpace(key))
{
return EvaluationValue.False;
}
return new EvaluationValue(component.Metadata.ContainsKey(key!));
}
return EvaluationValue.Null;
}
}
private sealed class RubyComponentScope
{
private readonly PolicyEvaluationComponent component;
private readonly ImmutableHashSet<string> groups;
public RubyComponentScope(PolicyEvaluationComponent component)
{
this.component = component;
groups = ParseGroups(component.Metadata);
}
public EvaluationValue Get(string member)
{
return member.ToLowerInvariant() switch
{
"groups" => new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray()),
"declaredonly" => new EvaluationValue(IsDeclaredOnly()),
"source" => new EvaluationValue(GetSource() ?? string.Empty),
_ => 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 "group":
{
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
return new EvaluationValue(name is not null && groups.Contains(name));
}
case "groups":
return new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray());
case "declared_only":
return new EvaluationValue(IsDeclaredOnly());
case "source":
{
if (arguments.Length == 0)
{
return new EvaluationValue(GetSource() ?? string.Empty);
}
var requested = evaluator.Evaluate(arguments[0], scope).AsString();
if (string.IsNullOrWhiteSpace(requested))
{
return EvaluationValue.False;
}
var kind = GetSourceKind();
return new EvaluationValue(string.Equals(kind, requested, StringComparison.OrdinalIgnoreCase));
}
case "capability":
{
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
return new EvaluationValue(HasCapability(name));
}
case "capability_any":
{
var capabilities = EvaluateAsStringSet(arguments, scope, evaluator);
return new EvaluationValue(capabilities.Any(HasCapability));
}
default:
return EvaluationValue.Null;
}
}
private bool HasCapability(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var normalized = name.Trim();
if (normalized.Length == 0)
{
return false;
}
if (component.Metadata.TryGetValue($"capability.{normalized}", out var value))
{
return IsTruthy(value);
}
if (normalized.StartsWith("scheduler.", StringComparison.OrdinalIgnoreCase))
{
var group = normalized.Substring("scheduler.".Length);
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
? listValue
: null;
return ContainsDelimitedValue(schedulerList, group);
}
if (normalized.Equals("scheduler", StringComparison.OrdinalIgnoreCase))
{
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
? listValue
: null;
return !string.IsNullOrWhiteSpace(schedulerList);
}
return false;
}
private bool IsDeclaredOnly()
{
return component.Metadata.TryGetValue("declaredOnly", out var value) && IsTruthy(value);
}
private string? GetSource()
{
return component.Metadata.TryGetValue("source", out var value) ? value : null;
}
private string? GetSourceKind()
{
var source = GetSource();
if (string.IsNullOrWhiteSpace(source))
{
return null;
}
source = source.Trim();
if (source.StartsWith("git:", StringComparison.OrdinalIgnoreCase))
{
return "git";
}
if (source.StartsWith("path:", StringComparison.OrdinalIgnoreCase))
{
return "path";
}
if (source.StartsWith("vendor-cache", StringComparison.OrdinalIgnoreCase))
{
return "vendor-cache";
}
if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return "registry";
}
return source;
}
private static ImmutableHashSet<string> ParseGroups(ImmutableDictionary<string, string> metadata)
{
if (!metadata.TryGetValue("groups", out var value) || string.IsNullOrWhiteSpace(value))
{
return ImmutableHashSet<string>.Empty;
}
var groups = value
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(static g => !string.IsNullOrWhiteSpace(g))
.Select(static g => g.Trim())
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
return groups;
}
private static bool ContainsDelimitedValue(string? delimited, string value)
{
if (string.IsNullOrWhiteSpace(delimited) || string.IsNullOrWhiteSpace(value))
{
return false;
}
return delimited
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(entry => entry.Equals(value, StringComparison.OrdinalIgnoreCase));
}
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> 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();
}
}
private sealed class VexScope
{
private readonly PolicyExpressionEvaluator evaluator;
private readonly PolicyEvaluationVexEvidence vex;
public VexScope(PolicyExpressionEvaluator evaluator, PolicyEvaluationVexEvidence vex)

View File

@@ -51,14 +51,26 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
because "Respect strong vendor VEX claims."
}
rule alert_warn_eol_runtime priority 1 {
when severity.normalized <= "Medium"
and sbom.has_tag("runtime:eol")
then warn message "Runtime marked as EOL; upgrade recommended."
because "Deprecated runtime should be upgraded."
}
}
""";
rule alert_warn_eol_runtime priority 1 {
when severity.normalized <= "Medium"
and sbom.has_tag("runtime:eol")
then warn message "Runtime marked as EOL; upgrade recommended."
because "Deprecated runtime should be upgraded."
}
rule block_ruby_dev priority 4 {
when sbom.any_component(ruby.group("development") and ruby.declared_only())
then status := "blocked"
because "Development-only Ruby gems without install evidence cannot ship."
}
rule warn_ruby_git_sources {
when sbom.any_component(ruby.source("git"))
then warn message "Git-sourced Ruby gem present; review required."
because "Git-sourced Ruby dependencies require explicit review."
}
}
""";
private readonly PolicyCompiler compiler = new();
private readonly PolicyEvaluationService evaluationService = new();
@@ -113,11 +125,11 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
public void Evaluate_WarnRuleEmitsWarning()
{
var document = CompileBaseline();
var tags = ImmutableHashSet.Create("runtime:eol");
var context = CreateContext("Medium", "internal") with
{
Sbom = new PolicyEvaluationSbom(tags)
};
var tags = ImmutableHashSet.Create("runtime:eol");
var context = CreateContext("Medium", "internal") with
{
Sbom = new PolicyEvaluationSbom(tags)
};
var result = evaluationService.Evaluate(document, context);
@@ -261,16 +273,74 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
Assert.NotNull(result.AppliedException);
Assert.Equal("exc-rule", result.AppliedException!.ExceptionId);
Assert.Equal("Rule Critical Suppress", result.AppliedException!.Metadata["effectName"]);
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
}
private PolicyIrDocument CompileBaseline()
{
var compilation = compiler.Compile(BaselinePolicy);
Assert.True(compilation.Success, Describe(compilation.Diagnostics));
return Assert.IsType<PolicyIrDocument>(compilation.Document);
}
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
}
[Fact]
public void Evaluate_RubyDevComponentBlocked()
{
var document = CompileBaseline();
var component = CreateRubyComponent(
name: "dev-only",
version: "1.0.0",
groups: "development;test",
declaredOnly: true,
source: "https://rubygems.org/",
capabilities: new[] { "exec" });
var context = CreateContext("Medium", "internal") with
{
Sbom = new PolicyEvaluationSbom(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableArray.Create(component))
};
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("block_ruby_dev", result.RuleName);
Assert.Equal("blocked", result.Status);
}
[Fact]
public void Evaluate_RubyGitComponentWarns()
{
var document = CompileBaseline();
var component = CreateRubyComponent(
name: "git-gem",
version: "0.5.0",
groups: "default",
declaredOnly: false,
source: "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567",
capabilities: Array.Empty<string>(),
schedulerCapabilities: new[] { "sidekiq" });
var context = CreateContext("Low", "internal") with
{
Sbom = new PolicyEvaluationSbom(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableArray.Create(component))
};
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("warn_ruby_git_sources", result.RuleName);
Assert.Equal("warned", result.Status);
Assert.Contains(result.Warnings, warning => warning.Contains("Git-sourced", StringComparison.OrdinalIgnoreCase));
}
private PolicyIrDocument CompileBaseline()
{
var compilation = compiler.Compile(BaselinePolicy);
if (!compilation.Success)
{
Console.WriteLine(Describe(compilation.Diagnostics));
}
Assert.True(compilation.Success, Describe(compilation.Diagnostics));
return Assert.IsType<PolicyIrDocument>(compilation.Document);
}
private static PolicyEvaluationContext CreateContext(string severity, string exposure, PolicyEvaluationExceptions? exceptions = null)
{
@@ -282,10 +352,67 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
PolicyEvaluationVexEvidence.Empty,
new PolicyEvaluationSbom(ImmutableHashSet<string>.Empty),
exceptions ?? PolicyEvaluationExceptions.Empty);
}
PolicyEvaluationSbom.Empty,
exceptions ?? PolicyEvaluationExceptions.Empty);
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
private static PolicyEvaluationComponent CreateRubyComponent(
string name,
string version,
string groups,
bool declaredOnly,
string source,
IEnumerable<string>? capabilities = null,
IEnumerable<string>? schedulerCapabilities = null)
{
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(groups))
{
metadataBuilder["groups"] = groups;
}
metadataBuilder["declaredOnly"] = declaredOnly ? "true" : "false";
if (!string.IsNullOrWhiteSpace(source))
{
metadataBuilder["source"] = source.Trim();
}
if (capabilities is not null)
{
foreach (var capability in capabilities)
{
if (!string.IsNullOrWhiteSpace(capability))
{
metadataBuilder[$"capability.{capability.Trim()}"] = "true";
}
}
}
if (schedulerCapabilities is not null)
{
var schedulerList = string.Join(
';',
schedulerCapabilities
.Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim()));
if (!string.IsNullOrWhiteSpace(schedulerList))
{
metadataBuilder["capability.scheduler"] = schedulerList;
}
}
metadataBuilder["lockfile"] = "Gemfile.lock";
return new PolicyEvaluationComponent(
name,
version,
"gem",
$"pkg:gem/{name}@{version}",
metadataBuilder.ToImmutable());
}
}