Add Ruby language analyzer and related functionality

- Introduced global usings for Ruby analyzer.
- Implemented RubyLockData, RubyLockEntry, and RubyLockParser for handling Gemfile.lock files.
- Created RubyPackage and RubyPackageCollector to manage Ruby packages and vendor cache.
- Developed RubyAnalyzerPlugin and RubyLanguageAnalyzer for analyzing Ruby projects.
- Added tests for Ruby language analyzer with sample Gemfile.lock and expected output.
- Included necessary project files and references for the Ruby analyzer.
- Added third-party licenses for tree-sitter dependencies.
This commit is contained in:
master
2025-11-03 01:15:43 +02:00
parent ff0eca3a51
commit bf2bf4b395
88 changed files with 6557 additions and 1568 deletions

View File

@@ -0,0 +1,10 @@
global using System.Collections.Generic;
global using System.Diagnostics.CodeAnalysis;
global using System.Globalization;
global using System.IO;
global using System.Linq;
global using System.Text;
global using System.Text.RegularExpressions;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -0,0 +1,39 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal sealed class RubyLockData
{
private RubyLockData(string? lockFilePath, IReadOnlyList<RubyLockEntry> entries, string bundledWith)
{
LockFilePath = lockFilePath;
Entries = entries;
BundledWith = bundledWith;
}
public string? LockFilePath { get; }
public string BundledWith { get; }
public IReadOnlyList<RubyLockEntry> Entries { get; }
public bool IsEmpty => Entries.Count == 0;
public static async ValueTask<RubyLockData> LoadAsync(string rootPath, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(rootPath);
var lockPath = Path.Combine(rootPath, "Gemfile.lock");
if (!File.Exists(lockPath))
{
return Empty;
}
await using var stream = new FileStream(lockPath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var parser = RubyLockParser.Parse(content);
return new RubyLockData(lockPath, parser.Entries, parser.BundledWith);
}
public static RubyLockData Empty { get; } = new(lockFilePath: null, Array.Empty<RubyLockEntry>(), bundledWith: string.Empty);
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal sealed record RubyLockEntry(
string Name,
string Version,
string Source,
string? Platform,
IReadOnlyCollection<string> Groups);

View File

@@ -0,0 +1,129 @@
using System.IO;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal static class RubyLockParser
{
private static readonly Regex SpecLineRegex = new(@"^\s{4}([^\s]+)\s\(([^)]+)\)", RegexOptions.Compiled);
public static RubyLockParserResult Parse(string contents)
{
if (string.IsNullOrWhiteSpace(contents))
{
return new RubyLockParserResult(Array.Empty<RubyLockEntry>(), string.Empty);
}
var entries = new List<RubyLockEntry>();
using var reader = new StringReader(contents);
string? line;
string currentSection = string.Empty;
string? currentSource = null;
bool inSpecs = false;
var bundledWith = string.Empty;
while ((line = reader.ReadLine()) is not null)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (!char.IsWhiteSpace(line[0]))
{
currentSection = line.Trim();
inSpecs = false;
if (string.Equals(currentSection, "GEM", StringComparison.OrdinalIgnoreCase))
{
currentSource = "rubygems";
}
else if (string.Equals(currentSection, "GIT", StringComparison.OrdinalIgnoreCase))
{
currentSource = null;
}
else if (string.Equals(currentSection, "PATH", StringComparison.OrdinalIgnoreCase))
{
currentSource = null;
}
else if (string.Equals(currentSection, "BUNDLED WITH", StringComparison.OrdinalIgnoreCase))
{
var versionLine = reader.ReadLine();
if (!string.IsNullOrWhiteSpace(versionLine))
{
bundledWith = versionLine.Trim();
}
}
continue;
}
if (line.StartsWith(" remote:", StringComparison.OrdinalIgnoreCase))
{
currentSource = line[9..].Trim();
continue;
}
if (line.StartsWith(" revision:", StringComparison.OrdinalIgnoreCase)
&& currentSection.Equals("GIT", StringComparison.OrdinalIgnoreCase)
&& currentSource is not null)
{
currentSource = $"{currentSource}@{line[10..].Trim()}";
continue;
}
if (line.StartsWith(" path:", StringComparison.OrdinalIgnoreCase)
&& currentSection.Equals("PATH", StringComparison.OrdinalIgnoreCase))
{
currentSource = $"path:{line[6..].Trim()}";
continue;
}
if (line.StartsWith(" specs:", StringComparison.OrdinalIgnoreCase))
{
inSpecs = true;
continue;
}
if (!inSpecs)
{
continue;
}
var match = SpecLineRegex.Match(line);
if (!match.Success)
{
continue;
}
if (line.Length > 4 && char.IsWhiteSpace(line[4]))
{
continue;
}
var name = match.Groups[1].Value.Trim();
var versionToken = match.Groups[2].Value.Trim();
string version;
string? platform = null;
var tokens = versionToken.Split(new[] { " " }, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length > 1)
{
version = tokens[0];
platform = string.Join(" ", tokens.Skip(1));
}
else
{
version = versionToken;
}
var source = currentSource ?? "unknown";
entries.Add(new RubyLockEntry(name, version, source, platform, Array.Empty<string>()));
}
return new RubyLockParserResult(entries, bundledWith);
}
}
internal sealed record RubyLockParserResult(IReadOnlyList<RubyLockEntry> Entries, string BundledWith);

View File

@@ -0,0 +1,113 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal sealed class RubyPackage
{
private RubyPackage(
string name,
string version,
string source,
string? platform,
IReadOnlyCollection<string> groups,
string locator,
bool declaredOnly)
{
Name = name;
Version = version;
Source = source;
Platform = platform;
Groups = groups;
Locator = locator;
DeclaredOnly = declaredOnly;
}
public string Name { get; }
public string Version { get; }
public string Source { get; }
public string? Platform { get; }
public IReadOnlyCollection<string> Groups { get; }
public string Locator { get; }
public bool DeclaredOnly { get; }
public string Purl => $"pkg:gem/{Name}@{Version}";
public string ComponentKey => $"purl::{Purl}";
public IReadOnlyCollection<KeyValuePair<string, string?>> CreateMetadata(RubyCapabilities? capabilities)
{
var metadata = new List<KeyValuePair<string, string?>>
{
new("source", Source),
new("lockfile", string.IsNullOrWhiteSpace(Locator) ? "Gemfile.lock" : Locator),
new("declaredOnly", DeclaredOnly ? "true" : "false")
};
if (!string.IsNullOrWhiteSpace(Platform))
{
metadata.Add(new KeyValuePair<string, string?>("platform", Platform));
}
if (Groups.Count > 0)
{
metadata.Add(new KeyValuePair<string, string?>("groups", string.Join(';', Groups)));
}
if (capabilities is not null)
{
if (capabilities.UsesExec)
{
metadata.Add(new KeyValuePair<string, string?>("capability.exec", "true"));
}
if (capabilities.UsesNetwork)
{
metadata.Add(new KeyValuePair<string, string?>("capability.net", "true"));
}
if (capabilities.UsesSerialization)
{
metadata.Add(new KeyValuePair<string, string?>("capability.serialization", "true"));
}
}
return metadata
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.ToArray();
}
public IReadOnlyCollection<LanguageComponentEvidence> CreateEvidence()
{
var locator = string.IsNullOrWhiteSpace(Locator)
? "Gemfile.lock"
: Locator;
return new[]
{
new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"Gemfile.lock",
locator,
Value: null,
Sha256: null)
};
}
public static RubyPackage From(RubyLockEntry entry, string lockFileRelativePath)
{
var groups = entry.Groups.Count == 0
? Array.Empty<string>()
: entry.Groups.OrderBy(static g => g, StringComparer.OrdinalIgnoreCase).ToArray();
return new RubyPackage(entry.Name, entry.Version, entry.Source, entry.Platform, groups, lockFileRelativePath, declaredOnly: true);
}
public static RubyPackage FromVendor(string name, string version, string source, string? platform, string locator)
{
return new RubyPackage(name, version, source, platform, Array.Empty<string>(), locator, declaredOnly: true);
}
}

