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:
@@ -7098,6 +7098,7 @@ internal static class CommandHandlers
|
||||
var source = snapshots ?? Array.Empty<LanguageComponentSnapshot>();
|
||||
|
||||
var entries = source
|
||||
.Where(static snapshot => string.Equals(snapshot.Type, "gem", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(RubyInspectEntry.FromSnapshot)
|
||||
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
|
||||
|
||||
internal static class RubyObservationBuilder
|
||||
{
|
||||
public static RubyObservationDocument Build(
|
||||
IReadOnlyList<RubyPackage> packages,
|
||||
RubyRuntimeGraph runtimeGraph,
|
||||
RubyCapabilities capabilities)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packages);
|
||||
ArgumentNullException.ThrowIfNull(runtimeGraph);
|
||||
ArgumentNullException.ThrowIfNull(capabilities);
|
||||
|
||||
var packageItems = packages
|
||||
.OrderBy(static package => package.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static package => package.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(CreatePackage)
|
||||
.ToImmutableArray();
|
||||
|
||||
var runtimeItems = packages
|
||||
.Select(package => CreateRuntimeEdge(package, runtimeGraph))
|
||||
.Where(static edge => edge is not null)
|
||||
.Select(static edge => edge!)
|
||||
.OrderBy(static edge => edge.Package, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var capabilitySummary = new RubyObservationCapabilitySummary(
|
||||
capabilities.UsesExec,
|
||||
capabilities.UsesNetwork,
|
||||
capabilities.UsesSerialization,
|
||||
capabilities.JobSchedulers
|
||||
.OrderBy(static scheduler => scheduler, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray());
|
||||
|
||||
return new RubyObservationDocument(packageItems, runtimeItems, capabilitySummary);
|
||||
}
|
||||
|
||||
private static RubyObservationPackage CreatePackage(RubyPackage package)
|
||||
{
|
||||
var groups = package.Groups
|
||||
.OrderBy(static group => group, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RubyObservationPackage(
|
||||
package.Name,
|
||||
package.Version,
|
||||
package.Source,
|
||||
package.Platform,
|
||||
package.DeclaredOnly,
|
||||
package.LockfileLocator,
|
||||
package.ArtifactLocator,
|
||||
groups);
|
||||
}
|
||||
|
||||
private static RubyObservationRuntimeEdge? CreateRuntimeEdge(RubyPackage package, RubyRuntimeGraph runtimeGraph)
|
||||
{
|
||||
if (!runtimeGraph.TryGetUsage(package, out var usage) || usage is null || !usage.HasFiles)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var files = usage.ReferencingFiles
|
||||
.OrderBy(static file => file, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypoints = usage.Entrypoints
|
||||
.OrderBy(static file => file, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var reasons = usage.Reasons
|
||||
.OrderBy(static reason => reason, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RubyObservationRuntimeEdge(
|
||||
package.Name,
|
||||
usage.UsedByEntrypoint,
|
||||
files,
|
||||
entrypoints,
|
||||
reasons);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
|
||||
|
||||
internal sealed record RubyObservationDocument(
|
||||
ImmutableArray<RubyObservationPackage> Packages,
|
||||
ImmutableArray<RubyObservationRuntimeEdge> RuntimeEdges,
|
||||
RubyObservationCapabilitySummary Capabilities);
|
||||
|
||||
internal sealed record RubyObservationPackage(
|
||||
string Name,
|
||||
string Version,
|
||||
string Source,
|
||||
string? Platform,
|
||||
bool DeclaredOnly,
|
||||
string? Lockfile,
|
||||
string? Artifact,
|
||||
ImmutableArray<string> Groups);
|
||||
|
||||
internal sealed record RubyObservationRuntimeEdge(
|
||||
string Package,
|
||||
bool UsedByEntrypoint,
|
||||
ImmutableArray<string> Files,
|
||||
ImmutableArray<string> Entrypoints,
|
||||
ImmutableArray<string> Reasons);
|
||||
|
||||
internal sealed record RubyObservationCapabilitySummary(
|
||||
bool UsesExec,
|
||||
bool UsesNetwork,
|
||||
bool UsesSerialization,
|
||||
ImmutableArray<string> JobSchedulers);
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
|
||||
|
||||
internal static class RubyObservationSerializer
|
||||
{
|
||||
public static string Serialize(RubyObservationDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false }))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
WritePackages(writer, document.Packages);
|
||||
WriteRuntimeEdges(writer, document.RuntimeEdges);
|
||||
WriteCapabilities(writer, document.Capabilities);
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
public static string ComputeSha256(string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static void WritePackages(Utf8JsonWriter writer, ImmutableArray<RubyObservationPackage> packages)
|
||||
{
|
||||
writer.WritePropertyName("packages");
|
||||
writer.WriteStartArray();
|
||||
foreach (var package in packages)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", package.Name);
|
||||
writer.WriteString("version", package.Version);
|
||||
writer.WriteString("source", package.Source);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(package.Platform))
|
||||
{
|
||||
writer.WriteString("platform", package.Platform);
|
||||
}
|
||||
|
||||
writer.WriteBoolean("declaredOnly", package.DeclaredOnly);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(package.Lockfile))
|
||||
{
|
||||
writer.WriteString("lockfile", package.Lockfile);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(package.Artifact))
|
||||
{
|
||||
writer.WriteString("artifact", package.Artifact);
|
||||
}
|
||||
|
||||
WriteStringArray(writer, "groups", package.Groups);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteRuntimeEdges(Utf8JsonWriter writer, ImmutableArray<RubyObservationRuntimeEdge> runtimeEdges)
|
||||
{
|
||||
writer.WritePropertyName("runtimeEdges");
|
||||
writer.WriteStartArray();
|
||||
foreach (var edge in runtimeEdges)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("package", edge.Package);
|
||||
writer.WriteBoolean("usedByEntrypoint", edge.UsedByEntrypoint);
|
||||
WriteStringArray(writer, "files", edge.Files);
|
||||
WriteStringArray(writer, "entrypoints", edge.Entrypoints);
|
||||
WriteStringArray(writer, "reasons", edge.Reasons);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteCapabilities(Utf8JsonWriter writer, RubyObservationCapabilitySummary summary)
|
||||
{
|
||||
writer.WritePropertyName("capabilities");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteBoolean("usesExec", summary.UsesExec);
|
||||
writer.WriteBoolean("usesNetwork", summary.UsesNetwork);
|
||||
writer.WriteBoolean("usesSerialization", summary.UsesSerialization);
|
||||
WriteStringArray(writer, "jobSchedulers", summary.JobSchedulers);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
|
||||
{
|
||||
writer.WritePropertyName(propertyName);
|
||||
writer.WriteStartArray();
|
||||
foreach (var value in values)
|
||||
{
|
||||
writer.WriteStringValue(value);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ internal sealed class RubyBundlerConfig
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var configPath = Path.Combine(rootPath, \".bundle\", \"config\");
|
||||
var configPath = Path.Combine(rootPath, ".bundle", "config");
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return Empty;
|
||||
@@ -35,7 +35,9 @@ internal sealed class RubyBundlerConfig
|
||||
foreach (var rawLine in File.ReadAllLines(configPath))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.Length == 0 || line.StartsWith(\"#\", StringComparison.Ordinal) || line.StartsWith(\"---\", StringComparison.Ordinal))
|
||||
if (line.Length == 0
|
||||
|| line.StartsWith("#", StringComparison.Ordinal)
|
||||
|| line.StartsWith("---", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -53,13 +55,13 @@ internal sealed class RubyBundlerConfig
|
||||
continue;
|
||||
}
|
||||
|
||||
value = value.Trim('\"', '\'');
|
||||
value = value.Trim('"', '\'');
|
||||
|
||||
if (key.Equals(\"BUNDLE_GEMFILE\", StringComparison.OrdinalIgnoreCase))
|
||||
if (key.Equals("BUNDLE_GEMFILE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddPath(gemfiles, rootPath, value);
|
||||
}
|
||||
else if (key.Equals(\"BUNDLE_PATH\", StringComparison.OrdinalIgnoreCase))
|
||||
else if (key.Equals("BUNDLE_PATH", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddPath(bundlePaths, rootPath, value);
|
||||
}
|
||||
|
||||
@@ -103,19 +103,19 @@ internal static class RubyLockParser
|
||||
|
||||
if (line.StartsWith(" revision:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentRevision = line[10..].Trim();
|
||||
currentRevision = ExtractValue(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" ref:", StringComparison.OrdinalIgnoreCase) && currentRevision is null)
|
||||
{
|
||||
currentRevision = line[6..].Trim();
|
||||
currentRevision = ExtractValue(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.StartsWith(" path:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentPath = line[6..].Trim();
|
||||
currentPath = ExtractValue(line);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -200,6 +200,17 @@ internal static class RubyLockParser
|
||||
_ => "rubygems",
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractValue(string line)
|
||||
{
|
||||
var separatorIndex = line.IndexOf(':');
|
||||
if (separatorIndex < 0 || separatorIndex + 1 >= line.Length)
|
||||
{
|
||||
return line.Trim();
|
||||
}
|
||||
|
||||
return line[(separatorIndex + 1)..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record RubyLockParserEntry(string Name, string Version, string Source, string? Platform);
|
||||
|
||||
@@ -112,7 +112,7 @@ internal sealed class RubyPackageBuilder
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var source = _lockSource ?? _artifactSource ?? "unknown";
|
||||
var source = _artifactSource ?? _lockSource ?? "unknown";
|
||||
var evidenceSource = _hasVendor
|
||||
? _artifactEvidenceSource ?? "vendor"
|
||||
: _lockEvidenceSource ?? "Gemfile.lock";
|
||||
|
||||
@@ -219,12 +219,37 @@ internal static class RubyVendorArtifactCollector
|
||||
{
|
||||
var normalized = relativePath.Replace('\\', '/');
|
||||
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0)
|
||||
if (segments.Length >= 2)
|
||||
{
|
||||
return normalized;
|
||||
if (MatchesPrefix(segments, "vendor", "cache"))
|
||||
{
|
||||
return "vendor-cache";
|
||||
}
|
||||
|
||||
if (MatchesPrefix(segments, "vendor", "bundle"))
|
||||
{
|
||||
return "vendor-bundle";
|
||||
}
|
||||
|
||||
if (MatchesPrefix(segments, ".bundle", "cache"))
|
||||
{
|
||||
return "bundle-cache";
|
||||
}
|
||||
}
|
||||
|
||||
return segments[0];
|
||||
if (segments.Length > 0)
|
||||
{
|
||||
return segments[0];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool MatchesPrefix(IReadOnlyList<string> segments, string first, string second)
|
||||
{
|
||||
return segments.Count >= 2
|
||||
&& segments[0].Equals(first, StringComparison.OrdinalIgnoreCase)
|
||||
&& segments[1].Equals(second, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
@@ -43,6 +47,11 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
evidence: package.CreateEvidence(),
|
||||
usedByEntrypoint: runtimeUsage?.UsedByEntrypoint ?? false);
|
||||
}
|
||||
|
||||
if (packages.Count > 0)
|
||||
{
|
||||
EmitObservation(context, writer, packages, runtimeGraph, capabilities);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask EnsureSurfaceValidationAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
@@ -60,16 +69,120 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
|
||||
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["analyzerId"] = \"ruby\",
|
||||
[\"rootPath\"] = context.RootPath
|
||||
["analyzerId"] = "ruby",
|
||||
["rootPath"] = context.RootPath
|
||||
};
|
||||
|
||||
var validationContext = SurfaceValidationContext.Create(
|
||||
context.Services,
|
||||
\"StellaOps.Scanner.Analyzers.Lang.Ruby\",
|
||||
"StellaOps.Scanner.Analyzers.Lang.Ruby",
|
||||
environment.Settings,
|
||||
properties);
|
||||
|
||||
await validatorRunner.EnsureAsync(validationContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void EmitObservation(
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
IReadOnlyList<RubyPackage> packages,
|
||||
RubyRuntimeGraph runtimeGraph,
|
||||
RubyCapabilities capabilities)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
ArgumentNullException.ThrowIfNull(packages);
|
||||
ArgumentNullException.ThrowIfNull(runtimeGraph);
|
||||
ArgumentNullException.ThrowIfNull(capabilities);
|
||||
|
||||
var observationDocument = RubyObservationBuilder.Build(packages, runtimeGraph, capabilities);
|
||||
var observationJson = RubyObservationSerializer.Serialize(observationDocument);
|
||||
var observationHash = RubyObservationSerializer.ComputeSha256(observationJson);
|
||||
var observationBytes = Encoding.UTF8.GetBytes(observationJson);
|
||||
|
||||
var observationMetadata = BuildObservationMetadata(
|
||||
packages.Count,
|
||||
observationDocument.RuntimeEdges.Length,
|
||||
observationDocument.Capabilities);
|
||||
|
||||
TryPersistObservation(Id, context, observationBytes, observationMetadata);
|
||||
|
||||
var observationEvidence = new[]
|
||||
{
|
||||
new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.Derived,
|
||||
"ruby.observation",
|
||||
"document",
|
||||
observationJson,
|
||||
observationHash)
|
||||
};
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: "observation::ruby",
|
||||
purl: null,
|
||||
name: "Ruby Observation Summary",
|
||||
version: null,
|
||||
type: "ruby-observation",
|
||||
metadata: observationMetadata,
|
||||
evidence: observationEvidence);
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string?>> BuildObservationMetadata(
|
||||
int packageCount,
|
||||
int runtimeEdgeCount,
|
||||
RubyObservationCapabilitySummary capabilities)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.packages", packageCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_edges", runtimeEdgeCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.capability.exec", capabilities.UsesExec ? "true" : "false");
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.capability.net", capabilities.UsesNetwork ? "true" : "false");
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.capability.serialization", capabilities.UsesSerialization ? "true" : "false");
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.capability.schedulers", capabilities.JobSchedulers.Length.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private static void TryPersistObservation(
|
||||
string analyzerId,
|
||||
LanguageAnalyzerContext context,
|
||||
byte[] observationBytes,
|
||||
IEnumerable<KeyValuePair<string, string?>> metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(analyzerId))
|
||||
{
|
||||
throw new ArgumentException("Analyzer id is required", nameof(analyzerId));
|
||||
}
|
||||
|
||||
if (context.AnalysisStore is not { } analysisStore)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var metadataDictionary = CreateMetadata(metadata);
|
||||
var payload = new AnalyzerObservationPayload(
|
||||
analyzerId: analyzerId,
|
||||
kind: "ruby.observation",
|
||||
mediaType: "application/json",
|
||||
content: observationBytes,
|
||||
metadata: metadataDictionary,
|
||||
view: "observations");
|
||||
|
||||
analysisStore.Set(ScanAnalysisKeys.RubyObservationPayload, payload);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string?>? CreateMetadata(IEnumerable<KeyValuePair<string, string?>> metadata)
|
||||
{
|
||||
Dictionary<string, string?>? dictionary = null;
|
||||
foreach (var pair in metadata ?? Array.Empty<KeyValuePair<string, string?>>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
dictionary ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
dictionary[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
| Task ID | State | Notes |
|
||||
| --- | --- | --- |
|
||||
| `SCANNER-ENG-0016` | DOING (2025-11-10) | Building RubyLockCollector + multi-source vendor ingestion per design §4.1–4.3 (Codex agent). |
|
||||
| `SCANNER-ENG-0016` | DONE (2025-11-10) | RubyLockCollector merged with vendor cache ingestion; workspace overrides, bundler groups, git/path fixture, and offline-kit mirror updated. |
|
||||
| `SCANNER-ENG-0017` | DONE (2025-11-09) | Build runtime require/autoload graph builder with tree-sitter Ruby per design §4.4, feed EntryTrace hints. |
|
||||
| `SCANNER-ENG-0018` | DONE (2025-11-09) | Emit Ruby capability + framework surface signals, align with design §4.5 / Sprint 138. |
|
||||
|
||||
@@ -21,4 +21,6 @@ public static class ScanAnalysisKeys
|
||||
public const string RegistryCredentials = "analysis.registry.credentials";
|
||||
|
||||
public const string DenoObservationPayload = "analysis.lang.deno.observation";
|
||||
|
||||
public const string RubyObservationPayload = "analysis.lang.ruby.observation";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "observation::ruby",
|
||||
"name": "Ruby Observation Summary",
|
||||
"type": "ruby-observation",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"ruby.observation.capability.exec": "true",
|
||||
"ruby.observation.capability.net": "true",
|
||||
"ruby.observation.capability.schedulers": "4",
|
||||
"ruby.observation.capability.serialization": "true",
|
||||
"ruby.observation.packages": "3",
|
||||
"ruby.observation.runtime_edges": "3"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "ruby.observation",
|
||||
"locator": "document",
|
||||
"value": "{\u0022packages\u0022:[{\u0022name\u0022:\u0022custom-gem\u0022,\u0022version\u0022:\u00221.0.0\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022artifact\u0022:\u0022vendor/cache/custom-gem-1.0.0.gem\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022puma\u0022,\u0022version\u0022:\u00226.4.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rake\u0022,\u0022version\u0022:\u002213.1.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022custom-gem\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022puma\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rake\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022capabilities\u0022:{\u0022usesExec\u0022:true,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:true,\u0022jobSchedulers\u0022:[\u0022activejob\u0022,\u0022clockwork\u0022,\u0022resque\u0022,\u0022sidekiq\u0022]}}",
|
||||
"sha256": "sha256:3818fd050909977a44167565a419a307777bc38998ad49d6a41c054982c6f46e"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/custom-gem@1.0.0",
|
||||
@@ -8,6 +32,7 @@
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"artifact": "vendor/cache/custom-gem-1.0.0.gem",
|
||||
"capability.exec": "true",
|
||||
"capability.net": "true",
|
||||
"capability.scheduler": "activejob;clockwork;resque;sidekiq",
|
||||
@@ -16,7 +41,8 @@
|
||||
"capability.scheduler.resque": "true",
|
||||
"capability.scheduler.sidekiq": "true",
|
||||
"capability.serialization": "true",
|
||||
"declaredOnly": "true",
|
||||
"declaredOnly": "false",
|
||||
"groups": "default",
|
||||
"lockfile": "vendor/cache/custom-gem-1.0.0.gem",
|
||||
"runtime.entrypoints": "app/main.rb",
|
||||
"runtime.files": "app/main.rb",
|
||||
@@ -27,7 +53,7 @@
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"source": "custom-gem-1.0.0.gem",
|
||||
"locator": "vendor/cache/custom-gem-1.0.0.gem"
|
||||
}
|
||||
]
|
||||
@@ -50,6 +76,7 @@
|
||||
"capability.scheduler.sidekiq": "true",
|
||||
"capability.serialization": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.entrypoints": "app/main.rb",
|
||||
"runtime.files": "app/main.rb",
|
||||
@@ -83,6 +110,7 @@
|
||||
"capability.scheduler.sidekiq": "true",
|
||||
"capability.serialization": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.entrypoints": "app/main.rb",
|
||||
"runtime.files": "app/main.rb",
|
||||
@@ -98,4 +126,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
git "https://github.com/example/git-gem.git", branch: "main" do
|
||||
gem "git-gem"
|
||||
end
|
||||
|
||||
gem "httparty", "~> 0.21.0"
|
||||
|
||||
path "vendor/plugins/path-gem" do
|
||||
gem "path-gem", "~> 2.1"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
GIT
|
||||
remote: https://github.com/example/git-gem.git
|
||||
revision: 0123456789abcdef0123456789abcdef01234567
|
||||
branch: main
|
||||
specs:
|
||||
git-gem (0.5.0)
|
||||
|
||||
PATH
|
||||
remote: vendor/plugins/path-gem
|
||||
specs:
|
||||
path-gem (2.1.3)
|
||||
rake (~> 13.0)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
httparty (0.21.0)
|
||||
multi_xml (~> 0.5)
|
||||
multi_xml (0.6.0)
|
||||
rake (13.1.0)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
git-gem!
|
||||
httparty (~> 0.21.0)
|
||||
path-gem (~> 2.1)!
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.10
|
||||
@@ -0,0 +1,7 @@
|
||||
require "git-gem"
|
||||
require "path-gem"
|
||||
require "httparty"
|
||||
|
||||
puts GitGem.version
|
||||
puts PathGem::Runner.new.perform
|
||||
puts HTTParty.get("https://example.invalid")
|
||||
@@ -0,0 +1,154 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "observation::ruby",
|
||||
"name": "Ruby Observation Summary",
|
||||
"type": "ruby-observation",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"ruby.observation.capability.exec": "false",
|
||||
"ruby.observation.capability.net": "true",
|
||||
"ruby.observation.capability.schedulers": "0",
|
||||
"ruby.observation.capability.serialization": "false",
|
||||
"ruby.observation.packages": "5",
|
||||
"ruby.observation.runtime_edges": "3"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "ruby.observation",
|
||||
"locator": "document",
|
||||
"value": "{\u0022packages\u0022:[{\u0022name\u0022:\u0022git-gem\u0022,\u0022version\u0022:\u00220.5.0\u0022,\u0022source\u0022:\u0022git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022httparty\u0022,\u0022version\u0022:\u00220.21.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022multi_xml\u0022,\u0022version\u0022:\u00220.6.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022path-gem\u0022,\u0022version\u0022:\u00222.1.3\u0022,\u0022source\u0022:\u0022vendor-cache\u0022,\u0022declaredOnly\u0022:false,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022artifact\u0022:\u0022vendor/cache/path-gem-2.1.3.gem\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rake\u0022,\u0022version\u0022:\u002213.1.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022git-gem\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022httparty\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022path-gem\u0022,\u0022usedByEntrypoint\u0022:true,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[\u0022app/main.rb\u0022],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:true,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[]}}",
|
||||
"sha256": "sha256:1cd5eb20a226916b9d1acbfc7182845a3ebca8284c7f558b23b7e87395e0a2c2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/git-gem@0.5.0",
|
||||
"purl": "pkg:gem/git-gem@0.5.0",
|
||||
"name": "git-gem",
|
||||
"version": "0.5.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.entrypoints": "app/main.rb",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/httparty@0.21.0",
|
||||
"purl": "pkg:gem/httparty@0.21.0",
|
||||
"name": "httparty",
|
||||
"version": "0.21.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.entrypoints": "app/main.rb",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/multi_xml@0.6.0",
|
||||
"purl": "pkg:gem/multi_xml@0.6.0",
|
||||
"name": "multi_xml",
|
||||
"version": "0.6.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/path-gem@2.1.3",
|
||||
"purl": "pkg:gem/path-gem@2.1.3",
|
||||
"name": "path-gem",
|
||||
"version": "2.1.3",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"artifact": "vendor/cache/path-gem-2.1.3.gem",
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "false",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.entrypoints": "app/main.rb",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "vendor-cache"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "path-gem-2.1.3.gem",
|
||||
"locator": "vendor/cache/path-gem-2.1.3.gem"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/rake@13.1.0",
|
||||
"purl": "pkg:gem/rake@13.1.0",
|
||||
"name": "rake",
|
||||
"version": "13.1.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +1,193 @@
|
||||
[]
|
||||
[
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "observation::ruby",
|
||||
"name": "Ruby Observation Summary",
|
||||
"type": "ruby-observation",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"ruby.observation.capability.exec": "false",
|
||||
"ruby.observation.capability.net": "false",
|
||||
"ruby.observation.capability.schedulers": "0",
|
||||
"ruby.observation.capability.serialization": "false",
|
||||
"ruby.observation.packages": "7",
|
||||
"ruby.observation.runtime_edges": "4"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "ruby.observation",
|
||||
"locator": "document",
|
||||
"value": "{\u0022packages\u0022:[{\u0022name\u0022:\u0022api-gem\u0022,\u0022version\u0022:\u00220.1.0\u0022,\u0022source\u0022:\u0022apps\u0022,\u0022declaredOnly\u0022:false,\u0022artifact\u0022:\u0022apps/api/vendor/bundle/ruby/3.1.0/gems/api-gem-0.1.0\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022bootsnap\u0022,\u0022version\u0022:\u00221.18.4\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022apps/api/Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022pry\u0022,\u0022version\u0022:\u00221.0.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022development\u0022,\u0022test\u0022]},{\u0022name\u0022:\u0022puma\u0022,\u0022version\u0022:\u00226.4.2\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022console\u0022,\u0022production\u0022]},{\u0022name\u0022:\u0022rails\u0022,\u0022version\u0022:\u00227.1.3\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022default\u0022]},{\u0022name\u0022:\u0022rubocop\u0022,\u0022version\u0022:\u00221.60.0\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022Gemfile.lock\u0022,\u0022groups\u0022:[\u0022development\u0022,\u0022test\u0022]},{\u0022name\u0022:\u0022sidekiq\u0022,\u0022version\u0022:\u00227.2.4\u0022,\u0022source\u0022:\u0022https://rubygems.org/\u0022,\u0022declaredOnly\u0022:true,\u0022lockfile\u0022:\u0022apps/api/Gemfile.lock\u0022,\u0022groups\u0022:[\u0022jobs\u0022]}],\u0022runtimeEdges\u0022:[{\u0022package\u0022:\u0022bootsnap\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022puma\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022rails\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]},{\u0022package\u0022:\u0022sidekiq\u0022,\u0022usedByEntrypoint\u0022:false,\u0022files\u0022:[\u0022app/main.rb\u0022],\u0022entrypoints\u0022:[],\u0022reasons\u0022:[\u0022require-static\u0022]}],\u0022capabilities\u0022:{\u0022usesExec\u0022:false,\u0022usesNetwork\u0022:false,\u0022usesSerialization\u0022:false,\u0022jobSchedulers\u0022:[]}}",
|
||||
"sha256": "sha256:6f9996b97be3dbbf3a18c2cb91624d45ddd16b2a374dd4a7f48049f5192114e2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/api-gem@0.1.0",
|
||||
"purl": "pkg:gem/api-gem@0.1.0",
|
||||
"name": "api-gem",
|
||||
"version": "0.1.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"artifact": "apps/api/vendor/bundle/ruby/3.1.0/gems/api-gem-0.1.0",
|
||||
"declaredOnly": "false",
|
||||
"groups": "default",
|
||||
"lockfile": "apps/api/vendor/bundle/ruby/3.1.0/gems/api-gem-0.1.0",
|
||||
"source": "apps"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "api-gem-0.1.0",
|
||||
"locator": "apps/api/vendor/bundle/ruby/3.1.0/gems/api-gem-0.1.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/bootsnap@1.18.4",
|
||||
"purl": "pkg:gem/bootsnap@1.18.4",
|
||||
"name": "bootsnap",
|
||||
"version": "1.18.4",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "apps/api/Gemfile.lock",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "apps/api/Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/pry@1.0.0",
|
||||
"purl": "pkg:gem/pry@1.0.0",
|
||||
"name": "pry",
|
||||
"version": "1.0.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"groups": "development;test",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/puma@6.4.2",
|
||||
"purl": "pkg:gem/puma@6.4.2",
|
||||
"name": "puma",
|
||||
"version": "6.4.2",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"groups": "console;production",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/rails@7.1.3",
|
||||
"purl": "pkg:gem/rails@7.1.3",
|
||||
"name": "rails",
|
||||
"version": "7.1.3",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/rubocop@1.60.0",
|
||||
"purl": "pkg:gem/rubocop@1.60.0",
|
||||
"name": "rubocop",
|
||||
"version": "1.60.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"groups": "development;test",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/sidekiq@7.2.4",
|
||||
"purl": "pkg:gem/sidekiq@7.2.4",
|
||||
"name": "sidekiq",
|
||||
"version": "7.2.4",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"groups": "jobs",
|
||||
"lockfile": "apps/api/Gemfile.lock",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "apps/api/Gemfile.lock"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -34,4 +34,19 @@ public sealed class RubyLanguageAnalyzerTests
|
||||
new ILanguageAnalyzer[] { new RubyLanguageAnalyzer() },
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitAndPathSourcesAsync()
|
||||
{
|
||||
var fixture = TestPaths.ResolveFixture("lang", "ruby", "git-sources");
|
||||
var golden = Path.Combine(fixture, "expected.json");
|
||||
var usageHints = new LanguageUsageHints(new[] { Path.Combine(fixture, "app", "main.rb") });
|
||||
|
||||
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
|
||||
fixture,
|
||||
golden,
|
||||
new ILanguageAnalyzer[] { new RubyLanguageAnalyzer() },
|
||||
cancellationToken: TestContext.Current.CancellationToken,
|
||||
usageHints: usageHints);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user