File: Npm\SigstoreNpmProvenanceChecker.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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;
using Sigstore;
 
namespace Aspire.Cli.Npm;
 
/// <summary>
/// Verifies a Sigstore bundle after the caller constructs the expected identity policy.
/// </summary>
internal delegate Task<(bool Success, VerificationResult? Result)> SigstoreBundleVerificationHandler(
    SigstoreVerifier verifier,
    SigstoreBundle bundle,
    VerificationPolicy policy,
    string? sriIntegrity,
    CancellationToken cancellationToken);
 
/// <summary>
/// Verifies npm package provenance by cryptographically verifying Sigstore bundles
/// from the public npm registry attestations API using the Sigstore .NET library.
/// Uses Fulcio certificate extensions and in-toto statement APIs for attestation analysis.
/// </summary>
internal sealed class SigstoreNpmProvenanceChecker : INpmProvenanceChecker
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<SigstoreNpmProvenanceChecker> _logger;
    private readonly SigstoreBundleVerificationHandler _verifyBundleWithPolicyAsync;
 
    internal const string NpmRegistryAttestationsBaseUrl = "https://registry.npmjs.org/-/npm/v1/attestations";
    internal const string SlsaProvenancePredicateType = "https://slsa.dev/provenance/v1";
 
    /// <summary>
    /// Initializes a new instance of the <see cref="SigstoreNpmProvenanceChecker"/> class.
    /// </summary>
    public SigstoreNpmProvenanceChecker(
        HttpClient httpClient,
        ILogger<SigstoreNpmProvenanceChecker> logger)
        : this(httpClient, logger, VerifyBundleWithPolicyAsync)
    {
    }
 
    internal SigstoreNpmProvenanceChecker(
        HttpClient httpClient,
        ILogger<SigstoreNpmProvenanceChecker> logger,
        SigstoreBundleVerificationHandler verifyBundleWithPolicyAsync)
    {
        ArgumentNullException.ThrowIfNull(httpClient);
        ArgumentNullException.ThrowIfNull(logger);
        ArgumentNullException.ThrowIfNull(verifyBundleWithPolicyAsync);
 
        _httpClient = httpClient;
        _logger = logger;
        _verifyBundleWithPolicyAsync = verifyBundleWithPolicyAsync;
    }
 
    /// <inheritdoc />
    public async Task<ProvenanceVerificationResult> VerifyProvenanceAsync(
        string packageName,
        string version,
        string expectedSourceRepository,
        string expectedWorkflowPath,
        string expectedBuildType,
        Func<WorkflowRefInfo, bool>? validateWorkflowRef,
        string? sriIntegrity,
        CancellationToken cancellationToken)
    {
        _logger.LogDebug("Verifying provenance for {PackageSpecifier} from {ExpectedSourceRepository}", NpmPackageInfo.FormatPackageSpecifier(packageName, version), expectedSourceRepository);
 
        var json = await FetchAttestationJsonAsync(packageName, version, cancellationToken).ConfigureAwait(false);
        if (json is null)
        {
            _logger.LogDebug("Attestation fetch failed for {PackageSpecifier}", NpmPackageInfo.FormatPackageSpecifier(packageName, version));
            return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationFetchFailed };
        }
 
        // Extract the SLSA provenance bundle JSON from the npm attestation response.
        var bundleJson = ExtractSlsaBundleJson(json, out var parseFailed);
        if (bundleJson is null)
        {
            var outcome = parseFailed
                    ? ProvenanceVerificationOutcome.AttestationParseFailed
                    : ProvenanceVerificationOutcome.SlsaProvenanceNotFound;
            _logger.LogDebug("SLSA bundle extraction failed for {PackageSpecifier}: {Outcome}", NpmPackageInfo.FormatPackageSpecifier(packageName, version), outcome);
            return new ProvenanceVerificationResult
            {
                Outcome = outcome
            };
        }
 
        SigstoreBundle bundle;
        try
        {
            bundle = SigstoreBundle.Deserialize(bundleJson);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Failed to deserialize Sigstore bundle for {PackageSpecifier}", NpmPackageInfo.FormatPackageSpecifier(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;
        }
 
        var subjectOutcome = VerifyNpmSubject(
            verificationResult?.Statement,
            packageName,
            version,
            sriIntegrity);
        if (subjectOutcome is not ProvenanceVerificationOutcome.Verified)
        {
            _logger.LogDebug(
                "Signed npm subject verification failed for {PackageSpecifier}: {Outcome}",
                NpmPackageInfo.FormatPackageSpecifier(packageName, version),
                subjectOutcome);
            return new ProvenanceVerificationResult { Outcome = subjectOutcome };
        }
 
        // Extract provenance from the verified result's in-toto statement and certificate extensions.
        var provenance = ExtractProvenanceFromResult(verificationResult!);
        if (provenance is null)
        {
            _logger.LogDebug("Failed to extract provenance data from verified result for {PackageSpecifier}", NpmPackageInfo.FormatPackageSpecifier(packageName, version));
            return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed };
        }
 
        var result = VerifyProvenanceFields(
            provenance, expectedSourceRepository, expectedWorkflowPath,
            expectedBuildType, validateWorkflowRef);
 
        _logger.LogDebug("Provenance verification for {PackageSpecifier} completed with outcome {Outcome}", NpmPackageInfo.FormatPackageSpecifier(packageName, version), result.Outcome);
 
        return result;
    }
 
    /// <summary>
    /// Fetches the attestation JSON from the public npm registry for the given package and version.
    /// </summary>
    private async Task<string?> FetchAttestationJsonAsync(
        string packageName, string version, CancellationToken cancellationToken)
    {
        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 null;
            }
 
            return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (HttpRequestException ex)
        {
            _logger.LogDebug(ex, "Failed to fetch attestations for {PackageSpecifier}", NpmPackageInfo.FormatPackageSpecifier(packageName, version));
            return null;
        }
    }
 
    /// <summary>
    /// Extracts the Sigstore bundle JSON string for the SLSA provenance attestation
    /// from the public npm registry attestations API response.
    /// Returns the bundle JSON on success, or <c>null</c> if the JSON is malformed or
    /// no SLSA provenance attestation is found.
    /// </summary>
    /// <param name="attestationJson">The raw JSON from the npm attestations API.</param>
    /// <param name="parseFailed">Set to <c>true</c> when the input is not valid JSON; <c>false</c> otherwise.</param>
    internal static string? ExtractSlsaBundleJson(string attestationJson, out bool parseFailed)
    {
        parseFailed = false;
        JsonNode? doc;
        try
        {
            doc = JsonNode.Parse(attestationJson);
        }
        catch (JsonException)
        {
            parseFailed = true;
            return null;
        }
 
        var attestationsNode = doc?["attestations"];
        if (attestationsNode is not JsonArray { Count: > 0 } attestations)
        {
            return null;
        }
 
        foreach (var attestation in attestations)
        {
            if (attestation is not JsonObject attestationObj)
            {
                continue;
            }
 
            var predicateTypeNode = attestationObj["predicateType"];
            if (predicateTypeNode is not JsonValue predicateTypeValue)
            {
                continue;
            }
 
            string? predicateType;
            try
            {
                predicateType = predicateTypeValue.GetValue<string>();
            }
            catch (InvalidOperationException)
            {
                continue;
            }
 
            if (!string.Equals(predicateType, SlsaProvenancePredicateType, StringComparison.Ordinal))
            {
                continue;
            }
 
            var bundleNode = attestationObj["bundle"];
            return bundleNode?.ToJsonString();
        }
 
        return null;
    }
 
    /// <summary>
    /// Cryptographically verifies the Sigstore bundle using the Sigstore library.
    /// Checks the Fulcio certificate chain, Rekor transparency log inclusion, OIDC identity,
    /// and source repository via CertificateExtensionPolicy.
    /// </summary>
    /// <returns>A failure result and null verification result on error; null failure and the verification result on success.</returns>
    private async Task<(ProvenanceVerificationResult? Failure, VerificationResult? Result)> VerifySigstoreBundleAsync(
        SigstoreBundle bundle,
        string expectedSourceRepository,
        string? sriIntegrity,
        string packageName,
        string version,
        CancellationToken cancellationToken)
    {
        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 }, null);
        }
 
        var verifier = new SigstoreVerifier();
        var identityPolicy = CertificateIdentity.ForGitHubActions(owner, repo);
        var policy = new VerificationPolicy
        {
            CertificateIdentity = identityPolicy
        };
 
        try
        {
            var (success, result) = await _verifyBundleWithPolicyAsync(
                verifier, bundle, policy, sriIntegrity, cancellationToken).ConfigureAwait(false);
 
            if (!success)
            {
                _logger.LogWarning(
                    "Sigstore verification failed for {PackageSpecifier}: {FailureReason}",
                    NpmPackageInfo.FormatPackageSpecifier(packageName, version), result?.FailureReason);
                return (new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }, null);
            }
 
            _logger.LogDebug(
                "Sigstore verification passed for {PackageSpecifier}. Signed by: {Signer}",
                NpmPackageInfo.FormatPackageSpecifier(packageName, version), result?.SignerIdentity?.SubjectAlternativeName);
 
            return (null, result);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Sigstore verification threw an exception for {PackageSpecifier}", NpmPackageInfo.FormatPackageSpecifier(packageName, version));
            return (new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }, null);
        }
    }
 
    /// <summary>
    /// Dispatches bundle verification to the appropriate Sigstore verifier method
    /// based on whether an SRI integrity digest is available.
    /// </summary>
    private static async Task<(bool Success, VerificationResult? Result)> VerifyBundleWithPolicyAsync(
        SigstoreVerifier verifier,
        SigstoreBundle bundle,
        VerificationPolicy policy,
        string? sriIntegrity,
        CancellationToken cancellationToken)
    {
        if (sriIntegrity is not null && sriIntegrity.StartsWith("sha512-", StringComparison.OrdinalIgnoreCase))
        {
            var hashBase64 = sriIntegrity["sha512-".Length..];
            var digestBytes = Convert.FromBase64String(hashBase64);
 
            return await verifier.TryVerifyDigestAsync(
                digestBytes, HashAlgorithmType.Sha512, bundle, policy, cancellationToken).ConfigureAwait(false);
        }
 
        // NOTE: When there is no SRI integrity digest and no DSSE envelope, this returns a generic
        // VerificationResult with a failure reason string. The caller maps any verification failure
        // to AttestationParseFailed, which differs from the previous behavior (PayloadDecodeFailed)
        // that occurred when TryVerifyAsync was called with a null payload.
        if (bundle.DsseEnvelope is null)
        {
            return (false, new VerificationResult { FailureReason = "No DSSE envelope found in bundle." });
        }
 
        return await verifier.TryVerifyAsync(
            bundle.DsseEnvelope.Payload, bundle, policy, cancellationToken).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Verifies that the signed in-toto subject identifies the expected npm package archive.
    /// </summary>
    internal static ProvenanceVerificationOutcome VerifyNpmSubject(
        InTotoStatement? statement,
        string packageName,
        string version,
        string? sriIntegrity)
    {
        if (statement?.Subject is not [var subject])
        {
            return ProvenanceVerificationOutcome.PackageIdentityMismatch;
        }
 
        // npm-package-arg encodes the leading '@' in scoped package names when creating the
        // provenance PURL: @playwright/cli@0.1.1 => pkg:npm/%40playwright/cli@0.1.1.
        // See https://github.com/npm/npm-package-arg/blob/main/lib/npa.js.
        var purlPackageName = packageName.StartsWith('@')
            ? $"%40{packageName[1..]}"
            : packageName;
        var expectedSubjectName = $"pkg:npm/{purlPackageName}@{version}";
        if (!string.Equals(subject.Name, expectedSubjectName, StringComparison.Ordinal))
        {
            return ProvenanceVerificationOutcome.PackageIdentityMismatch;
        }
 
        if (sriIntegrity is null ||
            !sriIntegrity.StartsWith("sha512-", StringComparison.OrdinalIgnoreCase) ||
            !subject.Digest.TryGetValue("sha512", out var subjectDigest))
        {
            return ProvenanceVerificationOutcome.PackageDigestMismatch;
        }
 
        string expectedDigest;
        try
        {
            var digestBytes = Convert.FromBase64String(sriIntegrity["sha512-".Length..]);
            expectedDigest = Convert.ToHexStringLower(digestBytes);
        }
        catch (FormatException)
        {
            return ProvenanceVerificationOutcome.PackageDigestMismatch;
        }
 
        return string.Equals(subjectDigest, expectedDigest, StringComparison.Ordinal)
            ? ProvenanceVerificationOutcome.Verified
            : ProvenanceVerificationOutcome.PackageDigestMismatch;
    }
 
    /// <summary>
    /// 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.
    /// </summary>
    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
        };
    }
 
    /// <summary>
    /// Verifies that the extracted provenance fields match the expected values.
    /// Source repository is already verified cryptographically via CertificateExtensionPolicy
    /// during Sigstore bundle verification, but is also checked here for defense-in-depth.
    /// </summary>
    internal static ProvenanceVerificationResult VerifyProvenanceFields(
        NpmProvenanceData provenance,
        string expectedSourceRepository,
        string expectedWorkflowPath,
        string expectedBuildType,
        Func<WorkflowRefInfo, bool>? validateWorkflowRef)
    {
        if (!string.Equals(provenance.SourceRepository, expectedSourceRepository, StringComparison.OrdinalIgnoreCase))
        {
            return new ProvenanceVerificationResult
            {
                Outcome = ProvenanceVerificationOutcome.SourceRepositoryMismatch,
                Provenance = provenance
            };
        }
 
        if (!string.Equals(provenance.WorkflowPath, expectedWorkflowPath, StringComparison.Ordinal))
        {
            return new ProvenanceVerificationResult
            {
                Outcome = ProvenanceVerificationOutcome.WorkflowMismatch,
                Provenance = provenance
            };
        }
 
        if (!string.Equals(provenance.BuildType, expectedBuildType, StringComparison.Ordinal))
        {
            return new ProvenanceVerificationResult
            {
                Outcome = ProvenanceVerificationOutcome.BuildTypeMismatch,
                Provenance = provenance
            };
        }
 
        if (validateWorkflowRef is not null)
        {
            if (!WorkflowRefInfo.TryParse(provenance.WorkflowRef, out var refInfo) || refInfo is null)
            {
                return new ProvenanceVerificationResult
                {
                    Outcome = ProvenanceVerificationOutcome.WorkflowRefMismatch,
                    Provenance = provenance
                };
            }
 
            if (!validateWorkflowRef(refInfo))
            {
                return new ProvenanceVerificationResult
                {
                    Outcome = ProvenanceVerificationOutcome.WorkflowRefMismatch,
                    Provenance = provenance
                };
            }
        }
 
        return new ProvenanceVerificationResult
        {
            Outcome = ProvenanceVerificationOutcome.Verified,
            Provenance = provenance
        };
    }
 
    /// <summary>
    /// Parses a GitHub repository URL into owner and repo components.
    /// </summary>
    internal static bool TryParseGitHubOwnerRepo(string repositoryUrl, out string owner, out string repo)
    {
        owner = string.Empty;
        repo = string.Empty;
 
        if (!Uri.TryCreate(repositoryUrl, UriKind.Absolute, out var uri))
        {
            return false;
        }
 
        var segments = uri.AbsolutePath.Trim('/').Split('/');
        if (segments.Length < 2)
        {
            return false;
        }
 
        owner = segments[0];
        repo = segments[1];
        return true;
    }
}