feat(scanner): Implement Deno analyzer and associated tests
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added Deno analyzer with comprehensive metadata and evidence structure.
- Created a detailed implementation plan for Sprint 130 focusing on Deno analyzer.
- Introduced AdvisoryAiGuardrailOptions for managing guardrail configurations.
- Developed GuardrailPhraseLoader for loading blocked phrases from JSON files.
- Implemented tests for AdvisoryGuardrailOptions binding and phrase loading.
- Enhanced telemetry for Advisory AI with metrics tracking.
- Added VexObservationProjectionService for querying VEX observations.
- Created extensive tests for VexObservationProjectionService functionality.
- Introduced Ruby language analyzer with tests for simple and complex workspaces.
- Added Ruby application fixtures for testing purposes.
This commit is contained in:
master
2025-11-12 10:01:54 +02:00
parent 0e8655cbb1
commit babb81af52
75 changed files with 3346 additions and 187 deletions

View File

@@ -7,6 +7,7 @@ namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.Deno;
public sealed class DenoWorkspaceNormalizerTests
{
[Fact]
public async Task WorkspaceFixtureProducesDeterministicOutputAsync()
{
@@ -79,18 +80,50 @@ public sealed class DenoWorkspaceNormalizerTests
node => node.Kind == DenoModuleKind.RemoteModule &&
node.Id == "remote::https://deno.land/std@0.207.0/http/server.ts");
Assert.NotNull(remoteNode);
Assert.Equal("sha256-deadbeef", remoteNode!.Integrity);
var expectedIntegrity = lockFile.RemoteEntries["https://deno.land/std@0.207.0/http/server.ts"];
Assert.Equal(expectedIntegrity, remoteNode!.Integrity);
var vendorCacheEdges = graph.Edges
.Where(edge => edge.ImportKind == DenoImportKind.Cache &&
edge.Provenance.StartsWith("vendor-cache:", StringComparison.Ordinal))
.ToArray();
if (vendorCacheEdges.Length == 0)
{
var sample = string.Join(
Environment.NewLine,
graph.Edges
.Select(edge => $"{edge.ImportKind}:{edge.Specifier}:{edge.Provenance}")
.Take(10));
Assert.Fail($"Expected vendor cache edges but none were found. Sample edges:{Environment.NewLine}{sample}");
}
var vendorEdge = vendorCacheEdges.FirstOrDefault(
edge => edge.Specifier.Contains("https://deno.land/std@0.207.0/http/server.ts", StringComparison.Ordinal));
if (vendorEdge is null)
{
var details = string.Join(
Environment.NewLine,
vendorCacheEdges.Select(edge => $"{edge.Specifier} [{edge.Provenance}] -> {edge.Resolution}"));
Assert.Fail($"Unable to locate vendor cache edge for std server.ts. Observed edges:{Environment.NewLine}{details}");
}
var npmBridgeEdges = graph.Edges
.Where(edge => edge.ImportKind == DenoImportKind.NpmBridge)
.ToArray();
if (npmBridgeEdges.Length == 0)
{
var bridgeSample = string.Join(
Environment.NewLine,
graph.Edges
.Select(edge => $"{edge.ImportKind}:{edge.Specifier}:{edge.Resolution}")
.Take(10));
Assert.Fail($"No npm bridge edges discovered. Sample:{Environment.NewLine}{bridgeSample}");
}
Assert.Contains(
graph.Edges,
edge => edge.ImportKind == DenoImportKind.Cache &&
edge.Provenance.StartsWith("vendor-cache:", StringComparison.Ordinal) &&
edge.Specifier.Contains("https://deno.land/std@0.207.0/http/server.ts", StringComparison.Ordinal));
Assert.Contains(
graph.Edges,
edge => edge.ImportKind == DenoImportKind.NpmBridge &&
edge.Specifier == "npm:dayjs@1" &&
npmBridgeEdges,
edge => edge.Specifier == "npm:dayjs@1" &&
edge.Resolution == "dayjs@1.11.12");
Assert.Contains(

View File

@@ -1,5 +1,10 @@
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Scanner.Analyzers.Lang;
using StellaOps.Scanner.Analyzers.Lang.Deno;
using StellaOps.Scanner.Analyzers.Lang.Deno.Tests.TestFixtures;
using StellaOps.Scanner.Analyzers.Lang.Deno.Tests.TestUtilities;
namespace StellaOps.Scanner.Analyzers.Lang.Deno.Tests.Golden;
@@ -9,25 +14,37 @@ public sealed class DenoAnalyzerGoldenTests
[Fact]
public async Task AnalyzerMatchesGoldenSnapshotAsync()
{
var fixture = TestPaths.ResolveFixture("lang", "deno", "full");
var golden = Path.Combine(fixture, "expected.json");
var fixtureRoot = TestPaths.ResolveFixture("lang", "deno", "full");
var golden = Path.Combine(fixtureRoot, "expected.json");
var analyzers = new ILanguageAnalyzer[] { new DenoLanguageAnalyzer() };
var cancellationToken = TestContext.Current.CancellationToken;
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(fixture, analyzers, cancellationToken);
var normalized = Normalize(json, fixture);
var expected = await File.ReadAllTextAsync(golden, cancellationToken);
normalized = normalized.TrimEnd();
expected = expected.TrimEnd();
if (!string.Equals(expected, normalized, StringComparison.Ordinal))
var (workspaceRoot, envDir) = DenoWorkspaceTestFixture.Create();
var previousDenoDir = Environment.GetEnvironmentVariable("DENO_DIR");
try
{
var actualPath = golden + ".actual";
await File.WriteAllTextAsync(actualPath, normalized, cancellationToken);
}
Environment.SetEnvironmentVariable("DENO_DIR", envDir);
Assert.Equal(expected, normalized);
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(workspaceRoot, analyzers, cancellationToken);
var normalized = Normalize(json, workspaceRoot);
var expected = await File.ReadAllTextAsync(golden, cancellationToken);
normalized = normalized.TrimEnd();
expected = expected.TrimEnd();
if (!string.Equals(expected, normalized, StringComparison.Ordinal))
{
var actualPath = golden + ".actual";
await File.WriteAllTextAsync(actualPath, normalized, cancellationToken);
}
Assert.Equal(expected, normalized);
}
finally
{
Environment.SetEnvironmentVariable("DENO_DIR", previousDenoDir);
DenoWorkspaceTestFixture.Cleanup(workspaceRoot);
}
}
private static string Normalize(string json, string workspaceRoot)
@@ -37,10 +54,206 @@ public sealed class DenoAnalyzerGoldenTests
return string.Empty;
}
var normalizedRoot = workspaceRoot.Replace("\\", "/", StringComparison.Ordinal);
var builder = json.Replace(normalizedRoot, "<workspace>", StringComparison.Ordinal);
var altRoot = workspaceRoot.Replace("/", "\\", StringComparison.Ordinal);
builder = builder.Replace(altRoot, "<workspace>", StringComparison.Ordinal);
return builder;
var node = JsonNode.Parse(json) ?? new JsonArray();
if (node is JsonArray array)
{
foreach (var element in array.OfType<JsonObject>())
{
NormalizeComponent(element);
}
}
var normalized = node.ToJsonString(JsonSerializerOptionsProvider);
normalized = ReplaceWorkspacePaths(normalized, workspaceRoot);
return normalized;
}
private static void NormalizeComponent(JsonObject component)
{
if (component is null)
{
return;
}
SortMetadata(component);
if (!component.TryGetPropertyValue("type", out var typeNode))
{
return;
}
var type = typeNode?.GetValue<string>();
if (string.Equals(type, "deno-container", StringComparison.OrdinalIgnoreCase))
{
NormalizeContainer(component);
}
else if (string.Equals(type, "deno-observation", StringComparison.OrdinalIgnoreCase))
{
NormalizeObservation(component);
}
}
private static void NormalizeContainer(JsonObject container)
{
NormalizeAliasProperty(container, "name");
NormalizeComponentKey(container);
if (container.TryGetPropertyValue("metadata", out var metadataNode) &&
metadataNode is JsonObject metadata)
{
NormalizeAliasProperty(metadata, "deno.container.identifier");
NormalizeAliasProperty(metadata, "deno.container.meta.alias");
}
if (container.TryGetPropertyValue("evidence", out var evidenceNode) &&
evidenceNode is JsonArray evidenceArray)
{
foreach (var evidence in evidenceArray.OfType<JsonObject>())
{
if (evidence.TryGetPropertyValue("source", out var sourceNode) &&
string.Equals(sourceNode?.GetValue<string>(), "deno.container", StringComparison.OrdinalIgnoreCase))
{
NormalizeAliasProperty(evidence, "value");
}
}
}
}
private static void NormalizeComponentKey(JsonObject container)
{
if (!container.TryGetPropertyValue("componentKey", out var keyNode) ||
keyNode is not JsonValue keyValue ||
!keyValue.TryGetValue<string>(out var componentKey))
{
return;
}
var lastSeparator = componentKey.LastIndexOf(':');
if (lastSeparator < 0)
{
container["componentKey"] = NormalizeAliasValue(componentKey);
return;
}
var prefix = componentKey[..(lastSeparator + 1)];
var alias = componentKey[(lastSeparator + 1)..];
container["componentKey"] = prefix + NormalizeAliasValue(alias);
}
private static void NormalizeAliasProperty(JsonObject obj, string propertyName)
{
if (!obj.TryGetPropertyValue(propertyName, out var node) ||
node is not JsonValue valueNode ||
!valueNode.TryGetValue<string>(out var value) ||
string.IsNullOrWhiteSpace(value))
{
return;
}
obj[propertyName] = NormalizeAliasValue(value);
}
private static string NormalizeAliasValue(string value)
=> TryNormalizeAlias(value, out var normalized) ? normalized : value;
private static bool TryNormalizeAlias(string value, out string normalized)
{
normalized = value;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var lastDash = value.LastIndexOf('-');
if (lastDash <= 0)
{
return false;
}
var suffix = value[(lastDash + 1)..];
if (suffix.Length != 12)
{
return false;
}
foreach (var character in suffix)
{
if (!IsLowerHex(character))
{
return false;
}
}
normalized = value[..lastDash] + "-<hash>";
return true;
}
private static bool IsLowerHex(char value)
=> (value is >= '0' and <= '9') || (value is >= 'a' and <= 'f');
private static string ReplaceWorkspacePaths(string value, string workspaceRoot)
{
var normalizedRoot = workspaceRoot.Replace("\\", "/", StringComparison.Ordinal);
var normalizedRootLower = normalizedRoot.ToLowerInvariant();
var result = value
.Replace(normalizedRoot, "<workspace>", StringComparison.Ordinal)
.Replace(normalizedRootLower, "<workspace>", StringComparison.Ordinal);
var altRoot = workspaceRoot.Replace("/", "\\", StringComparison.Ordinal);
var altRootLower = altRoot.ToLowerInvariant();
result = result
.Replace(altRoot, "<workspace>", StringComparison.Ordinal)
.Replace(altRootLower, "<workspace>", StringComparison.Ordinal);
return result;
}
private static void NormalizeObservation(JsonObject observation)
{
if (observation.TryGetPropertyValue("metadata", out var metadataNode) &&
metadataNode is JsonObject metadata &&
metadata.TryGetPropertyValue("deno.observation.hash", out var hashNode) &&
hashNode is JsonValue)
{
metadata["deno.observation.hash"] = "<hash>";
}
if (observation.TryGetPropertyValue("evidence", out var evidenceNode) &&
evidenceNode is JsonArray evidenceArray)
{
foreach (var evidence in evidenceArray.OfType<JsonObject>())
{
if (evidence.TryGetPropertyValue("source", out var sourceNode) &&
string.Equals(sourceNode?.GetValue<string>(), "deno.observation", StringComparison.OrdinalIgnoreCase) &&
evidence.TryGetPropertyValue("sha256", out var shaNode) &&
shaNode is JsonValue)
{
evidence["sha256"] = "<hash>";
}
}
}
}
private static void SortMetadata(JsonObject component)
{
if (!component.TryGetPropertyValue("metadata", out var metadataNode) ||
metadataNode is not JsonObject metadata)
{
return;
}
var sorted = new JsonObject();
foreach (var entry in metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
sorted[entry.Key] = entry.Value?.DeepClone();
}
component["metadata"] = sorted;
}
private static readonly JsonSerializerOptions JsonSerializerOptionsProvider = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
}