|
// 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;
/// <summary>
/// Verifies npm package provenance by fetching and parsing SLSA attestations from the npm registry API.
/// </summary>
internal sealed class NpmProvenanceChecker(HttpClient httpClient, ILogger<NpmProvenanceChecker> logger) : INpmProvenanceChecker
{
internal const string NpmRegistryAttestationsBaseUrl = "https://registry.npmjs.org/-/npm/v1/attestations";
internal const string SlsaProvenancePredicateType = "https://slsa.dev/provenance/v1";
/// <inheritdoc />
public async Task<ProvenanceVerificationResult> VerifyProvenanceAsync(string packageName, string version, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, Func<WorkflowRefInfo, bool>? 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
};
}
/// <summary>
/// Parses provenance data from the npm attestation API response.
/// </summary>
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<string>();
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<string>();
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<string>();
var workflowPath = workflow?["path"]?.GetValue<string>();
var workflowRef = workflow?["ref"]?.GetValue<string>();
var builderId = predicate
?["runDetails"]
?["builder"]
?["id"]
?.GetValue<string>();
var buildType = buildDefinition?["buildType"]?.GetValue<string>();
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);
}
}
|