use miette::{IntoDiagnostic, miette};
use sha2::{Digest as _, Sha512};
use sigstore_oidc::IdentityToken;
use sigstore_sign::SigningContext;
use sigstore_types::{Digest, Statement, Subject};
const SLSA_V1_PREDICATE_TYPE: &str = "https://slsa.dev/provenance/v1";
const GITHUB_ACTIONS_BUILD_TYPE: &str =
"https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1";
pub async fn probe_oidc_available() -> miette::Result<()> {
detect_oidc_token().await.map(|_| ())
}
pub async fn generate(
tarball_bytes: &[u8],
package_name: &str,
package_version: &str,
) -> miette::Result<String> {
let token = detect_oidc_token().await?;
let predicate = build_slsa_predicate()?;
let sha512_hex = hex::encode(Sha512::digest(tarball_bytes));
let statement = Statement {
type_: "https://in-toto.io/Statement/v1".to_string(),
subject: vec![Subject {
name: npm_purl(package_name, package_version),
digest: Digest {
sha256: None,
sha512: Some(sha512_hex),
},
}],
predicate_type: SLSA_V1_PREDICATE_TYPE.to_string(),
predicate,
};
let statement_json = serde_json::to_vec(&statement)
.map_err(|e| miette!("failed to serialize in-toto statement: {e}"))?;
let signer = SigningContext::production().signer(token);
let bundle = signer
.sign_raw_statement(&statement_json)
.await
.map_err(|e| miette!("sigstore signing failed: {e}"))?;
bundle
.to_json()
.map_err(|e| miette!("failed to serialize sigstore bundle: {e}"))
}
fn npm_purl(name: &str, version: &str) -> String {
let encoded_name = name.replace('@', "%40");
format!("pkg:npm/{encoded_name}@{version}")
}
async fn detect_oidc_token() -> miette::Result<IdentityToken> {
let detector = ambient_id::Detector::new();
let token = detector
.detect("sigstore")
.await
.map_err(|e| miette!("OIDC detection failed: {e}"))?
.ok_or_else(|| {
miette!(
"--provenance requires an OIDC-capable CI environment \
(GitHub Actions with `id-token: write`, GitLab CI, \
Buildkite, or CircleCI). No ambient credentials detected."
)
})?;
IdentityToken::from_jwt(token.reveal())
.into_diagnostic()
.map_err(|e| e.wrap_err("failed to parse detected OIDC token as JWT"))
}
fn build_slsa_predicate() -> miette::Result<serde_json::Value> {
if std::env::var("GITHUB_ACTIONS").is_ok() {
return Ok(github_actions_predicate());
}
Ok(generic_predicate())
}
fn github_actions_predicate() -> serde_json::Value {
let env = |k: &str| std::env::var(k).unwrap_or_default();
let server_url = env("GITHUB_SERVER_URL");
let repository = env("GITHUB_REPOSITORY");
let repo_url = if server_url.is_empty() || repository.is_empty() {
String::new()
} else {
format!("{server_url}/{repository}")
};
let workflow_ref_raw = env("GITHUB_WORKFLOW_REF");
let (workflow_path, workflow_ref) = parse_workflow_ref(&workflow_ref_raw);
let run_id = env("GITHUB_RUN_ID");
let run_attempt = env("GITHUB_RUN_ATTEMPT");
let invocation_id = if repo_url.is_empty() || run_id.is_empty() {
String::new()
} else if run_attempt.is_empty() {
format!("{repo_url}/actions/runs/{run_id}")
} else {
format!("{repo_url}/actions/runs/{run_id}/attempts/{run_attempt}")
};
let runner_environment = env("RUNNER_ENVIRONMENT");
let builder_id = if runner_environment == "self-hosted" {
"https://github.com/actions/runner/self-hosted"
} else {
"https://github.com/actions/runner/github-hosted"
};
let sha = env("GITHUB_SHA");
let git_ref = env("GITHUB_REF");
let resolved_uri = if repo_url.is_empty() {
String::new()
} else {
format!("git+{repo_url}@{git_ref}")
};
serde_json::json!({
"buildDefinition": {
"buildType": GITHUB_ACTIONS_BUILD_TYPE,
"externalParameters": {
"workflow": {
"ref": workflow_ref,
"repository": repo_url,
"path": workflow_path,
}
},
"internalParameters": {
"github": {
"event_name": env("GITHUB_EVENT_NAME"),
"repository_id": env("GITHUB_REPOSITORY_ID"),
"repository_owner_id": env("GITHUB_REPOSITORY_OWNER_ID"),
"runner_environment": runner_environment,
}
},
"resolvedDependencies": [{
"uri": resolved_uri,
"digest": { "gitCommit": sha },
}],
},
"runDetails": {
"builder": {
"id": builder_id,
},
"metadata": {
"invocationId": invocation_id,
},
},
})
}
fn parse_workflow_ref(raw: &str) -> (String, String) {
if raw.is_empty() {
return (String::new(), String::new());
}
let (path_and_owner, git_ref) = raw.split_once('@').unwrap_or((raw, ""));
let path = path_and_owner
.splitn(3, '/')
.nth(2)
.unwrap_or("")
.to_string();
(path, git_ref.to_string())
}
fn generic_predicate() -> serde_json::Value {
serde_json::json!({
"buildDefinition": {
"buildType": "https://aube.sh/publish/v1",
"externalParameters": {},
"internalParameters": {},
"resolvedDependencies": [],
},
"runDetails": {
"builder": { "id": "https://aube.sh/publish" },
"metadata": {},
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn purl_plain_package() {
assert_eq!(npm_purl("lodash", "4.17.21"), "pkg:npm/lodash@4.17.21");
}
#[test]
fn purl_scoped_package_encodes_only_at_sign() {
assert_eq!(
npm_purl("@scope/foo", "1.0.0"),
"pkg:npm/%40scope/foo@1.0.0"
);
}
#[test]
fn generic_predicate_has_slsa_shape() {
let v = generic_predicate();
assert!(v.get("buildDefinition").is_some());
assert!(v.get("runDetails").is_some());
}
#[test]
fn parse_workflow_ref_strips_owner_and_repo() {
let (path, git_ref) =
parse_workflow_ref("octocat/hello/.github/workflows/ci.yml@refs/heads/main");
assert_eq!(path, ".github/workflows/ci.yml");
assert_eq!(git_ref, "refs/heads/main");
}
#[test]
fn parse_workflow_ref_handles_nested_workflow_dirs() {
let (path, git_ref) =
parse_workflow_ref("octocat/hello/subdir/.github/workflows/ci.yml@refs/tags/v1.0.0");
assert_eq!(path, "subdir/.github/workflows/ci.yml");
assert_eq!(git_ref, "refs/tags/v1.0.0");
}
#[test]
fn parse_workflow_ref_empty_is_empty() {
assert_eq!(parse_workflow_ref(""), (String::new(), String::new()));
}
#[test]
fn parse_workflow_ref_missing_at_yields_empty_ref() {
let (path, git_ref) = parse_workflow_ref("octocat/hello/.github/workflows/ci.yml");
assert_eq!(path, ".github/workflows/ci.yml");
assert_eq!(git_ref, "");
}
#[test]
fn github_predicate_reads_env_safely_when_unset() {
let v = github_actions_predicate();
assert_eq!(
v["buildDefinition"]["buildType"],
serde_json::json!(GITHUB_ACTIONS_BUILD_TYPE)
);
}
}