|
| 1 | +use super::shared::{self, Fetched}; |
| 2 | +use crate::commands::contract::info::shared::fetch; |
| 3 | +use crate::{commands::global, print::Print, utils::http}; |
| 4 | +use base64::Engine as _; |
| 5 | +use clap::{command, Parser}; |
| 6 | +use sha2::{Digest, Sha256}; |
| 7 | +use soroban_spec_tools::contract; |
| 8 | +use soroban_spec_tools::contract::Spec; |
| 9 | +use std::fmt::Debug; |
| 10 | +use stellar_xdr::curr::{ScMetaEntry, ScMetaV0}; |
| 11 | + |
| 12 | +#[derive(Parser, Debug, Clone)] |
| 13 | +#[group(skip)] |
| 14 | +pub struct Cmd { |
| 15 | + #[command(flatten)] |
| 16 | + pub common: shared::Args, |
| 17 | +} |
| 18 | + |
| 19 | +#[derive(thiserror::Error, Debug)] |
| 20 | +pub enum Error { |
| 21 | + #[error(transparent)] |
| 22 | + Wasm(#[from] shared::Error), |
| 23 | + |
| 24 | + #[error(transparent)] |
| 25 | + Spec(#[from] contract::Error), |
| 26 | + |
| 27 | + #[error("'source_repo' meta entry is not stored in the contract")] |
| 28 | + SourceRepoNotSpecified, |
| 29 | + |
| 30 | + #[error("'source_repo' meta entry '{0}' has prefix unsupported, only 'github:' supported")] |
| 31 | + SourceRepoUnsupported(String), |
| 32 | + |
| 33 | + #[error(transparent)] |
| 34 | + Json(#[from] serde_json::Error), |
| 35 | + |
| 36 | + #[error(transparent)] |
| 37 | + Reqwest(#[from] reqwest::Error), |
| 38 | + |
| 39 | + #[error("GitHub attestation not found")] |
| 40 | + AttestationNotFound, |
| 41 | + |
| 42 | + #[error("GitHub attestation invalid")] |
| 43 | + AttestationInvalid, |
| 44 | + |
| 45 | + #[error("Stellar asset contract doesn't contain meta information")] |
| 46 | + NoSACMeta(), |
| 47 | +} |
| 48 | + |
| 49 | +impl Cmd { |
| 50 | + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { |
| 51 | + let print = Print::new(global_args.quiet); |
| 52 | + print.warnln("\x1b[31mThis command displays information about the GitHub Actions run that attested to have built the wasm, and does not verify the source code. Please review the run, its workflow, and source code.\x1b[0m".to_string()); |
| 53 | + |
| 54 | + let Fetched { contract, .. } = fetch(&self.common, &print).await?; |
| 55 | + |
| 56 | + let bytes = match contract { |
| 57 | + shared::Contract::Wasm { wasm_bytes } => wasm_bytes, |
| 58 | + shared::Contract::StellarAssetContract => return Err(Error::NoSACMeta()), |
| 59 | + }; |
| 60 | + |
| 61 | + let wasm_hash = Sha256::digest(&bytes); |
| 62 | + let wasm_hash_hex = hex::encode(wasm_hash); |
| 63 | + print.infoln(format!("Wasm Hash: {wasm_hash_hex}")); |
| 64 | + |
| 65 | + let spec = Spec::new(&bytes)?; |
| 66 | + let Some(source_repo) = spec.meta.iter().find_map(|meta_entry| { |
| 67 | + let ScMetaEntry::ScMetaV0(ScMetaV0 { key, val }) = meta_entry; |
| 68 | + if key.to_string() == "source_repo" { |
| 69 | + Some(val.to_string()) |
| 70 | + } else { |
| 71 | + None |
| 72 | + } |
| 73 | + }) else { |
| 74 | + return Err(Error::SourceRepoNotSpecified); |
| 75 | + }; |
| 76 | + print.infoln(format!("Source Repo: {source_repo}")); |
| 77 | + let Some(github_source_repo) = source_repo.strip_prefix("github:") else { |
| 78 | + return Err(Error::SourceRepoUnsupported(source_repo)); |
| 79 | + }; |
| 80 | + |
| 81 | + let url = format!( |
| 82 | + "https://api.github.com/repos/{github_source_repo}/attestations/sha256:{wasm_hash_hex}" |
| 83 | + ); |
| 84 | + print.infoln(format!("Collecting GitHub attestation from {url}")); |
| 85 | + let resp = http::client().get(url).send().await?; |
| 86 | + let resp: gh_attest_resp::Root = resp.json().await?; |
| 87 | + let Some(attestation) = resp.attestations.first() else { |
| 88 | + return Err(Error::AttestationNotFound); |
| 89 | + }; |
| 90 | + let Ok(payload) = base64::engine::general_purpose::STANDARD |
| 91 | + .decode(&attestation.bundle.dsse_envelope.payload) |
| 92 | + else { |
| 93 | + return Err(Error::AttestationInvalid); |
| 94 | + }; |
| 95 | + let payload: gh_payload::Root = serde_json::from_slice(&payload)?; |
| 96 | + print.checkln("Attestation found linked to GitHub Actions Workflow Run:"); |
| 97 | + let workflow_repo = payload |
| 98 | + .predicate |
| 99 | + .build_definition |
| 100 | + .external_parameters |
| 101 | + .workflow |
| 102 | + .repository; |
| 103 | + let workflow_ref = payload |
| 104 | + .predicate |
| 105 | + .build_definition |
| 106 | + .external_parameters |
| 107 | + .workflow |
| 108 | + .ref_field; |
| 109 | + let workflow_path = payload |
| 110 | + .predicate |
| 111 | + .build_definition |
| 112 | + .external_parameters |
| 113 | + .workflow |
| 114 | + .path; |
| 115 | + let git_commit = &payload |
| 116 | + .predicate |
| 117 | + .build_definition |
| 118 | + .resolved_dependencies |
| 119 | + .first() |
| 120 | + .unwrap() |
| 121 | + .digest |
| 122 | + .git_commit; |
| 123 | + let runner_environment = payload |
| 124 | + .predicate |
| 125 | + .build_definition |
| 126 | + .internal_parameters |
| 127 | + .github |
| 128 | + .runner_environment |
| 129 | + .as_str(); |
| 130 | + print.blankln(format!(" \x1b[34mRepository:\x1b[0m {workflow_repo}")); |
| 131 | + print.blankln(format!(" \x1b[34mRef:\x1b[0m {workflow_ref}")); |
| 132 | + print.blankln(format!(" \x1b[34mPath:\x1b[0m {workflow_path}")); |
| 133 | + print.blankln(format!(" \x1b[34mGit Commit:\x1b[0m {git_commit}")); |
| 134 | + match runner_environment |
| 135 | + { |
| 136 | + runner @ "github-hosted" => print.blankln(format!(" \x1b[34mRunner:\x1b[0m {runner}")), |
| 137 | + runner => print.warnln(format!(" \x1b[34mRunner:\x1b[0m {runner} (runners not hosted by GitHub could have any configuration or environmental changes)")), |
| 138 | + } |
| 139 | + print.blankln(format!( |
| 140 | + " \x1b[34mRun:\x1b[0m {}", |
| 141 | + payload.predicate.run_details.metadata.invocation_id |
| 142 | + )); |
| 143 | + print.globeln(format!( |
| 144 | + "View the workflow at {workflow_repo}/blob/{git_commit}/{workflow_path}" |
| 145 | + )); |
| 146 | + print.globeln(format!( |
| 147 | + "View the repo at {workflow_repo}/tree/{git_commit}" |
| 148 | + )); |
| 149 | + |
| 150 | + Ok(()) |
| 151 | + } |
| 152 | +} |
| 153 | + |
| 154 | +mod gh_attest_resp { |
| 155 | + use serde::Deserialize; |
| 156 | + use serde::Serialize; |
| 157 | + |
| 158 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 159 | + #[serde(rename_all = "camelCase")] |
| 160 | + pub struct Root { |
| 161 | + pub attestations: Vec<Attestation>, |
| 162 | + } |
| 163 | + |
| 164 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 165 | + #[serde(rename_all = "camelCase")] |
| 166 | + pub struct Attestation { |
| 167 | + pub bundle: Bundle, |
| 168 | + } |
| 169 | + |
| 170 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 171 | + #[serde(rename_all = "camelCase")] |
| 172 | + pub struct Bundle { |
| 173 | + pub dsse_envelope: DsseEnvelope, |
| 174 | + } |
| 175 | + |
| 176 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 177 | + #[serde(rename_all = "camelCase")] |
| 178 | + pub struct DsseEnvelope { |
| 179 | + pub payload: String, |
| 180 | + } |
| 181 | +} |
| 182 | + |
| 183 | +mod gh_payload { |
| 184 | + use serde::Deserialize; |
| 185 | + use serde::Serialize; |
| 186 | + |
| 187 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 188 | + #[serde(rename_all = "camelCase")] |
| 189 | + pub struct Root { |
| 190 | + pub predicate_type: String, |
| 191 | + pub predicate: Predicate, |
| 192 | + } |
| 193 | + |
| 194 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 195 | + #[serde(rename_all = "camelCase")] |
| 196 | + pub struct Predicate { |
| 197 | + pub build_definition: BuildDefinition, |
| 198 | + pub run_details: RunDetails, |
| 199 | + } |
| 200 | + |
| 201 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 202 | + #[serde(rename_all = "camelCase")] |
| 203 | + pub struct BuildDefinition { |
| 204 | + pub external_parameters: ExternalParameters, |
| 205 | + pub internal_parameters: InternalParameters, |
| 206 | + pub resolved_dependencies: Vec<ResolvedDependency>, |
| 207 | + } |
| 208 | + |
| 209 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 210 | + #[serde(rename_all = "camelCase")] |
| 211 | + pub struct ExternalParameters { |
| 212 | + pub workflow: Workflow, |
| 213 | + } |
| 214 | + |
| 215 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 216 | + #[serde(rename_all = "camelCase")] |
| 217 | + pub struct Workflow { |
| 218 | + #[serde(rename = "ref")] |
| 219 | + pub ref_field: String, |
| 220 | + pub repository: String, |
| 221 | + pub path: String, |
| 222 | + } |
| 223 | + |
| 224 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 225 | + #[serde(rename_all = "camelCase")] |
| 226 | + pub struct InternalParameters { |
| 227 | + pub github: Github, |
| 228 | + } |
| 229 | + |
| 230 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 231 | + #[serde(rename_all = "camelCase")] |
| 232 | + pub struct Github { |
| 233 | + #[serde(rename = "runner_environment")] |
| 234 | + pub runner_environment: String, |
| 235 | + } |
| 236 | + |
| 237 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 238 | + #[serde(rename_all = "camelCase")] |
| 239 | + pub struct ResolvedDependency { |
| 240 | + pub uri: String, |
| 241 | + pub digest: Digest, |
| 242 | + } |
| 243 | + |
| 244 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 245 | + #[serde(rename_all = "camelCase")] |
| 246 | + pub struct Digest { |
| 247 | + pub git_commit: String, |
| 248 | + } |
| 249 | + |
| 250 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 251 | + #[serde(rename_all = "camelCase")] |
| 252 | + pub struct RunDetails { |
| 253 | + pub metadata: Metadata, |
| 254 | + } |
| 255 | + |
| 256 | + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] |
| 257 | + #[serde(rename_all = "camelCase")] |
| 258 | + pub struct Metadata { |
| 259 | + pub invocation_id: String, |
| 260 | + } |
| 261 | +} |
0 commit comments