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
}