Support artifact attestations verification

This commit is contained in:
Taiki Endo
2026-03-21 04:30:53 +09:00
parent 68bba89805
commit 8418e9f725
22 changed files with 2654 additions and 324 deletions

View File

@@ -10,6 +10,10 @@ Note: In this file, do not use the hard wrap in the middle of a sentence for com
## [Unreleased]
- Support artifact attestations verification for `biome`, `cargo-cyclonedx`, `cargo-hack`, `cargo-llvm-cov`, `cargo-minimal-versions`, `cargo-no-dev-deps`, `martin`, `parse-changelog`, `parse-dockerfile`, `prek`, `uv`, `wasmtime`, `zizmor`, and `zola`. ([#1606](https://github.com/taiki-e/install-action/pull/1606))
- Update `biome@latest` to 2.4.8.
- Update `tombi@latest` to 0.9.8.
- Update `parse-dockerfile@latest` to 0.1.5.

View File

@@ -104,7 +104,7 @@ When installing the tool from GitHub Releases, this action will download the too
Additionally, this action will also verify SHA256 checksums for downloaded files in all tools installed from GitHub Releases. This is enabled by default and can be disabled by setting the `checksum` input option to `false`.
Additionally, we also verify signature if the tool distributes signed archives. Signature verification is done at the stage of getting the checksum, so disabling the checksum will also disable signature verification.
Additionally, we also verify [artifact attestations](https://docs.github.com/en/actions/concepts/security/artifact-attestations) or signature if the tool publishes artifact attestations or distributes signed archives. Verification is done at the stage of getting the checksum, so disabling the checksum will also disable verification.
See the linked documentation for information on security when installed using [snap](https://snapcraft.io/docs) or [cargo-binstall](https://github.com/cargo-bins/cargo-binstall#faq).

2467
manifests/biome.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,16 @@
"repository": "https://github.com/biomejs/biome",
"website": "https://biomejs.dev",
"license_markdown": "[Apache-2.0](https://github.com/biomejs/biome/blob/main/LICENSE-APACHE) OR [MIT](https://github.com/biomejs/biome/blob/main/LICENSE-MIT)",
"tag_prefix": "cli/v",
"tag_prefix": ["@biomejs/biome@", "cli/v"],
"bin": "${package}${exe}",
"signing": {
"version_range": ">= 2.3.9",
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/release.yml"
}
}
},
"platform": {
"x86_64_linux_gnu": {
"asset_name": "${package}-linux-x64"

View File

@@ -4,6 +4,14 @@
"rust_crate": "${package}",
"bin": "${package}-${rust_target}/${package}${exe}",
"version_range": ">= 0.5.0",
"signing": {
"version_range": ">= 0.5.4",
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/release.yml"
}
}
},
"platform": {
"x86_64_linux_gnu": {
"asset_name": "${package}-linux-amd64.tar.gz"

View File

@@ -8,6 +8,14 @@
"${package}-${rust_target}.zip",
"${package}-v${version}-${rust_target}.zip"
],
"signing": {
"version_range": ">= 0.6.44",
"kind": {
"gh-attestation": {
"signer-workflow": "taiki-e/github-actions/.github/workflows/rust-release.yml"
}
}
},
"platform": {
"x86_64_linux_gnu": {},
"x86_64_linux_musl": {},

View File

@@ -3,6 +3,14 @@
"tag_prefix": "v",
"rust_crate": "${package}",
"asset_name": "${package}-${rust_target}.tar.gz",
"signing": {
"version_range": ">= 0.8.5",
"kind": {
"gh-attestation": {
"signer-workflow": "taiki-e/github-actions/.github/workflows/rust-release.yml"
}
}
},
"platform": {
"x86_64_linux_musl": {},
"x86_64_macos": {},

View File

@@ -3,6 +3,14 @@
"tag_prefix": "v",
"rust_crate": "${package}",
"asset_name": "${package}-${rust_target}.tar.gz",
"signing": {
"version_range": ">= 0.1.37",
"kind": {
"gh-attestation": {
"signer-workflow": "taiki-e/github-actions/.github/workflows/rust-release.yml"
}
}
},
"platform": {
"x86_64_linux_musl": {},
"x86_64_macos": {},

View File

@@ -3,6 +3,14 @@
"tag_prefix": "v",
"rust_crate": "${package}",
"asset_name": "${package}-${rust_target}.tar.gz",
"signing": {
"version_range": ">= 0.2.23",
"kind": {
"gh-attestation": {
"signer-workflow": "taiki-e/github-actions/.github/workflows/rust-release.yml"
}
}
},
"platform": {
"x86_64_linux_musl": {},
"x86_64_macos": {},

View File

@@ -6,6 +6,13 @@
"asset_name": "${package}-${rust_target}.tar.gz",
"bin": ["${package}${exe}", "${package}-cp${exe}"],
"version_range": ">= 1.0.0",
"signing": {
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/ci.yml"
}
}
},
"platform": {
"x86_64_linux_musl": {},
"x86_64_macos": {},

View File

@@ -6,6 +6,14 @@
"${package}-${rust_target}.tar.gz",
"${package}-${rust_target}.zip"
],
"signing": {
"version_range": ">= 0.6.16",
"kind": {
"gh-attestation": {
"signer-workflow": "taiki-e/github-actions/.github/workflows/rust-release.yml"
}
}
},
"platform": {
"x86_64_linux_gnu": {},
"x86_64_linux_musl": {},

View File

@@ -3,6 +3,14 @@
"tag_prefix": "v",
"rust_crate": "${package}",
"asset_name": "${package}-${rust_target}.tar.gz",
"signing": {
"version_range": ">= 0.1.5",
"kind": {
"gh-attestation": {
"signer-workflow": "taiki-e/github-actions/.github/workflows/rust-release.yml"
}
}
},
"platform": {
"x86_64_linux_musl": {},
"x86_64_macos": {},

View File

@@ -6,6 +6,14 @@
"asset_name": "${package}-${rust_target}.tar.gz",
"bin": "${package}-${rust_target}/${package}${exe}",
"version_range": ">= 0.2.20",
"signing": {
"version_range": ">= 0.3.1",
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/release.yml"
}
}
},
"platform": {
"x86_64_linux_musl": {},
"x86_64_macos": {},

View File

@@ -3,6 +3,14 @@
"tag_prefix": "v",
"bin": "${package}${exe}",
"version_range": ">= 0.62.0",
"signing": {
"version_range": ">= 0.69.4",
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/reusable-release.yaml"
}
}
},
"platform": {
"x86_64_linux_gnu": {
"asset_name": "${package}_${version}_Linux-64bit.tar.gz"

View File

@@ -3,6 +3,14 @@
"license_markdown": "[Apache-2.0](https://github.com/astral-sh/uv/blob/main/LICENSE-APACHE) OR [MIT](https://github.com/astral-sh/uv/blob/main/LICENSE-MIT)",
"tag_prefix": "",
"version_range": ">= 0.8.16",
"signing": {
"version_range": ">= 0.9.13",
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/release.yml"
}
}
},
"platform": {
"x86_64_linux_musl": {
"asset_name": "${package}-x86_64-unknown-linux-musl.tar.gz",

View File

@@ -3,6 +3,14 @@
"tag_prefix": "wash-v",
"rust_crate": "${package}",
"asset_name": "${package}-${rust_target}${exe}",
"signing": {
"version_range": ">= 2.0.0",
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/wash.yml"
}
}
},
"platform": {
"x86_64_linux_musl": {},
"x86_64_macos": {},

View File

@@ -4,6 +4,14 @@
"rust_crate": "${package}-cli",
"asset_name": "${package}-v${version}-${rust_target_arch}-${rust_target_os}.tar.xz",
"bin": "${package}-v${version}-${rust_target_arch}-${rust_target_os}/${package}${exe}",
"signing": {
"version_range": ">= 28.0.0",
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/publish-artifacts.yml"
}
}
},
"platform": {
"x86_64_linux_gnu": {},
"x86_64_macos": {},

View File

@@ -4,6 +4,13 @@
"rust_crate": "${package}",
"asset_name": "${package}-${rust_target}.tar.gz",
"version_range": ">= 1.9.0",
"signing": {
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/release-binaries.yml"
}
}
},
"platform": {
"x86_64_linux_gnu": {},
"x86_64_macos": {},

View File

@@ -2,6 +2,14 @@
"repository": "https://github.com/getzola/zola",
"tag_prefix": "v",
"asset_name": "${package}-v${version}-${rust_target}.tar.gz",
"signing": {
"version_range": ">= 0.20.0",
"kind": {
"gh-attestation": {
"signer-workflow": "${repo}/.github/workflows/release.yml"
}
}
},
"platform": {
"x86_64_linux_gnu": {},
"x86_64_linux_musl": {},

View File

@@ -22,7 +22,7 @@ pub struct BaseManifest {
/// Markdown syntax for links to licenses. Automatically detected if possible.
pub license_markdown: Option<String>,
/// Prefix of release tag.
pub tag_prefix: String,
pub tag_prefix: StringOrArray,
/// Crate name, if this is Rust crate.
pub rust_crate: Option<String>,
pub default_major_version: Option<String>,
@@ -67,6 +67,7 @@ impl BaseManifest {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Signing {
pub version_range: Option<String>,
pub kind: SigningKind,
}
@@ -74,6 +75,10 @@ pub struct Signing {
#[serde(rename_all = "kebab-case")]
#[serde(deny_unknown_fields)]
pub enum SigningKind {
/// gh attestation
/// <https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations>
#[serde(rename_all = "kebab-case")]
GhAttestation { signer_workflow: String },
/// algorithm: minisign
/// public key: package.metadata.binstall.signing.pubkey at Cargo.toml
/// <https://github.com/cargo-bins/cargo-binstall/blob/HEAD/SIGNING.md>

View File

@@ -1,5 +1,8 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
#[macro_use]
mod process;
use std::{
cmp::Reverse,
collections::{BTreeMap, BTreeSet},
@@ -15,7 +18,7 @@ use anyhow::{Context as _, Result, bail};
use fs_err as fs;
use install_action_internal_codegen::{
BaseManifest, HostPlatform, Manifest, ManifestDownloadInfo, ManifestRef, ManifestTemplate,
ManifestTemplateDownloadInfo, Manifests, Signing, SigningKind, Version, workspace_root,
ManifestTemplateDownloadInfo, Manifests, SigningKind, Version, workspace_root,
};
use spdx::expression::{ExprNode, ExpressionReq, Operator};
@@ -76,7 +79,14 @@ fn main() -> Result<()> {
if release.prerelease {
return None;
}
let version = release.tag_name.strip_prefix(&base_info.tag_prefix)?;
let mut version = None;
for tag_prefix in base_info.tag_prefix.as_slice() {
if let Some(v) = release.tag_name.strip_prefix(tag_prefix) {
version = Some(v);
break;
}
}
let version = version?;
let mut semver_version = version.parse::<semver::Version>();
if semver_version.is_err() {
if let Some(default_major_version) = &base_info.default_major_version {
@@ -215,15 +225,15 @@ fn main() -> Result<()> {
}
}
let version_req: Option<semver::VersionReq> = match version_req {
let version_req: semver::VersionReq = match version_req {
_ if latest_only => {
let req = format!("={}", releases.first_key_value().unwrap().0.0).parse()?;
eprintln!("update manifest for versions '{req}'");
Some(req)
req
}
None => match base_info.version_range {
Some(version_range) => Some(version_range.parse()?),
None => Some(">= 0.0.1".parse()?), // HACK: ignore pre-releases
Some(version_range) => version_range.parse()?,
None => ">= 0.0.1".parse()?, // HACK: ignore pre-releases
},
Some(version_req) => {
for version in manifests.map.keys() {
@@ -247,17 +257,15 @@ fn main() -> Result<()> {
version_req.parse()?
};
eprintln!("update manifest for versions '{req}'");
Some(req)
req
}
};
let mut buf = vec![];
let mut buf2 = vec![];
for (Reverse(semver_version), (version, release)) in &releases {
if let Some(version_req) = &version_req {
if !version_req.matches(semver_version) {
continue;
}
if !version_req.matches(semver_version) {
continue;
}
// Specifically skip versions of xbuild with build metadata.
@@ -274,6 +282,28 @@ fn main() -> Result<()> {
continue;
}
let signing_version_req: Option<semver::VersionReq> = match &base_info.signing {
Some(signing) => {
match &signing.version_range {
Some(version_range) => Some(version_range.parse()?),
None => Some(">= 0.0.1".parse()?), // HACK: ignore pre-releases
}
}
None => {
if let Some(asset) = release.assets.iter().find(|asset| {
asset.name.contains(".asc")
|| asset.name.contains(".gpg")
|| asset.name.contains(".sig")
}) {
eprintln!(
"{package} may supports other signing verification method using {}",
asset.name
);
}
None
}
};
let mut download_info = BTreeMap::new();
let mut pubkey = None;
for (&platform, base_download_info) in &base_info.platform {
@@ -323,6 +353,7 @@ fn main() -> Result<()> {
if let Some(entry) = manifest.download_info.get(&platform) {
if entry.etag == etag {
eprintln!("existing etag matched");
// NB: Comment out these two lines when adding verification for old release.
download_info.insert(platform, entry.clone());
continue;
}
@@ -354,81 +385,103 @@ fn main() -> Result<()> {
eprintln!("{hash} *{asset_name}");
let bin_url = &url;
match base_info.signing {
Some(Signing { kind: SigningKind::MinisignBinstall }) => {
let url = url.clone() + ".sig";
let sig_download_cache = &download_cache.with_extension(format!(
"{}.sig",
download_cache.extension().unwrap_or_default().to_str().unwrap()
));
eprint!("downloading {url} for signature validation ... ");
let sig = if sig_download_cache.is_file() {
eprintln!("already downloaded");
minisign_verify::Signature::from_file(sig_download_cache)?
} else {
let buf = download(&url)?.into_string()?;
eprintln!("download complete");
fs::write(sig_download_cache, &buf)?;
minisign_verify::Signature::decode(&buf)?
};
if let Some(signing) = &base_info.signing {
match &signing.kind {
_ if !signing_version_req.as_ref().unwrap().matches(semver_version) => {}
SigningKind::GhAttestation { signer_workflow } => {
eprintln!("verifying {url} with gh attestation verify");
let signer_workflow = signer_workflow.replace("${repo}", repo);
cmd!(
"gh",
"attestation",
"verify",
"--repo",
repo,
"--signer-workflow",
signer_workflow,
&download_cache
)
.run()?;
}
SigningKind::MinisignBinstall => {
let url = url.clone() + ".sig";
let sig_download_cache = &download_cache.with_extension(format!(
"{}.sig",
download_cache.extension().unwrap_or_default().to_str().unwrap()
));
eprint!("downloading {url} for signature validation ... ");
let sig = if sig_download_cache.is_file() {
eprintln!("already downloaded");
minisign_verify::Signature::from_file(sig_download_cache)?
} else {
let buf = download(&url)?.into_string()?;
eprintln!("download complete");
fs::write(sig_download_cache, &buf)?;
minisign_verify::Signature::decode(&buf)?
};
let Some(crates_io_info) = &crates_io_info else {
bail!("signing kind minisign-binstall is supported only for rust crate");
};
let v =
crates_io_info.versions.iter().find(|v| v.num == *semver_version).unwrap();
let url = format!("https://crates.io{}", v.dl_path);
let crate_download_cache =
&download_cache_dir.join(format!("{version}-Cargo.toml"));
eprint!("downloading {url} for signature verification ... ");
if crate_download_cache.is_file() {
eprintln!("already downloaded");
} else {
download(&url)?.into_reader().read_to_end(&mut buf2)?;
let hash = ring::digest::digest(&ring::digest::SHA256, &buf2);
if format!("{hash:?}").strip_prefix("SHA256:").unwrap() != v.checksum {
bail!("checksum mismatch for {url}");
}
let decoder = flate2::read::GzDecoder::new(&*buf2);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?;
if path.file_name() == Some(OsStr::new("Cargo.toml")) {
entry.unpack(crate_download_cache)?;
break;
let Some(crates_io_info) = &crates_io_info else {
bail!(
"signing kind minisign-binstall is supported only for rust crate"
);
};
let v = crates_io_info
.versions
.iter()
.find(|v| v.num == *semver_version)
.unwrap();
let url = format!("https://crates.io{}", v.dl_path);
let crate_download_cache =
&download_cache_dir.join(format!("{version}-Cargo.toml"));
eprint!("downloading {url} for signature verification ... ");
if crate_download_cache.is_file() {
eprintln!("already downloaded");
} else {
download(&url)?.into_reader().read_to_end(&mut buf2)?;
let hash = ring::digest::digest(&ring::digest::SHA256, &buf2);
if format!("{hash:?}").strip_prefix("SHA256:").unwrap() != v.checksum {
bail!("checksum mismatch for {url}");
}
let decoder = flate2::read::GzDecoder::new(&*buf2);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?;
if path.file_name() == Some(OsStr::new("Cargo.toml")) {
entry.unpack(crate_download_cache)?;
break;
}
}
buf2.clear();
eprintln!("download complete");
}
buf2.clear();
eprintln!("download complete");
if pubkey.is_none() {
let cargo_manifest = toml::de::from_str::<cargo_manifest::Manifest>(
&fs::read_to_string(crate_download_cache)?,
)?;
eprintln!(
"algorithm: {}",
cargo_manifest.package.metadata.binstall.signing.algorithm
);
eprintln!(
"pubkey: {}",
cargo_manifest.package.metadata.binstall.signing.pubkey
);
assert_eq!(
cargo_manifest.package.metadata.binstall.signing.algorithm,
"minisign"
);
pubkey = Some(minisign_verify::PublicKey::from_base64(
&cargo_manifest.package.metadata.binstall.signing.pubkey,
)?);
}
let pubkey = pubkey.as_ref().unwrap();
eprint!("verifying signature for {bin_url} ... ");
let allow_legacy = false;
pubkey.verify(&buf, &sig, allow_legacy)?;
eprintln!("done");
}
if pubkey.is_none() {
let cargo_manifest = toml::de::from_str::<cargo_manifest::Manifest>(
&fs::read_to_string(crate_download_cache)?,
)?;
eprintln!(
"algorithm: {}",
cargo_manifest.package.metadata.binstall.signing.algorithm
);
eprintln!(
"pubkey: {}",
cargo_manifest.package.metadata.binstall.signing.pubkey
);
assert_eq!(
cargo_manifest.package.metadata.binstall.signing.algorithm,
"minisign"
);
pubkey = Some(minisign_verify::PublicKey::from_base64(
&cargo_manifest.package.metadata.binstall.signing.pubkey,
)?);
}
let pubkey = pubkey.as_ref().unwrap();
eprint!("verifying signature for {bin_url} ... ");
let allow_legacy = false;
pubkey.verify(&buf, &sig, allow_legacy)?;
eprintln!("done");
}
None => {}
}
download_info.insert(

View File

@@ -0,0 +1,155 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
use std::{
fmt, format,
process::{Command, ExitStatus, Output},
str,
string::{String, ToString as _},
};
use anyhow::{Context as _, Error, Result};
macro_rules! cmd {
($program:expr $(, $arg:expr)* $(,)?) => {{
let mut _cmd = std::process::Command::new($program);
$(
_cmd.arg($arg);
)*
$crate::process::ProcessBuilder::from_std(_cmd)
}};
}
// A builder for an external process, inspired by https://github.com/rust-lang/cargo/blob/0.47.0/src/cargo/util/process_builder.rs
#[must_use]
pub(crate) struct ProcessBuilder {
cmd: Command,
}
impl ProcessBuilder {
pub(crate) fn from_std(cmd: Command) -> Self {
Self { cmd }
}
// pub(crate) fn into_std(self) -> Command {
// self.cmd
// }
// /// Adds an argument to pass to the program.
// pub(crate) fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
// self.cmd.arg(arg.as_ref());
// self
// }
// /// Adds multiple arguments to pass to the program.
// pub(crate) fn args(&mut self, args: impl IntoIterator<Item = impl AsRef<OsStr>>) -> &mut Self {
// self.cmd.args(args);
// self
// }
// /// Set a variable in the process's environment.
// pub(crate) fn env(&mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> &mut Self {
// self.cmd.env(key.as_ref(), val.as_ref());
// self
// }
/// Executes a process, waiting for completion, and mapping non-zero exit
/// status to an error.
pub(crate) fn run(&mut self) -> Result<()> {
let status = self.cmd.status().with_context(|| {
process_error(format!("could not execute process {self}"), None, None)
})?;
if status.success() {
Ok(())
} else {
Err(process_error(
format!("process didn't exit successfully: {self}"),
Some(status),
None,
))
}
}
// /// Executes a process, captures its stdio output, returning the captured
// /// output, or an error if non-zero exit status.
// pub(crate) fn run_with_output(&mut self) -> Result<Output> {
// let output = self.cmd.output().with_context(|| {
// process_error(format!("could not execute process {self}"), None, None)
// })?;
// if output.status.success() {
// Ok(output)
// } else {
// Err(process_error(
// format!("process didn't exit successfully: {self}"),
// Some(output.status),
// Some(&output),
// ))
// }
// }
// /// Executes a process, captures its stdio output, returning the captured
// /// standard output as a `String`.
// pub(crate) fn read(&mut self) -> Result<String> {
// let mut output = String::from_utf8(self.run_with_output()?.stdout)
// .with_context(|| format!("failed to parse output from {self}"))?;
// while output.ends_with('\n') || output.ends_with('\r') {
// output.pop();
// }
// Ok(output)
// }
}
// Based on https://github.com/rust-lang/cargo/blob/0.47.0/src/cargo/util/process_builder.rs
impl fmt::Display for ProcessBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if !f.alternate() {
f.write_str("`")?;
}
f.write_str(&self.cmd.get_program().to_string_lossy())?;
for arg in self.cmd.get_args() {
write!(f, " {}", arg.to_string_lossy())?;
}
if !f.alternate() {
f.write_str("`")?;
}
Ok(())
}
}
// Based on https://github.com/rust-lang/cargo/blob/0.47.0/src/cargo/util/errors.rs
/// Creates a new process error.
///
/// `status` can be `None` if the process did not launch.
/// `output` can be `None` if the process did not launch, or output was not captured.
fn process_error(mut msg: String, status: Option<ExitStatus>, output: Option<&Output>) -> Error {
match status {
Some(s) => {
msg.push_str(" (");
msg.push_str(&s.to_string());
msg.push(')');
}
None => msg.push_str(" (never executed)"),
}
if let Some(out) = output {
match str::from_utf8(&out.stdout) {
Ok(s) if !s.trim_start().is_empty() => {
msg.push_str("\n--- stdout\n");
msg.push_str(s);
}
Ok(_) | Err(_) => {}
}
match str::from_utf8(&out.stderr) {
Ok(s) if !s.trim_start().is_empty() => {
msg.push_str("\n--- stderr\n");
msg.push_str(s);
}
Ok(_) | Err(_) => {}
}
}
Error::msg(msg)
}