| File: Agents\AspireSkills\GitHubArtifactAttestationVerifier.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.Security.Cryptography; using System.Text.Json; using System.Text.Json.Nodes; using Aspire.Cli.Npm; using Microsoft.Extensions.Logging; using Sigstore; namespace Aspire.Cli.Agents.AspireSkills; /// <summary> /// Verifies GitHub release asset attestations for downloaded Aspire skills artifacts. /// </summary> internal interface IGitHubArtifactAttestationVerifier { Task<ProvenanceVerificationResult> VerifyAsync( string repository, string artifactPath, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, string expectedVersion, CancellationToken cancellationToken); } internal sealed class GitHubArtifactAttestationVerifier(HttpClient httpClient, ILogger<GitHubArtifactAttestationVerifier> logger) : IGitHubArtifactAttestationVerifier { private const string GitHubApiBaseUrl = "https://api.github.com"; public async Task<ProvenanceVerificationResult> VerifyAsync( string repository, string artifactPath, string expectedSourceRepository, string expectedWorkflowPath, string expectedBuildType, string expectedVersion, CancellationToken cancellationToken) { var digestBytes = ComputeSha256(artifactPath); var digestHex = Convert.ToHexString(digestBytes).ToLowerInvariant(); var attestationUrl = $"{GitHubApiBaseUrl}/repos/{repository}/attestations/sha256:{digestHex}"; string attestationJson; try { using var request = CreateGitHubRequest(attestationUrl); using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { logger.LogDebug("Failed to fetch GitHub artifact attestations for {Repository} digest {Digest}: HTTP {StatusCode}", repository, digestHex, response.StatusCode); return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationFetchFailed }; } attestationJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } catch (HttpRequestException ex) { logger.LogDebug(ex, "Failed to fetch GitHub artifact attestations for {Repository} digest {Digest}.", repository, digestHex); return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationFetchFailed }; } var bundleJson = ExtractBundleJson(attestationJson, out var parseFailed); if (bundleJson is null) { return new ProvenanceVerificationResult { Outcome = parseFailed ? ProvenanceVerificationOutcome.AttestationParseFailed : ProvenanceVerificationOutcome.SlsaProvenanceNotFound }; } SigstoreBundle bundle; try { bundle = SigstoreBundle.Deserialize(bundleJson); } catch (Exception ex) { logger.LogDebug(ex, "Failed to deserialize GitHub artifact attestation bundle for {Repository} digest {Digest}.", repository, digestHex); return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; } if (!SigstoreNpmProvenanceChecker.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 }; } try { var verifier = new SigstoreVerifier(); var policy = new VerificationPolicy { CertificateIdentity = CertificateIdentity.ForGitHubActions(owner, repo) }; var (success, result) = await verifier.TryVerifyDigestAsync( digestBytes, HashAlgorithmType.Sha256, bundle, policy, cancellationToken).ConfigureAwait(false); if (!success || result is null) { logger.LogWarning("Sigstore verification failed for GitHub artifact {ArtifactPath}: {FailureReason}", artifactPath, result?.FailureReason); return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; } var provenance = SigstoreNpmProvenanceChecker.ExtractProvenanceFromResult(result); if (provenance is null) { logger.LogDebug("Failed to extract provenance data from GitHub artifact attestation for {ArtifactPath}.", artifactPath); return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; } return SigstoreNpmProvenanceChecker.VerifyProvenanceFields( provenance, expectedSourceRepository, expectedWorkflowPath, expectedBuildType, refInfo => IsExpectedTagRef(refInfo, expectedVersion)); } catch (Exception ex) { logger.LogWarning(ex, "Sigstore verification threw an exception for GitHub artifact {ArtifactPath}.", artifactPath); return new ProvenanceVerificationResult { Outcome = ProvenanceVerificationOutcome.AttestationParseFailed }; } } private static HttpRequestMessage CreateGitHubRequest(string url) { var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.UserAgent.ParseAdd("aspire-cli"); request.Headers.Accept.ParseAdd("application/vnd.github+json"); request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28"); return request; } private static string? ExtractBundleJson(string attestationJson, out bool parseFailed) { var bundleJson = SigstoreNpmProvenanceChecker.ExtractSlsaBundleJson(attestationJson, out parseFailed); if (bundleJson is not null || parseFailed) { return bundleJson; } JsonNode? document; try { document = JsonNode.Parse(attestationJson); } catch (JsonException) { parseFailed = true; return null; } if (document?["attestations"] is not JsonArray attestations) { return null; } foreach (var attestation in attestations) { var bundleNode = attestation?["bundle"]; if (bundleNode is null) { continue; } if (bundleNode is JsonValue bundleValue && bundleValue.TryGetValue<string>(out var bundleString)) { return bundleString; } return bundleNode.ToJsonString(); } return null; } private static byte[] ComputeSha256(string artifactPath) { using var stream = File.OpenRead(artifactPath); return SHA256.HashData(stream); } private static bool IsExpectedTagRef(WorkflowRefInfo refInfo, string version) { return string.Equals(refInfo.Kind, "tags", StringComparison.Ordinal) && GetGitHubTagCandidates(version).Any(tag => string.Equals(refInfo.Name, tag, StringComparison.Ordinal)); } private static IEnumerable<string> GetGitHubTagCandidates(string version) { if (version.StartsWith('v') || version.StartsWith('V')) { yield return version; yield return version[1..]; yield break; } yield return $"v{version}"; yield return version; } }