feat(rust): Implement RustCargoLockParser and RustFingerprintScanner
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			- Added RustCargoLockParser to parse Cargo.lock files and extract package information. - Introduced RustFingerprintScanner to scan for Rust fingerprint records in JSON files. - Created test fixtures for Rust language analysis, including Cargo.lock and fingerprint JSON files. - Developed tests for RustLanguageAnalyzer to ensure deterministic output based on provided fixtures. - Added expected output files for both simple and signed Rust applications.
This commit is contained in:
		| @@ -2,9 +2,10 @@ using System.Collections.Immutable; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Excititor.Attestation.Dsse; | ||||
| using StellaOps.Excititor.Attestation.Signing; | ||||
| using StellaOps.Excititor.Attestation.Transparency; | ||||
| using StellaOps.Excititor.Core; | ||||
| using StellaOps.Excititor.Attestation.Signing; | ||||
| using StellaOps.Excititor.Attestation.Transparency; | ||||
| using StellaOps.Excititor.Attestation.Verification; | ||||
| using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation.Tests; | ||||
|  | ||||
| @@ -16,7 +17,8 @@ public sealed class VexAttestationClientTests | ||||
|         var signer = new FakeSigner(); | ||||
|         var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance); | ||||
|         var options = Options.Create(new VexAttestationClientOptions()); | ||||
|         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance); | ||||
|         var verifier = new FakeVerifier(); | ||||
|         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier); | ||||
|  | ||||
|         var request = new VexAttestationRequest( | ||||
|             ExportId: "exports/456", | ||||
| @@ -40,8 +42,9 @@ public sealed class VexAttestationClientTests | ||||
|         var signer = new FakeSigner(); | ||||
|         var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance); | ||||
|         var options = Options.Create(new VexAttestationClientOptions()); | ||||
|         var transparency = new FakeTransparencyLogClient(); | ||||
|         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, transparencyLogClient: transparency); | ||||
|         var transparency = new FakeTransparencyLogClient(); | ||||
|         var verifier = new FakeVerifier(); | ||||
|         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparencyLogClient: transparency); | ||||
|  | ||||
|         var request = new VexAttestationRequest( | ||||
|             ExportId: "exports/789", | ||||
| @@ -65,9 +68,9 @@ public sealed class VexAttestationClientTests | ||||
|             => ValueTask.FromResult(new VexSignedPayload("signature", "key")); | ||||
|     } | ||||
|  | ||||
|     private sealed class FakeTransparencyLogClient : ITransparencyLogClient | ||||
|     { | ||||
|         public bool SubmitCalled { get; private set; } | ||||
|     private sealed class FakeTransparencyLogClient : ITransparencyLogClient | ||||
|     { | ||||
|         public bool SubmitCalled { get; private set; } | ||||
|  | ||||
|         public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken) | ||||
|         { | ||||
| @@ -75,7 +78,13 @@ public sealed class VexAttestationClientTests | ||||
|             return ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "23", null)); | ||||
|         } | ||||
|  | ||||
|         public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(true); | ||||
|     } | ||||
| } | ||||
|         public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(true); | ||||
|     } | ||||
|  | ||||
|     private sealed class FakeVerifier : IVexAttestationVerifier | ||||
|     { | ||||
|         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty)); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,132 @@ | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Excititor.Attestation.Dsse; | ||||
| using StellaOps.Excititor.Attestation.Signing; | ||||
| using StellaOps.Excititor.Attestation.Transparency; | ||||
| using StellaOps.Excititor.Attestation.Verification; | ||||
| using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation.Tests; | ||||
|  | ||||
| public sealed class VexAttestationVerifierTests : IDisposable | ||||
| { | ||||
|     private readonly VexAttestationMetrics _metrics = new(); | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task VerifyAsync_ReturnsValid_WhenEnvelopeMatches() | ||||
|     { | ||||
|         var (request, metadata, envelope) = await CreateSignedAttestationAsync(); | ||||
|         var verifier = CreateVerifier(options => options.RequireTransparencyLog = false); | ||||
|  | ||||
|         var verification = await verifier.VerifyAsync( | ||||
|             new VexAttestationVerificationRequest(request, metadata, envelope), | ||||
|             CancellationToken.None); | ||||
|  | ||||
|         Assert.True(verification.IsValid); | ||||
|         Assert.Equal("valid", verification.Diagnostics["result"]); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task VerifyAsync_ReturnsInvalid_WhenDigestMismatch() | ||||
|     { | ||||
|         var (request, metadata, envelope) = await CreateSignedAttestationAsync(); | ||||
|         var verifier = CreateVerifier(options => options.RequireTransparencyLog = false); | ||||
|  | ||||
|         var tamperedMetadata = new VexAttestationMetadata( | ||||
|             metadata.PredicateType, | ||||
|             metadata.Rekor, | ||||
|             "sha256:deadbeef", | ||||
|             metadata.SignedAt); | ||||
|  | ||||
|         var verification = await verifier.VerifyAsync( | ||||
|             new VexAttestationVerificationRequest(request, tamperedMetadata, envelope), | ||||
|             CancellationToken.None); | ||||
|  | ||||
|         Assert.False(verification.IsValid); | ||||
|         Assert.Equal("invalid", verification.Diagnostics["result"]); | ||||
|         Assert.Equal("sha256:deadbeef", verification.Diagnostics["metadata.envelopeDigest"]); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task VerifyAsync_AllowsOfflineTransparency_WhenConfigured() | ||||
|     { | ||||
|         var (request, metadata, envelope) = await CreateSignedAttestationAsync(includeRekor: true); | ||||
|         var transparency = new ThrowingTransparencyLogClient(); | ||||
|         var verifier = CreateVerifier(options => | ||||
|         { | ||||
|             options.AllowOfflineTransparency = true; | ||||
|             options.RequireTransparencyLog = true; | ||||
|         }, transparency); | ||||
|  | ||||
|         var verification = await verifier.VerifyAsync( | ||||
|             new VexAttestationVerificationRequest(request, metadata, envelope), | ||||
|             CancellationToken.None); | ||||
|  | ||||
|         Assert.True(verification.IsValid); | ||||
|         Assert.Equal("offline", verification.Diagnostics["rekor.state"]); | ||||
|     } | ||||
|  | ||||
|     private async Task<(VexAttestationRequest Request, VexAttestationMetadata Metadata, string Envelope)> CreateSignedAttestationAsync(bool includeRekor = false) | ||||
|     { | ||||
|         var signer = new FakeSigner(); | ||||
|         var builder = new VexDsseBuilder(signer, NullLogger<VexDsseBuilder>.Instance); | ||||
|         var options = Options.Create(new VexAttestationClientOptions()); | ||||
|         var transparency = includeRekor ? new FakeTransparencyLogClient() : null; | ||||
|         var verifier = CreateVerifier(options => options.RequireTransparencyLog = false); | ||||
|         var client = new VexAttestationClient(builder, options, NullLogger<VexAttestationClient>.Instance, verifier, transparency); | ||||
|  | ||||
|         var request = new VexAttestationRequest( | ||||
|             ExportId: "exports/unit-test", | ||||
|             QuerySignature: new VexQuerySignature("filters"), | ||||
|             Artifact: new VexContentAddress("sha256", "cafebabe"), | ||||
|             Format: VexExportFormat.Json, | ||||
|             CreatedAt: DateTimeOffset.UtcNow, | ||||
|             SourceProviders: ImmutableArray.Create("provider-a"), | ||||
|             Metadata: ImmutableDictionary<string, string>.Empty); | ||||
|  | ||||
|         var response = await client.SignAsync(request, CancellationToken.None); | ||||
|         var envelope = response.Diagnostics["envelope"]; | ||||
|         return (request, response.Attestation, envelope); | ||||
|     } | ||||
|  | ||||
|     private VexAttestationVerifier CreateVerifier(Action<VexAttestationVerificationOptions>? configureOptions = null, ITransparencyLogClient? transparency = null) | ||||
|     { | ||||
|         var options = new VexAttestationVerificationOptions(); | ||||
|         configureOptions?.Invoke(options); | ||||
|         return new VexAttestationVerifier( | ||||
|             NullLogger<VexAttestationVerifier>.Instance, | ||||
|             transparency, | ||||
|             Options.Create(options), | ||||
|             _metrics); | ||||
|     } | ||||
|  | ||||
|     public void Dispose() | ||||
|     { | ||||
|         _metrics.Dispose(); | ||||
|     } | ||||
|  | ||||
|     private sealed class FakeSigner : IVexSigner | ||||
|     { | ||||
|         public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(new VexSignedPayload("signature", "key")); | ||||
|     } | ||||
|  | ||||
|     private sealed class FakeTransparencyLogClient : ITransparencyLogClient | ||||
|     { | ||||
|         public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(new TransparencyLogEntry(Guid.NewGuid().ToString(), "https://rekor.example/entries/123", "42", null)); | ||||
|  | ||||
|         public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(true); | ||||
|     } | ||||
|  | ||||
|     private sealed class ThrowingTransparencyLogClient : ITransparencyLogClient | ||||
|     { | ||||
|         public ValueTask<TransparencyLogEntry> SubmitAsync(DsseEnvelope envelope, CancellationToken cancellationToken) | ||||
|             => throw new NotSupportedException(); | ||||
|  | ||||
|         public ValueTask<bool> VerifyAsync(string entryLocation, CancellationToken cancellationToken) | ||||
|             => throw new HttpRequestException("rekor unavailable"); | ||||
|     } | ||||
| } | ||||
| @@ -14,9 +14,9 @@ using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation.Dsse; | ||||
|  | ||||
| public sealed class VexDsseBuilder | ||||
| { | ||||
|     private const string PayloadType = "application/vnd.in-toto+json"; | ||||
| public sealed class VexDsseBuilder | ||||
| { | ||||
|     internal const string PayloadType = "application/vnd.in-toto+json"; | ||||
|  | ||||
|     private readonly IVexSigner _signer; | ||||
|     private readonly ILogger<VexDsseBuilder> _logger; | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| # EXCITITOR-ATTEST-01-003 - Verification & Observability Plan | ||||
|  | ||||
| - **Date:** 2025-10-19 | ||||
| - **Status:** Draft | ||||
| - **Status:** In progress (2025-10-22) | ||||
| - **Owner:** Team Excititor Attestation | ||||
| - **Related tasks:** EXCITITOR-ATTEST-01-003 (Wave 0), EXCITITOR-WEB-01-003/004, EXCITITOR-WORKER-01-003 | ||||
| - **Prerequisites satisfied:** EXCITITOR-ATTEST-01-002 (Rekor v2 client integration) | ||||
| @@ -141,9 +141,17 @@ Metrics must register via static helper using `Meter` and support offline operat | ||||
| - Do we need cross-module eventing when verification fails (e.g., notify Export module) or is logging sufficient in Wave 0? (Proposed: log + metrics now, escalate in later wave.) | ||||
| - Confirm whether Worker re-verification writes to Mongo or triggers Export module to re-sign artifacts automatically; placeholder: record status + timestamp only. | ||||
|  | ||||
| ## 10. Acceptance Criteria | ||||
| ## 10. Acceptance Criteria | ||||
|  | ||||
| - Plan approved by Attestation + WebService + Worker leads. | ||||
| - Metrics/logging names peer-reviewed to avoid collisions. | ||||
| - Test backlog items entered into respective `TASKS.md` once implementation starts. | ||||
| - Documentation (this plan) linked from `TASKS.md` notes for discoverability. | ||||
| - Documentation (this plan) linked from `TASKS.md` notes for discoverability. | ||||
|  | ||||
| ## 11. 2025-10-22 Progress Notes | ||||
|  | ||||
| - Implemented `IVexAttestationVerifier`/`VexAttestationVerifier` with structural validation (subject/predicate checks, digest comparison, Rekor probes) and diagnostics map. | ||||
| - Added `VexAttestationVerificationOptions` (RequireTransparencyLog, AllowOfflineTransparency, MaxClockSkew) and wired configuration through WebService DI. | ||||
| - Created `VexAttestationMetrics` (`excititor.attestation.verify_total`, `excititor.attestation.verify_duration_seconds`) and hooked into verification flow with component/rekor tags. | ||||
| - `VexAttestationClient.VerifyAsync` now delegates to the verifier; DI registers metrics + verifier via `AddVexAttestation`. | ||||
| - Added unit coverage in `VexAttestationVerifierTests` (happy path, digest mismatch, offline Rekor) and updated client/export/webservice stubs to new verification signature. | ||||
|   | ||||
| @@ -1,18 +1,21 @@ | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using StellaOps.Excititor.Attestation.Dsse; | ||||
| using StellaOps.Excititor.Attestation.Transparency; | ||||
| using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation.Extensions; | ||||
|  | ||||
| public static class VexAttestationServiceCollectionExtensions | ||||
| { | ||||
|     public static IServiceCollection AddVexAttestation(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddSingleton<VexDsseBuilder>(); | ||||
|         services.AddSingleton<IVexAttestationClient, VexAttestationClient>(); | ||||
|         return services; | ||||
|     } | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using StellaOps.Excititor.Attestation.Dsse; | ||||
| using StellaOps.Excititor.Attestation.Transparency; | ||||
| using StellaOps.Excititor.Attestation.Verification; | ||||
| using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation.Extensions; | ||||
|  | ||||
| public static class VexAttestationServiceCollectionExtensions | ||||
| { | ||||
|     public static IServiceCollection AddVexAttestation(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddSingleton<VexDsseBuilder>(); | ||||
|         services.AddSingleton<VexAttestationMetrics>(); | ||||
|         services.AddSingleton<IVexAttestationVerifier, VexAttestationVerifier>(); | ||||
|         services.AddSingleton<IVexAttestationClient, VexAttestationClient>(); | ||||
|         return services; | ||||
|     } | ||||
|  | ||||
|     public static IServiceCollection AddVexRekorClient(this IServiceCollection services, Action<RekorHttpClientOptions> configure) | ||||
|     { | ||||
|   | ||||
| @@ -4,4 +4,6 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md | ||||
| |---|---|---|---| | ||||
| |EXCITITOR-ATTEST-01-001 – In-toto predicate & DSSE builder|Team Excititor Attestation|EXCITITOR-CORE-01-001|**DONE (2025-10-16)** – Added deterministic in-toto predicate/statement models, DSSE envelope builder wired to signer abstraction, and attestation client producing metadata + diagnostics.| | ||||
| |EXCITITOR-ATTEST-01-002 – Rekor v2 client integration|Team Excititor Attestation|EXCITITOR-ATTEST-01-001|**DONE (2025-10-16)** – Implemented Rekor HTTP client with retry/backoff, transparency log abstraction, DI helpers, and attestation client integration capturing Rekor metadata + diagnostics.| | ||||
| |EXCITITOR-ATTEST-01-003 – Verification suite & observability|Team Excititor Attestation|EXCITITOR-ATTEST-01-002|DOING (2025-10-19) – Add verification helpers for Worker/WebService, metrics/logging hooks, and negative-path regression tests. Draft plan logged in `EXCITITOR-ATTEST-01-003-plan.md` (2025-10-19).| | ||||
| |EXCITITOR-ATTEST-01-003 – Verification suite & observability|Team Excititor Attestation|EXCITITOR-ATTEST-01-002|DOING (2025-10-22) – Continuing implementation: build `IVexAttestationVerifier`, wire metrics/logging, and add regression tests. Draft plan in `EXCITITOR-ATTEST-01-003-plan.md` (2025-10-19) guides scope; updating with worknotes as progress lands.| | ||||
|  | ||||
| > Remark (2025-10-22): Added verifier implementation + metrics/tests; next steps include wiring into WebService/Worker flows and expanding negative-path coverage. | ||||
|   | ||||
| @@ -0,0 +1,10 @@ | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation.Verification; | ||||
|  | ||||
| public interface IVexAttestationVerifier | ||||
| { | ||||
|     ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken); | ||||
| } | ||||
| @@ -0,0 +1,35 @@ | ||||
| using System; | ||||
| using System.Diagnostics.Metrics; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation.Verification; | ||||
|  | ||||
| public sealed class VexAttestationMetrics : IDisposable | ||||
| { | ||||
|     public const string MeterName = "StellaOps.Excititor.Attestation"; | ||||
|     public const string MeterVersion = "1.0"; | ||||
|  | ||||
|     private readonly Meter _meter; | ||||
|     private bool _disposed; | ||||
|  | ||||
|     public VexAttestationMetrics() | ||||
|     { | ||||
|         _meter = new Meter(MeterName, MeterVersion); | ||||
|         VerifyTotal = _meter.CreateCounter<long>("excititor.attestation.verify_total", description: "Attestation verification attempts grouped by result/component/rekor."); | ||||
|         VerifyDuration = _meter.CreateHistogram<double>("excititor.attestation.verify_duration_seconds", unit: "s", description: "Attestation verification latency in seconds."); | ||||
|     } | ||||
|  | ||||
|     public Counter<long> VerifyTotal { get; } | ||||
|  | ||||
|     public Histogram<double> VerifyDuration { get; } | ||||
|  | ||||
|     public void Dispose() | ||||
|     { | ||||
|         if (_disposed) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         _meter.Dispose(); | ||||
|         _disposed = true; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,28 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation.Verification; | ||||
|  | ||||
| public sealed class VexAttestationVerificationOptions | ||||
| { | ||||
|     private TimeSpan _maxClockSkew = TimeSpan.FromMinutes(5); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// When true, verification fails if no transparency record is present. | ||||
|     /// </summary> | ||||
|     public bool RequireTransparencyLog { get; set; } = true; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Allows verification to succeed when the transparency log cannot be reached. | ||||
|     /// A diagnostic entry is still emitted to signal the degraded state. | ||||
|     /// </summary> | ||||
|     public bool AllowOfflineTransparency { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Maximum tolerated clock skew between the attestation creation time and the verification context timestamp. | ||||
|     /// </summary> | ||||
|     public TimeSpan MaxClockSkew | ||||
|     { | ||||
|         get => _maxClockSkew; | ||||
|         set => _maxClockSkew = value < TimeSpan.Zero ? TimeSpan.Zero : value; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,470 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Diagnostics; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Excititor.Attestation.Dsse; | ||||
| using StellaOps.Excititor.Attestation.Models; | ||||
| using StellaOps.Excititor.Attestation.Transparency; | ||||
| using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation.Verification; | ||||
|  | ||||
| internal sealed class VexAttestationVerifier : IVexAttestationVerifier | ||||
| { | ||||
|     private static readonly JsonSerializerOptions EnvelopeSerializerOptions = new() | ||||
|     { | ||||
|         PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||||
|     }; | ||||
|  | ||||
|     private static readonly JsonSerializerOptions StatementSerializerOptions = new() | ||||
|     { | ||||
|         PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||||
|         DefaultIgnoreCondition = JsonIgnoreCondition.Never, | ||||
|         Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, | ||||
|     }; | ||||
|  | ||||
|     private readonly ILogger<VexAttestationVerifier> _logger; | ||||
|     private readonly ITransparencyLogClient? _transparencyLogClient; | ||||
|     private readonly VexAttestationVerificationOptions _options; | ||||
|     private readonly VexAttestationMetrics _metrics; | ||||
|  | ||||
|     public VexAttestationVerifier( | ||||
|         ILogger<VexAttestationVerifier> logger, | ||||
|         ITransparencyLogClient? transparencyLogClient, | ||||
|         IOptions<VexAttestationVerificationOptions> options, | ||||
|         VexAttestationMetrics metrics) | ||||
|     { | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|         _transparencyLogClient = transparencyLogClient; | ||||
|         _options = options.Value; | ||||
|         _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<VexAttestationVerification> VerifyAsync( | ||||
|         VexAttestationVerificationRequest request, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(request); | ||||
|  | ||||
|         var stopwatch = Stopwatch.StartNew(); | ||||
|         var diagnostics = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal); | ||||
|         var resultLabel = "valid"; | ||||
|         var rekorState = "skipped"; | ||||
|         var component = request.IsReverify ? "worker" : "webservice"; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(request.Envelope)) | ||||
|             { | ||||
|                 diagnostics["envelope.state"] = "missing"; | ||||
|                 _logger.LogWarning("Attestation envelope is missing for export {ExportId}", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (!TryDeserializeEnvelope(request.Envelope, out var envelope, diagnostics)) | ||||
|             { | ||||
|                 _logger.LogWarning("Failed to deserialize attestation envelope for export {ExportId}", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (!string.Equals(envelope.PayloadType, VexDsseBuilder.PayloadType, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 diagnostics["payload.type"] = envelope.PayloadType ?? string.Empty; | ||||
|                 _logger.LogWarning( | ||||
|                     "Unexpected DSSE payload type {PayloadType} for export {ExportId}", | ||||
|                     envelope.PayloadType, | ||||
|                     request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (envelope.Signatures is null || envelope.Signatures.Length == 0) | ||||
|             { | ||||
|                 diagnostics["signature.state"] = "missing"; | ||||
|                 _logger.LogWarning("Attestation envelope for export {ExportId} does not contain signatures.", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (!TryDecodePayload(envelope.PayloadBase64, out var payloadBytes, diagnostics)) | ||||
|             { | ||||
|                 _logger.LogWarning("Failed to decode attestation payload for export {ExportId}", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (!TryDeserializeStatement(payloadBytes, out var statement, diagnostics)) | ||||
|             { | ||||
|                 _logger.LogWarning("Failed to deserialize DSSE statement for export {ExportId}", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (!ValidatePredicateType(statement, request, diagnostics)) | ||||
|             { | ||||
|                 _logger.LogWarning("Predicate type mismatch for export {ExportId}", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (!ValidateSubject(statement, request, diagnostics)) | ||||
|             { | ||||
|                 _logger.LogWarning("Subject mismatch for export {ExportId}", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (!ValidatePredicate(statement, request, diagnostics)) | ||||
|             { | ||||
|                 _logger.LogWarning("Predicate payload mismatch for export {ExportId}", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (!ValidateMetadataDigest(envelope, request.Metadata, diagnostics)) | ||||
|             { | ||||
|                 _logger.LogWarning("Attestation digest mismatch for export {ExportId}", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             if (!ValidateSignedAt(request.Metadata, request.Attestation.CreatedAt, diagnostics)) | ||||
|             { | ||||
|                 _logger.LogWarning("SignedAt validation failed for export {ExportId}", request.Attestation.ExportId); | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             rekorState = await VerifyTransparencyAsync(request.Metadata, diagnostics, cancellationToken).ConfigureAwait(false); | ||||
|             if (rekorState is "missing" or "unverified" or "client_unavailable") | ||||
|             { | ||||
|                 resultLabel = "invalid"; | ||||
|                 return BuildResult(false); | ||||
|             } | ||||
|  | ||||
|             diagnostics["signature.state"] = "present"; | ||||
|             return BuildResult(true); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             diagnostics["error"] = ex.GetType().Name; | ||||
|             diagnostics["error.message"] = ex.Message; | ||||
|             resultLabel = "error"; | ||||
|             _logger.LogError(ex, "Unexpected exception verifying attestation for export {ExportId}", request.Attestation.ExportId); | ||||
|             return BuildResult(false); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             stopwatch.Stop(); | ||||
|             var tags = new KeyValuePair<string, object?>[] | ||||
|             { | ||||
|                 new("result", resultLabel), | ||||
|                 new("component", component), | ||||
|                 new("rekor", rekorState), | ||||
|             }; | ||||
|             _metrics.VerifyTotal.Add(1, tags); | ||||
|             _metrics.VerifyDuration.Record(stopwatch.Elapsed.TotalSeconds, tags); | ||||
|         } | ||||
|  | ||||
|         VexAttestationVerification BuildResult(bool isValid) | ||||
|         { | ||||
|             diagnostics["result"] = resultLabel; | ||||
|             diagnostics["component"] = component; | ||||
|             diagnostics["rekor.state"] = rekorState; | ||||
|             return new VexAttestationVerification(isValid, diagnostics.ToImmutable()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static bool TryDeserializeEnvelope( | ||||
|         string envelopeJson, | ||||
|         out DsseEnvelope envelope, | ||||
|         ImmutableDictionary<string, string>.Builder diagnostics) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeJson, EnvelopeSerializerOptions) | ||||
|                 ?? throw new InvalidOperationException("Envelope deserialized to null."); | ||||
|             return true; | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             diagnostics["envelope.error"] = ex.GetType().Name; | ||||
|             envelope = default!; | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static bool TryDecodePayload( | ||||
|         string payloadBase64, | ||||
|         out byte[] payloadBytes, | ||||
|         ImmutableDictionary<string, string>.Builder diagnostics) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             payloadBytes = Convert.FromBase64String(payloadBase64); | ||||
|             return true; | ||||
|         } | ||||
|         catch (FormatException) | ||||
|         { | ||||
|             diagnostics["payload.base64"] = "invalid"; | ||||
|             payloadBytes = Array.Empty<byte>(); | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static bool TryDeserializeStatement( | ||||
|         byte[] payload, | ||||
|         out VexInTotoStatement statement, | ||||
|         ImmutableDictionary<string, string>.Builder diagnostics) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             statement = JsonSerializer.Deserialize<VexInTotoStatement>(payload, StatementSerializerOptions) | ||||
|                 ?? throw new InvalidOperationException("Statement deserialized to null."); | ||||
|             return true; | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             diagnostics["payload.error"] = ex.GetType().Name; | ||||
|             statement = default!; | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static bool ValidatePredicateType( | ||||
|         VexInTotoStatement statement, | ||||
|         VexAttestationVerificationRequest request, | ||||
|         ImmutableDictionary<string, string>.Builder diagnostics) | ||||
|     { | ||||
|         var predicateType = statement.PredicateType ?? string.Empty; | ||||
|         if (!string.Equals(predicateType, request.Metadata.PredicateType, StringComparison.Ordinal)) | ||||
|         { | ||||
|             diagnostics["predicate.type"] = predicateType; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static bool ValidateSubject( | ||||
|         VexInTotoStatement statement, | ||||
|         VexAttestationVerificationRequest request, | ||||
|         ImmutableDictionary<string, string>.Builder diagnostics) | ||||
|     { | ||||
|         if (statement.Subject is null || statement.Subject.Count != 1) | ||||
|         { | ||||
|             diagnostics["subject.count"] = (statement.Subject?.Count ?? 0).ToString(); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var subject = statement.Subject[0]; | ||||
|         if (!string.Equals(subject.Name, request.Attestation.ExportId, StringComparison.Ordinal)) | ||||
|         { | ||||
|             diagnostics["subject.name"] = subject.Name ?? string.Empty; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (subject.Digest is null) | ||||
|         { | ||||
|             diagnostics["subject.digest"] = "missing"; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var algorithmKey = request.Attestation.Artifact.Algorithm.ToLowerInvariant(); | ||||
|         if (!subject.Digest.TryGetValue(algorithmKey, out var digest) | ||||
|             || !string.Equals(digest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             diagnostics["subject.digest"] = digest ?? string.Empty; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private bool ValidatePredicate( | ||||
|         VexInTotoStatement statement, | ||||
|         VexAttestationVerificationRequest request, | ||||
|         ImmutableDictionary<string, string>.Builder diagnostics) | ||||
|     { | ||||
|         var predicate = statement.Predicate; | ||||
|         if (predicate is null) | ||||
|         { | ||||
|             diagnostics["predicate.state"] = "missing"; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!string.Equals(predicate.ExportId, request.Attestation.ExportId, StringComparison.Ordinal)) | ||||
|         { | ||||
|             diagnostics["predicate.exportId"] = predicate.ExportId ?? string.Empty; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!string.Equals(predicate.QuerySignature, request.Attestation.QuerySignature.Value, StringComparison.Ordinal)) | ||||
|         { | ||||
|             diagnostics["predicate.querySignature"] = predicate.QuerySignature ?? string.Empty; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!string.Equals(predicate.ArtifactAlgorithm, request.Attestation.Artifact.Algorithm, StringComparison.OrdinalIgnoreCase) | ||||
|             || !string.Equals(predicate.ArtifactDigest, request.Attestation.Artifact.Digest, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             diagnostics["predicate.artifact"] = $"{predicate.ArtifactAlgorithm}:{predicate.ArtifactDigest}"; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (predicate.Format != request.Attestation.Format) | ||||
|         { | ||||
|             diagnostics["predicate.format"] = predicate.Format.ToString(); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var createdDelta = (predicate.CreatedAt - request.Attestation.CreatedAt).Duration(); | ||||
|         if (createdDelta > _options.MaxClockSkew) | ||||
|         { | ||||
|             diagnostics["predicate.createdAtDelta"] = createdDelta.ToString(); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!SetEquals(predicate.SourceProviders, request.Attestation.SourceProviders)) | ||||
|         { | ||||
|             diagnostics["predicate.sourceProviders"] = string.Join(",", predicate.SourceProviders ?? Array.Empty<string>()); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (request.Attestation.Metadata.Count > 0) | ||||
|         { | ||||
|             if (predicate.Metadata is null) | ||||
|             { | ||||
|                 diagnostics["predicate.metadata"] = "missing"; | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             foreach (var kvp in request.Attestation.Metadata) | ||||
|             { | ||||
|                 if (!predicate.Metadata.TryGetValue(kvp.Key, out var actual) | ||||
|                     || !string.Equals(actual, kvp.Value, StringComparison.Ordinal)) | ||||
|                 { | ||||
|                     diagnostics[$"predicate.metadata.{kvp.Key}"] = actual ?? string.Empty; | ||||
|                     return false; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private bool ValidateMetadataDigest( | ||||
|         DsseEnvelope envelope, | ||||
|         VexAttestationMetadata metadata, | ||||
|         ImmutableDictionary<string, string>.Builder diagnostics) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(metadata.EnvelopeDigest)) | ||||
|         { | ||||
|             diagnostics["metadata.envelopeDigest"] = "missing"; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var computed = VexDsseBuilder.ComputeEnvelopeDigest(envelope); | ||||
|         if (!string.Equals(computed, metadata.EnvelopeDigest, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             diagnostics["metadata.envelopeDigest"] = metadata.EnvelopeDigest; | ||||
|             diagnostics["metadata.envelopeDigest.computed"] = computed; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         diagnostics["metadata.envelopeDigest"] = "match"; | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private bool ValidateSignedAt( | ||||
|         VexAttestationMetadata metadata, | ||||
|         DateTimeOffset createdAt, | ||||
|         ImmutableDictionary<string, string>.Builder diagnostics) | ||||
|     { | ||||
|         if (metadata.SignedAt is null) | ||||
|         { | ||||
|             diagnostics["metadata.signedAt"] = "missing"; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var delta = (metadata.SignedAt.Value - createdAt).Duration(); | ||||
|         if (delta > _options.MaxClockSkew) | ||||
|         { | ||||
|             diagnostics["metadata.signedAtDelta"] = delta.ToString(); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private async ValueTask<string> VerifyTransparencyAsync( | ||||
|         VexAttestationMetadata metadata, | ||||
|         ImmutableDictionary<string, string>.Builder diagnostics, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (metadata.Rekor is null) | ||||
|         { | ||||
|             if (_options.RequireTransparencyLog) | ||||
|             { | ||||
|                 diagnostics["rekor.state"] = "missing"; | ||||
|                 return "missing"; | ||||
|             } | ||||
|  | ||||
|             diagnostics["rekor.state"] = "disabled"; | ||||
|             return "disabled"; | ||||
|         } | ||||
|  | ||||
|         if (_transparencyLogClient is null) | ||||
|         { | ||||
|             diagnostics["rekor.state"] = "client_unavailable"; | ||||
|             return _options.RequireTransparencyLog ? "client_unavailable" : "disabled"; | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var verified = await _transparencyLogClient.VerifyAsync(metadata.Rekor.Location, cancellationToken).ConfigureAwait(false); | ||||
|             diagnostics["rekor.state"] = verified ? "verified" : "unverified"; | ||||
|             return verified ? "verified" : "unverified"; | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             diagnostics["rekor.error"] = ex.GetType().Name; | ||||
|             if (_options.AllowOfflineTransparency) | ||||
|             { | ||||
|                 diagnostics["rekor.state"] = "offline"; | ||||
|                 return "offline"; | ||||
|             } | ||||
|  | ||||
|             diagnostics["rekor.state"] = "unreachable"; | ||||
|             return "unreachable"; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static bool SetEquals(IReadOnlyCollection<string>? left, ImmutableArray<string> right) | ||||
|     { | ||||
|         if (left is null) | ||||
|         { | ||||
|             return right.IsDefaultOrEmpty; | ||||
|         } | ||||
|  | ||||
|         if (left.Count != right.Length) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var leftSet = new HashSet<string>(left, StringComparer.Ordinal); | ||||
|         return right.All(leftSet.Contains); | ||||
|     } | ||||
| } | ||||
| @@ -4,13 +4,14 @@ using System.Collections.Immutable; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Excititor.Attestation.Dsse; | ||||
| using StellaOps.Excititor.Attestation.Models; | ||||
| using StellaOps.Excititor.Attestation.Signing; | ||||
| using StellaOps.Excititor.Attestation.Transparency; | ||||
| using StellaOps.Excititor.Core; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Excititor.Attestation.Dsse; | ||||
| using StellaOps.Excititor.Attestation.Models; | ||||
| using StellaOps.Excititor.Attestation.Signing; | ||||
| using StellaOps.Excititor.Attestation.Transparency; | ||||
| using StellaOps.Excititor.Attestation.Verification; | ||||
| using StellaOps.Excititor.Core; | ||||
|  | ||||
| namespace StellaOps.Excititor.Attestation; | ||||
|  | ||||
| @@ -19,28 +20,31 @@ public sealed class VexAttestationClientOptions | ||||
|     public IReadOnlyDictionary<string, string> DefaultMetadata { get; set; } = ImmutableDictionary<string, string>.Empty; | ||||
| } | ||||
|  | ||||
| public sealed class VexAttestationClient : IVexAttestationClient | ||||
| { | ||||
|     private readonly VexDsseBuilder _builder; | ||||
|     private readonly ILogger<VexAttestationClient> _logger; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private readonly IReadOnlyDictionary<string, string> _defaultMetadata; | ||||
|     private readonly ITransparencyLogClient? _transparencyLogClient; | ||||
|  | ||||
|     public VexAttestationClient( | ||||
|         VexDsseBuilder builder, | ||||
|         IOptions<VexAttestationClientOptions> options, | ||||
|         ILogger<VexAttestationClient> logger, | ||||
|         TimeProvider? timeProvider = null, | ||||
|         ITransparencyLogClient? transparencyLogClient = null) | ||||
|     { | ||||
|         _builder = builder ?? throw new ArgumentNullException(nameof(builder)); | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         _defaultMetadata = options.Value.DefaultMetadata; | ||||
|         _transparencyLogClient = transparencyLogClient; | ||||
|     } | ||||
| public sealed class VexAttestationClient : IVexAttestationClient | ||||
| { | ||||
|     private readonly VexDsseBuilder _builder; | ||||
|     private readonly ILogger<VexAttestationClient> _logger; | ||||
|     private readonly TimeProvider _timeProvider; | ||||
|     private readonly IReadOnlyDictionary<string, string> _defaultMetadata; | ||||
|     private readonly ITransparencyLogClient? _transparencyLogClient; | ||||
|     private readonly IVexAttestationVerifier _verifier; | ||||
|  | ||||
|     public VexAttestationClient( | ||||
|         VexDsseBuilder builder, | ||||
|         IOptions<VexAttestationClientOptions> options, | ||||
|         ILogger<VexAttestationClient> logger, | ||||
|         IVexAttestationVerifier verifier, | ||||
|         TimeProvider? timeProvider = null, | ||||
|         ITransparencyLogClient? transparencyLogClient = null) | ||||
|     { | ||||
|         _builder = builder ?? throw new ArgumentNullException(nameof(builder)); | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         _verifier = verifier ?? throw new ArgumentNullException(nameof(verifier)); | ||||
|         _timeProvider = timeProvider ?? TimeProvider.System; | ||||
|         _defaultMetadata = options.Value.DefaultMetadata; | ||||
|         _transparencyLogClient = transparencyLogClient; | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken) | ||||
|     { | ||||
| @@ -82,11 +86,10 @@ public sealed class VexAttestationClient : IVexAttestationClient | ||||
|         return new VexAttestationResponse(metadata, diagnosticsBuilder); | ||||
|     } | ||||
|  | ||||
|     public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken) | ||||
|     { | ||||
|         // Placeholder until verification flow is implemented in EXCITITOR-ATTEST-01-003. | ||||
|         return ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty)); | ||||
|     } | ||||
|     public ValueTask<VexAttestationVerification> VerifyAsync( | ||||
|         VexAttestationVerificationRequest request, | ||||
|         CancellationToken cancellationToken) | ||||
|         => _verifier.VerifyAsync(request, cancellationToken); | ||||
|  | ||||
|     private static IReadOnlyDictionary<string, string> MergeMetadata( | ||||
|         IReadOnlyDictionary<string, string> requestMetadata, | ||||
|   | ||||
| @@ -5,26 +5,32 @@ using System.Threading.Tasks; | ||||
|  | ||||
| namespace StellaOps.Excititor.Core; | ||||
|  | ||||
| public interface IVexAttestationClient | ||||
| { | ||||
|     ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken); | ||||
| } | ||||
|  | ||||
| public sealed record VexAttestationRequest( | ||||
|     string ExportId, | ||||
|     VexQuerySignature QuerySignature, | ||||
| public interface IVexAttestationClient | ||||
| { | ||||
|     ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken); | ||||
| } | ||||
|  | ||||
| public sealed record VexAttestationRequest( | ||||
|     string ExportId, | ||||
|     VexQuerySignature QuerySignature, | ||||
|     VexContentAddress Artifact, | ||||
|     VexExportFormat Format, | ||||
|     DateTimeOffset CreatedAt, | ||||
|     ImmutableArray<string> SourceProviders, | ||||
|     ImmutableDictionary<string, string> Metadata); | ||||
|  | ||||
| public sealed record VexAttestationResponse( | ||||
|     VexAttestationMetadata Attestation, | ||||
|     ImmutableDictionary<string, string> Diagnostics); | ||||
|  | ||||
| public sealed record VexAttestationVerification( | ||||
|     bool IsValid, | ||||
|     ImmutableDictionary<string, string> Diagnostics); | ||||
| public sealed record VexAttestationResponse( | ||||
|     VexAttestationMetadata Attestation, | ||||
|     ImmutableDictionary<string, string> Diagnostics); | ||||
|  | ||||
| public sealed record VexAttestationVerificationRequest( | ||||
|     VexAttestationRequest Attestation, | ||||
|     VexAttestationMetadata Metadata, | ||||
|     string Envelope, | ||||
|     bool IsReverify = false); | ||||
|  | ||||
| public sealed record VexAttestationVerification( | ||||
|     bool IsValid, | ||||
|     ImmutableDictionary<string, string> Diagnostics); | ||||
|   | ||||
| @@ -374,19 +374,29 @@ public static class VexCanonicalJsonSerializer | ||||
|                 "metadata", | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             typeof(VexAttestationResponse), | ||||
|             new[] | ||||
|             { | ||||
|                 "attestation", | ||||
|                 "diagnostics", | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             typeof(VexAttestationVerification), | ||||
|             new[] | ||||
|             { | ||||
|                 "isValid", | ||||
|         { | ||||
|             typeof(VexAttestationResponse), | ||||
|             new[] | ||||
|             { | ||||
|                 "attestation", | ||||
|                 "diagnostics", | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             typeof(VexAttestationVerificationRequest), | ||||
|             new[] | ||||
|             { | ||||
|                 "attestation", | ||||
|                 "metadata", | ||||
|                 "envelope", | ||||
|                 "isReverify", | ||||
|             } | ||||
|         }, | ||||
|         { | ||||
|             typeof(VexAttestationVerification), | ||||
|             new[] | ||||
|             { | ||||
|                 "isValid", | ||||
|                 "diagnostics", | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -290,7 +290,7 @@ public sealed class ExportEngineTests | ||||
|             return ValueTask.FromResult(Response); | ||||
|         } | ||||
|  | ||||
|         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken) | ||||
|         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty)); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -140,7 +140,7 @@ internal static class TestServiceOverrides | ||||
|         { | ||||
|             var envelope = new DsseEnvelope( | ||||
|                 Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"stub\":\"payload\"}")), | ||||
|                 "application/vnd.stellaops.resolve+json", | ||||
|                 "application/vnd.in-toto+json", | ||||
|                 new[] | ||||
|                 { | ||||
|                     new DsseSignature("attestation-signature", "attestation-key"), | ||||
| @@ -149,13 +149,18 @@ internal static class TestServiceOverrides | ||||
|             var diagnostics = ImmutableDictionary<string, string>.Empty | ||||
|                 .Add("envelope", JsonSerializer.Serialize(envelope)); | ||||
|  | ||||
|             var metadata = new VexAttestationMetadata( | ||||
|                 "stub", | ||||
|                 envelopeDigest: VexDsseBuilder.ComputeEnvelopeDigest(envelope), | ||||
|                 signedAt: DateTimeOffset.UtcNow); | ||||
|  | ||||
|             var response = new VexAttestationResponse( | ||||
|                 new VexAttestationMetadata("stub"), | ||||
|                 metadata, | ||||
|                 diagnostics); | ||||
|             return ValueTask.FromResult(response); | ||||
|         } | ||||
|  | ||||
|         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationRequest request, CancellationToken cancellationToken) | ||||
|         public ValueTask<VexAttestationVerification> VerifyAsync(VexAttestationVerificationRequest request, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var verification = new VexAttestationVerification(true, ImmutableDictionary<string, string>.Empty); | ||||
|             return ValueTask.FromResult(verification); | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using Microsoft.Extensions.Options; | ||||
| using System.Linq; | ||||
| using System.Collections.Immutable; | ||||
| using Microsoft.AspNetCore.Authentication; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Excititor.Attestation.Verification; | ||||
| using StellaOps.Excititor.Attestation.Extensions; | ||||
| using StellaOps.Excititor.Attestation; | ||||
| using StellaOps.Excititor.Attestation.Transparency; | ||||
| @@ -35,8 +36,9 @@ services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>(); | ||||
| services.AddScoped<IVexIngestOrchestrator, VexIngestOrchestrator>(); | ||||
| services.AddVexExportEngine(); | ||||
| services.AddVexExportCacheServices(); | ||||
| services.AddVexAttestation(); | ||||
| services.Configure<VexAttestationClientOptions>(configuration.GetSection("Excititor:Attestation:Client")); | ||||
| services.AddVexAttestation(); | ||||
| services.Configure<VexAttestationClientOptions>(configuration.GetSection("Excititor:Attestation:Client")); | ||||
| services.Configure<VexAttestationVerificationOptions>(configuration.GetSection("Excititor:Attestation:Verification")); | ||||
| services.AddVexPolicy(); | ||||
| services.AddRedHatCsafConnector(); | ||||
| services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName)); | ||||
|   | ||||
| @@ -0,0 +1,14 @@ | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.DotNet; | ||||
|  | ||||
| public interface IDotNetAuthenticodeInspector | ||||
| { | ||||
|     DotNetAuthenticodeMetadata? TryInspect(string assemblyPath, CancellationToken cancellationToken); | ||||
| } | ||||
|  | ||||
| public sealed record DotNetAuthenticodeMetadata( | ||||
|     string? Subject, | ||||
|     string? Issuer, | ||||
|     DateTimeOffset? NotBefore, | ||||
|     DateTimeOffset? NotAfter, | ||||
|     string? Thumbprint, | ||||
|     string? SerialNumber); | ||||
| @@ -1,4 +1,8 @@ | ||||
| using System.Diagnostics; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Reflection; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.DotNet.Internal; | ||||
| @@ -26,7 +30,7 @@ internal static class DotNetDependencyCollector | ||||
|             return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(Array.Empty<DotNetPackage>()); | ||||
|         } | ||||
|  | ||||
|         var aggregator = new DotNetPackageAggregator(); | ||||
|         var aggregator = new DotNetPackageAggregator(context); | ||||
|  | ||||
|         foreach (var depsPath in depsFiles) | ||||
|         { | ||||
| @@ -65,7 +69,7 @@ internal static class DotNetDependencyCollector | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var packages = aggregator.Build(); | ||||
|         var packages = aggregator.Build(cancellationToken); | ||||
|         return ValueTask.FromResult<IReadOnlyList<DotNetPackage>>(packages); | ||||
|     } | ||||
|  | ||||
| @@ -83,8 +87,19 @@ internal static class DotNetDependencyCollector | ||||
|  | ||||
| internal sealed class DotNetPackageAggregator | ||||
| { | ||||
|     private readonly LanguageAnalyzerContext _context; | ||||
|     private readonly IDotNetAuthenticodeInspector? _authenticodeInspector; | ||||
|     private readonly Dictionary<string, DotNetPackageBuilder> _packages = new(StringComparer.Ordinal); | ||||
|  | ||||
|     public DotNetPackageAggregator(LanguageAnalyzerContext context) | ||||
|     { | ||||
|         _context = context ?? throw new ArgumentNullException(nameof(context)); | ||||
|         if (context.TryGetService<IDotNetAuthenticodeInspector>(out var inspector)) | ||||
|         { | ||||
|             _authenticodeInspector = inspector; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void Add(DotNetDepsFile depsFile, DotNetRuntimeConfig? runtimeConfig) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(depsFile); | ||||
| @@ -101,7 +116,7 @@ internal sealed class DotNetPackageAggregator | ||||
|  | ||||
|             if (!_packages.TryGetValue(key, out var builder)) | ||||
|             { | ||||
|                 builder = new DotNetPackageBuilder(library.Id, normalizedId, library.Version); | ||||
|                 builder = new DotNetPackageBuilder(_context, _authenticodeInspector, library.Id, normalizedId, library.Version); | ||||
|                 _packages[key] = builder; | ||||
|             } | ||||
|  | ||||
| @@ -109,7 +124,7 @@ internal sealed class DotNetPackageAggregator | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyList<DotNetPackage> Build() | ||||
|     public IReadOnlyList<DotNetPackage> Build(CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (_packages.Count == 0) | ||||
|         { | ||||
| @@ -119,7 +134,8 @@ internal sealed class DotNetPackageAggregator | ||||
|         var items = new List<DotNetPackage>(_packages.Count); | ||||
|         foreach (var builder in _packages.Values) | ||||
|         { | ||||
|             items.Add(builder.Build()); | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|             items.Add(builder.Build(cancellationToken)); | ||||
|         } | ||||
|  | ||||
|         items.Sort(static (left, right) => string.CompareOrdinal(left.ComponentKey, right.ComponentKey)); | ||||
| @@ -129,6 +145,9 @@ internal sealed class DotNetPackageAggregator | ||||
|  | ||||
| internal sealed class DotNetPackageBuilder | ||||
| { | ||||
|     private readonly LanguageAnalyzerContext _context; | ||||
|     private readonly IDotNetAuthenticodeInspector? _authenticodeInspector; | ||||
|  | ||||
|     private readonly string _originalId; | ||||
|     private readonly string _normalizedId; | ||||
|     private readonly string _version; | ||||
| @@ -147,10 +166,15 @@ internal sealed class DotNetPackageBuilder | ||||
|     private readonly SortedSet<string> _runtimeConfigFrameworks = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private readonly SortedSet<string> _runtimeConfigGraph = new(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|     private readonly Dictionary<string, AssemblyMetadataAggregate> _assemblies = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private readonly Dictionary<string, NativeAssetAggregate> _nativeAssets = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer()); | ||||
|     private bool _usedByEntrypoint; | ||||
|  | ||||
|     public DotNetPackageBuilder(string originalId, string normalizedId, string version) | ||||
|     public DotNetPackageBuilder(LanguageAnalyzerContext context, IDotNetAuthenticodeInspector? authenticodeInspector, string originalId, string normalizedId, string version) | ||||
|     { | ||||
|         _context = context ?? throw new ArgumentNullException(nameof(context)); | ||||
|         _authenticodeInspector = authenticodeInspector; | ||||
|         _originalId = string.IsNullOrWhiteSpace(originalId) ? normalizedId : originalId.Trim(); | ||||
|         _normalizedId = normalizedId; | ||||
|         _version = version ?? string.Empty; | ||||
| @@ -193,6 +217,8 @@ internal sealed class DotNetPackageBuilder | ||||
|             AddIfPresent(_runtimeIdentifiers, rid); | ||||
|         } | ||||
|  | ||||
|         AddRuntimeAssets(library); | ||||
|  | ||||
|         _evidence.Add(new LanguageComponentEvidence( | ||||
|             LanguageEvidenceKind.File, | ||||
|             "deps.json", | ||||
| @@ -202,34 +228,11 @@ internal sealed class DotNetPackageBuilder | ||||
|  | ||||
|         if (runtimeConfig is not null) | ||||
|         { | ||||
|             AddIfPresent(_runtimeConfigPaths, runtimeConfig.RelativePath); | ||||
|  | ||||
|             foreach (var tfm in runtimeConfig.Tfms) | ||||
|             { | ||||
|                 AddIfPresent(_runtimeConfigTfms, tfm); | ||||
|             } | ||||
|  | ||||
|             foreach (var framework in runtimeConfig.Frameworks) | ||||
|             { | ||||
|                 AddIfPresent(_runtimeConfigFrameworks, framework); | ||||
|             } | ||||
|  | ||||
|             foreach (var entry in runtimeConfig.RuntimeGraph) | ||||
|             { | ||||
|                 var value = BuildRuntimeGraphValue(entry.Rid, entry.Fallbacks); | ||||
|                 AddIfPresent(_runtimeConfigGraph, value); | ||||
|             } | ||||
|  | ||||
|             _evidence.Add(new LanguageComponentEvidence( | ||||
|                 LanguageEvidenceKind.File, | ||||
|                 "runtimeconfig.json", | ||||
|                 runtimeConfig.RelativePath, | ||||
|                 Value: null, | ||||
|                 Sha256: null)); | ||||
|             AddRuntimeConfig(runtimeConfig); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public DotNetPackage Build() | ||||
|     public DotNetPackage Build(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var metadata = new List<KeyValuePair<string, string?>>(32) | ||||
|         { | ||||
| @@ -255,6 +258,12 @@ internal sealed class DotNetPackageBuilder | ||||
|         AddIndexed(metadata, "runtimeconfig.framework", _runtimeConfigFrameworks); | ||||
|         AddIndexed(metadata, "runtimeconfig.graph", _runtimeConfigGraph); | ||||
|  | ||||
|         var assemblies = CollectAssemblyMetadata(cancellationToken); | ||||
|         AddAssemblyMetadata(metadata, assemblies); | ||||
|  | ||||
|         var nativeAssets = CollectNativeMetadata(cancellationToken); | ||||
|         AddNativeMetadata(metadata, nativeAssets); | ||||
|  | ||||
|         metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||
|  | ||||
|         var evidence = _evidence | ||||
| @@ -269,11 +278,231 @@ internal sealed class DotNetPackageBuilder | ||||
|             version: _version, | ||||
|             metadata: metadata, | ||||
|             evidence: evidence, | ||||
|             usedByEntrypoint: false); | ||||
|             usedByEntrypoint: _usedByEntrypoint); | ||||
|     } | ||||
|  | ||||
|     private IReadOnlyList<AssemblyMetadataResult> CollectAssemblyMetadata(CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (_assemblies.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<AssemblyMetadataResult>(); | ||||
|         } | ||||
|  | ||||
|         var results = new List<AssemblyMetadataResult>(_assemblies.Count); | ||||
|         foreach (var aggregate in _assemblies.Values.OrderBy(static aggregate => aggregate.AssetRelativePath, StringComparer.Ordinal)) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|             results.Add(aggregate.Build(_context, _authenticodeInspector, cancellationToken)); | ||||
|         } | ||||
|  | ||||
|         return results; | ||||
|     } | ||||
|  | ||||
|     private IReadOnlyList<NativeAssetResult> CollectNativeMetadata(CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (_nativeAssets.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<NativeAssetResult>(); | ||||
|         } | ||||
|  | ||||
|         var results = new List<NativeAssetResult>(_nativeAssets.Count); | ||||
|         foreach (var aggregate in _nativeAssets.Values.OrderBy(static aggregate => aggregate.AssetRelativePath, StringComparer.Ordinal)) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|             results.Add(aggregate.Build(_context, cancellationToken)); | ||||
|         } | ||||
|  | ||||
|         return results; | ||||
|     } | ||||
|  | ||||
|     private void AddAssemblyMetadata(ICollection<KeyValuePair<string, string?>> metadata, IReadOnlyList<AssemblyMetadataResult> assemblies) | ||||
|     { | ||||
|         if (assemblies.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         for (var index = 0; index < assemblies.Count; index++) | ||||
|         { | ||||
|             var record = assemblies[index]; | ||||
|             var prefix = $"assembly[{index}]"; | ||||
|  | ||||
|             if (record.UsedByEntrypoint) | ||||
|             { | ||||
|                 _usedByEntrypoint = true; | ||||
|             } | ||||
|  | ||||
|             AddIfPresent(metadata, $"{prefix}.assetPath", record.AssetPath); | ||||
|             AddIfPresent(metadata, $"{prefix}.path", record.RelativePath); | ||||
|             AddIndexed(metadata, $"{prefix}.tfm", record.TargetFrameworks); | ||||
|             AddIndexed(metadata, $"{prefix}.rid", record.RuntimeIdentifiers); | ||||
|             AddIfPresent(metadata, $"{prefix}.version", record.AssemblyVersion); | ||||
|             AddIfPresent(metadata, $"{prefix}.fileVersion", record.FileVersion); | ||||
|             AddIfPresent(metadata, $"{prefix}.publicKeyToken", record.PublicKeyToken); | ||||
|             AddIfPresent(metadata, $"{prefix}.strongName", record.StrongName); | ||||
|             AddIfPresent(metadata, $"{prefix}.company", record.CompanyName); | ||||
|             AddIfPresent(metadata, $"{prefix}.product", record.ProductName); | ||||
|             AddIfPresent(metadata, $"{prefix}.productVersion", record.ProductVersion); | ||||
|             AddIfPresent(metadata, $"{prefix}.fileDescription", record.FileDescription); | ||||
|             AddIfPresent(metadata, $"{prefix}.sha256", record.Sha256); | ||||
|  | ||||
|             if (record.Authenticode is { } authenticode) | ||||
|             { | ||||
|                 AddIfPresent(metadata, $"{prefix}.authenticode.subject", authenticode.Subject); | ||||
|                 AddIfPresent(metadata, $"{prefix}.authenticode.issuer", authenticode.Issuer); | ||||
|                 AddIfPresent(metadata, $"{prefix}.authenticode.notBefore", FormatTimestamp(authenticode.NotBefore)); | ||||
|                 AddIfPresent(metadata, $"{prefix}.authenticode.notAfter", FormatTimestamp(authenticode.NotAfter)); | ||||
|                 AddIfPresent(metadata, $"{prefix}.authenticode.thumbprint", authenticode.Thumbprint); | ||||
|                 AddIfPresent(metadata, $"{prefix}.authenticode.serialNumber", authenticode.SerialNumber); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrEmpty(record.RelativePath)) | ||||
|             { | ||||
|                 _evidence.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.File, | ||||
|                     Source: "assembly", | ||||
|                     Locator: record.RelativePath!, | ||||
|                     Value: record.AssetPath, | ||||
|                     Sha256: record.Sha256)); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void AddNativeMetadata(ICollection<KeyValuePair<string, string?>> metadata, IReadOnlyList<NativeAssetResult> nativeAssets) | ||||
|     { | ||||
|         if (nativeAssets.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         for (var index = 0; index < nativeAssets.Count; index++) | ||||
|         { | ||||
|             var record = nativeAssets[index]; | ||||
|             var prefix = $"native[{index}]"; | ||||
|  | ||||
|             if (record.UsedByEntrypoint) | ||||
|             { | ||||
|                 _usedByEntrypoint = true; | ||||
|             } | ||||
|  | ||||
|             AddIfPresent(metadata, $"{prefix}.assetPath", record.AssetPath); | ||||
|             AddIfPresent(metadata, $"{prefix}.path", record.RelativePath); | ||||
|             AddIndexed(metadata, $"{prefix}.tfm", record.TargetFrameworks); | ||||
|             AddIndexed(metadata, $"{prefix}.rid", record.RuntimeIdentifiers); | ||||
|             AddIfPresent(metadata, $"{prefix}.sha256", record.Sha256); | ||||
|  | ||||
|             if (!string.IsNullOrEmpty(record.RelativePath)) | ||||
|             { | ||||
|                 _evidence.Add(new LanguageComponentEvidence( | ||||
|                     LanguageEvidenceKind.File, | ||||
|                     Source: "native", | ||||
|                     Locator: record.RelativePath!, | ||||
|                     Value: record.AssetPath, | ||||
|                     Sha256: record.Sha256)); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void AddRuntimeAssets(DotNetLibrary library) | ||||
|     { | ||||
|         foreach (var asset in library.RuntimeAssets) | ||||
|         { | ||||
|             switch (asset.Kind) | ||||
|             { | ||||
|                 case DotNetLibraryAssetKind.Runtime: | ||||
|                     AddRuntimeAssemblyAsset(asset, library.PackagePath); | ||||
|                     break; | ||||
|                 case DotNetLibraryAssetKind.Native: | ||||
|                     AddNativeAsset(asset, library.PackagePath); | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void AddRuntimeAssemblyAsset(DotNetLibraryAsset asset, string? packagePath) | ||||
|     { | ||||
|         var key = NormalizePath(asset.RelativePath); | ||||
|         if (string.IsNullOrEmpty(key)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!_assemblies.TryGetValue(key, out var aggregate)) | ||||
|         { | ||||
|             aggregate = new AssemblyMetadataAggregate(key); | ||||
|             _assemblies[key] = aggregate; | ||||
|         } | ||||
|  | ||||
|         aggregate.AddManifestData(asset, packagePath); | ||||
|     } | ||||
|  | ||||
|     private void AddNativeAsset(DotNetLibraryAsset asset, string? packagePath) | ||||
|     { | ||||
|         var key = NormalizePath(asset.RelativePath); | ||||
|         if (string.IsNullOrEmpty(key)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!_nativeAssets.TryGetValue(key, out var aggregate)) | ||||
|         { | ||||
|             aggregate = new NativeAssetAggregate(key); | ||||
|             _nativeAssets[key] = aggregate; | ||||
|         } | ||||
|  | ||||
|         aggregate.AddManifestData(asset, packagePath); | ||||
|     } | ||||
|  | ||||
|     private void AddRuntimeConfig(DotNetRuntimeConfig runtimeConfig) | ||||
|     { | ||||
|         AddIfPresent(_runtimeConfigPaths, runtimeConfig.RelativePath); | ||||
|  | ||||
|         foreach (var tfm in runtimeConfig.Tfms) | ||||
|         { | ||||
|             AddIfPresent(_runtimeConfigTfms, tfm); | ||||
|         } | ||||
|  | ||||
|         foreach (var framework in runtimeConfig.Frameworks) | ||||
|         { | ||||
|             AddIfPresent(_runtimeConfigFrameworks, framework); | ||||
|         } | ||||
|  | ||||
|         foreach (var entry in runtimeConfig.RuntimeGraph) | ||||
|         { | ||||
|             var value = BuildRuntimeGraphValue(entry.Rid, entry.Fallbacks); | ||||
|             AddIfPresent(_runtimeConfigGraph, value); | ||||
|         } | ||||
|  | ||||
|         _evidence.Add(new LanguageComponentEvidence( | ||||
|             LanguageEvidenceKind.File, | ||||
|             "runtimeconfig.json", | ||||
|             runtimeConfig.RelativePath, | ||||
|             Value: null, | ||||
|             Sha256: null)); | ||||
|     } | ||||
|  | ||||
|     private static void AddIfPresent(ICollection<KeyValuePair<string, string?>> metadata, string key, string? value) | ||||
|     { | ||||
|         if (metadata is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(metadata)); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         metadata.Add(new KeyValuePair<string, string?>(key, value)); | ||||
|     } | ||||
|  | ||||
|     private static void AddIfPresent(ISet<string> set, string? value, bool normalizeLower = false) | ||||
|     { | ||||
|         if (set is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(set)); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return; | ||||
| @@ -322,6 +551,148 @@ internal sealed class DotNetPackageBuilder | ||||
|         return path.Replace('\\', '/'); | ||||
|     } | ||||
|  | ||||
|     private static string NormalizePath(string? path) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(path)) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         var normalized = path.Replace('\\', '/').Trim(); | ||||
|         return string.IsNullOrEmpty(normalized) ? string.Empty : normalized; | ||||
|     } | ||||
|  | ||||
|     private static string ConvertToPlatformPath(string path) | ||||
|         => string.IsNullOrEmpty(path) ? "." : path.Replace('/', Path.DirectorySeparatorChar); | ||||
|  | ||||
|     private static string CombineRelative(string basePath, string relativePath) | ||||
|     { | ||||
|         var left = NormalizePath(basePath); | ||||
|         var right = NormalizePath(relativePath); | ||||
|  | ||||
|         if (string.IsNullOrEmpty(left)) | ||||
|         { | ||||
|             return right; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrEmpty(right)) | ||||
|         { | ||||
|             return left; | ||||
|         } | ||||
|  | ||||
|         return NormalizePath($"{left}/{right}"); | ||||
|     } | ||||
|  | ||||
|     private static string? ComputeSha256(string path) | ||||
|     { | ||||
|         using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var sha = SHA256.Create(); | ||||
|         var hash = sha.ComputeHash(stream); | ||||
|         return Convert.ToHexString(hash).ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     private static AssemblyName? TryGetAssemblyName(string path) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             return AssemblyName.GetAssemblyName(path); | ||||
|         } | ||||
|         catch (FileNotFoundException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|         catch (BadImageFormatException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|         catch (FileLoadException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static FileVersionInfo? TryGetFileVersionInfo(string path) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             return FileVersionInfo.GetVersionInfo(path); | ||||
|         } | ||||
|         catch (FileNotFoundException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string? FormatPublicKeyToken(byte[]? token) | ||||
|     { | ||||
|         if (token is null || token.Length == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return Convert.ToHexString(token).ToLowerInvariant(); | ||||
|     } | ||||
|  | ||||
|     private static string? BuildStrongName(AssemblyName assemblyName, string? publicKeyToken) | ||||
|     { | ||||
|         if (assemblyName is null || string.IsNullOrWhiteSpace(assemblyName.Name)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var version = assemblyName.Version?.ToString() ?? "0.0.0.0"; | ||||
|         var culture = string.IsNullOrWhiteSpace(assemblyName.CultureName) ? "neutral" : assemblyName.CultureName; | ||||
|         var token = string.IsNullOrWhiteSpace(publicKeyToken) ? "null" : publicKeyToken; | ||||
|         return $"{assemblyName.Name}, Version={version}, Culture={culture}, PublicKeyToken={token}"; | ||||
|     } | ||||
|  | ||||
|     private static string? NormalizeMetadataValue(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return value.Trim(); | ||||
|     } | ||||
|  | ||||
|     private static string? FormatTimestamp(DateTimeOffset? value) | ||||
|     { | ||||
|         if (value is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return value.Value.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); | ||||
|     } | ||||
|  | ||||
|     private static IEnumerable<string> EnumeratePackageBases(string packagePath) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(packagePath)) | ||||
|         { | ||||
|             yield break; | ||||
|         } | ||||
|  | ||||
|         var normalized = NormalizePath(packagePath); | ||||
|         if (string.IsNullOrEmpty(normalized)) | ||||
|         { | ||||
|             yield break; | ||||
|         } | ||||
|  | ||||
|         yield return normalized; | ||||
|         yield return NormalizePath($".nuget/packages/{normalized}"); | ||||
|         yield return NormalizePath($"packages/{normalized}"); | ||||
|         yield return NormalizePath($"usr/share/dotnet/packs/{normalized}"); | ||||
|     } | ||||
|  | ||||
|     private static string BuildRuntimeGraphValue(string rid, IReadOnlyList<string> fallbacks) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(rid)) | ||||
| @@ -346,6 +717,343 @@ internal sealed class DotNetPackageBuilder | ||||
|             : $"{rid.Trim()}=>{string.Join(';', ordered)}"; | ||||
|     } | ||||
|  | ||||
|     private sealed class AssemblyMetadataAggregate | ||||
|     { | ||||
|         private readonly string _assetRelativePath; | ||||
|         private readonly SortedSet<string> _tfms = new(StringComparer.OrdinalIgnoreCase); | ||||
|         private readonly SortedSet<string> _runtimeIdentifiers = new(StringComparer.OrdinalIgnoreCase); | ||||
|         private readonly SortedSet<string> _packagePaths = new(StringComparer.Ordinal); | ||||
|         private string? _assemblyVersion; | ||||
|         private string? _fileVersion; | ||||
|  | ||||
|         public AssemblyMetadataAggregate(string assetRelativePath) | ||||
|         { | ||||
|             _assetRelativePath = NormalizePath(assetRelativePath); | ||||
|         } | ||||
|  | ||||
|         public string AssetRelativePath => _assetRelativePath; | ||||
|  | ||||
|         public void AddManifestData(DotNetLibraryAsset asset, string? packagePath) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(asset.TargetFramework)) | ||||
|             { | ||||
|                 _tfms.Add(asset.TargetFramework); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(asset.RuntimeIdentifier)) | ||||
|             { | ||||
|                 _runtimeIdentifiers.Add(asset.RuntimeIdentifier); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(asset.AssemblyVersion) && string.IsNullOrEmpty(_assemblyVersion)) | ||||
|             { | ||||
|                 _assemblyVersion = asset.AssemblyVersion; | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(asset.FileVersion) && string.IsNullOrEmpty(_fileVersion)) | ||||
|             { | ||||
|                 _fileVersion = asset.FileVersion; | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(packagePath)) | ||||
|             { | ||||
|                 var normalized = NormalizePath(packagePath); | ||||
|                 if (!string.IsNullOrEmpty(normalized)) | ||||
|                 { | ||||
|                     _packagePaths.Add(normalized); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public AssemblyMetadataResult Build(LanguageAnalyzerContext context, IDotNetAuthenticodeInspector? authenticodeInspector, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var fileMetadata = ResolveFileMetadata(context, authenticodeInspector, cancellationToken); | ||||
|  | ||||
|             var assemblyName = fileMetadata?.AssemblyName; | ||||
|             var versionInfo = fileMetadata?.FileVersionInfo; | ||||
|  | ||||
|             var assemblyVersion = assemblyName?.Version?.ToString() ?? _assemblyVersion; | ||||
|             var fileVersion = !string.IsNullOrWhiteSpace(versionInfo?.FileVersion) ? versionInfo?.FileVersion : _fileVersion; | ||||
|             var usedByEntrypoint = fileMetadata?.UsedByEntrypoint ?? false; | ||||
|  | ||||
|             string? publicKeyToken = null; | ||||
|             string? strongName = null; | ||||
|             if (assemblyName is not null) | ||||
|             { | ||||
|                 publicKeyToken = FormatPublicKeyToken(assemblyName.GetPublicKeyToken()); | ||||
|                 strongName = BuildStrongName(assemblyName, publicKeyToken); | ||||
|             } | ||||
|  | ||||
|             return new AssemblyMetadataResult( | ||||
|                 AssetPath: _assetRelativePath, | ||||
|                 RelativePath: fileMetadata?.RelativePath, | ||||
|                 TargetFrameworks: _tfms.ToArray(), | ||||
|                 RuntimeIdentifiers: _runtimeIdentifiers.ToArray(), | ||||
|                 AssemblyVersion: assemblyVersion, | ||||
|                 FileVersion: fileVersion, | ||||
|                 PublicKeyToken: publicKeyToken, | ||||
|                 StrongName: strongName, | ||||
|                 CompanyName: NormalizeMetadataValue(versionInfo?.CompanyName), | ||||
|                 ProductName: NormalizeMetadataValue(versionInfo?.ProductName), | ||||
|                 ProductVersion: NormalizeMetadataValue(versionInfo?.ProductVersion), | ||||
|                 FileDescription: NormalizeMetadataValue(versionInfo?.FileDescription), | ||||
|                 Sha256: fileMetadata?.Sha256, | ||||
|                 Authenticode: fileMetadata?.Authenticode, | ||||
|                 UsedByEntrypoint: usedByEntrypoint); | ||||
|         } | ||||
|  | ||||
|         private AssemblyFileMetadata? ResolveFileMetadata(LanguageAnalyzerContext context, IDotNetAuthenticodeInspector? authenticodeInspector, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var candidates = BuildCandidateRelativePaths(); | ||||
|  | ||||
|             foreach (var candidate in candidates) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|                 var absolutePath = context.ResolvePath(ConvertToPlatformPath(candidate)); | ||||
|                 if (!File.Exists(absolutePath)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 try | ||||
|                 { | ||||
|                     var relativePath = NormalizePath(context.GetRelativePath(absolutePath)); | ||||
|                     var sha256 = ComputeSha256(absolutePath); | ||||
|                     var assemblyName = TryGetAssemblyName(absolutePath); | ||||
|                     var versionInfo = TryGetFileVersionInfo(absolutePath); | ||||
|  | ||||
|                     DotNetAuthenticodeMetadata? authenticode = null; | ||||
|                     if (authenticodeInspector is not null) | ||||
|                     { | ||||
|                         try | ||||
|                         { | ||||
|                             authenticode = authenticodeInspector.TryInspect(absolutePath, cancellationToken); | ||||
|                         } | ||||
|                         catch | ||||
|                         { | ||||
|                             authenticode = null; | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     var usedByEntrypoint = context.UsageHints.IsPathUsed(absolutePath); | ||||
|  | ||||
|                     return new AssemblyFileMetadata( | ||||
|                         AbsolutePath: absolutePath, | ||||
|                         RelativePath: relativePath, | ||||
|                         Sha256: sha256, | ||||
|                         AssemblyName: assemblyName, | ||||
|                         FileVersionInfo: versionInfo, | ||||
|                         Authenticode: authenticode, | ||||
|                         UsedByEntrypoint: usedByEntrypoint); | ||||
|                 } | ||||
|                 catch (IOException) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|                 catch (UnauthorizedAccessException) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|                 catch (BadImageFormatException) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         private IEnumerable<string> BuildCandidateRelativePaths() | ||||
|         { | ||||
|             var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|             if (_packagePaths.Count > 0) | ||||
|             { | ||||
|                 foreach (var packagePath in _packagePaths) | ||||
|                 { | ||||
|                     foreach (var basePath in EnumeratePackageBases(packagePath)) | ||||
|                     { | ||||
|                         var combined = CombineRelative(basePath, _assetRelativePath); | ||||
|                         if (string.IsNullOrEmpty(combined)) | ||||
|                         { | ||||
|                             continue; | ||||
|                         } | ||||
|  | ||||
|                         if (seen.Add(combined)) | ||||
|                         { | ||||
|                             yield return combined; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (seen.Add(_assetRelativePath)) | ||||
|             { | ||||
|                 yield return _assetRelativePath; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class NativeAssetAggregate | ||||
|     { | ||||
|         private readonly string _assetRelativePath; | ||||
|         private readonly SortedSet<string> _tfms = new(StringComparer.OrdinalIgnoreCase); | ||||
|         private readonly SortedSet<string> _runtimeIdentifiers = new(StringComparer.OrdinalIgnoreCase); | ||||
|         private readonly SortedSet<string> _packagePaths = new(StringComparer.Ordinal); | ||||
|  | ||||
|         public NativeAssetAggregate(string assetRelativePath) | ||||
|         { | ||||
|             _assetRelativePath = NormalizePath(assetRelativePath); | ||||
|         } | ||||
|  | ||||
|         public string AssetRelativePath => _assetRelativePath; | ||||
|  | ||||
|         public void AddManifestData(DotNetLibraryAsset asset, string? packagePath) | ||||
|         { | ||||
|             if (!string.IsNullOrWhiteSpace(asset.TargetFramework)) | ||||
|             { | ||||
|                 _tfms.Add(asset.TargetFramework); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(asset.RuntimeIdentifier)) | ||||
|             { | ||||
|                 _runtimeIdentifiers.Add(asset.RuntimeIdentifier); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(packagePath)) | ||||
|             { | ||||
|                 var normalized = NormalizePath(packagePath); | ||||
|                 if (!string.IsNullOrEmpty(normalized)) | ||||
|                 { | ||||
|                     _packagePaths.Add(normalized); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public NativeAssetResult Build(LanguageAnalyzerContext context, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var fileMetadata = ResolveFileMetadata(context, cancellationToken); | ||||
|  | ||||
|             return new NativeAssetResult( | ||||
|                 AssetPath: _assetRelativePath, | ||||
|                 RelativePath: fileMetadata?.RelativePath, | ||||
|                 TargetFrameworks: _tfms.ToArray(), | ||||
|                 RuntimeIdentifiers: _runtimeIdentifiers.ToArray(), | ||||
|                 Sha256: fileMetadata?.Sha256, | ||||
|                 UsedByEntrypoint: fileMetadata?.UsedByEntrypoint ?? false); | ||||
|         } | ||||
|  | ||||
|         private NativeAssetFileMetadata? ResolveFileMetadata(LanguageAnalyzerContext context, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var candidates = BuildCandidateRelativePaths(); | ||||
|  | ||||
|             foreach (var candidate in candidates) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|                 var absolutePath = context.ResolvePath(ConvertToPlatformPath(candidate)); | ||||
|                 var usedByEntrypoint = context.UsageHints.IsPathUsed(absolutePath); | ||||
|  | ||||
|                 if (!File.Exists(absolutePath)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 try | ||||
|                 { | ||||
|                     var relativePath = NormalizePath(context.GetRelativePath(absolutePath)); | ||||
|                     var sha256 = ComputeSha256(absolutePath); | ||||
|                     return new NativeAssetFileMetadata( | ||||
|                         AbsolutePath: absolutePath, | ||||
|                         RelativePath: relativePath, | ||||
|                         Sha256: sha256, | ||||
|                         UsedByEntrypoint: usedByEntrypoint); | ||||
|                 } | ||||
|                 catch (IOException) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|                 catch (UnauthorizedAccessException) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         private IEnumerable<string> BuildCandidateRelativePaths() | ||||
|         { | ||||
|             var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|             if (_packagePaths.Count > 0) | ||||
|             { | ||||
|                 foreach (var packagePath in _packagePaths) | ||||
|                 { | ||||
|                     foreach (var basePath in EnumeratePackageBases(packagePath)) | ||||
|                     { | ||||
|                         var combined = CombineRelative(basePath, _assetRelativePath); | ||||
|                         if (string.IsNullOrEmpty(combined)) | ||||
|                         { | ||||
|                             continue; | ||||
|                         } | ||||
|  | ||||
|                         if (seen.Add(combined)) | ||||
|                         { | ||||
|                             yield return combined; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (seen.Add(_assetRelativePath)) | ||||
|             { | ||||
|                 yield return _assetRelativePath; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed record AssemblyMetadataResult( | ||||
|         string AssetPath, | ||||
|         string? RelativePath, | ||||
|         IReadOnlyList<string> TargetFrameworks, | ||||
|         IReadOnlyList<string> RuntimeIdentifiers, | ||||
|         string? AssemblyVersion, | ||||
|         string? FileVersion, | ||||
|         string? PublicKeyToken, | ||||
|         string? StrongName, | ||||
|         string? CompanyName, | ||||
|         string? ProductName, | ||||
|         string? ProductVersion, | ||||
|         string? FileDescription, | ||||
|         string? Sha256, | ||||
|         DotNetAuthenticodeMetadata? Authenticode, | ||||
|         bool UsedByEntrypoint); | ||||
|  | ||||
|     private sealed record NativeAssetResult( | ||||
|         string AssetPath, | ||||
|         string? RelativePath, | ||||
|         IReadOnlyList<string> TargetFrameworks, | ||||
|         IReadOnlyList<string> RuntimeIdentifiers, | ||||
|         string? Sha256, | ||||
|         bool UsedByEntrypoint); | ||||
|  | ||||
|     private sealed record AssemblyFileMetadata( | ||||
|         string AbsolutePath, | ||||
|         string? RelativePath, | ||||
|         string? Sha256, | ||||
|         AssemblyName? AssemblyName, | ||||
|         FileVersionInfo? FileVersionInfo, | ||||
|         DotNetAuthenticodeMetadata? Authenticode, | ||||
|         bool UsedByEntrypoint); | ||||
|  | ||||
|     private sealed record NativeAssetFileMetadata( | ||||
|         string AbsolutePath, | ||||
|         string? RelativePath, | ||||
|         string? Sha256, | ||||
|         bool UsedByEntrypoint); | ||||
|  | ||||
|     private sealed class LanguageComponentEvidenceComparer : IEqualityComparer<LanguageComponentEvidence> | ||||
|     { | ||||
|         public bool Equals(LanguageComponentEvidence? x, LanguageComponentEvidence? y) | ||||
|   | ||||
| @@ -98,7 +98,7 @@ internal sealed class DotNetDepsFile | ||||
|                     library.AddRuntimeIdentifier(rid); | ||||
|                 } | ||||
|  | ||||
|                 library.MergeTargetMetadata(libraryProperty.Value); | ||||
|                 library.MergeTargetMetadata(libraryProperty.Value, tfm, rid); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -126,6 +126,7 @@ internal sealed class DotNetLibrary | ||||
| { | ||||
|     private readonly HashSet<string> _dependencies = new(StringComparer.OrdinalIgnoreCase); | ||||
|     private readonly HashSet<string> _runtimeIdentifiers = new(StringComparer.Ordinal); | ||||
|     private readonly List<DotNetLibraryAsset> _runtimeAssets = new(); | ||||
|     private readonly HashSet<string> _targetFrameworks = new(StringComparer.Ordinal); | ||||
|  | ||||
|     private DotNetLibrary( | ||||
| @@ -172,6 +173,8 @@ internal sealed class DotNetLibrary | ||||
|  | ||||
|     public IReadOnlyCollection<string> RuntimeIdentifiers => _runtimeIdentifiers; | ||||
|  | ||||
|     public IReadOnlyCollection<DotNetLibraryAsset> RuntimeAssets => _runtimeAssets; | ||||
|  | ||||
|     public static bool TryCreate(string key, JsonElement element, [NotNullWhen(true)] out DotNetLibrary? library) | ||||
|     { | ||||
|         library = null; | ||||
| @@ -230,29 +233,67 @@ internal sealed class DotNetLibrary | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void MergeTargetMetadata(JsonElement element) | ||||
|     public void MergeTargetMetadata(JsonElement element, string? tfm, string? rid) | ||||
|     { | ||||
|         if (!element.TryGetProperty("dependencies", out var dependenciesElement) || dependenciesElement.ValueKind is not JsonValueKind.Object) | ||||
|         if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object) | ||||
|         { | ||||
|             return; | ||||
|             foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) | ||||
|             { | ||||
|                 AddDependency(dependencyProperty.Name); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) | ||||
|         { | ||||
|             AddDependency(dependencyProperty.Name); | ||||
|         } | ||||
|         MergeRuntimeAssets(element, tfm, rid); | ||||
|     } | ||||
|  | ||||
|     public void MergeLibraryMetadata(JsonElement element) | ||||
|     { | ||||
|         if (!element.TryGetProperty("dependencies", out var dependenciesElement) || dependenciesElement.ValueKind is not JsonValueKind.Object) | ||||
|         if (element.TryGetProperty("dependencies", out var dependenciesElement) && dependenciesElement.ValueKind is JsonValueKind.Object) | ||||
|         { | ||||
|             foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) | ||||
|             { | ||||
|                 AddDependency(dependencyProperty.Name); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         MergeRuntimeAssets(element, tfm: null, rid: null); | ||||
|     } | ||||
|  | ||||
|     private void MergeRuntimeAssets(JsonElement element, string? tfm, string? rid) | ||||
|     { | ||||
|         AddRuntimeAssetsFromRuntime(element, tfm, rid); | ||||
|         AddRuntimeAssetsFromRuntimeTargets(element, tfm, rid); | ||||
|     } | ||||
|  | ||||
|     private void AddRuntimeAssetsFromRuntime(JsonElement element, string? tfm, string? rid) | ||||
|     { | ||||
|         if (!element.TryGetProperty("runtime", out var runtimeElement) || runtimeElement.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         foreach (var dependencyProperty in dependenciesElement.EnumerateObject()) | ||||
|         foreach (var assetProperty in runtimeElement.EnumerateObject()) | ||||
|         { | ||||
|             AddDependency(dependencyProperty.Name); | ||||
|             if (DotNetLibraryAsset.TryCreateFromRuntime(assetProperty.Name, assetProperty.Value, tfm, rid, out var asset)) | ||||
|             { | ||||
|                 _runtimeAssets.Add(asset); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void AddRuntimeAssetsFromRuntimeTargets(JsonElement element, string? tfm, string? rid) | ||||
|     { | ||||
|         if (!element.TryGetProperty("runtimeTargets", out var runtimeTargetsElement) || runtimeTargetsElement.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         foreach (var assetProperty in runtimeTargetsElement.EnumerateObject()) | ||||
|         { | ||||
|             if (DotNetLibraryAsset.TryCreateFromRuntimeTarget(assetProperty.Name, assetProperty.Value, tfm, rid, out var asset)) | ||||
|             { | ||||
|                 _runtimeAssets.Add(asset); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -316,3 +357,162 @@ internal sealed class DotNetLibrary | ||||
|         return value.Trim(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal enum DotNetLibraryAssetKind | ||||
| { | ||||
|     Runtime, | ||||
|     Native | ||||
| } | ||||
|  | ||||
| internal sealed record DotNetLibraryAsset( | ||||
|     string RelativePath, | ||||
|     string? TargetFramework, | ||||
|     string? RuntimeIdentifier, | ||||
|     string? AssemblyVersion, | ||||
|     string? FileVersion, | ||||
|     DotNetLibraryAssetKind Kind) | ||||
| { | ||||
|     public static bool TryCreateFromRuntime(string name, JsonElement element, string? tfm, string? rid, [NotNullWhen(true)] out DotNetLibraryAsset? asset) | ||||
|     { | ||||
|         asset = null; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(name)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var normalizedPath = NormalizePath(name); | ||||
|         if (string.IsNullOrEmpty(normalizedPath)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!IsManagedAssembly(normalizedPath)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         string? assemblyVersion = null; | ||||
|         string? fileVersion = null; | ||||
|  | ||||
|         if (element.ValueKind == JsonValueKind.Object) | ||||
|         { | ||||
|             if (element.TryGetProperty("assemblyVersion", out var assemblyVersionElement) && assemblyVersionElement.ValueKind == JsonValueKind.String) | ||||
|             { | ||||
|                 assemblyVersion = NormalizeValue(assemblyVersionElement.GetString()); | ||||
|             } | ||||
|  | ||||
|             if (element.TryGetProperty("fileVersion", out var fileVersionElement) && fileVersionElement.ValueKind == JsonValueKind.String) | ||||
|             { | ||||
|                 fileVersion = NormalizeValue(fileVersionElement.GetString()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         asset = new DotNetLibraryAsset( | ||||
|             RelativePath: normalizedPath, | ||||
|             TargetFramework: NormalizeValue(tfm), | ||||
|             RuntimeIdentifier: NormalizeValue(rid), | ||||
|             AssemblyVersion: assemblyVersion, | ||||
|             FileVersion: fileVersion, | ||||
|             Kind: DotNetLibraryAssetKind.Runtime); | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public static bool TryCreateFromRuntimeTarget(string name, JsonElement element, string? tfm, string? rid, [NotNullWhen(true)] out DotNetLibraryAsset? asset) | ||||
|     { | ||||
|         asset = null; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(name) || element.ValueKind is not JsonValueKind.Object) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var assetType = element.TryGetProperty("assetType", out var assetTypeElement) && assetTypeElement.ValueKind == JsonValueKind.String | ||||
|             ? NormalizeValue(assetTypeElement.GetString()) | ||||
|             : null; | ||||
|  | ||||
|         var normalizedPath = NormalizePath(name); | ||||
|         if (string.IsNullOrEmpty(normalizedPath)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         DotNetLibraryAssetKind kind; | ||||
|         if (assetType is null || string.Equals(assetType, "runtime", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             if (!IsManagedAssembly(normalizedPath)) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             kind = DotNetLibraryAssetKind.Runtime; | ||||
|         } | ||||
|         else if (string.Equals(assetType, "native", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             kind = DotNetLibraryAssetKind.Native; | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         string? assemblyVersion = null; | ||||
|         string? fileVersion = null; | ||||
|  | ||||
|         if (kind == DotNetLibraryAssetKind.Runtime && | ||||
|             element.TryGetProperty("assemblyVersion", out var assemblyVersionElement) && | ||||
|             assemblyVersionElement.ValueKind == JsonValueKind.String) | ||||
|         { | ||||
|             assemblyVersion = NormalizeValue(assemblyVersionElement.GetString()); | ||||
|         } | ||||
|  | ||||
|         if (kind == DotNetLibraryAssetKind.Runtime && | ||||
|             element.TryGetProperty("fileVersion", out var fileVersionElement) && | ||||
|             fileVersionElement.ValueKind == JsonValueKind.String) | ||||
|         { | ||||
|             fileVersion = NormalizeValue(fileVersionElement.GetString()); | ||||
|         } | ||||
|  | ||||
|         string? runtimeIdentifier = rid; | ||||
|         if (element.TryGetProperty("rid", out var ridElement) && ridElement.ValueKind == JsonValueKind.String) | ||||
|         { | ||||
|             runtimeIdentifier = NormalizeValue(ridElement.GetString()); | ||||
|         } | ||||
|  | ||||
|         asset = new DotNetLibraryAsset( | ||||
|             RelativePath: normalizedPath, | ||||
|             TargetFramework: NormalizeValue(tfm), | ||||
|             RuntimeIdentifier: NormalizeValue(runtimeIdentifier), | ||||
|             AssemblyVersion: assemblyVersion, | ||||
|             FileVersion: fileVersion, | ||||
|             Kind: kind); | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static string NormalizePath(string value) | ||||
|     { | ||||
|         var normalized = NormalizeValue(value); | ||||
|         if (string.IsNullOrEmpty(normalized)) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         return normalized.Replace('\\', '/'); | ||||
|     } | ||||
|  | ||||
|     private static bool IsManagedAssembly(string path) | ||||
|         => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || | ||||
|            path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); | ||||
|  | ||||
|     private static string? NormalizeValue(string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return value.Trim(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -3,8 +3,8 @@ | ||||
| | Seq | ID | Status | Depends on | Description | Exit Criteria | | ||||
| |-----|----|--------|------------|-------------|---------------| | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-305A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse `*.deps.json` + `runtimeconfig.json`, build RID graph, and normalize to `pkg:nuget` components. | RID graph deterministic; fixtures confirm consistent component ordering; fallback to `bin:{sha256}` documented. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-305B | TODO | SCANNER-ANALYZERS-LANG-10-305A | Extract assembly metadata (strong name, file/product info) and optional Authenticode details when offline cert bundle provided. | Signing metadata captured for signed assemblies; offline trust store documented; hash validations deterministic. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-305C | TODO | SCANNER-ANALYZERS-LANG-10-305B | Handle self-contained apps and native assets; merge with EntryTrace usage hints. | Self-contained fixtures map to components with RID flags; usage hints propagate; tests cover linux/win variants. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-305B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-305A | Extract assembly metadata (strong name, file/product info) and optional Authenticode details when offline cert bundle provided. | Signing metadata captured for signed assemblies; offline trust store documented; hash validations deterministic. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-305C | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-305B | Handle self-contained apps and native assets; merge with EntryTrace usage hints. | Self-contained fixtures map to components with RID flags; usage hints propagate; tests cover linux/win variants. | | ||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307D | TODO | SCANNER-ANALYZERS-LANG-10-305C | Integrate shared helpers (license mapping, quiet provenance) and concurrency-safe caches. | Shared helpers reused; concurrency tests for parallel layer scans pass; no redundant allocations. | | ||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308D | TODO | SCANNER-ANALYZERS-LANG-10-307D | Determinism fixtures + benchmark harness; compare to competitor scanners for accuracy/perf. | Fixtures in `Fixtures/lang/dotnet/`; determinism CI guard; benchmark demonstrates lower duplication + faster runtime. | | ||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309D | TODO | SCANNER-ANALYZERS-LANG-10-308D | Package plug-in (manifest, DI registration) and update Offline Kit instructions. | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer; Offline Kit doc updated. | | ||||
|   | ||||
| @@ -0,0 +1,5 @@ | ||||
| MZfakebinaryheader | ||||
| Go build ID: "random-go-build-id" | ||||
| ....gopclntab.... | ||||
| runtime.buildVersion=go1.22.8 | ||||
| padding0000000000000000000000000000000000000000000000000000000000000000 | ||||
| @@ -0,0 +1,30 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "golang", | ||||
|     "componentKey": "golang::bin::sha256:80f528c90b72a4c4cc3fa078501154e4f2a3f49faea3ec380112d61740bde4c3", | ||||
|     "name": "app", | ||||
|     "type": "bin", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "binary.sha256": "80f528c90b72a4c4cc3fa078501154e4f2a3f49faea3ec380112d61740bde4c3", | ||||
|       "binaryPath": "app", | ||||
|       "go.version.hint": "go1.22.8", | ||||
|       "languageHint": "golang", | ||||
|       "provenance": "binary" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "binary", | ||||
|         "locator": "app", | ||||
|         "sha256": "80f528c90b72a4c4cc3fa078501154e4f2a3f49faea3ec380112d61740bde4c3" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "metadata", | ||||
|         "source": "go.heuristic", | ||||
|         "locator": "classification", | ||||
|         "value": "build-id" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| @@ -44,4 +44,23 @@ public sealed class GoLanguageAnalyzerTests | ||||
|             analyzers, | ||||
|             cancellationToken); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task StrippedBinaryFallsBackToHeuristicBinHashAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "go", "stripped"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
|  | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new GoLanguageAnalyzer(), | ||||
|         }; | ||||
|  | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -21,18 +21,31 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer | ||||
|         var candidatePaths = new List<string>(GoBinaryScanner.EnumerateCandidateFiles(context.RootPath)); | ||||
|         candidatePaths.Sort(StringComparer.Ordinal); | ||||
|  | ||||
|         var fallbackBinaries = new List<GoStrippedBinaryClassification>(); | ||||
|  | ||||
|         foreach (var absolutePath in candidatePaths) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             if (!GoBuildInfoProvider.TryGetBuildInfo(absolutePath, out var buildInfo) || buildInfo is null) | ||||
|             { | ||||
|                 if (GoBinaryScanner.TryClassifyStrippedBinary(absolutePath, out var classification)) | ||||
|                 { | ||||
|                     fallbackBinaries.Add(classification); | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             EmitComponents(buildInfo, context, writer); | ||||
|         } | ||||
|  | ||||
|         foreach (var fallback in fallbackBinaries) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|             EmitFallbackComponent(fallback, context, writer); | ||||
|         } | ||||
|  | ||||
|         return ValueTask.CompletedTask; | ||||
|     } | ||||
|  | ||||
| @@ -144,6 +157,84 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer | ||||
|         return entries; | ||||
|     } | ||||
|  | ||||
|     private void EmitFallbackComponent(GoStrippedBinaryClassification strippedBinary, LanguageAnalyzerContext context, LanguageComponentWriter writer) | ||||
|     { | ||||
|         var relativePath = context.GetRelativePath(strippedBinary.AbsolutePath); | ||||
|         var normalizedRelative = string.IsNullOrEmpty(relativePath) ? "." : relativePath; | ||||
|         var usedByEntrypoint = context.UsageHints.IsPathUsed(strippedBinary.AbsolutePath); | ||||
|  | ||||
|         var binaryHash = ComputeBinaryHash(strippedBinary.AbsolutePath); | ||||
|  | ||||
|         var metadata = new List<KeyValuePair<string, string?>> | ||||
|         { | ||||
|             new("binaryPath", normalizedRelative), | ||||
|             new("languageHint", "golang"), | ||||
|             new("provenance", "binary"), | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(binaryHash)) | ||||
|         { | ||||
|             metadata.Add(new KeyValuePair<string, string?>("binary.sha256", binaryHash)); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(strippedBinary.GoVersionHint)) | ||||
|         { | ||||
|             metadata.Add(new KeyValuePair<string, string?>("go.version.hint", strippedBinary.GoVersionHint)); | ||||
|         } | ||||
|  | ||||
|         metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||
|  | ||||
|         var evidence = new List<LanguageComponentEvidence> | ||||
|         { | ||||
|             new( | ||||
|                 LanguageEvidenceKind.File, | ||||
|                 "binary", | ||||
|                 normalizedRelative, | ||||
|                 null, | ||||
|                 string.IsNullOrEmpty(binaryHash) ? null : binaryHash), | ||||
|         }; | ||||
|  | ||||
|         var detectionSource = strippedBinary.Indicator switch | ||||
|         { | ||||
|             GoStrippedBinaryIndicator.BuildId => "build-id", | ||||
|             GoStrippedBinaryIndicator.GoRuntimeMarkers => "runtime-markers", | ||||
|             _ => null, | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrEmpty(detectionSource)) | ||||
|         { | ||||
|             evidence.Add(new LanguageComponentEvidence( | ||||
|                 LanguageEvidenceKind.Metadata, | ||||
|                 "go.heuristic", | ||||
|                 "classification", | ||||
|                 detectionSource, | ||||
|                 null)); | ||||
|         } | ||||
|  | ||||
|         evidence.Sort(static (left, right) => string.CompareOrdinal(left.ComparisonKey, right.ComparisonKey)); | ||||
|  | ||||
|         var componentName = Path.GetFileName(strippedBinary.AbsolutePath); | ||||
|         if (string.IsNullOrWhiteSpace(componentName)) | ||||
|         { | ||||
|             componentName = "golang-binary"; | ||||
|         } | ||||
|  | ||||
|         var componentKey = string.IsNullOrEmpty(binaryHash) | ||||
|             ? $"golang::bin::{normalizedRelative}" | ||||
|             : $"golang::bin::sha256:{binaryHash}"; | ||||
|  | ||||
|         writer.AddFromExplicitKey( | ||||
|             analyzerId: Id, | ||||
|             componentKey: componentKey, | ||||
|             purl: null, | ||||
|             name: componentName, | ||||
|             version: null, | ||||
|             type: "bin", | ||||
|             metadata: metadata, | ||||
|             evidence: evidence, | ||||
|             usedByEntrypoint: usedByEntrypoint); | ||||
|     } | ||||
|  | ||||
|     private static IEnumerable<LanguageComponentEvidence> BuildEvidence(GoBuildInfo buildInfo, GoModule module, string binaryRelativePath, LanguageAnalyzerContext context, ref string? binaryHash) | ||||
|     { | ||||
|         var evidence = new List<LanguageComponentEvidence> | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Buffers; | ||||
| using System.IO; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| @@ -11,6 +13,10 @@ internal static class GoBinaryScanner | ||||
|         0xFF, (byte)' ', (byte)'G', (byte)'o', (byte)' ', (byte)'b', (byte)'u', (byte)'i', (byte)'l', (byte)'d', (byte)'i', (byte)'n', (byte)'f', (byte)':' | ||||
|     }; | ||||
|  | ||||
|     private static readonly ReadOnlyMemory<byte> BuildIdMarker = Encoding.ASCII.GetBytes("Go build ID:"); | ||||
|     private static readonly ReadOnlyMemory<byte> GoPclnTabMarker = Encoding.ASCII.GetBytes(".gopclntab"); | ||||
|     private static readonly ReadOnlyMemory<byte> GoVersionPrefix = Encoding.ASCII.GetBytes("go1."); | ||||
|  | ||||
|     public static IEnumerable<string> EnumerateCandidateFiles(string rootPath) | ||||
|     { | ||||
|         var enumeration = new EnumerationOptions | ||||
| @@ -60,4 +66,151 @@ internal static class GoBinaryScanner | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static bool TryClassifyStrippedBinary(string filePath, out GoStrippedBinaryClassification classification) | ||||
|     { | ||||
|         classification = default; | ||||
|  | ||||
|         FileInfo fileInfo; | ||||
|         try | ||||
|         { | ||||
|             fileInfo = new FileInfo(filePath); | ||||
|             if (!fileInfo.Exists) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|         catch (System.Security.SecurityException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var length = fileInfo.Length; | ||||
|         if (length < 128) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         const int WindowSize = 128 * 1024; | ||||
|         var readSize = (int)Math.Min(length, WindowSize); | ||||
|         var buffer = ArrayPool<byte>.Shared.Rent(readSize); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|  | ||||
|             var headRead = stream.Read(buffer, 0, readSize); | ||||
|             if (headRead <= 0) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             var headSpan = new ReadOnlySpan<byte>(buffer, 0, headRead); | ||||
|             var hasBuildId = headSpan.IndexOf(BuildIdMarker.Span) >= 0; | ||||
|             var hasPcln = headSpan.IndexOf(GoPclnTabMarker.Span) >= 0; | ||||
|             var goVersion = ExtractGoVersion(headSpan); | ||||
|  | ||||
|             if (length > headRead) | ||||
|             { | ||||
|                 var tailSize = Math.Min(readSize, (int)length); | ||||
|                 if (tailSize > 0) | ||||
|                 { | ||||
|                     stream.Seek(-tailSize, SeekOrigin.End); | ||||
|                     var tailRead = stream.Read(buffer, 0, tailSize); | ||||
|                     if (tailRead > 0) | ||||
|                     { | ||||
|                         var tailSpan = new ReadOnlySpan<byte>(buffer, 0, tailRead); | ||||
|                         hasBuildId |= tailSpan.IndexOf(BuildIdMarker.Span) >= 0; | ||||
|                         hasPcln |= tailSpan.IndexOf(GoPclnTabMarker.Span) >= 0; | ||||
|                         goVersion ??= ExtractGoVersion(tailSpan); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             if (hasBuildId) | ||||
|             { | ||||
|                 classification = new GoStrippedBinaryClassification( | ||||
|                     filePath, | ||||
|                     GoStrippedBinaryIndicator.BuildId, | ||||
|                     goVersion); | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             if (hasPcln && !string.IsNullOrEmpty(goVersion)) | ||||
|             { | ||||
|                 classification = new GoStrippedBinaryClassification( | ||||
|                     filePath, | ||||
|                     GoStrippedBinaryIndicator.GoRuntimeMarkers, | ||||
|                     goVersion); | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             return false; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             Array.Clear(buffer, 0, readSize); | ||||
|             ArrayPool<byte>.Shared.Return(buffer); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string? ExtractGoVersion(ReadOnlySpan<byte> data) | ||||
|     { | ||||
|         var prefix = GoVersionPrefix.Span; | ||||
|         var span = data; | ||||
|  | ||||
|         while (!span.IsEmpty) | ||||
|         { | ||||
|             var index = span.IndexOf(prefix); | ||||
|             if (index < 0) | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             var absoluteIndex = data.Length - span.Length + index; | ||||
|  | ||||
|             if (absoluteIndex > 0) | ||||
|             { | ||||
|                 var previous = (char)data[absoluteIndex - 1]; | ||||
|                 if (char.IsLetterOrDigit(previous)) | ||||
|                 { | ||||
|                     span = span[(index + 1)..]; | ||||
|                     continue; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             var start = absoluteIndex; | ||||
|             var end = start + prefix.Length; | ||||
|  | ||||
|             while (end < data.Length && IsVersionCharacter((char)data[end])) | ||||
|             { | ||||
|                 end++; | ||||
|             } | ||||
|  | ||||
|             if (end - start <= prefix.Length) | ||||
|             { | ||||
|                 span = span[(index + 1)..]; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var candidate = data[start..end]; | ||||
|             return Encoding.ASCII.GetString(candidate); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static bool IsVersionCharacter(char value) | ||||
|         => (value >= '0' && value <= '9') | ||||
|            || (value >= 'a' && value <= 'z') | ||||
|            || (value >= 'A' && value <= 'Z') | ||||
|            || value is '.' or '-' or '+' or '_'; | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,13 @@ | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal; | ||||
|  | ||||
| internal readonly record struct GoStrippedBinaryClassification( | ||||
|     string AbsolutePath, | ||||
|     GoStrippedBinaryIndicator Indicator, | ||||
|     string? GoVersionHint); | ||||
|  | ||||
| internal enum GoStrippedBinaryIndicator | ||||
| { | ||||
|     None = 0, | ||||
|     BuildId, | ||||
|     GoRuntimeMarkers, | ||||
| } | ||||
| @@ -4,7 +4,8 @@ | ||||
| |-----|----|--------|------------|-------------|---------------| | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-304A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse Go build info blob (`runtime/debug` format) and `.note.go.buildid`; map to module/version and evidence. | Build info extracted across Go 1.18–1.23 fixtures; evidence includes VCS, module path, and build settings. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-304B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304A | Implement DWARF-lite reader for VCS metadata + dirty flag; add cache to avoid re-reading identical binaries. | DWARF reader supplies commit hash for ≥95 % fixtures; cache reduces duplicated IO by ≥70 %. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-304C | TODO | SCANNER-ANALYZERS-LANG-10-304B | Fallback heuristics for stripped binaries with deterministic `bin:{sha256}` labeling and quiet provenance. | Heuristic labels clearly separated; tests ensure no false “observed” provenance; documentation updated. | | ||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307G | TODO | SCANNER-ANALYZERS-LANG-10-304C | Wire shared helpers (license mapping, usage flags) and ensure concurrency-safe buffer reuse. | Analyzer reuses shared infrastructure; concurrency tests with parallel scans pass; no data races. | | ||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308G | TODO | SCANNER-ANALYZERS-LANG-10-307G | Determinism fixtures + benchmark harness (Vs competitor). | Fixtures under `Fixtures/lang/go/`; CI determinism check; benchmark runs showing ≥20 % speed advantage. | | ||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309G | TODO | SCANNER-ANALYZERS-LANG-10-308G | Package plug-in manifest + Offline Kit notes; ensure Worker DI registration. | Manifest copied; Worker loads analyzer; Offline Kit docs updated with Go analyzer presence. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-304C | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304B | Fallback heuristics for stripped binaries with deterministic `bin:{sha256}` labeling and quiet provenance. | Heuristic labels clearly separated; tests ensure no false “observed” provenance; documentation updated. | | ||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307G | TODO | SCANNER-ANALYZERS-LANG-10-304C | Wire shared helpers (license mapping, usage flags) and ensure concurrency-safe buffer reuse. | Analyzer reuses shared infrastructure; concurrency tests with parallel scans pass; no data races. | | ||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308G | TODO | SCANNER-ANALYZERS-LANG-10-307G | Determinism fixtures + benchmark harness (Vs competitor). | Fixtures under `Fixtures/lang/go/`; CI determinism check; benchmark runs showing ≥20 % speed advantage. | | ||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309G | TODO | SCANNER-ANALYZERS-LANG-10-308G | Package plug-in manifest + Offline Kit notes; ensure Worker DI registration. | Manifest copied; Worker loads analyzer; Offline Kit docs updated with Go analyzer presence. | | ||||
| | 7 | SCANNER-ANALYZERS-LANG-10-304D | TODO | SCANNER-ANALYZERS-LANG-10-304C | Emit telemetry counters for stripped-binary heuristics and document metrics wiring. | New `scanner_analyzer_golang_heuristic_total` counter recorded; docs updated with offline aggregation notes. | | ||||
|   | ||||
| @@ -0,0 +1,650 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||
|  | ||||
| internal static class RustAnalyzerCollector | ||||
| { | ||||
|     public static RustAnalyzerCollection Collect(LanguageAnalyzerContext context, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|  | ||||
|         var collector = new Collector(context); | ||||
|         collector.Execute(cancellationToken); | ||||
|         return collector.Build(); | ||||
|     } | ||||
|  | ||||
|     private sealed class Collector | ||||
|     { | ||||
|         private static readonly EnumerationOptions LockEnumeration = new() | ||||
|         { | ||||
|             MatchCasing = MatchCasing.CaseSensitive, | ||||
|             IgnoreInaccessible = true, | ||||
|             RecurseSubdirectories = true, | ||||
|             AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint, | ||||
|         }; | ||||
|  | ||||
|         private readonly LanguageAnalyzerContext _context; | ||||
|         private readonly Dictionary<RustCrateKey, RustCrateBuilder> _crates = new(); | ||||
|         private readonly Dictionary<string, List<RustCrateBuilder>> _cratesByName = new(StringComparer.Ordinal); | ||||
|         private readonly Dictionary<string, RustHeuristicBuilder> _heuristics = new(StringComparer.Ordinal); | ||||
|         private readonly Dictionary<string, RustBinaryRecord> _binaries = new(StringComparer.Ordinal); | ||||
|  | ||||
|         public Collector(LanguageAnalyzerContext context) | ||||
|         { | ||||
|             _context = context; | ||||
|         } | ||||
|  | ||||
|         public void Execute(CancellationToken cancellationToken) | ||||
|         { | ||||
|             CollectCargoLocks(cancellationToken); | ||||
|             CollectFingerprints(cancellationToken); | ||||
|             CollectBinaries(cancellationToken); | ||||
|         } | ||||
|  | ||||
|         public RustAnalyzerCollection Build() | ||||
|         { | ||||
|             var crateRecords = _crates.Values | ||||
|                 .Select(static builder => builder.Build()) | ||||
|                 .OrderBy(static record => record.ComponentKey, StringComparer.Ordinal) | ||||
|                 .ToImmutableArray(); | ||||
|  | ||||
|             var heuristicRecords = _heuristics.Values | ||||
|                 .Select(static builder => builder.Build()) | ||||
|                 .OrderBy(static record => record.ComponentKey, StringComparer.Ordinal) | ||||
|                 .ToImmutableArray(); | ||||
|  | ||||
|             var fallbackRecords = _binaries.Values | ||||
|                 .Where(static record => !record.HasMatches) | ||||
|                 .Select(BuildFallback) | ||||
|                 .OrderBy(static record => record.ComponentKey, StringComparer.Ordinal) | ||||
|                 .ToImmutableArray(); | ||||
|  | ||||
|             return new RustAnalyzerCollection(crateRecords, heuristicRecords, fallbackRecords); | ||||
|         } | ||||
|  | ||||
|         private void CollectCargoLocks(CancellationToken cancellationToken) | ||||
|         { | ||||
|             foreach (var lockPath in Directory.EnumerateFiles(_context.RootPath, "Cargo.lock", LockEnumeration)) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|                 var packages = RustCargoLockParser.Parse(lockPath, cancellationToken); | ||||
|                 if (packages.Count == 0) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var relativePath = NormalizeRelative(_context.GetRelativePath(lockPath)); | ||||
|                 foreach (var package in packages) | ||||
|                 { | ||||
|                     var builder = GetOrCreateCrate(package.Name, package.Version); | ||||
|                     builder.ApplyCargoPackage(package, relativePath); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private void CollectFingerprints(CancellationToken cancellationToken) | ||||
|         { | ||||
|             var records = RustFingerprintScanner.Scan(_context.RootPath, cancellationToken); | ||||
|             foreach (var record in records) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|                 var builder = GetOrCreateCrate(record.Name, record.Version); | ||||
|                 var relative = NormalizeRelative(_context.GetRelativePath(record.AbsolutePath)); | ||||
|                 builder.ApplyFingerprint(record, relative); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private void CollectBinaries(CancellationToken cancellationToken) | ||||
|         { | ||||
|             var binaries = RustBinaryClassifier.Scan(_context.RootPath, cancellationToken); | ||||
|             foreach (var binary in binaries) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|                 var relative = NormalizeRelative(_context.GetRelativePath(binary.AbsolutePath)); | ||||
|                 var usage = _context.UsageHints.IsPathUsed(binary.AbsolutePath); | ||||
|                 var hash = binary.ComputeSha256(); | ||||
|  | ||||
|                 if (!_binaries.TryGetValue(relative, out var record)) | ||||
|                 { | ||||
|                     record = new RustBinaryRecord(binary.AbsolutePath, relative, usage, hash); | ||||
|                     _binaries[relative] = record; | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     record.MergeUsage(usage); | ||||
|                     record.EnsureHash(hash); | ||||
|                 } | ||||
|  | ||||
|                 if (binary.CrateCandidates.IsDefaultOrEmpty || binary.CrateCandidates.Length == 0) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 foreach (var candidate in binary.CrateCandidates) | ||||
|                 { | ||||
|                     if (string.IsNullOrWhiteSpace(candidate)) | ||||
|                     { | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var crateBuilder = FindCrateByName(candidate); | ||||
|                     if (crateBuilder is not null) | ||||
|                     { | ||||
|                         crateBuilder.AddBinaryEvidence(relative, record.Hash, usage); | ||||
|                         record.MarkCrateMatch(); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var heuristic = GetOrCreateHeuristic(candidate); | ||||
|                     heuristic.AddBinary(relative, record.Hash, usage); | ||||
|                     record.MarkHeuristicMatch(); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private RustCrateBuilder GetOrCreateCrate(string name, string? version) | ||||
|         { | ||||
|             var key = new RustCrateKey(name, version); | ||||
|             if (_crates.TryGetValue(key, out var existing)) | ||||
|             { | ||||
|                 existing.EnsureVersion(version); | ||||
|                 return existing; | ||||
|             } | ||||
|  | ||||
|             var builder = new RustCrateBuilder(name, version); | ||||
|             _crates[key] = builder; | ||||
|  | ||||
|             if (!_cratesByName.TryGetValue(builder.Name, out var list)) | ||||
|             { | ||||
|                 list = new List<RustCrateBuilder>(); | ||||
|                 _cratesByName[builder.Name] = list; | ||||
|             } | ||||
|  | ||||
|             list.Add(builder); | ||||
|             return builder; | ||||
|         } | ||||
|  | ||||
|         private RustCrateBuilder? FindCrateByName(string candidate) | ||||
|         { | ||||
|             var normalized = RustCrateBuilder.NormalizeName(candidate); | ||||
|             if (!_cratesByName.TryGetValue(normalized, out var builders) || builders.Count == 0) | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             return builders | ||||
|                 .OrderBy(static builder => builder.Version ?? string.Empty, StringComparer.Ordinal) | ||||
|                 .FirstOrDefault(); | ||||
|         } | ||||
|  | ||||
|         private RustHeuristicBuilder GetOrCreateHeuristic(string crateName) | ||||
|         { | ||||
|             var normalized = RustCrateBuilder.NormalizeName(crateName); | ||||
|             if (_heuristics.TryGetValue(normalized, out var existing)) | ||||
|             { | ||||
|                 return existing; | ||||
|             } | ||||
|  | ||||
|             var builder = new RustHeuristicBuilder(normalized); | ||||
|             _heuristics[normalized] = builder; | ||||
|             return builder; | ||||
|         } | ||||
|  | ||||
|         private RustComponentRecord BuildFallback(RustBinaryRecord record) | ||||
|         { | ||||
|             var metadata = new List<KeyValuePair<string, string?>> | ||||
|             { | ||||
|                 new("binary.path", record.RelativePath), | ||||
|                 new("provenance", "binary"), | ||||
|             }; | ||||
|  | ||||
|             if (!string.IsNullOrEmpty(record.Hash)) | ||||
|             { | ||||
|                 metadata.Add(new KeyValuePair<string, string?>("binary.sha256", record.Hash)); | ||||
|             } | ||||
|  | ||||
|             metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||
|  | ||||
|             var evidence = new List<LanguageComponentEvidence> | ||||
|             { | ||||
|                 new( | ||||
|                     LanguageEvidenceKind.File, | ||||
|                     "binary", | ||||
|                     record.RelativePath, | ||||
|                     null, | ||||
|                     string.IsNullOrEmpty(record.Hash) ? null : record.Hash) | ||||
|             }; | ||||
|  | ||||
|             var componentName = Path.GetFileName(record.RelativePath); | ||||
|             if (string.IsNullOrWhiteSpace(componentName)) | ||||
|             { | ||||
|                 componentName = "binary"; | ||||
|             } | ||||
|  | ||||
|             var key = string.IsNullOrEmpty(record.Hash) | ||||
|                 ? $"bin::{record.RelativePath}" | ||||
|                 : $"bin::sha256:{record.Hash}"; | ||||
|  | ||||
|             return new RustComponentRecord( | ||||
|                 Name: componentName, | ||||
|                 Version: null, | ||||
|                 Type: "bin", | ||||
|                 Purl: null, | ||||
|                 ComponentKey: key, | ||||
|                 Metadata: metadata, | ||||
|                 Evidence: evidence, | ||||
|                 UsedByEntrypoint: record.UsedByEntrypoint); | ||||
|         } | ||||
|  | ||||
|         private static string NormalizeRelative(string relativePath) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(relativePath) || relativePath == ".") | ||||
|             { | ||||
|                 return "."; | ||||
|             } | ||||
|  | ||||
|             return relativePath.Replace('\\', '/'); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record RustAnalyzerCollection( | ||||
|     ImmutableArray<RustComponentRecord> Crates, | ||||
|     ImmutableArray<RustComponentRecord> Heuristics, | ||||
|     ImmutableArray<RustComponentRecord> Fallbacks); | ||||
|  | ||||
| internal sealed record RustComponentRecord( | ||||
|     string Name, | ||||
|     string? Version, | ||||
|     string Type, | ||||
|     string? Purl, | ||||
|     string ComponentKey, | ||||
|     IReadOnlyList<KeyValuePair<string, string?>> Metadata, | ||||
|     IReadOnlyCollection<LanguageComponentEvidence> Evidence, | ||||
|     bool UsedByEntrypoint); | ||||
|  | ||||
| internal sealed class RustCrateBuilder | ||||
| { | ||||
|     private readonly SortedDictionary<string, string?> _metadata = new(StringComparer.Ordinal); | ||||
|     private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer()); | ||||
|     private readonly SortedSet<string> _binaryPaths = new(StringComparer.Ordinal); | ||||
|     private readonly SortedSet<string> _binaryHashes = new(StringComparer.Ordinal); | ||||
|  | ||||
|     private string? _version; | ||||
|     private string? _source; | ||||
|     private string? _checksum; | ||||
|     private bool _usedByEntrypoint; | ||||
|  | ||||
|     public RustCrateBuilder(string name, string? version) | ||||
|     { | ||||
|         Name = NormalizeName(name); | ||||
|         EnsureVersion(version); | ||||
|     } | ||||
|  | ||||
|     public string Name { get; } | ||||
|  | ||||
|     public string? Version => _version; | ||||
|  | ||||
|     public static string NormalizeName(string value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         return value.Trim(); | ||||
|     } | ||||
|  | ||||
|     public void EnsureVersion(string? version) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(version)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         _version ??= version.Trim(); | ||||
|     } | ||||
|  | ||||
|     public void ApplyCargoPackage(RustCargoPackage package, string relativePath) | ||||
|     { | ||||
|         EnsureVersion(package.Version); | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(package.Source)) | ||||
|         { | ||||
|             _source ??= package.Source.Trim(); | ||||
|             _metadata["source"] = _source; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(package.Checksum)) | ||||
|         { | ||||
|             _checksum ??= package.Checksum.Trim(); | ||||
|             _metadata["checksum"] = _checksum; | ||||
|         } | ||||
|  | ||||
|         _metadata["cargo.lock.path"] = relativePath; | ||||
|  | ||||
|         _evidence.Add(new LanguageComponentEvidence( | ||||
|             LanguageEvidenceKind.File, | ||||
|             "cargo.lock", | ||||
|             relativePath, | ||||
|             $"{package.Name} {package.Version}", | ||||
|             string.IsNullOrWhiteSpace(package.Checksum) ? null : package.Checksum)); | ||||
|     } | ||||
|  | ||||
|     public void ApplyFingerprint(RustFingerprintRecord record, string relativePath) | ||||
|     { | ||||
|         EnsureVersion(record.Version); | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(record.Source)) | ||||
|         { | ||||
|             _source ??= record.Source.Trim(); | ||||
|             _metadata["source"] = _source; | ||||
|         } | ||||
|  | ||||
|         AddMetadataIfEmpty("fingerprint.profile", record.Profile); | ||||
|         AddMetadataIfEmpty("fingerprint.targetKind", record.TargetKind); | ||||
|  | ||||
|         _evidence.Add(new LanguageComponentEvidence( | ||||
|             LanguageEvidenceKind.File, | ||||
|             "cargo.fingerprint", | ||||
|             relativePath, | ||||
|             record.TargetKind ?? record.Profile ?? "fingerprint", | ||||
|             null)); | ||||
|     } | ||||
|  | ||||
|     public void AddBinaryEvidence(string relativePath, string? hash, bool usedByEntrypoint) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(relativePath)) | ||||
|         { | ||||
|             _binaryPaths.Add(relativePath); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(hash)) | ||||
|         { | ||||
|             _binaryHashes.Add(hash); | ||||
|         } | ||||
|  | ||||
|         if (usedByEntrypoint) | ||||
|         { | ||||
|             _usedByEntrypoint = true; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(relativePath)) | ||||
|         { | ||||
|             _evidence.Add(new LanguageComponentEvidence( | ||||
|                 LanguageEvidenceKind.File, | ||||
|                 "binary", | ||||
|                 relativePath, | ||||
|                 null, | ||||
|                 string.IsNullOrWhiteSpace(hash) ? null : hash)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public RustComponentRecord Build() | ||||
|     { | ||||
|         if (_binaryPaths.Count > 0) | ||||
|         { | ||||
|             _metadata["binary.paths"] = string.Join(';', _binaryPaths); | ||||
|         } | ||||
|  | ||||
|         if (_binaryHashes.Count > 0) | ||||
|         { | ||||
|             _metadata["binary.sha256"] = string.Join(';', _binaryHashes); | ||||
|         } | ||||
|  | ||||
|         var metadata = _metadata | ||||
|             .Select(static pair => new KeyValuePair<string, string?>(pair.Key, pair.Value)) | ||||
|             .OrderBy(static pair => pair.Key, StringComparer.Ordinal) | ||||
|             .ToList(); | ||||
|  | ||||
|         var evidence = _evidence | ||||
|             .OrderBy(static item => item.ComparisonKey, StringComparer.Ordinal) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         var purl = BuildPurl(Name, _version); | ||||
|         var componentKey = string.IsNullOrEmpty(purl) | ||||
|             ? $"cargo::{Name}::{_version ?? "unknown"}" | ||||
|             : $"purl::{purl}"; | ||||
|  | ||||
|         return new RustComponentRecord( | ||||
|             Name: Name, | ||||
|             Version: _version, | ||||
|             Type: "cargo", | ||||
|             Purl: purl, | ||||
|             ComponentKey: componentKey, | ||||
|             Metadata: metadata, | ||||
|             Evidence: evidence, | ||||
|             UsedByEntrypoint: _usedByEntrypoint); | ||||
|     } | ||||
|  | ||||
|     private void AddMetadataIfEmpty(string key, string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (_metadata.ContainsKey(key)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         _metadata[key] = value.Trim(); | ||||
|     } | ||||
|  | ||||
|     private static string? BuildPurl(string name, string? version) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(name)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var escapedName = Uri.EscapeDataString(name.Trim()); | ||||
|         if (string.IsNullOrWhiteSpace(version)) | ||||
|         { | ||||
|             return $"pkg:cargo/{escapedName}"; | ||||
|         } | ||||
|  | ||||
|         var escapedVersion = Uri.EscapeDataString(version.Trim()); | ||||
|         return $"pkg:cargo/{escapedName}@{escapedVersion}"; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class RustHeuristicBuilder | ||||
| { | ||||
|     private readonly HashSet<LanguageComponentEvidence> _evidence = new(new LanguageComponentEvidenceComparer()); | ||||
|     private readonly SortedSet<string> _binaryPaths = new(StringComparer.Ordinal); | ||||
|     private readonly SortedSet<string> _binaryHashes = new(StringComparer.Ordinal); | ||||
|     private bool _usedByEntrypoint; | ||||
|  | ||||
|     public RustHeuristicBuilder(string crateName) | ||||
|     { | ||||
|         CrateName = RustCrateBuilder.NormalizeName(crateName); | ||||
|     } | ||||
|  | ||||
|     public string CrateName { get; } | ||||
|  | ||||
|     public void AddBinary(string relativePath, string? hash, bool usedByEntrypoint) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(relativePath)) | ||||
|         { | ||||
|             _binaryPaths.Add(relativePath); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(hash)) | ||||
|         { | ||||
|             _binaryHashes.Add(hash); | ||||
|         } | ||||
|  | ||||
|         if (usedByEntrypoint) | ||||
|         { | ||||
|             _usedByEntrypoint = true; | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(relativePath)) | ||||
|         { | ||||
|             _evidence.Add(new LanguageComponentEvidence( | ||||
|                 LanguageEvidenceKind.Derived, | ||||
|                 "rust.heuristic", | ||||
|                 relativePath, | ||||
|                 CrateName, | ||||
|                 string.IsNullOrWhiteSpace(hash) ? null : hash)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public RustComponentRecord Build() | ||||
|     { | ||||
|         var metadata = new List<KeyValuePair<string, string?>> | ||||
|         { | ||||
|             new("crate", CrateName), | ||||
|             new("provenance", "heuristic"), | ||||
|             new("binary.paths", string.Join(';', _binaryPaths)), | ||||
|         }; | ||||
|  | ||||
|         if (_binaryHashes.Count > 0) | ||||
|         { | ||||
|             metadata.Add(new KeyValuePair<string, string?>("binary.sha256", string.Join(';', _binaryHashes))); | ||||
|         } | ||||
|  | ||||
|         metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key)); | ||||
|  | ||||
|         var evidence = _evidence | ||||
|             .OrderBy(static item => item.ComparisonKey, StringComparer.Ordinal) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         var suffix = string.Join("|", _binaryPaths); | ||||
|         var componentKey = $"rust::heuristic::{CrateName}::{suffix}"; | ||||
|  | ||||
|         return new RustComponentRecord( | ||||
|             Name: CrateName, | ||||
|             Version: null, | ||||
|             Type: "cargo", | ||||
|             Purl: null, | ||||
|             ComponentKey: componentKey, | ||||
|             Metadata: metadata, | ||||
|             Evidence: evidence, | ||||
|             UsedByEntrypoint: _usedByEntrypoint); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class RustBinaryRecord | ||||
| { | ||||
|     private string? _hash; | ||||
|  | ||||
|     public RustBinaryRecord(string absolutePath, string relativePath, bool usedByEntrypoint, string? hash) | ||||
|     { | ||||
|         AbsolutePath = absolutePath ?? throw new ArgumentNullException(nameof(absolutePath)); | ||||
|         RelativePath = string.IsNullOrWhiteSpace(relativePath) ? "." : relativePath; | ||||
|         UsedByEntrypoint = usedByEntrypoint; | ||||
|         _hash = string.IsNullOrWhiteSpace(hash) ? null : hash; | ||||
|     } | ||||
|  | ||||
|     public string AbsolutePath { get; } | ||||
|  | ||||
|     public string RelativePath { get; } | ||||
|  | ||||
|     public bool UsedByEntrypoint { get; private set; } | ||||
|  | ||||
|     public bool HasMatches => HasCrateMatch || HasHeuristicMatch; | ||||
|  | ||||
|     public bool HasCrateMatch { get; private set; } | ||||
|  | ||||
|     public bool HasHeuristicMatch { get; private set; } | ||||
|  | ||||
|     public string? Hash => _hash; | ||||
|  | ||||
|     public void MarkCrateMatch() => HasCrateMatch = true; | ||||
|  | ||||
|     public void MarkHeuristicMatch() => HasHeuristicMatch = true; | ||||
|  | ||||
|     public void MergeUsage(bool used) | ||||
|     { | ||||
|         if (used) | ||||
|         { | ||||
|             UsedByEntrypoint = true; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void EnsureHash(string? hash) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(hash)) | ||||
|         { | ||||
|             _hash ??= hash; | ||||
|         } | ||||
|  | ||||
|         if (_hash is null) | ||||
|         { | ||||
|             _hash = ComputeHashSafely(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private string? ComputeHashSafely() | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             using var stream = new FileStream(AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|             using var sha = SHA256.Create(); | ||||
|             var hash = sha.ComputeHash(stream); | ||||
|             return Convert.ToHexString(hash).ToLowerInvariant(); | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal readonly record struct RustCrateKey | ||||
| { | ||||
|     public RustCrateKey(string name, string? version) | ||||
|     { | ||||
|         Name = RustCrateBuilder.NormalizeName(name); | ||||
|         Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim(); | ||||
|     } | ||||
|  | ||||
|     public string Name { get; } | ||||
|  | ||||
|     public string? Version { get; } | ||||
| } | ||||
|  | ||||
| internal sealed class LanguageComponentEvidenceComparer : IEqualityComparer<LanguageComponentEvidence> | ||||
| { | ||||
|     public bool Equals(LanguageComponentEvidence? x, LanguageComponentEvidence? y) | ||||
|     { | ||||
|         if (ReferenceEquals(x, y)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (x is null || y is null) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return x.Kind == y.Kind && | ||||
|                string.Equals(x.Source, y.Source, StringComparison.Ordinal) && | ||||
|                string.Equals(x.Locator, y.Locator, StringComparison.Ordinal) && | ||||
|                string.Equals(x.Value, y.Value, StringComparison.Ordinal) && | ||||
|                string.Equals(x.Sha256, y.Sha256, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     public int GetHashCode(LanguageComponentEvidence obj) | ||||
|     { | ||||
|         var hash = new HashCode(); | ||||
|         hash.Add(obj.Kind); | ||||
|         hash.Add(obj.Source, StringComparer.Ordinal); | ||||
|         hash.Add(obj.Locator, StringComparer.Ordinal); | ||||
|         hash.Add(obj.Value, StringComparer.Ordinal); | ||||
|         hash.Add(obj.Sha256, StringComparer.Ordinal); | ||||
|         return hash.ToHashCode(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,250 @@ | ||||
| using System.Buffers; | ||||
| using System.Collections.Immutable; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||
|  | ||||
| internal static class RustBinaryClassifier | ||||
| { | ||||
|     private static readonly ReadOnlyMemory<byte> ElfMagic = new byte[] { 0x7F, (byte)'E', (byte)'L', (byte)'F' }; | ||||
|     private static readonly ReadOnlyMemory<byte> SymbolPrefix = new byte[] { (byte)'_', (byte)'Z', (byte)'N' }; | ||||
|  | ||||
|     private const int ChunkSize = 64 * 1024; | ||||
|     private const int OverlapSize = 48; | ||||
|     private const long MaxBinarySize = 128L * 1024L * 1024L; | ||||
|  | ||||
|     private static readonly HashSet<string> StandardCrates = new(StringComparer.Ordinal) | ||||
|     { | ||||
|         "core", | ||||
|         "alloc", | ||||
|         "std", | ||||
|         "panic_unwind", | ||||
|         "panic_abort", | ||||
|     }; | ||||
|  | ||||
|     private static readonly EnumerationOptions Enumeration = new() | ||||
|     { | ||||
|         MatchCasing = MatchCasing.CaseSensitive, | ||||
|         IgnoreInaccessible = true, | ||||
|         RecurseSubdirectories = true, | ||||
|         AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint, | ||||
|     }; | ||||
|  | ||||
|     public static IReadOnlyList<RustBinaryInfo> Scan(string rootPath, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(rootPath)) | ||||
|         { | ||||
|             throw new ArgumentException("Root path is required", nameof(rootPath)); | ||||
|         } | ||||
|  | ||||
|         var binaries = new List<RustBinaryInfo>(); | ||||
|         foreach (var path in Directory.EnumerateFiles(rootPath, "*", Enumeration)) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             if (!IsEligibleBinary(path)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var candidates = ExtractCrateNames(path, cancellationToken); | ||||
|             binaries.Add(new RustBinaryInfo(path, candidates)); | ||||
|         } | ||||
|  | ||||
|         return binaries; | ||||
|     } | ||||
|  | ||||
|     private static bool IsEligibleBinary(string path) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             var info = new FileInfo(path); | ||||
|             if (!info.Exists || info.Length == 0 || info.Length > MaxBinarySize) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             using var stream = info.OpenRead(); | ||||
|             Span<byte> buffer = stackalloc byte[4]; | ||||
|             var read = stream.Read(buffer); | ||||
|             if (read != 4) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             return buffer.SequenceEqual(ElfMagic.Span); | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<string> ExtractCrateNames(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var names = new HashSet<string>(StringComparer.Ordinal); | ||||
|         var buffer = ArrayPool<byte>.Shared.Rent(ChunkSize + OverlapSize); | ||||
|         var overlap = new byte[OverlapSize]; | ||||
|         var overlapLength = 0; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|             while (true) | ||||
|             { | ||||
|                 cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|                 // Copy previous overlap to buffer prefix. | ||||
|                 if (overlapLength > 0) | ||||
|                 { | ||||
|                     Array.Copy(overlap, 0, buffer, 0, overlapLength); | ||||
|                 } | ||||
|  | ||||
|                 var read = stream.Read(buffer, overlapLength, ChunkSize); | ||||
|                 if (read <= 0) | ||||
|                 { | ||||
|                     break; | ||||
|                 } | ||||
|  | ||||
|                 var span = new ReadOnlySpan<byte>(buffer, 0, overlapLength + read); | ||||
|                 ScanForSymbols(span, names); | ||||
|  | ||||
|                 overlapLength = Math.Min(OverlapSize, span.Length); | ||||
|                 if (overlapLength > 0) | ||||
|                 { | ||||
|                     span[^overlapLength..].CopyTo(overlap); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return ImmutableArray<string>.Empty; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return ImmutableArray<string>.Empty; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             ArrayPool<byte>.Shared.Return(buffer); | ||||
|         } | ||||
|  | ||||
|         if (names.Count == 0) | ||||
|         { | ||||
|             return ImmutableArray<string>.Empty; | ||||
|         } | ||||
|  | ||||
|         var ordered = names | ||||
|             .Where(static name => !string.IsNullOrWhiteSpace(name)) | ||||
|             .Select(static name => name.Trim()) | ||||
|             .Where(static name => name.Length > 1) | ||||
|             .Where(name => !StandardCrates.Contains(name)) | ||||
|             .Distinct(StringComparer.Ordinal) | ||||
|             .OrderBy(static name => name, StringComparer.Ordinal) | ||||
|             .ToImmutableArray(); | ||||
|  | ||||
|         return ordered; | ||||
|     } | ||||
|  | ||||
|     private static void ScanForSymbols(ReadOnlySpan<byte> span, HashSet<string> names) | ||||
|     { | ||||
|         var prefix = SymbolPrefix.Span; | ||||
|         var index = 0; | ||||
|  | ||||
|         while (index < span.Length) | ||||
|         { | ||||
|             var slice = span[index..]; | ||||
|             var offset = slice.IndexOf(prefix); | ||||
|             if (offset < 0) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             index += offset + prefix.Length; | ||||
|             if (index >= span.Length) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             var remaining = span[index..]; | ||||
|             if (!TryParseCrate(remaining, out var crate, out var consumed)) | ||||
|             { | ||||
|                 index += 1; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(crate)) | ||||
|             { | ||||
|                 names.Add(crate); | ||||
|             } | ||||
|  | ||||
|             index += Math.Max(consumed, 1); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static bool TryParseCrate(ReadOnlySpan<byte> span, out string? crate, out int consumed) | ||||
|     { | ||||
|         crate = null; | ||||
|         consumed = 0; | ||||
|  | ||||
|         var i = 0; | ||||
|         var length = 0; | ||||
|  | ||||
|         while (i < span.Length && span[i] is >= (byte)'0' and <= (byte)'9') | ||||
|         { | ||||
|             length = (length * 10) + (span[i] - (byte)'0'); | ||||
|             i++; | ||||
|  | ||||
|             if (length > 256) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (i == 0 || length <= 0 || i + length > span.Length) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         crate = Encoding.ASCII.GetString(span.Slice(i, length)); | ||||
|         consumed = i + length; | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record RustBinaryInfo(string AbsolutePath, ImmutableArray<string> CrateCandidates) | ||||
| { | ||||
|     private string? _sha256; | ||||
|  | ||||
|     public string ComputeSha256() | ||||
|     { | ||||
|         if (_sha256 is not null) | ||||
|         { | ||||
|             return _sha256; | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             using var stream = new FileStream(AbsolutePath, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|             using var sha = SHA256.Create(); | ||||
|             var hash = sha.ComputeHash(stream); | ||||
|             _sha256 = Convert.ToHexString(hash).ToLowerInvariant(); | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             _sha256 = string.Empty; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             _sha256 = string.Empty; | ||||
|         } | ||||
|  | ||||
|         return _sha256 ?? string.Empty; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,298 @@ | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||
|  | ||||
| internal static class RustCargoLockParser | ||||
| { | ||||
|     public static IReadOnlyList<RustCargoPackage> Parse(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(path)) | ||||
|         { | ||||
|             throw new ArgumentException("Lock path is required", nameof(path)); | ||||
|         } | ||||
|  | ||||
|         var info = new FileInfo(path); | ||||
|         if (!info.Exists) | ||||
|         { | ||||
|             return Array.Empty<RustCargoPackage>(); | ||||
|         } | ||||
|  | ||||
|         var packages = new List<RustCargoPackage>(); | ||||
|         using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var reader = new StreamReader(stream); | ||||
|  | ||||
|         RustCargoPackageBuilder? builder = null; | ||||
|         string? currentArrayKey = null; | ||||
|         var arrayValues = new List<string>(); | ||||
|  | ||||
|         while (!reader.EndOfStream) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var line = reader.ReadLine(); | ||||
|             if (line is null) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             var trimmed = TrimComments(line.AsSpan()); | ||||
|             if (trimmed.Length == 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (IsPackageHeader(trimmed)) | ||||
|             { | ||||
|                 FlushCurrent(builder, packages); | ||||
|                 builder = new RustCargoPackageBuilder(); | ||||
|                 currentArrayKey = null; | ||||
|                 arrayValues.Clear(); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (builder is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (currentArrayKey is not null) | ||||
|             { | ||||
|                 if (trimmed[0] == ']') | ||||
|                 { | ||||
|                     builder.SetArray(currentArrayKey, arrayValues); | ||||
|                     currentArrayKey = null; | ||||
|                     arrayValues.Clear(); | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var value = ExtractString(trimmed); | ||||
|                 if (!string.IsNullOrEmpty(value)) | ||||
|                 { | ||||
|                     arrayValues.Add(value); | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (trimmed[0] == '[') | ||||
|             { | ||||
|                 // Entering a new table; finish any pending package and skip section. | ||||
|                 FlushCurrent(builder, packages); | ||||
|                 builder = null; | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var equalsIndex = trimmed.IndexOf('='); | ||||
|             if (equalsIndex < 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var key = trimmed[..equalsIndex].Trim(); | ||||
|             var valuePart = trimmed[(equalsIndex + 1)..].Trim(); | ||||
|             if (valuePart.Length == 0) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (valuePart[0] == '[') | ||||
|             { | ||||
|                 currentArrayKey = key.ToString(); | ||||
|                 arrayValues.Clear(); | ||||
|  | ||||
|                 if (valuePart.Length > 1 && valuePart[^1] == ']') | ||||
|                 { | ||||
|                     var inline = valuePart[1..^1].Trim(); | ||||
|                     if (inline.Length > 0) | ||||
|                     { | ||||
|                         foreach (var token in SplitInlineArray(inline.ToString())) | ||||
|                         { | ||||
|                             var parsedValue = ExtractString(token.AsSpan()); | ||||
|                             if (!string.IsNullOrEmpty(parsedValue)) | ||||
|                             { | ||||
|                                 arrayValues.Add(parsedValue); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     builder.SetArray(currentArrayKey, arrayValues); | ||||
|                     currentArrayKey = null; | ||||
|                     arrayValues.Clear(); | ||||
|                 } | ||||
|  | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var parsed = ExtractString(valuePart); | ||||
|             if (parsed is not null) | ||||
|             { | ||||
|                 builder.SetField(key, parsed); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (currentArrayKey is not null && arrayValues.Count > 0) | ||||
|         { | ||||
|             builder?.SetArray(currentArrayKey, arrayValues); | ||||
|         } | ||||
|  | ||||
|         FlushCurrent(builder, packages); | ||||
|         return packages; | ||||
|     } | ||||
|  | ||||
|     private static ReadOnlySpan<char> TrimComments(ReadOnlySpan<char> line) | ||||
|     { | ||||
|         var index = line.IndexOf('#'); | ||||
|         if (index >= 0) | ||||
|         { | ||||
|             line = line[..index]; | ||||
|         } | ||||
|  | ||||
|         return line.Trim(); | ||||
|     } | ||||
|  | ||||
|     private static bool IsPackageHeader(ReadOnlySpan<char> value) | ||||
|         => value.SequenceEqual("[[package]]".AsSpan()); | ||||
|  | ||||
|     private static IEnumerable<string> SplitInlineArray(string value) | ||||
|     { | ||||
|         var start = 0; | ||||
|         var inString = false; | ||||
|  | ||||
|         for (var i = 0; i < value.Length; i++) | ||||
|         { | ||||
|             var current = value[i]; | ||||
|  | ||||
|             if (current == '"') | ||||
|             { | ||||
|                 inString = !inString; | ||||
|             } | ||||
|  | ||||
|             if (current == ',' && !inString) | ||||
|             { | ||||
|                 var item = value.AsSpan(start, i - start).Trim(); | ||||
|                 if (item.Length > 0) | ||||
|                 { | ||||
|                     yield return item.ToString(); | ||||
|                 } | ||||
|  | ||||
|                 start = i + 1; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (start < value.Length) | ||||
|         { | ||||
|             var item = value.AsSpan(start).Trim(); | ||||
|             if (item.Length > 0) | ||||
|             { | ||||
|                 yield return item.ToString(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string? ExtractString(ReadOnlySpan<char> value) | ||||
|     { | ||||
|         if (value.Length == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (value[0] == '"' && value[^1] == '"') | ||||
|         { | ||||
|             var inner = value[1..^1]; | ||||
|             return inner.ToString(); | ||||
|         } | ||||
|  | ||||
|         var trimmed = value.Trim(); | ||||
|         return trimmed.Length == 0 ? null : trimmed.ToString(); | ||||
|     } | ||||
|  | ||||
|     private static void FlushCurrent(RustCargoPackageBuilder? builder, List<RustCargoPackage> packages) | ||||
|     { | ||||
|         if (builder is null || !builder.HasData) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (builder.TryBuild(out var package)) | ||||
|         { | ||||
|             packages.Add(package); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class RustCargoPackageBuilder | ||||
|     { | ||||
|         private readonly SortedSet<string> _dependencies = new(StringComparer.Ordinal); | ||||
|  | ||||
|         private string? _name; | ||||
|         private string? _version; | ||||
|         private string? _source; | ||||
|         private string? _checksum; | ||||
|  | ||||
|         public bool HasData => !string.IsNullOrWhiteSpace(_name); | ||||
|  | ||||
|         public void SetField(ReadOnlySpan<char> key, string value) | ||||
|         { | ||||
|             if (key.SequenceEqual("name".AsSpan())) | ||||
|             { | ||||
|                 _name ??= value.Trim(); | ||||
|             } | ||||
|             else if (key.SequenceEqual("version".AsSpan())) | ||||
|             { | ||||
|                 _version ??= value.Trim(); | ||||
|             } | ||||
|             else if (key.SequenceEqual("source".AsSpan())) | ||||
|             { | ||||
|                 _source ??= value.Trim(); | ||||
|             } | ||||
|             else if (key.SequenceEqual("checksum".AsSpan())) | ||||
|             { | ||||
|                 _checksum ??= value.Trim(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public void SetArray(string key, IEnumerable<string> values) | ||||
|         { | ||||
|             if (!string.Equals(key, "dependencies", StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             foreach (var entry in values) | ||||
|             { | ||||
|                 if (string.IsNullOrWhiteSpace(entry)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var normalized = entry.Trim(); | ||||
|                 if (normalized.Length > 0) | ||||
|                 { | ||||
|                     _dependencies.Add(normalized); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public bool TryBuild(out RustCargoPackage package) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(_name)) | ||||
|             { | ||||
|                 package = null!; | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             package = new RustCargoPackage( | ||||
|                 _name!, | ||||
|                 _version ?? string.Empty, | ||||
|                 _source, | ||||
|                 _checksum, | ||||
|                 _dependencies.ToArray()); | ||||
|  | ||||
|             return true; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record RustCargoPackage( | ||||
|     string Name, | ||||
|     string Version, | ||||
|     string? Source, | ||||
|     string? Checksum, | ||||
|     IReadOnlyList<string> Dependencies); | ||||
| @@ -0,0 +1,178 @@ | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||
|  | ||||
| internal static class RustFingerprintScanner | ||||
| { | ||||
|     private static readonly EnumerationOptions Enumeration = new() | ||||
|     { | ||||
|         MatchCasing = MatchCasing.CaseSensitive, | ||||
|         IgnoreInaccessible = true, | ||||
|         RecurseSubdirectories = true, | ||||
|         AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint, | ||||
|     }; | ||||
|  | ||||
|     private static readonly string FingerprintSegment = $"{Path.DirectorySeparatorChar}.fingerprint{Path.DirectorySeparatorChar}"; | ||||
|  | ||||
|     public static IReadOnlyList<RustFingerprintRecord> Scan(string rootPath, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(rootPath)) | ||||
|         { | ||||
|             throw new ArgumentException("Root path is required", nameof(rootPath)); | ||||
|         } | ||||
|  | ||||
|         var results = new List<RustFingerprintRecord>(); | ||||
|         foreach (var path in Directory.EnumerateFiles(rootPath, "*.json", Enumeration)) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             if (!path.Contains(FingerprintSegment, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (TryParse(path, out var record)) | ||||
|             { | ||||
|                 results.Add(record); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return results; | ||||
|     } | ||||
|  | ||||
|     private static bool TryParse(string path, out RustFingerprintRecord record) | ||||
|     { | ||||
|         record = default!; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|             using var document = JsonDocument.Parse(stream); | ||||
|             var root = document.RootElement; | ||||
|  | ||||
|             var pkgId = TryGetString(root, "pkgid") | ||||
|                 ?? TryGetString(root, "package_id") | ||||
|                 ?? TryGetString(root, "packageId"); | ||||
|  | ||||
|             var (name, version, source) = ParseIdentity(pkgId, path); | ||||
|             if (string.IsNullOrWhiteSpace(name)) | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             var profile = TryGetString(root, "profile"); | ||||
|             var targetKind = TryGetKind(root); | ||||
|  | ||||
|             record = new RustFingerprintRecord( | ||||
|                 Name: name!, | ||||
|                 Version: version, | ||||
|                 Source: source, | ||||
|                 TargetKind: targetKind, | ||||
|                 Profile: profile, | ||||
|                 AbsolutePath: path); | ||||
|  | ||||
|             return true; | ||||
|         } | ||||
|         catch (JsonException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|         catch (IOException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|         catch (UnauthorizedAccessException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static (string? Name, string? Version, string? Source) ParseIdentity(string? pkgId, string filePath) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(pkgId)) | ||||
|         { | ||||
|             var span = pkgId.AsSpan().Trim(); | ||||
|             var firstSpace = span.IndexOf(' '); | ||||
|             if (firstSpace > 0 && firstSpace < span.Length - 1) | ||||
|             { | ||||
|                 var name = span[..firstSpace].ToString(); | ||||
|                 var remaining = span[(firstSpace + 1)..].Trim(); | ||||
|  | ||||
|                 var secondSpace = remaining.IndexOf(' '); | ||||
|                 if (secondSpace < 0) | ||||
|                 { | ||||
|                     return (name, remaining.ToString(), null); | ||||
|                 } | ||||
|  | ||||
|                 var version = remaining[..secondSpace].ToString(); | ||||
|                 var potentialSource = remaining[(secondSpace + 1)..].Trim(); | ||||
|  | ||||
|                 if (potentialSource.Length > 1 && potentialSource[0] == '(' && potentialSource[^1] == ')') | ||||
|                 { | ||||
|                     potentialSource = potentialSource[1..^1].Trim(); | ||||
|                 } | ||||
|  | ||||
|                 var source = potentialSource.Length == 0 ? null : potentialSource.ToString(); | ||||
|                 return (name, version, source); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var directory = Path.GetDirectoryName(filePath); | ||||
|         if (string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             return (null, null, null); | ||||
|         } | ||||
|  | ||||
|         var crateDirectory = Path.GetFileName(directory); | ||||
|         if (string.IsNullOrWhiteSpace(crateDirectory)) | ||||
|         { | ||||
|             return (null, null, null); | ||||
|         } | ||||
|  | ||||
|         var dashIndex = crateDirectory.LastIndexOf('-'); | ||||
|         if (dashIndex <= 0) | ||||
|         { | ||||
|             return (crateDirectory, null, null); | ||||
|         } | ||||
|  | ||||
|         var maybeName = crateDirectory[..dashIndex]; | ||||
|         return (maybeName, null, null); | ||||
|     } | ||||
|  | ||||
|     private static string? TryGetKind(JsonElement root) | ||||
|     { | ||||
|         if (root.TryGetProperty("target_kind", out var array) && array.ValueKind == JsonValueKind.Array && array.GetArrayLength() > 0) | ||||
|         { | ||||
|             var first = array[0]; | ||||
|             if (first.ValueKind == JsonValueKind.String) | ||||
|             { | ||||
|                 return first.GetString(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (root.TryGetProperty("target", out var target) && target.ValueKind == JsonValueKind.String) | ||||
|         { | ||||
|             return target.GetString(); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static string? TryGetString(JsonElement element, string propertyName) | ||||
|     { | ||||
|         if (element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String) | ||||
|         { | ||||
|             return value.GetString(); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record RustFingerprintRecord( | ||||
|     string Name, | ||||
|     string? Version, | ||||
|     string? Source, | ||||
|     string? TargetKind, | ||||
|     string? Profile, | ||||
|     string AbsolutePath); | ||||
| @@ -1,6 +0,0 @@ | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust; | ||||
|  | ||||
| internal static class Placeholder | ||||
| { | ||||
|     // Analyzer implementation will be added during Sprint LA5. | ||||
| } | ||||
| @@ -7,7 +7,7 @@ public sealed class RustAnalyzerPlugin : ILanguageAnalyzerPlugin | ||||
| { | ||||
|     public string Name => "StellaOps.Scanner.Analyzers.Lang.Rust"; | ||||
|  | ||||
|     public bool IsAvailable(IServiceProvider services) => false; | ||||
|     public bool IsAvailable(IServiceProvider services) => services is not null; | ||||
|  | ||||
|     public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services) | ||||
|     { | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Rust.Internal; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Rust; | ||||
|  | ||||
| @@ -11,5 +12,55 @@ public sealed class RustLanguageAnalyzer : ILanguageAnalyzer | ||||
|     public string DisplayName => "Rust Analyzer (preview)"; | ||||
|  | ||||
|     public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken) | ||||
|         => ValueTask.FromException(new NotImplementedException("Rust analyzer implementation pending Sprint LA5.")); | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|         ArgumentNullException.ThrowIfNull(writer); | ||||
|  | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         var collection = RustAnalyzerCollector.Collect(context, cancellationToken); | ||||
|  | ||||
|         EmitRecords(Id, writer, collection.Crates); | ||||
|         EmitRecords(Id, writer, collection.Heuristics); | ||||
|         EmitRecords(Id, writer, collection.Fallbacks); | ||||
|  | ||||
|         return ValueTask.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     private static void EmitRecords(string analyzerId, LanguageComponentWriter writer, IReadOnlyList<RustComponentRecord> records) | ||||
|     { | ||||
|         foreach (var record in records) | ||||
|         { | ||||
|             if (record is null) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrEmpty(record.Purl)) | ||||
|             { | ||||
|                 writer.AddFromPurl( | ||||
|                     analyzerId: analyzerId, | ||||
|                     purl: record.Purl!, | ||||
|                     name: record.Name, | ||||
|                     version: record.Version, | ||||
|                     type: record.Type, | ||||
|                     metadata: record.Metadata, | ||||
|                     evidence: record.Evidence, | ||||
|                     usedByEntrypoint: record.UsedByEntrypoint); | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 writer.AddFromExplicitKey( | ||||
|                     analyzerId: analyzerId, | ||||
|                     componentKey: record.ComponentKey, | ||||
|                     purl: null, | ||||
|                     name: record.Name, | ||||
|                     version: record.Version, | ||||
|                     type: record.Type, | ||||
|                     metadata: record.Metadata, | ||||
|                     evidence: record.Evidence, | ||||
|                     usedByEntrypoint: record.UsedByEntrypoint); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -2,9 +2,9 @@ | ||||
|  | ||||
| | Seq | ID | Status | Depends on | Description | Exit Criteria | | ||||
| |-----|----|--------|------------|-------------|---------------| | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-306A | TODO | SCANNER-ANALYZERS-LANG-10-307 | Parse Cargo metadata (`Cargo.lock`, `.fingerprint`, `.metadata`) and map crates to components with evidence. | Fixtures confirm crate attribution ≥85 % coverage; metadata normalized; evidence includes path + hash. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-306B | TODO | SCANNER-ANALYZERS-LANG-10-306A | Implement heuristic classifier using ELF section names, symbol mangling, and `.comment` data for stripped binaries. | Heuristic output flagged as `heuristic`; regression tests ensure no false “observed” classifications. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-306C | TODO | SCANNER-ANALYZERS-LANG-10-306B | Integrate binary hash fallback (`bin:{sha256}`) and tie into shared quiet provenance helpers. | Fallback path deterministic; shared helpers reused; tests verify consistent hashing. | | ||||
| | 1 | SCANNER-ANALYZERS-LANG-10-306A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse Cargo metadata (`Cargo.lock`, `.fingerprint`, `.metadata`) and map crates to components with evidence. | Fixtures confirm crate attribution ≥85 % coverage; metadata normalized; evidence includes path + hash. | | ||||
| | 2 | SCANNER-ANALYZERS-LANG-10-306B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-306A | Implement heuristic classifier using ELF section names, symbol mangling, and `.comment` data for stripped binaries. | Heuristic output flagged as `heuristic`; regression tests ensure no false “observed” classifications. | | ||||
| | 3 | SCANNER-ANALYZERS-LANG-10-306C | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-306B | Integrate binary hash fallback (`bin:{sha256}`) and tie into shared quiet provenance helpers. | Fallback path deterministic; shared helpers reused; tests verify consistent hashing. | | ||||
| | 4 | SCANNER-ANALYZERS-LANG-10-307R | TODO | SCANNER-ANALYZERS-LANG-10-306C | Finalize shared helper usage (license, usage flags) and concurrency-safe caches. | Analyzer uses shared utilities; concurrency tests pass; no race conditions. | | ||||
| | 5 | SCANNER-ANALYZERS-LANG-10-308R | TODO | SCANNER-ANALYZERS-LANG-10-307R | Determinism fixtures + performance benchmarks; compare against competitor heuristic coverage. | Fixtures `Fixtures/lang/rust/` committed; determinism guard; benchmark shows ≥15 % better coverage vs competitor. | | ||||
| | 6 | SCANNER-ANALYZERS-LANG-10-309R | TODO | SCANNER-ANALYZERS-LANG-10-308R | Package plug-in manifest + Offline Kit documentation; ensure Worker integration. | Manifest copied; Worker loads analyzer; Offline Kit doc updated. | | ||||
|   | ||||
| @@ -1,4 +1,7 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Scanner.Analyzers.Lang.DotNet; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||
| @@ -25,4 +28,79 @@ public sealed class DotNetLanguageAnalyzerTests | ||||
|             analyzers, | ||||
|             cancellationToken); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task SignedFixtureCapturesAssemblyMetadataAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "signed"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
|  | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new DotNetLanguageAnalyzer() | ||||
|         }; | ||||
|  | ||||
|         var inspector = new StubAuthenticodeInspector(); | ||||
|         var services = new SingleServiceProvider(inspector); | ||||
|  | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken, | ||||
|             usageHints: null, | ||||
|             services: services); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task SelfContainedFixtureHandlesNativeAssetsAndUsageAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "selfcontained"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
|  | ||||
|         var usageHints = new LanguageUsageHints(new[] | ||||
|         { | ||||
|             Path.Combine(fixturePath, "lib", "net10.0", "StellaOps.Toolkit.dll"), | ||||
|             Path.Combine(fixturePath, "runtimes", "linux-x64", "native", "libstellaopsnative.so") | ||||
|         }); | ||||
|  | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new DotNetLanguageAnalyzer() | ||||
|         }; | ||||
|  | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken, | ||||
|             usageHints); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubAuthenticodeInspector : IDotNetAuthenticodeInspector | ||||
|     { | ||||
|         public DotNetAuthenticodeMetadata? TryInspect(string assemblyPath, CancellationToken cancellationToken) | ||||
|             => new DotNetAuthenticodeMetadata( | ||||
|                 Subject: "CN=StellaOps Test Signing", | ||||
|                 Issuer: "CN=StellaOps Root", | ||||
|                 NotBefore: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), | ||||
|                 NotAfter: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), | ||||
|                 Thumbprint: "AA11BB22CC33DD44EE55FF66GG77HH88II99JJ00", | ||||
|                 SerialNumber: "0123456789ABCDEF"); | ||||
|     } | ||||
|  | ||||
|     private sealed class SingleServiceProvider : IServiceProvider | ||||
|     { | ||||
|         private readonly object _service; | ||||
|  | ||||
|         public SingleServiceProvider(object service) | ||||
|         { | ||||
|             _service = service; | ||||
|         } | ||||
|  | ||||
|         public object? GetService(Type serviceType) | ||||
|             => serviceType == typeof(IDotNetAuthenticodeInspector) ? _service : null; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,85 @@ | ||||
| { | ||||
|   "runtimeTarget": { | ||||
|     "name": ".NETCoreApp,Version=v10.0", | ||||
|     "signature": null | ||||
|   }, | ||||
|   "targets": { | ||||
|     ".NETCoreApp,Version=v10.0/linux-x64": { | ||||
|       "MyApp/1.0.0": { | ||||
|         "dependencies": { | ||||
|           "StellaOps.Toolkit": "1.2.3", | ||||
|           "StellaOps.Runtime.SelfContained": "2.1.0" | ||||
|         }, | ||||
|         "runtime": { | ||||
|           "MyApp.dll": {} | ||||
|         } | ||||
|       }, | ||||
|       "StellaOps.Toolkit/1.2.3": { | ||||
|         "runtime": { | ||||
|           "lib/net10.0/StellaOps.Toolkit.dll": { | ||||
|             "assemblyVersion": "1.2.3.0", | ||||
|             "fileVersion": "1.2.3.0" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       "StellaOps.Runtime.SelfContained/2.1.0": { | ||||
|         "runtimeTargets": { | ||||
|           "runtimes/linux-x64/native/libstellaopsnative.so": { | ||||
|             "rid": "linux-x64", | ||||
|             "assetType": "native" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     ".NETCoreApp,Version=v10.0/win-x64": { | ||||
|       "MyApp/1.0.0": { | ||||
|         "dependencies": { | ||||
|           "StellaOps.Toolkit": "1.2.3", | ||||
|           "StellaOps.Runtime.SelfContained": "2.1.0" | ||||
|         }, | ||||
|         "runtime": { | ||||
|           "MyApp.dll": {} | ||||
|         } | ||||
|       }, | ||||
|       "StellaOps.Toolkit/1.2.3": { | ||||
|         "runtime": { | ||||
|           "lib/net10.0/StellaOps.Toolkit.dll": { | ||||
|             "assemblyVersion": "1.2.3.0", | ||||
|             "fileVersion": "1.2.3.0" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       "StellaOps.Runtime.SelfContained/2.1.0": { | ||||
|         "runtimeTargets": { | ||||
|           "runtimes/win-x64/native/stellaopsnative.dll": { | ||||
|             "rid": "win-x64", | ||||
|             "assetType": "native" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "libraries": { | ||||
|     "MyApp/1.0.0": { | ||||
|       "type": "project", | ||||
|       "serviceable": false, | ||||
|       "sha512": "", | ||||
|       "path": null, | ||||
|       "hashPath": null | ||||
|     }, | ||||
|     "StellaOps.Toolkit/1.2.3": { | ||||
|       "type": "package", | ||||
|       "serviceable": true, | ||||
|       "sha512": "sha512-FAKE_TOOLKIT_SHA==", | ||||
|       "path": "stellaops.toolkit/1.2.3", | ||||
|       "hashPath": "stellaops.toolkit.1.2.3.nupkg.sha512" | ||||
|     }, | ||||
|     "StellaOps.Runtime.SelfContained/2.1.0": { | ||||
|       "type": "package", | ||||
|       "serviceable": true, | ||||
|       "sha512": "sha512-FAKE_RUNTIME_SHA==", | ||||
|       "path": "stellaops.runtime.selfcontained/2.1.0", | ||||
|       "hashPath": "stellaops.runtime.selfcontained.2.1.0.nupkg.sha512" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| { | ||||
|   "runtimeOptions": { | ||||
|     "tfm": "net10.0", | ||||
|     "framework": { | ||||
|       "name": "Microsoft.NETCore.App", | ||||
|       "version": "10.0.0" | ||||
|     }, | ||||
|     "includedFrameworks": [ | ||||
|       { | ||||
|         "name": "Microsoft.NETCore.DotNetAppHost", | ||||
|         "version": "10.0.0" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,101 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "dotnet", | ||||
|     "componentKey": "purl::pkg:nuget/stellaops.runtime.selfcontained@2.1.0", | ||||
|     "purl": "pkg:nuget/stellaops.runtime.selfcontained@2.1.0", | ||||
|     "name": "StellaOps.Runtime.SelfContained", | ||||
|     "version": "2.1.0", | ||||
|     "type": "nuget", | ||||
|     "usedByEntrypoint": true, | ||||
|     "metadata": { | ||||
|       "deps.path[0]": "MyApp.deps.json", | ||||
|       "deps.rid[0]": "linux-x64", | ||||
|       "deps.rid[1]": "win-x64", | ||||
|       "deps.tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "native[0].assetPath": "runtimes/linux-x64/native/libstellaopsnative.so", | ||||
|       "native[0].path": "runtimes/linux-x64/native/libstellaopsnative.so", | ||||
|       "native[0].rid[0]": "linux-x64", | ||||
|       "native[0].sha256": "c22d4a6584a3bb8fad4d255d1ab9e5a80d553eec35ea8dfcc2dd750e8581d3cb", | ||||
|       "native[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "native[1].assetPath": "runtimes/win-x64/native/stellaopsnative.dll", | ||||
|       "native[1].path": "runtimes/win-x64/native/stellaopsnative.dll", | ||||
|       "native[1].rid[0]": "win-x64", | ||||
|       "native[1].sha256": "29cddd69702aedc715050304bec85aad2ae017ee1f9390df5e68ebe79a8d4745", | ||||
|       "native[1].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "package.hashPath[0]": "stellaops.runtime.selfcontained.2.1.0.nupkg.sha512", | ||||
|       "package.id": "StellaOps.Runtime.SelfContained", | ||||
|       "package.id.normalized": "stellaops.runtime.selfcontained", | ||||
|       "package.path[0]": "stellaops.runtime.selfcontained/2.1.0", | ||||
|       "package.serviceable": "true", | ||||
|       "package.sha512[0]": "sha512-FAKE_RUNTIME_SHA==", | ||||
|       "package.version": "2.1.0" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "deps.json", | ||||
|         "locator": "MyApp.deps.json", | ||||
|         "value": "StellaOps.Runtime.SelfContained/2.1.0" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "native", | ||||
|         "locator": "runtimes/linux-x64/native/libstellaopsnative.so", | ||||
|         "value": "runtimes/linux-x64/native/libstellaopsnative.so", | ||||
|         "sha256": "c22d4a6584a3bb8fad4d255d1ab9e5a80d553eec35ea8dfcc2dd750e8581d3cb" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "native", | ||||
|         "locator": "runtimes/win-x64/native/stellaopsnative.dll", | ||||
|         "value": "runtimes/win-x64/native/stellaopsnative.dll", | ||||
|         "sha256": "29cddd69702aedc715050304bec85aad2ae017ee1f9390df5e68ebe79a8d4745" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "dotnet", | ||||
|     "componentKey": "purl::pkg:nuget/stellaops.toolkit@1.2.3", | ||||
|     "purl": "pkg:nuget/stellaops.toolkit@1.2.3", | ||||
|     "name": "StellaOps.Toolkit", | ||||
|     "version": "1.2.3", | ||||
|     "type": "nuget", | ||||
|     "usedByEntrypoint": true, | ||||
|     "metadata": { | ||||
|       "assembly[0].assetPath": "lib/net10.0/StellaOps.Toolkit.dll", | ||||
|       "assembly[0].fileVersion": "1.2.3.0", | ||||
|       "assembly[0].path": "lib/net10.0/StellaOps.Toolkit.dll", | ||||
|       "assembly[0].rid[0]": "linux-x64", | ||||
|       "assembly[0].rid[1]": "win-x64", | ||||
|       "assembly[0].sha256": "5b82fd11cf6c2ba6b351592587c4203f6af48b89427b954903534eac0e9f17f7", | ||||
|       "assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "assembly[0].version": "1.2.3.0", | ||||
|       "deps.path[0]": "MyApp.deps.json", | ||||
|       "deps.rid[0]": "linux-x64", | ||||
|       "deps.rid[1]": "win-x64", | ||||
|       "deps.tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "package.hashPath[0]": "stellaops.toolkit.1.2.3.nupkg.sha512", | ||||
|       "package.id": "StellaOps.Toolkit", | ||||
|       "package.id.normalized": "stellaops.toolkit", | ||||
|       "package.path[0]": "stellaops.toolkit/1.2.3", | ||||
|       "package.serviceable": "true", | ||||
|       "package.sha512[0]": "sha512-FAKE_TOOLKIT_SHA==", | ||||
|       "package.version": "1.2.3" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "assembly", | ||||
|         "locator": "lib/net10.0/StellaOps.Toolkit.dll", | ||||
|         "value": "lib/net10.0/StellaOps.Toolkit.dll", | ||||
|         "sha256": "5b82fd11cf6c2ba6b351592587c4203f6af48b89427b954903534eac0e9f17f7" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "deps.json", | ||||
|         "locator": "MyApp.deps.json", | ||||
|         "value": "StellaOps.Toolkit/1.2.3" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| @@ -0,0 +1 @@ | ||||
| ELF FAKE STELLAOPS NATIVE LIB | ||||
| @@ -0,0 +1,42 @@ | ||||
| { | ||||
|   "runtimeTarget": { | ||||
|     "name": ".NETCoreApp,Version=v10.0/linux-x64" | ||||
|   }, | ||||
|   "targets": { | ||||
|     ".NETCoreApp,Version=v10.0": { | ||||
|       "Signed.App/1.0.0": { | ||||
|         "dependencies": { | ||||
|           "Microsoft.Extensions.Logging": "9.0.0" | ||||
|         } | ||||
|       }, | ||||
|       "Microsoft.Extensions.Logging/9.0.0": { | ||||
|         "runtime": { | ||||
|           "lib/net9.0/Microsoft.Extensions.Logging.dll": { | ||||
|             "assemblyVersion": "9.0.0.0", | ||||
|             "fileVersion": "9.0.24.52809" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     ".NETCoreApp,Version=v10.0/linux-x64": { | ||||
|       "Microsoft.Extensions.Logging/9.0.0": { | ||||
|         "runtime": { | ||||
|           "runtimes/linux-x64/lib/net9.0/Microsoft.Extensions.Logging.dll": {} | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "libraries": { | ||||
|     "Signed.App/1.0.0": { | ||||
|       "type": "project", | ||||
|       "serviceable": false | ||||
|     }, | ||||
|     "Microsoft.Extensions.Logging/9.0.0": { | ||||
|       "type": "package", | ||||
|       "serviceable": true, | ||||
|       "sha512": "sha512-FAKE_LOGGING_SHA==", | ||||
|       "path": "microsoft.extensions.logging/9.0.0", | ||||
|       "hashPath": "microsoft.extensions.logging.9.0.0.nupkg.sha512" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,9 @@ | ||||
| { | ||||
|   "runtimeOptions": { | ||||
|     "tfm": "net10.0", | ||||
|     "framework": { | ||||
|       "name": "Microsoft.NETCore.App", | ||||
|       "version": "10.0.0" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,59 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "dotnet", | ||||
|     "componentKey": "purl::pkg:nuget/microsoft.extensions.logging@9.0.0", | ||||
|     "purl": "pkg:nuget/microsoft.extensions.logging@9.0.0", | ||||
|     "name": "Microsoft.Extensions.Logging", | ||||
|     "version": "9.0.0", | ||||
|     "type": "nuget", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "assembly[0].assetPath": "lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||
|       "assembly[0].authenticode.issuer": "CN=StellaOps Root", | ||||
|       "assembly[0].authenticode.notAfter": "2026-01-01T00:00:00.000Z", | ||||
|       "assembly[0].authenticode.notBefore": "2025-01-01T00:00:00.000Z", | ||||
|       "assembly[0].authenticode.serialNumber": "0123456789ABCDEF", | ||||
|       "assembly[0].authenticode.subject": "CN=StellaOps Test Signing", | ||||
|       "assembly[0].authenticode.thumbprint": "AA11BB22CC33DD44EE55FF66GG77HH88II99JJ00", | ||||
|       "assembly[0].company": "Microsoft Corporation", | ||||
|       "assembly[0].fileDescription": "Microsoft.Extensions.Logging", | ||||
|       "assembly[0].fileVersion": "9.0.24.52809", | ||||
|       "assembly[0].path": "packages/microsoft.extensions.logging/9.0.0/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||
|       "assembly[0].product": "Microsoft\u00ae .NET", | ||||
|       "assembly[0].productVersion": "9.0.0+9d5a6a9aa463d6d10b0b0ba6d5982cc82f363dc3", | ||||
|       "assembly[0].publicKeyToken": "adb9793829ddae60", | ||||
|       "assembly[0].sha256": "faed6cb5c9ca0d6077feaeb2df251251adccf0241f7a80b91c58e014cd5ad48f", | ||||
|       "assembly[0].strongName": "Microsoft.Extensions.Logging, Version=9.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60", | ||||
|       "assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "assembly[0].version": "9.0.0.0", | ||||
|       "assembly[1].assetPath": "runtimes/linux-x64/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||
|       "assembly[1].rid[0]": "linux-x64", | ||||
|       "assembly[1].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "deps.path[0]": "Signed.App.deps.json", | ||||
|       "deps.rid[0]": "linux-x64", | ||||
|       "deps.tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "package.hashPath[0]": "microsoft.extensions.logging.9.0.0.nupkg.sha512", | ||||
|       "package.id": "Microsoft.Extensions.Logging", | ||||
|       "package.id.normalized": "microsoft.extensions.logging", | ||||
|       "package.path[0]": "microsoft.extensions.logging/9.0.0", | ||||
|       "package.serviceable": "true", | ||||
|       "package.sha512[0]": "sha512-FAKE_LOGGING_SHA==", | ||||
|       "package.version": "9.0.0" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "assembly", | ||||
|         "locator": "packages/microsoft.extensions.logging/9.0.0/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||
|         "value": "lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||
|         "sha256": "faed6cb5c9ca0d6077feaeb2df251251adccf0241f7a80b91c58e014cd5ad48f" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "deps.json", | ||||
|         "locator": "Signed.App.deps.json", | ||||
|         "value": "Microsoft.Extensions.Logging/9.0.0" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| @@ -8,6 +8,16 @@ | ||||
|     "type": "nuget", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "assembly[0].assetPath": "lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||
|       "assembly[0].fileVersion": "9.0.24.52809", | ||||
|       "assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "assembly[0].version": "9.0.0.0", | ||||
|       "assembly[1].assetPath": "runtimes/linux-x64/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||
|       "assembly[1].rid[0]": "linux-x64", | ||||
|       "assembly[1].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "assembly[2].assetPath": "runtimes/win-x86/lib/net9.0/Microsoft.Extensions.Logging.dll", | ||||
|       "assembly[2].rid[0]": "win-x86", | ||||
|       "assembly[2].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "deps.path[0]": "Sample.App.deps.json", | ||||
|       "deps.rid[0]": "linux-x64", | ||||
|       "deps.rid[1]": "win-x86", | ||||
| @@ -38,6 +48,10 @@ | ||||
|     "type": "nuget", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "assembly[0].assetPath": "lib/net10.0/StellaOps.Toolkit.dll", | ||||
|       "assembly[0].fileVersion": "1.2.3.0", | ||||
|       "assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0", | ||||
|       "assembly[0].version": "1.2.3.0", | ||||
|       "deps.dependency[0]": "microsoft.extensions.logging", | ||||
|       "deps.path[0]": "Sample.App.deps.json", | ||||
|       "deps.rid[0]": "linux-x64", | ||||
|   | ||||
							
								
								
									
										12
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/rust/simple/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Scanner.Analyzers.Lang.Tests/Fixtures/lang/rust/simple/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| [[package]] | ||||
| name = "my_app" | ||||
| version = "0.1.0" | ||||
| dependencies = [ | ||||
|  "serde", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "serde" | ||||
| version = "1.0.188" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "abc123" | ||||
| @@ -0,0 +1,90 @@ | ||||
| [ | ||||
|   { | ||||
|     "analyzerId": "rust", | ||||
|     "componentKey": "bin::sha256:22caa7413d89026b52db64c8abc254bf9e7647ab9216e79c6972a39451f8c41e", | ||||
|     "name": "unknown_tool", | ||||
|     "type": "bin", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "binary.path": "usr/local/bin/unknown_tool", | ||||
|       "binary.sha256": "22caa7413d89026b52db64c8abc254bf9e7647ab9216e79c6972a39451f8c41e", | ||||
|       "provenance": "binary" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "binary", | ||||
|         "locator": "usr/local/bin/unknown_tool", | ||||
|         "sha256": "22caa7413d89026b52db64c8abc254bf9e7647ab9216e79c6972a39451f8c41e" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "rust", | ||||
|     "componentKey": "purl::pkg:cargo/my_app@0.1.0", | ||||
|     "purl": "pkg:cargo/my_app@0.1.0", | ||||
|     "name": "my_app", | ||||
|     "version": "0.1.0", | ||||
|     "type": "cargo", | ||||
|     "usedByEntrypoint": true, | ||||
|     "metadata": { | ||||
|       "binary.paths": "usr/local/bin/my_app", | ||||
|       "binary.sha256": "a95a4f4854bf973deacbd937bd1189fc3d0eef7a4fd4f7960f37cf66162c82fd", | ||||
|       "cargo.lock.path": "Cargo.lock", | ||||
|       "fingerprint.profile": "debug", | ||||
|       "fingerprint.targetKind": "bin", | ||||
|       "source": "registry\u002Bhttps://github.com/rust-lang/crates.io-index" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "binary", | ||||
|         "locator": "usr/local/bin/my_app", | ||||
|         "sha256": "a95a4f4854bf973deacbd937bd1189fc3d0eef7a4fd4f7960f37cf66162c82fd" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "cargo.fingerprint", | ||||
|         "locator": "target/debug/.fingerprint/my_app-1234567890abcdef/bin-my_app-1234567890abcdef.json", | ||||
|         "value": "bin" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "cargo.lock", | ||||
|         "locator": "Cargo.lock", | ||||
|         "value": "my_app 0.1.0" | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "analyzerId": "rust", | ||||
|     "componentKey": "purl::pkg:cargo/serde@1.0.188", | ||||
|     "purl": "pkg:cargo/serde@1.0.188", | ||||
|     "name": "serde", | ||||
|     "version": "1.0.188", | ||||
|     "type": "cargo", | ||||
|     "usedByEntrypoint": false, | ||||
|     "metadata": { | ||||
|       "cargo.lock.path": "Cargo.lock", | ||||
|       "checksum": "abc123", | ||||
|       "fingerprint.profile": "release", | ||||
|       "fingerprint.targetKind": "lib", | ||||
|       "source": "registry\u002Bhttps://github.com/rust-lang/crates.io-index" | ||||
|     }, | ||||
|     "evidence": [ | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "cargo.fingerprint", | ||||
|         "locator": "target/debug/.fingerprint/serde-abcdef1234567890/libserde-abcdef1234567890.json", | ||||
|         "value": "lib" | ||||
|       }, | ||||
|       { | ||||
|         "kind": "file", | ||||
|         "source": "cargo.lock", | ||||
|         "locator": "Cargo.lock", | ||||
|         "value": "serde 1.0.188", | ||||
|         "sha256": "abc123" | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| ] | ||||
| @@ -0,0 +1,5 @@ | ||||
| { | ||||
|   "pkgid": "my_app 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", | ||||
|   "profile": "debug", | ||||
|   "target_kind": ["bin"] | ||||
| } | ||||
| @@ -0,0 +1,5 @@ | ||||
| { | ||||
|   "pkgid": "serde 1.0.188 (registry+https://github.com/rust-lang/crates.io-index)", | ||||
|   "profile": "release", | ||||
|   "target_kind": ["lib"] | ||||
| } | ||||
| @@ -4,23 +4,23 @@ namespace StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
|  | ||||
| public static class LanguageAnalyzerTestHarness | ||||
| { | ||||
|     public static async Task<string> RunToJsonAsync(string fixturePath, IEnumerable<ILanguageAnalyzer> analyzers, CancellationToken cancellationToken = default, LanguageUsageHints? usageHints = null) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(fixturePath)) | ||||
|         { | ||||
|             throw new ArgumentException("Fixture path is required", nameof(fixturePath)); | ||||
|         } | ||||
|  | ||||
|         var engine = new LanguageAnalyzerEngine(analyzers ?? Array.Empty<ILanguageAnalyzer>()); | ||||
|         var context = new LanguageAnalyzerContext(fixturePath, TimeProvider.System, usageHints); | ||||
|         var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false); | ||||
|         return result.ToJson(indent: true); | ||||
|     } | ||||
|  | ||||
|     public static async Task AssertDeterministicAsync(string fixturePath, string goldenPath, IEnumerable<ILanguageAnalyzer> analyzers, CancellationToken cancellationToken = default, LanguageUsageHints? usageHints = null) | ||||
|     { | ||||
|         var actual = await RunToJsonAsync(fixturePath, analyzers, cancellationToken, usageHints).ConfigureAwait(false); | ||||
|         var expected = await File.ReadAllTextAsync(goldenPath, cancellationToken).ConfigureAwait(false); | ||||
|     public static async Task<string> RunToJsonAsync(string fixturePath, IEnumerable<ILanguageAnalyzer> analyzers, CancellationToken cancellationToken = default, LanguageUsageHints? usageHints = null, IServiceProvider? services = null) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(fixturePath)) | ||||
|         { | ||||
|             throw new ArgumentException("Fixture path is required", nameof(fixturePath)); | ||||
|         } | ||||
|  | ||||
|         var engine = new LanguageAnalyzerEngine(analyzers ?? Array.Empty<ILanguageAnalyzer>()); | ||||
|         var context = new LanguageAnalyzerContext(fixturePath, TimeProvider.System, usageHints, services); | ||||
|         var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false); | ||||
|         return result.ToJson(indent: true); | ||||
|     } | ||||
|  | ||||
|     public static async Task AssertDeterministicAsync(string fixturePath, string goldenPath, IEnumerable<ILanguageAnalyzer> analyzers, CancellationToken cancellationToken = default, LanguageUsageHints? usageHints = null, IServiceProvider? services = null) | ||||
|     { | ||||
|         var actual = await RunToJsonAsync(fixturePath, analyzers, cancellationToken, usageHints, services).ConfigureAwait(false); | ||||
|         var expected = await File.ReadAllTextAsync(goldenPath, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         // Normalize newlines for portability. | ||||
|         actual = NormalizeLineEndings(actual).TrimEnd(); | ||||
|   | ||||
| @@ -0,0 +1,34 @@ | ||||
| using System.IO; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Rust; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.Harness; | ||||
| using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities; | ||||
|  | ||||
| namespace StellaOps.Scanner.Analyzers.Lang.Tests.Rust; | ||||
|  | ||||
| public sealed class RustLanguageAnalyzerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task SimpleFixtureProducesDeterministicOutputAsync() | ||||
|     { | ||||
|         var cancellationToken = TestContext.Current.CancellationToken; | ||||
|         var fixturePath = TestPaths.ResolveFixture("lang", "rust", "simple"); | ||||
|         var goldenPath = Path.Combine(fixturePath, "expected.json"); | ||||
|  | ||||
|         var usageHints = new LanguageUsageHints(new[] | ||||
|         { | ||||
|             Path.Combine(fixturePath, "usr/local/bin/my_app") | ||||
|         }); | ||||
|  | ||||
|         var analyzers = new ILanguageAnalyzer[] | ||||
|         { | ||||
|             new RustLanguageAnalyzer() | ||||
|         }; | ||||
|  | ||||
|         await LanguageAnalyzerTestHarness.AssertDeterministicAsync( | ||||
|             fixturePath, | ||||
|             goldenPath, | ||||
|             analyzers, | ||||
|             cancellationToken, | ||||
|             usageHints); | ||||
|     } | ||||
| } | ||||
| @@ -32,6 +32,7 @@ | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.DotNet\StellaOps.Scanner.Analyzers.Lang.DotNet.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang.Rust\StellaOps.Scanner.Analyzers.Lang.Rust.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   | ||||
| @@ -51,7 +51,7 @@ All sprints below assume prerequisites from SP10-G2 (core scaffolding + Java ana | ||||
| - **Gate Artifacts:** | ||||
|   - Benchmarks vs competitor open-source tool (Trivy or Syft) demonstrating faster metadata extraction. | ||||
|   - Documentation snippet explaining VCS metadata fields for Policy team. | ||||
| - **Progress (2025-10-22):** Build-info decoder shipped with DWARF-string fallback for `vcs.*` markers, plus cached metadata keyed by binary length/timestamp. Added Go test fixtures covering build-info and DWARF-only binaries with deterministic goldens; analyzer now emits `go.dwarf` evidence alongside `go.buildinfo` metadata to feed downstream provenance rules. | ||||
| - **Progress (2025-10-22):** Build-info decoder shipped with DWARF-string fallback for `vcs.*` markers, plus cached metadata keyed by binary length/timestamp. Added Go test fixtures covering build-info and DWARF-only binaries with deterministic goldens; analyzer now emits `go.dwarf` evidence alongside `go.buildinfo` metadata to feed downstream provenance rules. Completed stripped-binary heuristics with deterministic `golang::bin::sha256` components and a new `stripped` fixture to guard quiet-provenance behaviour. | ||||
|  | ||||
| ## Sprint LA4 — .NET Analyzer & RID Variants (Tasks 10-305, 10-307, 10-308, 10-309 subset) | ||||
| - **Scope:** Parse `*.deps.json`, `runtimeconfig.json`, assembly metadata, and RID-specific assets; correlate with native dependencies. | ||||
|   | ||||
| @@ -6,8 +6,8 @@ | ||||
| | SCANNER-ANALYZERS-LANG-10-302 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Node analyzer resolving workspaces/symlinks into `pkg:npm` identities. | Node analyzer handles symlinks/workspaces; outputs sorted components; determinism harness covers hoisted deps. | | ||||
| | SCANNER-ANALYZERS-LANG-10-303 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Python analyzer consuming `*.dist-info` metadata and RECORD hashes. | Analyzer binds METADATA + RECORD evidence, includes entry points, determinism fixtures stable. | | ||||
| | SCANNER-ANALYZERS-LANG-10-304 | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Go analyzer leveraging buildinfo for `pkg:golang` components. | Buildinfo parser emits module path/version + vcs metadata; binaries without buildinfo downgraded gracefully. | | ||||
| | SCANNER-ANALYZERS-LANG-10-305 | DOING (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | .NET analyzer parsing `*.deps.json`, assembly metadata, and RID variants. | Analyzer merges deps.json + assembly info; dedupes per RID; determinism verified. | | ||||
| | SCANNER-ANALYZERS-LANG-10-306 | TODO | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Rust analyzer detecting crate provenance or falling back to `bin:{sha256}`. | Analyzer emits `pkg:cargo` when metadata present; falls back to binary hash; fixtures cover both paths. | | ||||
| | SCANNER-ANALYZERS-LANG-10-305 | DONE (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | .NET analyzer parsing `*.deps.json`, assembly metadata, and RID variants. | Analyzer merges deps.json + assembly info; dedupes per RID; determinism verified. | | ||||
| | SCANNER-ANALYZERS-LANG-10-306 | DONE (2025-10-22) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Rust analyzer detecting crate provenance or falling back to `bin:{sha256}`. | Analyzer emits `pkg:cargo` when metadata present; falls back to binary hash; fixtures cover both paths. | | ||||
| | SCANNER-ANALYZERS-LANG-10-307 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-CORE-09-501 | Shared language evidence helpers + usage flag propagation. | Shared abstractions implemented; analyzers reuse helpers; evidence includes usage hints; unit tests cover canonical ordering. | | ||||
| | SCANNER-ANALYZERS-LANG-10-308 | DONE (2025-10-19) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-307 | Determinism + fixture harness for language analyzers. | Harness executes analyzers against fixtures; golden JSON stored; CI helper ensures stable hashes. | | ||||
| | SCANNER-ANALYZERS-LANG-10-309 | DONE (2025-10-21) | Language Analyzer Guild | SCANNER-ANALYZERS-LANG-10-301..308 | Package language analyzers as restart-time plug-ins (manifest + host registration). | Plugin manifests authored under `plugins/scanner/analyzers/lang`; Worker loads via DI; restart required flag enforced; tests confirm manifest integrity. | | ||||
|   | ||||
		Reference in New Issue
	
	Block a user