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:
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user