diff --git a/src/Aspire.Cli/Npm/NpmProvenanceChecker.cs b/src/Aspire.Cli/Npm/NpmProvenanceChecker.cs deleted file mode 100644 index 423b5e1e686..00000000000 --- a/src/Aspire.Cli/Npm/NpmProvenanceChecker.cs +++ /dev/null @@ -1,233 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; -using System.Text.Json.Nodes; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Npm; - -/// -/// Verifies npm package provenance by fetching and parsing SLSA attestations from the npm registry API. -/// -internal sealed class NpmProvenanceChecker(HttpClient httpClient, ILogger logger) : INpmProvenanceChecker -{ - internal const string NpmRegistryAttestationsBaseUrl = "https://registry.npmjs.org/-/npm/v1/attestations"; - internal const string SlsaProvenancePredicateType = "https://slsa.dev/provenance/v1"; - - /// - public async Task VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func? validateWorkflowRef, CancellationToken cancellationToken, string? sriIntegrity = null) - { - // Gate 1: Fetch attestations from the npm registry. - string json; - try - { - var encodedPackage = Uri.EscapeDataString(packageName); - var url = $"{NpmRegistryAttestationsBaseUrl}/{encodedPackage}@{version}"; - - logger.LogDebug("Fetching attestations from {Url}", url); - var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); - - if (!response.IsSuccessStatusCode) - { - logger.LogDebug("Failed to fetch attestations: HTTP {StatusCode}", response.StatusCode); - return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationFetchFailed }; - } - - json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - } - catch (HttpRequestException ex) - { - logger.LogDebug(ex, "Failed to fetch attestations for {Package}@{Version}", packageName, version); - return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationFetchFailed }; - } - - // Gate 2: Parse the attestation JSON and extract provenance data. - NpmProvenanceData provenance; - try - { - var parseResult = ParseProvenance(json); - if (parseResult is null) - { - return new ProvenanceVerificationResult { Outcome = parseResult?.Outcome ?? ProvenanceVerificationOutcome.SlsaProvenanceNotFound }; - } - - provenance = parseResult.Value.Provenance; - if (parseResult.Value.Outcome is not ProvenanceVerificationOutcome.Verified) - { - return new ProvenanceVerificationResult - { - Outcome = parseResult.Value.Outcome, - Provenance = provenance - }; - } - } - catch (JsonException ex) - { - logger.LogDebug(ex, "Failed to parse attestation response for {Package}@{Version}", packageName, version); - return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; - } - - logger.LogDebug("SLSA provenance source repository: {SourceRepository}", provenance.SourceRepository); - - // Gate 3: Verify the source repository matches. - if (!string.Equals(provenance.SourceRepository, expectedSourceRepository, StringComparison.OrdinalIgnoreCase)) - { - logger.LogWarning( - "Provenance verification failed: expected source repository {Expected} but attestation says {Actual}", - expectedSourceRepository, - provenance.SourceRepository); - - return new ProvenanceVerificationResult - { - Outcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch, - Provenance = provenance - }; - } - - // Gate 4: Verify the workflow path matches. - if (!string.Equals(provenance.WorkflowPath, expectedWorkflowPath, StringComparison.Ordinal)) - { - logger.LogWarning( - "Provenance verification failed: expected workflow path {Expected} but attestation says {Actual}", - expectedWorkflowPath, - provenance.WorkflowPath); - - return new ProvenanceVerificationResult - { - Outcome = ProvenanceVerificationOutcome.WorkflowMismatch, - Provenance = provenance - }; - } - - // Gate 5: Verify the build type matches (confirms CI system and OIDC token issuer). - if (!string.Equals(provenance.BuildType, expectedBuildType, StringComparison.Ordinal)) - { - logger.LogWarning( - "Provenance verification failed: expected build type {Expected} but attestation says {Actual}", - expectedBuildType, - provenance.BuildType); - - return new ProvenanceVerificationResult - { - Outcome = ProvenanceVerificationOutcome.BuildTypeMismatch, - Provenance = provenance - }; - } - - // Gate 6: Verify the workflow ref using the caller-provided validation callback. - // Different packages use different tag formats (e.g., "v0.1.1", "0.1.1", "@scope/pkg@0.1.1"), - // so the caller decides what constitutes a valid ref. - if (validateWorkflowRef is not null) - { - if (!WorkflowRefInfo.TryParse(provenance.WorkflowRef, out var refInfo) || refInfo is null) - { - logger.LogWarning( - "Provenance verification failed: could not parse workflow ref {WorkflowRef}", - provenance.WorkflowRef); - - return new ProvenanceVerificationResult - { - Outcome = ProvenanceVerificationOutcome.WorkflowRefMismatch, - Provenance = provenance - }; - } - - if (!validateWorkflowRef(refInfo)) - { - logger.LogWarning( - "Provenance verification failed: workflow ref {WorkflowRef} did not pass validation", - provenance.WorkflowRef); - - return new ProvenanceVerificationResult - { - Outcome = ProvenanceVerificationOutcome.WorkflowRefMismatch, - Provenance = provenance - }; - } - } - - return new ProvenanceVerificationResult - { - Outcome = ProvenanceVerificationOutcome.Verified, - Provenance = provenance - }; - } - - /// - /// Parses provenance data from the npm attestation API response. - /// - internal static (NpmProvenanceData Provenance, ProvenanceVerificationOutcome Outcome)? ParseProvenance(string attestationJson) - { - var doc = JsonNode.Parse(attestationJson); - var attestations = doc?["attestations"]?.AsArray(); - - if (attestations is null || attestations.Count == 0) - { - return (new NpmProvenanceData(), ProvenanceVerificationOutcome.SlsaProvenanceNotFound); - } - - foreach (var attestation in attestations) - { - var predicateType = attestation?["predicateType"]?.GetValue(); - if (!string.Equals(predicateType, SlsaProvenancePredicateType, StringComparison.Ordinal)) - { - continue; - } - - // The SLSA provenance is in the DSSE envelope payload, base64-encoded. - var payload = attestation?["bundle"]?["dsseEnvelope"]?["payload"]?.GetValue(); - if (payload is null) - { - return (new NpmProvenanceData(), ProvenanceVerificationOutcome.PayloadDecodeFailed); - } - - byte[] decodedBytes; - try - { - decodedBytes = Convert.FromBase64String(payload); - } - catch (FormatException) - { - return (new NpmProvenanceData(), ProvenanceVerificationOutcome.PayloadDecodeFailed); - } - - var statement = JsonNode.Parse(decodedBytes); - var predicate = statement?["predicate"]; - var buildDefinition = predicate?["buildDefinition"]; - var workflow = buildDefinition - ?["externalParameters"] - ?["workflow"]; - - var repository = workflow?["repository"]?.GetValue(); - var workflowPath = workflow?["path"]?.GetValue(); - var workflowRef = workflow?["ref"]?.GetValue(); - - var builderId = predicate - ?["runDetails"] - ?["builder"] - ?["id"] - ?.GetValue(); - - var buildType = buildDefinition?["buildType"]?.GetValue(); - - var provenance = new NpmProvenanceData - { - SourceRepository = repository, - WorkflowPath = workflowPath, - WorkflowRef = workflowRef, - BuilderId = builderId, - BuildType = buildType - }; - - if (repository is null) - { - return (provenance, ProvenanceVerificationOutcome.SourceRepositoryNotFound); - } - - return (provenance, ProvenanceVerificationOutcome.Verified); - } - - return (new NpmProvenanceData(), ProvenanceVerificationOutcome.SlsaProvenanceNotFound); - } -} diff --git a/src/Aspire.Cli/Npm/SigstoreNpmProvenanceChecker.cs b/src/Aspire.Cli/Npm/SigstoreNpmProvenanceChecker.cs index 03f46bcb6cb..8cf739342ff 100644 --- a/src/Aspire.Cli/Npm/SigstoreNpmProvenanceChecker.cs +++ b/src/Aspire.Cli/Npm/SigstoreNpmProvenanceChecker.cs @@ -8,31 +8,10 @@ namespace Aspire.Cli.Npm; -/// -/// The parsed result of an npm attestation response, containing both the Sigstore bundle -/// and the provenance data extracted from the DSSE envelope in a single pass. -/// -internal sealed class NpmAttestationParseResult -{ - /// - /// Gets the outcome of the parse operation. - /// - public required ProvenanceVerificationOutcome Outcome { get; init; } - - /// - /// Gets the raw Sigstore bundle JSON node for deserialization by the Sigstore library. - /// - public JsonNode? BundleNode { get; init; } - - /// - /// Gets the provenance data extracted from the DSSE envelope payload. - /// - public NpmProvenanceData? Provenance { get; init; } -} - /// /// Verifies npm package provenance by cryptographically verifying Sigstore bundles /// from the npm registry attestations API using the Sigstore .NET library. +/// Uses Fulcio certificate extensions and in-toto statement APIs for attestation analysis. /// internal sealed class SigstoreNpmProvenanceChecker(HttpClient httpClient, ILogger logger) : INpmProvenanceChecker { @@ -56,22 +35,49 @@ public async Task VerifyProvenanceAsync( return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationFetchFailed }; } - var attestation = ParseAttestation(json); - if (attestation.Outcome is not ProvenanceVerificationOutcome.Verified) + // Extract the SLSA provenance bundle JSON from the npm attestation response. + var bundleJson = ExtractSlsaBundleJson(json, out var parseFailed); + if (bundleJson is null) { - return new ProvenanceVerificationResult { Outcome = attestation.Outcome, Provenance = attestation.Provenance }; + return new ProvenanceVerificationResult + { + Outcome = parseFailed + ? ProvenanceVerificationOutcome.AttestationParseFailed + : ProvenanceVerificationOutcome.SlsaProvenanceNotFound + }; } - var sigstoreFailure = await VerifySigstoreBundleAsync( - attestation.BundleNode!, expectedSourceRepository, sriIntegrity, + SigstoreBundle bundle; + try + { + bundle = SigstoreBundle.Deserialize(bundleJson); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to deserialize Sigstore bundle for {Package}@{Version}", packageName, version); + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + } + + // Verify the bundle with a policy that uses CertificateIdentity.ForGitHubActions + // to check the SAN (Subject Alternative Name) and issuer in the Fulcio certificate. + // This verifies the signing identity originates from the expected GitHub repository. + var (sigstoreFailure, verificationResult) = await VerifySigstoreBundleAsync( + bundle, expectedSourceRepository, sriIntegrity, packageName, version, cancellationToken).ConfigureAwait(false); if (sigstoreFailure is not null) { return sigstoreFailure; } + // Extract provenance from the verified result's in-toto statement and certificate extensions. + var provenance = ExtractProvenanceFromResult(verificationResult!); + if (provenance is null) + { + return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + } + return VerifyProvenanceFields( - attestation.Provenance!, expectedSourceRepository, expectedWorkflowPath, + provenance, expectedSourceRepository, expectedWorkflowPath, expectedBuildType, validateWorkflowRef); } @@ -105,11 +111,16 @@ public async Task VerifyProvenanceAsync( } /// - /// Parses the npm attestation JSON in a single pass, extracting both the Sigstore bundle - /// node and the provenance data from the SLSA provenance attestation's DSSE envelope. + /// Extracts the Sigstore bundle JSON string for the SLSA provenance attestation + /// from the npm registry attestations API response. + /// Returns the bundle JSON on success, or null if the JSON is malformed or + /// no SLSA provenance attestation is found. /// - internal static NpmAttestationParseResult ParseAttestation(string attestationJson) + /// The raw JSON from the npm attestations API. + /// Set to true when the input is not valid JSON; false otherwise. + internal static string? ExtractSlsaBundleJson(string attestationJson, out bool parseFailed) { + parseFailed = false; JsonNode? doc; try { @@ -117,134 +128,69 @@ internal static NpmAttestationParseResult ParseAttestation(string attestationJso } catch (JsonException) { - return new NpmAttestationParseResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + parseFailed = true; + return null; } - var attestations = doc?["attestations"]?.AsArray(); - if (attestations is null || attestations.Count == 0) + var attestationsNode = doc?["attestations"]; + if (attestationsNode is not JsonArray { Count: >0 } attestations) { - return new NpmAttestationParseResult { Outcome = ProvenanceVerificationOutcome.SlsaProvenanceNotFound }; + return null; } foreach (var attestation in attestations) { - var predicateType = attestation?["predicateType"]?.GetValue(); - if (!string.Equals(predicateType, SlsaProvenancePredicateType, StringComparison.Ordinal)) + if (attestation is not JsonObject attestationObj) { continue; } - var bundleNode = attestation?["bundle"]; - if (bundleNode is null) - { - return new NpmAttestationParseResult { Outcome = ProvenanceVerificationOutcome.SlsaProvenanceNotFound }; - } - - var payload = bundleNode["dsseEnvelope"]?["payload"]?.GetValue(); - if (payload is null) + var predicateTypeNode = attestationObj["predicateType"]; + if (predicateTypeNode is not JsonValue predicateTypeValue) { - return new NpmAttestationParseResult - { - Outcome = ProvenanceVerificationOutcome.PayloadDecodeFailed, - BundleNode = bundleNode - }; + continue; } - byte[] decodedBytes; + string? predicateType; try { - decodedBytes = Convert.FromBase64String(payload); + predicateType = predicateTypeValue.GetValue(); } - catch (FormatException) + catch (InvalidOperationException) { - return new NpmAttestationParseResult - { - Outcome = ProvenanceVerificationOutcome.PayloadDecodeFailed, - BundleNode = bundleNode - }; + continue; } - var provenance = ParseProvenanceFromStatement(decodedBytes); - if (provenance is null) + if (!string.Equals(predicateType, SlsaProvenancePredicateType, StringComparison.Ordinal)) { - return new NpmAttestationParseResult - { - Outcome = ProvenanceVerificationOutcome.AttestationParseFailed, - BundleNode = bundleNode - }; + continue; } - var outcome = provenance.SourceRepository is null - ? ProvenanceVerificationOutcome.SourceRepositoryNotFound - : ProvenanceVerificationOutcome.Verified; - - return new NpmAttestationParseResult - { - Outcome = outcome, - BundleNode = bundleNode, - Provenance = provenance - }; + var bundleNode = attestationObj["bundle"]; + return bundleNode?.ToJsonString(); } - return new NpmAttestationParseResult { Outcome = ProvenanceVerificationOutcome.SlsaProvenanceNotFound }; - } - - /// - /// Extracts provenance fields from a decoded in-toto statement. - /// - internal static NpmProvenanceData? ParseProvenanceFromStatement(byte[] statementBytes) - { - try - { - var statement = JsonNode.Parse(statementBytes); - var predicate = statement?["predicate"]; - var buildDefinition = predicate?["buildDefinition"]; - var workflow = buildDefinition?["externalParameters"]?["workflow"]; - - return new NpmProvenanceData - { - SourceRepository = workflow?["repository"]?.GetValue(), - WorkflowPath = workflow?["path"]?.GetValue(), - WorkflowRef = workflow?["ref"]?.GetValue(), - BuilderId = predicate?["runDetails"]?["builder"]?["id"]?.GetValue(), - BuildType = buildDefinition?["buildType"]?.GetValue() - }; - } - catch (JsonException) - { - return null; - } + return null; } /// /// Cryptographically verifies the Sigstore bundle using the Sigstore library. - /// Checks the Fulcio certificate chain, Rekor transparency log inclusion, and OIDC identity. + /// Checks the Fulcio certificate chain, Rekor transparency log inclusion, OIDC identity, + /// and source repository via CertificateExtensionPolicy. /// - /// null if verification succeeded; otherwise a failure result. - private async Task VerifySigstoreBundleAsync( - JsonNode bundleNode, + /// A failure result and null verification result on error; null failure and the verification result on success. + private async Task<(ProvenanceVerificationResult? Failure, VerificationResult? Result)> VerifySigstoreBundleAsync( + SigstoreBundle bundle, string expectedSourceRepository, string? sriIntegrity, string packageName, string version, CancellationToken cancellationToken) { - var bundleJson = bundleNode.ToJsonString(); - SigstoreBundle bundle; - try - { - bundle = SigstoreBundle.Deserialize(bundleJson); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Failed to deserialize Sigstore bundle for {Package}@{Version}", packageName, version); - return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; - } - if (!TryParseGitHubOwnerRepo(expectedSourceRepository, out var owner, out var repo)) { logger.LogWarning("Could not parse GitHub owner/repo from expected source repository: {ExpectedSourceRepository}", expectedSourceRepository); - return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch }; + return (new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch }, null); } var verifier = new SigstoreVerifier(); @@ -268,16 +214,14 @@ internal static NpmAttestationParseResult ParseAttestation(string attestationJso } else { - var payloadBase64 = bundleNode["dsseEnvelope"]?["payload"]?.GetValue(); - if (payloadBase64 is null) + if (bundle.DsseEnvelope is null) { - logger.LogDebug("No DSSE payload found in bundle for {Package}@{Version}", packageName, version); - return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.PayloadDecodeFailed }; + logger.LogDebug("No DSSE envelope found in bundle for {Package}@{Version}", packageName, version); + return (new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.PayloadDecodeFailed }, null); } - var payloadBytes = Convert.FromBase64String(payloadBase64); (success, result) = await verifier.TryVerifyAsync( - payloadBytes, bundle, policy, cancellationToken).ConfigureAwait(false); + bundle.DsseEnvelope.Payload, bundle, policy, cancellationToken).ConfigureAwait(false); } if (!success) @@ -285,25 +229,106 @@ internal static NpmAttestationParseResult ParseAttestation(string attestationJso logger.LogWarning( "Sigstore verification failed for {Package}@{Version}: {FailureReason}", packageName, version, result?.FailureReason); - return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + return (new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }, null); } logger.LogDebug( "Sigstore verification passed for {Package}@{Version}. Signed by: {Signer}", packageName, version, result?.SignerIdentity?.SubjectAlternativeName); - return null; + return (null, result); } catch (Exception ex) { logger.LogWarning(ex, "Sigstore verification threw an exception for {Package}@{Version}", packageName, version); - return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; + return (new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }, null); + } + } + + /// + /// Extracts provenance data from a verified Sigstore result using the in-toto statement + /// and Fulcio certificate extensions, avoiding manual JSON parsing of the DSSE payload. + /// + internal static NpmProvenanceData? ExtractProvenanceFromResult(VerificationResult result) + { + var extensions = result.SignerIdentity?.Extensions; + var statement = result.Statement; + + // Extract SLSA-specific fields from the in-toto statement predicate. + string? workflowPath = null; + string? buildType = null; + string? builderId = null; + string? sourceRepository = null; + string? workflowRef = null; + + if (statement?.PredicateType == SlsaProvenancePredicateType && statement.Predicate is { } predicate) + { + if (predicate.ValueKind == JsonValueKind.Object) + { + if (predicate.TryGetProperty("buildDefinition", out var buildDefinition) && + buildDefinition.ValueKind == JsonValueKind.Object) + { + if (buildDefinition.TryGetProperty("buildType", out var buildTypeElement) && + buildTypeElement.ValueKind == JsonValueKind.String) + { + buildType = buildTypeElement.GetString(); + } + + if (buildDefinition.TryGetProperty("externalParameters", out var extParams) && + extParams.ValueKind == JsonValueKind.Object && + extParams.TryGetProperty("workflow", out var workflow) && + workflow.ValueKind == JsonValueKind.Object) + { + if (workflow.TryGetProperty("repository", out var repoEl) && + repoEl.ValueKind == JsonValueKind.String) + { + sourceRepository = repoEl.GetString(); + } + + if (workflow.TryGetProperty("path", out var pathEl) && + pathEl.ValueKind == JsonValueKind.String) + { + workflowPath = pathEl.GetString(); + } + + if (workflow.TryGetProperty("ref", out var refEl) && + refEl.ValueKind == JsonValueKind.String) + { + workflowRef = refEl.GetString(); + } + } + } + + if (predicate.TryGetProperty("runDetails", out var runDetails) && + runDetails.ValueKind == JsonValueKind.Object && + runDetails.TryGetProperty("builder", out var builder) && + builder.ValueKind == JsonValueKind.Object) + { + if (builder.TryGetProperty("id", out var idEl) && + idEl.ValueKind == JsonValueKind.String) + { + builderId = idEl.GetString(); + } + } + } } + + // Prefer certificate extensions for source repository and ref when available, + // as they are cryptographically bound to the signing certificate. + return new NpmProvenanceData + { + SourceRepository = extensions?.SourceRepositoryUri ?? sourceRepository, + WorkflowPath = workflowPath, + WorkflowRef = extensions?.SourceRepositoryRef ?? workflowRef, + BuilderId = builderId, + BuildType = buildType + }; } /// /// Verifies that the extracted provenance fields match the expected values. - /// Checks source repository, workflow path, build type, and workflow ref in order. + /// Source repository is already verified cryptographically via CertificateExtensionPolicy + /// during Sigstore bundle verification, but is also checked here for defense-in-depth. /// internal static ProvenanceVerificationResult VerifyProvenanceFields( NpmProvenanceData provenance, diff --git a/tests/Aspire.Cli.Tests/Agents/NpmProvenanceCheckerTests.cs b/tests/Aspire.Cli.Tests/Agents/NpmProvenanceCheckerTests.cs deleted file mode 100644 index a6368649911..00000000000 --- a/tests/Aspire.Cli.Tests/Agents/NpmProvenanceCheckerTests.cs +++ /dev/null @@ -1,308 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using Aspire.Cli.Npm; - -namespace Aspire.Cli.Tests.Agents; - -public class NpmProvenanceCheckerTests -{ - [Fact] - public void ParseProvenance_WithValidSlsaProvenance_ReturnsVerifiedWithData() - { - var json = BuildAttestationJson("https://github.com/microsoft/playwright-cli"); - - var result = NpmProvenanceChecker.ParseProvenance(json); - - Assert.NotNull(result); - Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Value.Outcome); - Assert.Equal("https://github.com/microsoft/playwright-cli", result.Value.Provenance.SourceRepository); - Assert.Equal(".github/workflows/publish.yml", result.Value.Provenance.WorkflowPath); - Assert.Equal("https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", result.Value.Provenance.BuildType); - Assert.Equal("https://github.com/actions/runner/github-hosted", result.Value.Provenance.BuilderId); - Assert.Equal("refs/tags/v0.1.1", result.Value.Provenance.WorkflowRef); - } - - [Fact] - public void ParseProvenance_WithDifferentRepository_ReturnsVerifiedWithThatRepository() - { - var json = BuildAttestationJson("https://github.com/attacker/malicious-package"); - - var result = NpmProvenanceChecker.ParseProvenance(json); - - Assert.NotNull(result); - Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Value.Outcome); - Assert.Equal("https://github.com/attacker/malicious-package", result.Value.Provenance.SourceRepository); - } - - [Fact] - public void ParseProvenance_WithNoSlsaPredicate_ReturnsSlsaProvenanceNotFound() - { - var json = """ - { - "attestations": [ - { - "predicateType": "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", - "bundle": { - "dsseEnvelope": { - "payload": "" - } - } - } - ] - } - """; - - var result = NpmProvenanceChecker.ParseProvenance(json); - - Assert.NotNull(result); - Assert.Equal(ProvenanceVerificationOutcome.SlsaProvenanceNotFound, result.Value.Outcome); - } - - [Fact] - public void ParseProvenance_WithEmptyAttestations_ReturnsSlsaProvenanceNotFound() - { - var json = """{"attestations": []}"""; - - var result = NpmProvenanceChecker.ParseProvenance(json); - - Assert.NotNull(result); - Assert.Equal(ProvenanceVerificationOutcome.SlsaProvenanceNotFound, result.Value.Outcome); - } - - [Fact] - public void ParseProvenance_WithMalformedJson_ThrowsException() - { - Assert.ThrowsAny(() => NpmProvenanceChecker.ParseProvenance("not json")); - } - - [Fact] - public void ParseProvenance_WithMissingWorkflowNode_ReturnsSourceRepositoryNotFound() - { - var statement = new JsonObject - { - ["_type"] = "https://in-toto.io/Statement/v1", - ["predicateType"] = "https://slsa.dev/provenance/v1", - ["predicate"] = new JsonObject - { - ["buildDefinition"] = new JsonObject - { - ["externalParameters"] = new JsonObject() - } - } - }; - - var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(statement.ToJsonString())); - var json = $$""" - { - "attestations": [ - { - "predicateType": "https://slsa.dev/provenance/v1", - "bundle": { - "dsseEnvelope": { - "payload": "{{payload}}" - } - } - } - ] - } - """; - - var result = NpmProvenanceChecker.ParseProvenance(json); - - Assert.NotNull(result); - Assert.Equal(ProvenanceVerificationOutcome.SourceRepositoryNotFound, result.Value.Outcome); - } - - [Fact] - public void ParseProvenance_WithMissingPayload_ReturnsPayloadDecodeFailed() - { - var json = """ - { - "attestations": [ - { - "predicateType": "https://slsa.dev/provenance/v1", - "bundle": { - "dsseEnvelope": {} - } - } - ] - } - """; - - var result = NpmProvenanceChecker.ParseProvenance(json); - - Assert.NotNull(result); - Assert.Equal(ProvenanceVerificationOutcome.PayloadDecodeFailed, result.Value.Outcome); - } - - [Fact] - public async Task VerifyProvenanceAsync_WithMismatchedWorkflowRef_ReturnsWorkflowRefMismatch() - { - var json = BuildAttestationJson( - "https://github.com/microsoft/playwright-cli", - workflowRef: "refs/tags/v9.9.9"); - - var handler = new TestHttpMessageHandler(json); - var httpClient = new HttpClient(handler); - var checker = new NpmProvenanceChecker(httpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - var result = await checker.VerifyProvenanceAsync( - "@playwright/cli", - "0.1.1", - "https://github.com/microsoft/playwright-cli", - ".github/workflows/publish.yml", - "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", - refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && - string.Equals(refInfo.Name, "v0.1.1", StringComparison.Ordinal), - CancellationToken.None); - - Assert.Equal(ProvenanceVerificationOutcome.WorkflowRefMismatch, result.Outcome); - Assert.Equal("refs/tags/v9.9.9", result.Provenance?.WorkflowRef); - } - - [Fact] - public async Task VerifyProvenanceAsync_WithMatchingWorkflowRef_ReturnsVerified() - { - var json = BuildAttestationJson( - "https://github.com/microsoft/playwright-cli", - workflowRef: "refs/tags/v0.1.1"); - - var handler = new TestHttpMessageHandler(json); - var httpClient = new HttpClient(handler); - var checker = new NpmProvenanceChecker(httpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - var result = await checker.VerifyProvenanceAsync( - "@playwright/cli", - "0.1.1", - "https://github.com/microsoft/playwright-cli", - ".github/workflows/publish.yml", - "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", - refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && - string.Equals(refInfo.Name, "v0.1.1", StringComparison.Ordinal), - CancellationToken.None); - - Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); - } - - [Fact] - public async Task VerifyProvenanceAsync_WithNullCallback_SkipsRefValidation() - { - var json = BuildAttestationJson( - "https://github.com/microsoft/playwright-cli", - workflowRef: "refs/tags/any-format-at-all"); - - var handler = new TestHttpMessageHandler(json); - var httpClient = new HttpClient(handler); - var checker = new NpmProvenanceChecker(httpClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); - - var result = await checker.VerifyProvenanceAsync( - "@playwright/cli", - "0.1.1", - "https://github.com/microsoft/playwright-cli", - ".github/workflows/publish.yml", - "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", - validateWorkflowRef: null, - CancellationToken.None); - - Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); - } - - private static string BuildAttestationJson(string sourceRepository, string workflowPath = ".github/workflows/publish.yml", string buildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", string workflowRef = "refs/tags/v0.1.1") - { - var statement = new JsonObject - { - ["_type"] = "https://in-toto.io/Statement/v1", - ["predicateType"] = "https://slsa.dev/provenance/v1", - ["predicate"] = new JsonObject - { - ["buildDefinition"] = new JsonObject - { - ["buildType"] = buildType, - ["externalParameters"] = new JsonObject - { - ["workflow"] = new JsonObject - { - ["repository"] = sourceRepository, - ["path"] = workflowPath, - ["ref"] = workflowRef - } - } - }, - ["runDetails"] = new JsonObject - { - ["builder"] = new JsonObject - { - ["id"] = "https://github.com/actions/runner/github-hosted" - } - } - } - }; - - var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(statement.ToJsonString())); - - var attestationResponse = new JsonObject - { - ["attestations"] = new JsonArray - { - new JsonObject - { - ["predicateType"] = "https://slsa.dev/provenance/v1", - ["bundle"] = new JsonObject - { - ["dsseEnvelope"] = new JsonObject - { - ["payload"] = payload - } - } - } - } - }; - - return attestationResponse.ToJsonString(); - } - - private sealed class TestHttpMessageHandler(string responseContent) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) - { - Content = new StringContent(responseContent, Encoding.UTF8, "application/json") - }); - } - } - - [Theory] - [InlineData("refs/tags/v0.1.1", "tags", "v0.1.1")] - [InlineData("refs/heads/main", "heads", "main")] - [InlineData("refs/tags/@scope/pkg@1.0.0", "tags", "@scope/pkg@1.0.0")] - [InlineData("refs/tags/release/1.0.0", "tags", "release/1.0.0")] - public void WorkflowRefInfo_TryParse_ValidRefs_ParsesCorrectly(string raw, string expectedKind, string expectedName) - { - var success = WorkflowRefInfo.TryParse(raw, out var refInfo); - - Assert.True(success); - Assert.NotNull(refInfo); - Assert.Equal(raw, refInfo.Raw); - Assert.Equal(expectedKind, refInfo.Kind); - Assert.Equal(expectedName, refInfo.Name); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("not-a-ref")] - [InlineData("refs/")] - [InlineData("refs/tags/")] - public void WorkflowRefInfo_TryParse_InvalidRefs_ReturnsFalse(string? raw) - { - var success = WorkflowRefInfo.TryParse(raw, out var refInfo); - - Assert.False(success); - Assert.Null(refInfo); - } -} diff --git a/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs b/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs index c0a6541e9fb..3f8c063a81f 100644 --- a/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/SigstoreNpmProvenanceCheckerTests.cs @@ -1,30 +1,30 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Aspire.Cli.Npm; +using Sigstore; namespace Aspire.Cli.Tests.Agents; public class SigstoreNpmProvenanceCheckerTests { + #region ExtractSlsaBundleJson Tests + [Fact] - public void ParseAttestation_WithValidSlsaAttestation_ReturnsBundleAndProvenance() + public void ExtractSlsaBundleJson_WithValidSlsaAttestation_ReturnsBundleJson() { var json = BuildAttestationJsonWithBundle("https://github.com/microsoft/playwright-cli"); - var result = SigstoreNpmProvenanceChecker.ParseAttestation(json); + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out _); - Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); - Assert.NotNull(result.BundleNode); - Assert.NotNull(result.BundleNode["dsseEnvelope"]); - Assert.NotNull(result.Provenance); - Assert.Equal("https://github.com/microsoft/playwright-cli", result.Provenance.SourceRepository); - Assert.Equal(".github/workflows/publish.yml", result.Provenance.WorkflowPath); - Assert.Equal("refs/tags/v0.1.1", result.Provenance.WorkflowRef); + Assert.NotNull(bundleJson); + var bundleDoc = JsonDocument.Parse(bundleJson); + Assert.True(bundleDoc.RootElement.TryGetProperty("dsseEnvelope", out _)); } [Fact] - public void ParseAttestation_WithNoSlsaPredicate_ReturnsSlsaProvenanceNotFound() + public void ExtractSlsaBundleJson_WithNoSlsaPredicate_ReturnsNull() { var json = """ { @@ -41,58 +41,89 @@ public void ParseAttestation_WithNoSlsaPredicate_ReturnsSlsaProvenanceNotFound() } """; - var result = SigstoreNpmProvenanceChecker.ParseAttestation(json); + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out _); - Assert.Equal(ProvenanceVerificationOutcome.SlsaProvenanceNotFound, result.Outcome); + Assert.Null(bundleJson); } [Fact] - public void ParseAttestation_WithEmptyAttestations_ReturnsSlsaProvenanceNotFound() + public void ExtractSlsaBundleJson_WithEmptyAttestations_ReturnsNull() { - var json = """{"attestations": []}"""; + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson("""{"attestations": []}""", out _); - var result = SigstoreNpmProvenanceChecker.ParseAttestation(json); - - Assert.Equal(ProvenanceVerificationOutcome.SlsaProvenanceNotFound, result.Outcome); + Assert.Null(bundleJson); } [Fact] - public void ParseAttestation_WithInvalidJson_ReturnsAttestationParseFailed() + public void ExtractSlsaBundleJson_WithInvalidJson_ReturnsNullAndSetsParseFailed() { - var result = SigstoreNpmProvenanceChecker.ParseAttestation("not valid json {{{"); + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson("not valid json {{{", out var parseFailed); - Assert.Equal(ProvenanceVerificationOutcome.AttestationParseFailed, result.Outcome); + Assert.Null(bundleJson); + Assert.True(parseFailed); } [Fact] - public void ParseAttestation_WithMissingPayload_ReturnsPayloadDecodeFailed() + public void ExtractSlsaBundleJson_WithMultipleMixedAttestations_FindsSlsaPredicate() { - var json = """ + var json = $$""" { "attestations": [ + { + "predicateType": "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", + "bundle": { "wrong": true } + }, { "predicateType": "https://slsa.dev/provenance/v1", "bundle": { - "dsseEnvelope": {} + "dsseEnvelope": { "payload": "dGVzdA==", "payloadType": "application/vnd.in-toto+json" } } } ] } """; - var result = SigstoreNpmProvenanceChecker.ParseAttestation(json); + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out _); - Assert.Equal(ProvenanceVerificationOutcome.PayloadDecodeFailed, result.Outcome); - Assert.NotNull(result.BundleNode); + Assert.NotNull(bundleJson); + var doc = JsonDocument.Parse(bundleJson); + Assert.True(doc.RootElement.TryGetProperty("dsseEnvelope", out _)); } [Fact] - public void ParseProvenanceFromStatement_WithValidStatement_ReturnsProvenance() + public void ExtractSlsaBundleJson_WithNoBundleProperty_ReturnsNull() { - var payload = BuildProvenancePayload("https://github.com/microsoft/playwright-cli"); - var bytes = System.Text.Encoding.UTF8.GetBytes(payload); + var json = """ + { + "attestations": [ + { + "predicateType": "https://slsa.dev/provenance/v1" + } + ] + } + """; - var provenance = SigstoreNpmProvenanceChecker.ParseProvenanceFromStatement(bytes); + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out _); + + Assert.Null(bundleJson); + } + + #endregion + + #region ExtractProvenanceFromResult Tests + + [Fact] + public void ExtractProvenanceFromResult_WithStatementAndExtensions_ReturnsProvenance() + { + var result = BuildVerificationResult( + sourceRepoUri: "https://github.com/microsoft/playwright-cli", + sourceRepoRef: "refs/tags/v0.1.1", + workflowPath: ".github/workflows/publish.yml", + buildType: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + builderId: "https://github.com/actions/runner/github-hosted", + sourceRepoInPredicate: "https://github.com/microsoft/playwright-cli"); + + var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); Assert.NotNull(provenance); Assert.Equal("https://github.com/microsoft/playwright-cli", provenance.SourceRepository); @@ -103,15 +134,109 @@ public void ParseProvenanceFromStatement_WithValidStatement_ReturnsProvenance() } [Fact] - public void ParseProvenanceFromStatement_WithInvalidJson_ReturnsNull() + public void ExtractProvenanceFromResult_PrefersExtensionsOverPredicate() + { + var result = BuildVerificationResult( + sourceRepoUri: "https://github.com/microsoft/playwright-cli", + sourceRepoRef: "refs/tags/v0.1.1", + workflowPath: ".github/workflows/publish.yml", + buildType: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + builderId: "https://github.com/actions/runner/github-hosted", + sourceRepoInPredicate: "https://github.com/evil/repo", + workflowRefInPredicate: "refs/heads/main"); + + var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); + + Assert.NotNull(provenance); + // Certificate extensions should win over predicate values + Assert.Equal("https://github.com/microsoft/playwright-cli", provenance.SourceRepository); + Assert.Equal("refs/tags/v0.1.1", provenance.WorkflowRef); + } + + [Fact] + public void ExtractProvenanceFromResult_WithNoStatement_ReturnsPartialProvenance() { - var bytes = System.Text.Encoding.UTF8.GetBytes("not json"); + var result = new VerificationResult + { + SignerIdentity = new VerifiedIdentity + { + SubjectAlternativeName = "https://github.com/microsoft/playwright-cli/.github/workflows/publish.yml@refs/tags/v0.1.1", + Issuer = "https://token.actions.githubusercontent.com", + Extensions = new FulcioCertificateExtensions + { + SourceRepositoryUri = "https://github.com/microsoft/playwright-cli", + SourceRepositoryRef = "refs/tags/v0.1.1" + } + }, + Statement = null + }; - var provenance = SigstoreNpmProvenanceChecker.ParseProvenanceFromStatement(bytes); + var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); - Assert.Null(provenance); + Assert.NotNull(provenance); + Assert.Equal("https://github.com/microsoft/playwright-cli", provenance.SourceRepository); + Assert.Equal("refs/tags/v0.1.1", provenance.WorkflowRef); + Assert.Null(provenance.WorkflowPath); + Assert.Null(provenance.BuildType); + Assert.Null(provenance.BuilderId); } + [Fact] + public void ExtractProvenanceFromResult_WithNoExtensions_FallsToPredicate() + { + var result = BuildVerificationResult( + sourceRepoUri: null, + sourceRepoRef: null, + workflowPath: ".github/workflows/publish.yml", + buildType: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + builderId: "https://github.com/actions/runner/github-hosted", + sourceRepoInPredicate: "https://github.com/microsoft/playwright-cli", + workflowRefInPredicate: "refs/tags/v0.1.1", + includeExtensions: false); + + var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); + + Assert.NotNull(provenance); + Assert.Equal("https://github.com/microsoft/playwright-cli", provenance.SourceRepository); + Assert.Equal("refs/tags/v0.1.1", provenance.WorkflowRef); + } + + [Fact] + public void ExtractProvenanceFromResult_WithWrongPredicateType_ReturnsNullFields() + { + var predicateJson = """ + { + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://example.com/custom/v1", + "subject": [], + "predicate": { "custom": true } + } + """; + + var statement = InTotoStatement.Parse(predicateJson); + var result = new VerificationResult + { + SignerIdentity = new VerifiedIdentity + { + SubjectAlternativeName = "test", + Issuer = "test", + Extensions = new FulcioCertificateExtensions() + }, + Statement = statement + }; + + var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); + + Assert.NotNull(provenance); + Assert.Null(provenance.WorkflowPath); + Assert.Null(provenance.BuildType); + Assert.Null(provenance.BuilderId); + } + + #endregion + + #region VerifyProvenanceFields Tests + [Fact] public void VerifyProvenanceFields_WithAllFieldsMatching_ReturnsVerified() { @@ -215,6 +340,10 @@ public void VerifyProvenanceFields_WithWorkflowRefValidationFailure_ReturnsWorkf Assert.Equal(ProvenanceVerificationOutcome.WorkflowRefMismatch, result.Outcome); } + #endregion + + #region TryParseGitHubOwnerRepo Tests + [Theory] [InlineData("https://github.com/microsoft/playwright-cli", "microsoft", "playwright-cli")] [InlineData("https://github.com/dotnet/aspire", "dotnet", "aspire")] @@ -239,9 +368,634 @@ public void TryParseGitHubOwnerRepo_WithInvalidUrl_ReturnsFalse(string url) Assert.False(result); } + #endregion + + #region Adversarial Tests - Malformed JSON + + [Fact] + public void ExtractSlsaBundleJson_WithDeeplyNestedJson_ReturnsNull() + { + // Build a valid deeply-nested JSON object to test stack safety. + // Each level wraps the previous in {"key": ...}. + var depth = 200; + var inner = """{"attestations":[]}"""; + for (var i = 0; i < depth; i++) + { + inner = $$"""{"level{{i}}":{{inner}}}"""; + } + + // Should either return null or handle gracefully (no exception) + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(inner, out _); + + // The deeply nested JSON has "attestations" buried inside — not at root level + Assert.Null(bundleJson); + } + + [Fact] + public void ExtractSlsaBundleJson_WithTruncatedJson_ReturnsNull() + { + var json = """{"attestations": [{"predicateType": "https://slsa.dev/provenance/v1", "bundle": {"dsse"""; + + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out _); + + Assert.Null(bundleJson); + } + + [Fact] + public void ExtractSlsaBundleJson_WithWrongJsonTypes_ReturnsNull() + { + // attestations is a string instead of array + var json = """{"attestations": "not an array"}"""; + + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out _); + + Assert.Null(bundleJson); + } + + [Fact] + public void ExtractSlsaBundleJson_WithNullAttestations_ReturnsNull() + { + var json = """{"attestations": null}"""; + + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out _); + + Assert.Null(bundleJson); + } + + [Fact] + public void ExtractSlsaBundleJson_WithEmptyObject_ReturnsNullWithoutParseFailed() + { + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson("{}", out var parseFailed); + + Assert.Null(bundleJson); + Assert.False(parseFailed); + } + + [Fact] + public void ExtractSlsaBundleJson_WithNonObjectArrayElements_SkipsThem() + { + // Array contains string, number, and null elements instead of objects + var json = """ + { + "attestations": [ + "not an object", + 42, + null, + { + "predicateType": "https://slsa.dev/provenance/v1", + "bundle": { "dsseEnvelope": {} } + } + ] + } + """; + + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out var parseFailed); + + Assert.NotNull(bundleJson); + Assert.False(parseFailed); + } + + [Fact] + public void ExtractSlsaBundleJson_WithNonStringPredicateType_SkipsElement() + { + // predicateType is a number instead of a string + var json = """ + { + "attestations": [ + { + "predicateType": 42, + "bundle": { "dsseEnvelope": {} } + } + ] + } + """; + + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out var parseFailed); + + Assert.Null(bundleJson); + Assert.False(parseFailed); + } + + [Fact] + public void ExtractSlsaBundleJson_WithEmptyString_ReturnsNullAndSetsParseFailed() + { + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson("", out var parseFailed); + + Assert.Null(bundleJson); + Assert.True(parseFailed); + } + + #endregion + + #region Adversarial Tests - Provenance Spoofing + + [Theory] + [InlineData("https://github.com/micr0soft/playwright-cli")] // Homoglyph: zero instead of 'o' + [InlineData("https://github.com/microsofт/playwright-cli")] // Homoglyph: Turkish dotless t + [InlineData("https://github.com/microsoft-/playwright-cli")] // Trailing dash + [InlineData("https://github.com/MICROSOFT/playwright-cli")] // Case should match (OrdinalIgnoreCase) + public void VerifyProvenanceFields_WithSimilarRepositoryUrls_ChecksCorrectly(string spoofedUrl) + { + var provenance = new NpmProvenanceData + { + SourceRepository = spoofedUrl, + WorkflowPath = ".github/workflows/publish.yml", + BuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + null); + + // MICROSOFT should match (OrdinalIgnoreCase), all others should fail + if (string.Equals(spoofedUrl, "https://github.com/microsoft/playwright-cli", StringComparison.OrdinalIgnoreCase)) + { + Assert.Equal(ProvenanceVerificationOutcome.Verified, result.Outcome); + } + else + { + Assert.Equal(ProvenanceVerificationOutcome.SourceRepositoryMismatch, result.Outcome); + } + } + + [Theory] + [InlineData("../../.github/workflows/evil.yml")] + [InlineData(".github/workflows/../../../evil.yml")] + [InlineData(".github/workflows/publish.yml\0evil")] + [InlineData("")] + public void VerifyProvenanceFields_WithWorkflowPathManipulation_RejectsInvalid(string spoofedPath) + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/playwright-cli", + WorkflowPath = spoofedPath, + BuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + null); + + Assert.Equal(ProvenanceVerificationOutcome.WorkflowMismatch, result.Outcome); + } + + [Theory] + [InlineData("refs/heads/main")] // Branch instead of tag + [InlineData("refs/tags/v0.1.1/../../heads/main")] // Path traversal in ref + [InlineData("refs/tags/")] // Empty tag name + [InlineData("refs/")] // No kind or name + [InlineData("tags/v0.1.1")] // Missing refs/ prefix + [InlineData("")] // Empty string + public void VerifyProvenanceFields_WithRefManipulation_RejectsInvalidRefs(string spoofedRef) + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/playwright-cli", + WorkflowPath = ".github/workflows/publish.yml", + BuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + WorkflowRef = spoofedRef + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + refInfo => string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && + string.Equals(refInfo.Name, "v0.1.1", StringComparison.Ordinal)); + + Assert.Equal(ProvenanceVerificationOutcome.WorkflowRefMismatch, result.Outcome); + } + + [Fact] + public void VerifyProvenanceFields_WithNullWorkflowRef_ReturnsWorkflowRefMismatch() + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/playwright-cli", + WorkflowPath = ".github/workflows/publish.yml", + BuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + WorkflowRef = null + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + refInfo => refInfo.Kind == "tags"); + + Assert.Equal(ProvenanceVerificationOutcome.WorkflowRefMismatch, result.Outcome); + } + + #endregion + + #region Adversarial Tests - Build Type Spoofing + + [Theory] + [InlineData("https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1?inject=true")] + [InlineData("https://evil.com/github-actions-buildtypes/workflow/v1")] + [InlineData("")] + public void VerifyProvenanceFields_WithBuildTypeSpoofing_Rejects(string spoofedBuildType) + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/playwright-cli", + WorkflowPath = ".github/workflows/publish.yml", + BuildType = spoofedBuildType, + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + null); + + Assert.Equal(ProvenanceVerificationOutcome.BuildTypeMismatch, result.Outcome); + } + + [Fact] + public void VerifyProvenanceFields_WithNullBuildType_ReturnsBuildTypeMismatch() + { + var provenance = new NpmProvenanceData + { + SourceRepository = "https://github.com/microsoft/playwright-cli", + WorkflowPath = ".github/workflows/publish.yml", + BuildType = null, + }; + + var result = SigstoreNpmProvenanceChecker.VerifyProvenanceFields( + provenance, + "https://github.com/microsoft/playwright-cli", + ".github/workflows/publish.yml", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + null); + + Assert.Equal(ProvenanceVerificationOutcome.BuildTypeMismatch, result.Outcome); + } + + #endregion + + #region Adversarial Tests - URL Parsing Edge Cases + + [Theory] + [InlineData("https://github.com.evil.com/microsoft/playwright-cli")] // Subdomain attack + [InlineData("https://githüb.com/microsoft/playwright-cli")] // Unicode domain + [InlineData("ftp://github.com/microsoft/playwright-cli")] // Wrong scheme + public void TryParseGitHubOwnerRepo_WithSuspiciousUrls_HandlesCorrectly(string url) + { + var result = SigstoreNpmProvenanceChecker.TryParseGitHubOwnerRepo(url, out var owner, out var repo); + + // These are syntactically valid URLs so TryParseGitHubOwnerRepo will succeed, + // but the domain mismatch would be caught by Sigstore's certificate identity check + // (SAN pattern matching against github.com). TryParseGitHubOwnerRepo only extracts + // the path segments — the security boundary is in the VerificationPolicy. + if (result) + { + // Verify it at least parsed the path segments + Assert.False(string.IsNullOrEmpty(owner)); + Assert.False(string.IsNullOrEmpty(repo)); + } + } + + [Theory] + [InlineData("https://github.com/microsoft/playwright-cli/../evil-repo")] + [InlineData("https://github.com/microsoft/playwright-cli/extra/segments")] + public void TryParseGitHubOwnerRepo_WithExtraPathSegments_ExtractsFirstTwo(string url) + { + var result = SigstoreNpmProvenanceChecker.TryParseGitHubOwnerRepo(url, out var owner, out _); + + Assert.True(result); + Assert.Equal("microsoft", owner); + // URI normalization resolves ".." so the path may differ + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("relative/path")] + public void TryParseGitHubOwnerRepo_WithNonAbsoluteUri_ReturnsFalse(string url) + { + var result = SigstoreNpmProvenanceChecker.TryParseGitHubOwnerRepo(url, out _, out _); + + Assert.False(result); + } + + #endregion + + #region Adversarial Tests - Statement Extraction Edge Cases + + [Fact] + public void ExtractProvenanceFromResult_WithMissingPredicateFields_ReturnsPartialData() + { + var predicateJson = """ + { + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": [], + "predicate": { + "buildDefinition": { + "buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1" + } + } + } + """; + + var statement = InTotoStatement.Parse(predicateJson); + var result = new VerificationResult + { + SignerIdentity = new VerifiedIdentity + { + SubjectAlternativeName = "test", + Issuer = "test", + Extensions = new FulcioCertificateExtensions() + }, + Statement = statement + }; + + var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); + + Assert.NotNull(provenance); + Assert.Equal("https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", provenance.BuildType); + Assert.Null(provenance.WorkflowPath); + Assert.Null(provenance.BuilderId); + } + + [Fact] + public void ExtractProvenanceFromResult_WithEmptyPredicate_ReturnsNullFields() + { + var predicateJson = """ + { + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": [], + "predicate": {} + } + """; + + var statement = InTotoStatement.Parse(predicateJson); + var result = new VerificationResult + { + SignerIdentity = new VerifiedIdentity + { + SubjectAlternativeName = "test", + Issuer = "test", + Extensions = new FulcioCertificateExtensions() + }, + Statement = statement + }; + + var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); + + Assert.NotNull(provenance); + Assert.Null(provenance.BuildType); + Assert.Null(provenance.WorkflowPath); + } + + [Fact] + public void ExtractProvenanceFromResult_WithNullSignerIdentity_ReturnsProvenanceFromPredicate() + { + var result = BuildVerificationResult( + sourceRepoUri: null, + sourceRepoRef: null, + workflowPath: ".github/workflows/publish.yml", + buildType: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + builderId: "https://github.com/actions/runner/github-hosted", + sourceRepoInPredicate: "https://github.com/microsoft/playwright-cli", + includeIdentity: false); + + var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); + + Assert.NotNull(provenance); + Assert.Equal("https://github.com/microsoft/playwright-cli", provenance.SourceRepository); + } + + #endregion + + #region Adversarial Tests - Predicate Type Safety + + [Fact] + public void ExtractProvenanceFromResult_WithWrongTypedPredicateValues_ReturnsNullFields() + { + // buildType is a number, workflow fields are arrays/booleans — all wrong types + var predicateJson = """ + { + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/provenance/v1", + "subject": [], + "predicate": { + "buildDefinition": { + "buildType": 42, + "externalParameters": { + "workflow": { + "repository": true, + "path": [], + "ref": {} + } + } + }, + "runDetails": { + "builder": { + "id": 999 + } + } + } + } + """; + + var statement = InTotoStatement.Parse(predicateJson); + var result = new VerificationResult + { + SignerIdentity = new VerifiedIdentity + { + SubjectAlternativeName = "test", + Issuer = "test", + Extensions = new FulcioCertificateExtensions() + }, + Statement = statement + }; + + var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); + + Assert.NotNull(provenance); + Assert.Null(provenance.BuildType); + Assert.Null(provenance.SourceRepository); + Assert.Null(provenance.WorkflowPath); + Assert.Null(provenance.WorkflowRef); + Assert.Null(provenance.BuilderId); + } + + #endregion + + #region Adversarial Tests - Attestation Structure + + [Fact] + public void ExtractSlsaBundleJson_WithNullPredicateType_ReturnsNull() + { + var json = """ + { + "attestations": [ + { + "bundle": { "dsseEnvelope": {} } + } + ] + } + """; + + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out _); + + Assert.Null(bundleJson); + } + + [Fact] + public void ExtractSlsaBundleJson_WithCaseSensitivePredicateType_ReturnsNull() + { + // Predicate type comparison is case-sensitive (Ordinal) + var json = """ + { + "attestations": [ + { + "predicateType": "HTTPS://SLSA.DEV/PROVENANCE/V1", + "bundle": { "dsseEnvelope": {} } + } + ] + } + """; + + var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(json, out _); + + Assert.Null(bundleJson); + } + + #endregion + + #region WorkflowRefInfo Adversarial Tests + + [Theory] + [InlineData("refs/tags/v1.0.0", true, "tags", "v1.0.0")] + [InlineData("refs/heads/main", true, "heads", "main")] + [InlineData("refs/tags/@scope/pkg@1.0.0", true, "tags", "@scope/pkg@1.0.0")] + [InlineData("refs/tags/", false, null, null)] // Empty name + [InlineData("refs/", false, null, null)] // No kind/name + [InlineData("", false, null, null)] // Empty string + [InlineData("heads/main", false, null, null)] // Missing refs/ prefix + [InlineData("refs/tags/v1/../../../etc/passwd", true, "tags", "v1/../../../etc/passwd")] // Path traversal in name (accepted as-is) + public void WorkflowRefInfo_TryParse_HandlesEdgeCases(string? input, bool expectedSuccess, string? expectedKind, string? expectedName) + { + var result = WorkflowRefInfo.TryParse(input, out var refInfo); + + Assert.Equal(expectedSuccess, result); + if (expectedSuccess) + { + Assert.NotNull(refInfo); + Assert.Equal(expectedKind, refInfo.Kind); + Assert.Equal(expectedName, refInfo.Name); + } + else + { + Assert.Null(refInfo); + } + } + + #endregion + + #region Test Helpers + + private static VerificationResult BuildVerificationResult( + string? sourceRepoUri, + string? sourceRepoRef, + string? workflowPath, + string? buildType, + string? builderId, + string? sourceRepoInPredicate = null, + string? workflowRefInPredicate = null, + bool includeExtensions = true, + bool includeIdentity = true) + { + var predicateJson = BuildSlsaPredicateStatementJson( + sourceRepoInPredicate ?? sourceRepoUri ?? "https://github.com/test/repo", + workflowPath ?? ".github/workflows/test.yml", + workflowRefInPredicate ?? sourceRepoRef ?? "refs/tags/v0.0.1", + buildType ?? "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + builderId ?? "https://github.com/actions/runner/github-hosted"); + + var statement = InTotoStatement.Parse(predicateJson); + + VerifiedIdentity? identity = null; + if (includeIdentity) + { + identity = new VerifiedIdentity + { + SubjectAlternativeName = "https://github.com/test/repo/.github/workflows/test.yml@refs/tags/v0.0.1", + Issuer = "https://token.actions.githubusercontent.com", + Extensions = includeExtensions ? new FulcioCertificateExtensions + { + SourceRepositoryUri = sourceRepoUri, + SourceRepositoryRef = sourceRepoRef + } : null + }; + } + + return new VerificationResult + { + SignerIdentity = identity, + Statement = statement + }; + } + + private static string BuildSlsaPredicateStatementJson( + string sourceRepository, + string workflowPath, + string workflowRef, + string buildType, + string builderId) + { + return $$""" + { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "pkg:npm/@playwright/cli@0.1.1", + "digest": { "sha512": "abc123" } + } + ], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "{{buildType}}", + "externalParameters": { + "workflow": { + "ref": "{{workflowRef}}", + "repository": "{{sourceRepository}}", + "path": "{{workflowPath}}" + } + } + }, + "runDetails": { + "builder": { + "id": "{{builderId}}" + } + } + } + } + """; + } + private static string BuildAttestationJsonWithBundle(string sourceRepository) { - var payload = BuildProvenancePayload(sourceRepository); + var payload = BuildSlsaPredicateStatementJson( + sourceRepository, + ".github/workflows/publish.yml", + "refs/tags/v0.1.1", + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + "https://github.com/actions/runner/github-hosted"); var payloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)); return $$""" @@ -290,36 +1044,5 @@ private static string BuildAttestationJsonWithBundle(string sourceRepository) """; } - private static string BuildProvenancePayload(string sourceRepository) - { - return $$""" - { - "_type": "https://in-toto.io/Statement/v1", - "subject": [ - { - "name": "pkg:npm/@playwright/cli@0.1.1", - "digest": { "sha512": "abc123" } - } - ], - "predicateType": "https://slsa.dev/provenance/v1", - "predicate": { - "buildDefinition": { - "buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", - "externalParameters": { - "workflow": { - "ref": "refs/tags/v0.1.1", - "repository": "{{sourceRepository}}", - "path": ".github/workflows/publish.yml" - } - } - }, - "runDetails": { - "builder": { - "id": "https://github.com/actions/runner/github-hosted" - } - } - } - } - """; - } + #endregion }