View File

@@ -0,0 +1,105 @@
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
internal static class RubyPackageCollector
{
public static IReadOnlyList<RubyPackage> CollectPackages(RubyLockData lockData, LanguageAnalyzerContext context)
{
var packages = new List<RubyPackage>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!lockData.IsEmpty)
{
var relativeLockPath = lockData.LockFilePath is null
? Gemfile.lock
: context.GetRelativePath(lockData.LockFilePath);
if (string.IsNullOrWhiteSpace(relativeLockPath))
{
relativeLockPath = Gemfile.lock;
}
foreach (var entry in lockData.Entries)
{
var key = ${entry.Name}@{entry.Version};
if (!seen.Add(key))
{
continue;
}
packages.Add(RubyPackage.From(entry, relativeLockPath));
}
}
CollectVendorCachePackages(context, packages, seen);
return packages;
}
private static void CollectVendorCachePackages(LanguageAnalyzerContext context, List<RubyPackage> packages, HashSet<string> seen)
{
var vendorCache = Path.Combine(context.RootPath, vendor, cache);
if (!Directory.Exists(vendorCache))
{
return;
}
foreach (var gemPath in Directory.EnumerateFiles(vendorCache, *.gem, SearchOption.AllDirectories))
{
if (!TryParseGemArchive(gemPath, out var name, out var version, out var platform))
{
continue;
}
var key = ${name}@{version};
if (!seen.Add(key))
{
continue;
}
var locator = context.GetRelativePath(gemPath);
packages.Add(RubyPackage.FromVendor(name, version, source: vendor-cache, platform, locator));
}
}
private static bool TryParseGemArchive(string gemPath, out string name, out string version, out string? platform)
{
name = string.Empty;
version = string.Empty;
platform = null;
var fileName = Path.GetFileNameWithoutExtension(gemPath);
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
var segments = fileName.Split('-', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2)
{
return false;
}
var versionIndex = -1;
for (var i = 1; i < segments.Length; i++)
{
if (char.IsDigit(segments[i][0]))
{
versionIndex = i;
break;
}
}
if (versionIndex <= 0)
{
return false;
}
name = string.Join('-', segments[..versionIndex]);
version = segments[versionIndex];
platform = segments.Length > versionIndex + 1
? string.Join('-', segments[(versionIndex + 1)..])
: null;
return !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(version);
}
}

View File

@@ -0,0 +1,18 @@
using System;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby;
public sealed class RubyAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => StellaOps.Scanner.Analyzers.Lang.Ruby;
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new RubyLanguageAnalyzer();
}
}

View File

@@ -0,0 +1,38 @@
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Ruby;
public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "ruby";
public string DisplayName => "Ruby Analyzer";
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var lockData = await RubyLockData.LoadAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
if (lockData.IsEmpty)
{
return;
}
var packages = RubyPackageCollector.CollectPackages(lockData, context);
foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
writer.AddFromPurl(
analyzerId: Id,
purl: package.Purl,
name: package.Name,
version: package.Version,
type: "gem",
metadata: package.CreateMetadata(),
evidence: package.CreateEvidence(),
usedByEntrypoint: false);
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
</ItemGroup>
</Project>