use std::path::Path;
use std::time::Duration;
use tirith_core::artifact::inspect::inspect_artifact_file;
use tirith_core::artifact::InspectionSubject;
use tirith_core::policy::Policy;
use tirith_core::provenance::pypi_integrity::{
AttestationOutcome, PublisherIdentity, PublisherPolicy, SubjectBinding,
};
use tirith_core::threatdb::Ecosystem;
const PYPI_INTEGRITY_BASE: &str = "https://pypi.org/integrity";
const FETCH_TIMEOUT_SECS: u64 = 15;
const MAX_PROVENANCE_BYTES: usize = 4 * 1024 * 1024;
pub fn run(wheel: &Path, json: bool) -> i32 {
let inspected = match inspect_artifact_file(wheel) {
Ok(i) => i,
Err(e) => {
report_input_error(&format!("the wheel could not be inspected: {e:?}"), json);
return 2;
}
};
let identity = match &inspected.inspection.subject {
InspectionSubject::Artifact(a) if a.ecosystem == Ecosystem::PyPI => a.clone(),
InspectionSubject::Artifact(a) => {
report_input_error(
&format!(
"pkg attest covers PyPI artifacts; this artifact is {} (the PyPI Integrity \
API does not apply)",
a.ecosystem
),
json,
);
return 2;
}
_ => {
report_input_error(
"pkg attest needs a distributable wheel artifact (not an installed distribution \
or a generic archive)",
json,
);
return 2;
}
};
let version = match &identity.version {
Some(v) => v.clone(),
None => {
report_input_error(
"the wheel does not declare a version, which the Integrity API URL requires",
json,
);
return 2;
}
};
let cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let policy = Policy::discover_local_only(cwd.as_deref());
let publisher_policy = publisher_policy_from(&policy);
let outcome = match fetch_provenance(&identity.name, &version, &identity.filename) {
Ok(body) => parse_integrity_provenance(&body, &identity.sha256, &publisher_policy),
Err(FetchError::NotFound) => AttestationOutcome::Missing {
reason: "the Integrity API has no provenance for this file".to_string(),
},
Err(FetchError::Transport(reason)) => AttestationOutcome::Missing {
reason: format!("the provenance could not be fetched: {reason}"),
},
};
render(&identity.name, &version, &identity.sha256, &outcome, json);
0
}
fn publisher_policy_from(_policy: &Policy) -> PublisherPolicy {
PublisherPolicy::default()
}
enum FetchError {
NotFound,
Transport(String),
}
fn fetch_provenance(project: &str, version: &str, filename: &str) -> Result<String, FetchError> {
let url = integrity_provenance_url(PYPI_INTEGRITY_BASE, project, version, filename);
fetch_provenance_at(&url)
}
fn fetch_provenance_at(url: &str) -> Result<String, FetchError> {
tirith_core::url_validate::validate_fetch_url(url)
.map_err(|reason| FetchError::Transport(format!("URL rejected: {reason}")))?;
let client = pypi_integrity_client(tirith_core::ssrf_guard::fetch_resolver())
.map_err(|e| FetchError::Transport(format!("HTTP client error: {e}")))?;
let resp = client
.get(url)
.header("Accept", "application/vnd.pypi.integrity.v1+json")
.send()
.map_err(|e| FetchError::Transport(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(FetchError::NotFound);
}
if !resp.status().is_success() {
return Err(FetchError::Transport(format!(
"Integrity API returned HTTP {}",
resp.status().as_u16()
)));
}
if let Some(len) = resp.content_length() {
if len > MAX_PROVENANCE_BYTES as u64 {
return Err(FetchError::Transport(format!(
"provenance response exceeds the {MAX_PROVENANCE_BYTES}-byte cap"
)));
}
}
use std::io::Read as _;
let mut limited = resp.take(MAX_PROVENANCE_BYTES as u64 + 1);
let mut bytes = Vec::new();
limited
.read_to_end(&mut bytes)
.map_err(|e| FetchError::Transport(e.to_string()))?;
if bytes.len() > MAX_PROVENANCE_BYTES {
return Err(FetchError::Transport(format!(
"provenance response exceeds the {MAX_PROVENANCE_BYTES}-byte cap"
)));
}
String::from_utf8(bytes)
.map_err(|_| FetchError::Transport("provenance response is not valid UTF-8".to_string()))
}
fn pypi_integrity_client(
resolver: std::sync::Arc<tirith_core::ssrf_guard::SsrfGuardResolver>,
) -> Result<reqwest::blocking::Client, reqwest::Error> {
reqwest::blocking::Client::builder()
.no_proxy()
.dns_resolver(resolver)
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.redirect(tirith_core::ssrf_guard::server_redirect_policy())
.build()
}
fn integrity_provenance_url(base: &str, project: &str, version: &str, filename: &str) -> String {
let proj = tirith_core::artifact::normalize_project_name_public(project);
match url::Url::parse(base) {
Ok(mut u) => {
if let Ok(mut segs) = u.path_segments_mut() {
segs.push(&proj);
segs.push(version);
segs.push(filename);
segs.push("provenance");
}
u.to_string()
}
Err(_) => format!("{base}/{proj}/{version}/{filename}/provenance"),
}
}
pub fn parse_integrity_provenance(
body: &str,
artifact_sha256: &str,
publisher_policy: &PublisherPolicy,
) -> AttestationOutcome {
let value: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(e) => {
return AttestationOutcome::Invalid {
reason: format!("provenance response is not valid JSON: {e}"),
};
}
};
let Some(claim) = extract_attestation_claim(&value) else {
return AttestationOutcome::Missing {
reason: "the provenance response contains no parseable attestation".to_string(),
};
};
let Some(attested) = claim.subject_sha256.as_deref() else {
return AttestationOutcome::Invalid {
reason: "the attestation in-toto statement carries no subject sha256 digest"
.to_string(),
};
};
match tirith_core::provenance::pypi_integrity::bind_subject_digest(attested, artifact_sha256) {
SubjectBinding::Bound => {}
SubjectBinding::Mismatch | SubjectBinding::Malformed => {
return AttestationOutcome::SubjectMismatch {
attested_sha256: attested.trim().to_ascii_lowercase(),
artifact_sha256: artifact_sha256.trim().to_ascii_lowercase(),
};
}
}
verify_and_finalize(&value, &claim, artifact_sha256, publisher_policy)
}
struct AttestationClaim {
subject_sha256: Option<String>,
#[cfg_attr(not(feature = "sigstore-attestations"), allow(dead_code))]
identity: PublisherIdentity,
}
fn extract_attestation_claim(value: &serde_json::Value) -> Option<AttestationClaim> {
let bundles = value.get("attestation_bundles")?.as_array()?;
for bundle in bundles {
let publisher = bundle
.get("publisher")
.map(extract_publisher_identity)
.unwrap_or_default();
let Some(attestations) = bundle.get("attestations").and_then(|a| a.as_array()) else {
continue;
};
for att in attestations {
let statement = att
.get("statement")
.or_else(|| att.get("envelope").and_then(|e| e.get("statement")));
let subject_sha256 = statement
.and_then(|s| s.get("subject"))
.and_then(|s| s.as_array())
.and_then(|subjects| subjects.first())
.and_then(|s| s.get("digest"))
.and_then(|d| d.get("sha256"))
.and_then(|h| h.as_str())
.map(|s| s.to_string());
if subject_sha256.is_some() {
return Some(AttestationClaim {
subject_sha256,
identity: publisher,
});
}
}
}
None
}
fn extract_publisher_identity(publisher: &serde_json::Value) -> PublisherIdentity {
let repo = publisher
.get("repository")
.or_else(|| publisher.get("repository_full_name"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let workflow = publisher
.get("workflow")
.or_else(|| publisher.get("workflow_filename"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let signer = publisher
.get("environment")
.or_else(|| publisher.get("signer_identity"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
PublisherIdentity {
repository: repo,
workflow,
signer_identity: signer,
}
}
#[cfg(not(feature = "sigstore-attestations"))]
fn verify_and_finalize(
_value: &serde_json::Value,
_claim: &AttestationClaim,
_artifact_sha256: &str,
_publisher_policy: &PublisherPolicy,
) -> AttestationOutcome {
AttestationOutcome::VerificationUnavailable {
reason: "the sigstore-attestations feature is not compiled in (the sigstore verification \
backend requires a newer Rust than the workspace MSRV), so the attestation was \
fetched and its subject digest bound to the artifact, but the Sigstore bundle \
could not be cryptographically verified; this is unavailable evidence, not trust"
.to_string(),
}
}
#[cfg(feature = "sigstore-attestations")]
fn verify_and_finalize(
_value: &serde_json::Value,
claim: &AttestationClaim,
artifact_sha256: &str,
publisher_policy: &PublisherPolicy,
) -> AttestationOutcome {
let _claimed_identity = &claim.identity;
let _ = (
claim.subject_sha256.as_ref(),
artifact_sha256,
publisher_policy,
);
AttestationOutcome::Invalid {
reason: "sigstore verification backend is enabled but the verify call is not yet wired \
(F3 spike); refusing to report an unverified bundle as trusted"
.to_string(),
}
}
fn render(
project: &str,
version: &str,
artifact_sha256: &str,
outcome: &AttestationOutcome,
json: bool,
) {
if json {
let out = serde_json::json!({
"project": project,
"version": version,
"artifact_sha256": artifact_sha256,
"attestation": outcome,
"is_verified": outcome.is_verified(),
});
let _ = serde_json::to_writer_pretty(std::io::stdout().lock(), &out);
println!();
} else {
eprintln!(
"tirith pkg attest: {} {}",
super::sanitize_for_human_output(project, false),
super::sanitize_for_human_output(version, false)
);
eprintln!(" artifact sha256: {artifact_sha256}");
eprintln!(" attestation: {}", outcome.label());
match outcome {
AttestationOutcome::Verified { identity, .. } => {
eprintln!(" verified publish provenance (positive evidence, not an install authorization)");
render_identity(identity);
}
AttestationOutcome::Missing { reason } => {
eprintln!(
" no attestation: {}",
super::sanitize_for_human_output(reason, false)
);
eprintln!(" (absence of provenance is not a block; the firewall verdict is over the bytes)");
}
AttestationOutcome::Invalid { reason } => {
eprintln!(
" attestation invalid: {}",
super::sanitize_for_human_output(reason, false)
);
}
AttestationOutcome::SubjectMismatch {
attested_sha256,
artifact_sha256,
} => {
eprintln!(
" SUBJECT MISMATCH: the attestation covers different bytes than this artifact"
);
eprintln!(" attested: {attested_sha256}");
eprintln!(" artifact: {artifact_sha256}");
}
AttestationOutcome::PublisherNotAllowed { identity, reason } => {
eprintln!(
" publisher not allowed: {}",
super::sanitize_for_human_output(reason, false)
);
render_identity(identity);
}
AttestationOutcome::VerificationUnavailable { reason } => {
eprintln!(
" verification unavailable: {}",
super::sanitize_for_human_output(reason, false)
);
}
}
}
}
fn render_identity(identity: &PublisherIdentity) {
if let Some(repo) = &identity.repository {
eprintln!(
" repository: {}",
super::sanitize_for_human_output(repo, false)
);
}
if let Some(wf) = &identity.workflow {
eprintln!(
" workflow: {}",
super::sanitize_for_human_output(wf, false)
);
}
if let Some(sid) = &identity.signer_identity {
eprintln!(
" signer: {}",
super::sanitize_for_human_output(sid, false)
);
}
}
fn report_input_error(message: &str, json: bool) {
if json {
let out = serde_json::json!({ "error": message });
let _ = serde_json::to_writer_pretty(std::io::stdout().lock(), &out);
println!();
} else {
eprintln!("tirith pkg attest: {message}");
eprintln!(" try: tirith pkg attest <wheel.whl>");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_harness::{EnvGuard, ENV_LOCK};
const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn provenance_json(subject_sha: &str) -> String {
serde_json::json!({
"version": 1,
"attestation_bundles": [{
"publisher": {
"kind": "GitHub",
"repository": "pypa/sampleproject",
"workflow": "release.yml"
},
"attestations": [{
"version": 1,
"statement": {
"_type": "https://in-toto.io/Statement/v1",
"subject": [{
"name": "sampleproject-1.0-py3-none-any.whl",
"digest": { "sha256": subject_sha }
}],
"predicateType": "https://docs.pypi.org/attestations/publish/v1"
}
}]
}]
})
.to_string()
}
#[test]
fn url_is_normalized_and_encoded() {
let url = integrity_provenance_url(
"https://pypi.org/integrity",
"Sample.Project",
"1.0",
"Sample_Project-1.0-py3-none-any.whl",
);
assert!(
url.starts_with("https://pypi.org/integrity/sample-project/1.0/"),
"got {url}"
);
assert!(
url.contains("Sample_Project-1.0-py3-none-any.whl"),
"got {url}"
);
assert!(url.ends_with("/provenance"), "got {url}");
}
#[test]
fn url_segment_encoding_contains_no_path_breakout() {
let url = integrity_provenance_url("https://pypi.org/integrity", "demo", "1.0", "a/b.whl");
assert!(
url.contains("a%2Fb.whl"),
"slash must be encoded, got {url}"
);
assert!(url.ends_with("/provenance"), "got {url}");
}
#[test]
fn missing_attestation_is_missing_outcome() {
let body = serde_json::json!({ "version": 1, "attestation_bundles": [] }).to_string();
let out = parse_integrity_provenance(&body, SHA_A, &PublisherPolicy::default());
assert!(matches!(out, AttestationOutcome::Missing { .. }));
}
#[test]
fn invalid_json_is_invalid_outcome() {
let out = parse_integrity_provenance("not json", SHA_A, &PublisherPolicy::default());
assert!(matches!(out, AttestationOutcome::Invalid { .. }));
}
#[test]
fn subject_mismatch_detected_structurally_without_crypto() {
let body = provenance_json(SHA_B);
let out = parse_integrity_provenance(&body, SHA_A, &PublisherPolicy::default());
match out {
AttestationOutcome::SubjectMismatch {
attested_sha256,
artifact_sha256,
} => {
assert_eq!(attested_sha256, SHA_B);
assert_eq!(artifact_sha256, SHA_A);
}
other => panic!("expected SubjectMismatch, got {other:?}"),
}
}
#[test]
#[cfg(not(feature = "sigstore-attestations"))]
fn bound_without_crypto_is_unavailable_not_verified() {
let body = provenance_json(SHA_A);
let out = parse_integrity_provenance(&body, SHA_A, &PublisherPolicy::default());
assert!(
matches!(out, AttestationOutcome::VerificationUnavailable { .. }),
"bound-but-unverified must be VerificationUnavailable, got {out:?}"
);
assert!(!out.is_verified());
}
#[test]
fn statement_without_subject_digest_is_invalid() {
let body = serde_json::json!({
"version": 1,
"attestation_bundles": [{
"publisher": { "repository": "pypa/sampleproject" },
"attestations": [{
"statement": {
"subject": [{ "name": "x.whl", "digest": {} }]
}
}]
}]
})
.to_string();
let out = parse_integrity_provenance(&body, SHA_A, &PublisherPolicy::default());
assert!(
matches!(out, AttestationOutcome::Missing { .. }),
"got {out:?}"
);
}
#[test]
fn extract_publisher_reads_repo_and_workflow() {
let body = provenance_json(SHA_A);
let value: serde_json::Value = serde_json::from_str(&body).unwrap();
let claim = extract_attestation_claim(&value).expect("a claim");
assert_eq!(
claim.identity.repository.as_deref(),
Some("pypa/sampleproject")
);
assert_eq!(claim.identity.workflow.as_deref(), Some("release.yml"));
}
#[test]
fn fetch_404_is_not_found() {
let _global = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _allow = EnvGuard::set(
"TIRITH_PRIVATE_FETCH_ALLOW",
std::path::Path::new("127.0.0.1/32"),
);
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/integrity/demo/1.0/demo.whl/provenance")
.with_status(404)
.create();
let url = format!("{}/integrity/demo/1.0/demo.whl/provenance", server.url());
let res = fetch_provenance_at(&url);
assert!(matches!(res, Err(FetchError::NotFound)));
}
#[test]
fn fetch_success_returns_body() {
let _global = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _allow = EnvGuard::set(
"TIRITH_PRIVATE_FETCH_ALLOW",
std::path::Path::new("127.0.0.1/32"),
);
let mut server = mockito::Server::new();
let body = provenance_json(SHA_A);
let _m = server
.mock("GET", "/integrity/demo/1.0/demo.whl/provenance")
.with_status(200)
.with_body(&body)
.create();
let url = format!("{}/integrity/demo/1.0/demo.whl/provenance", server.url());
let res = fetch_provenance_at(&url);
let got = res.unwrap_or_else(|_| panic!("expected body"));
assert!(got.contains("attestation_bundles"));
}
#[test]
fn production_client_rejects_connect_time_private_rebind() {
use std::error::Error as _;
let url = "http://rebind.example.test/integrity/demo/1.0/demo.whl/provenance";
let preflight = tirith_core::url_validate::validate_fetch_url_with_resolver_for_test(
url,
&|host, _| {
assert_eq!(host, "rebind.example.test");
Ok(vec!["93.184.216.34".parse().unwrap()])
},
);
assert!(preflight.is_ok(), "the public preflight answer must pass");
let resolver = tirith_core::ssrf_guard::fetch_resolver_with_lookup_for_test(|host| {
assert_eq!(host, "rebind.example.test");
Ok(vec!["127.0.0.1:9".parse().unwrap()])
});
let client = pypi_integrity_client(resolver).expect("build guarded PyPI client");
let error = client
.get(url)
.header("Accept", "application/vnd.pypi.integrity.v1+json")
.send()
.expect_err("connect-time private DNS answer must be refused");
let mut messages = vec![error.to_string()];
let mut source = error.source();
while let Some(cause) = source {
messages.push(cause.to_string());
source = cause.source();
}
assert!(
messages.iter().any(|message| {
message.contains("ssrf_guard") && message.contains("non-public address")
}),
"failure must come from the guarded resolver, got: {messages:?}"
);
}
#[test]
fn fetch_rejects_private_url_without_optin() {
let _global = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _allow = EnvGuard::remove("TIRITH_PRIVATE_FETCH_ALLOW");
let res = fetch_provenance_at("http://127.0.0.1:9/integrity/x/1/x.whl/provenance");
assert!(matches!(res, Err(FetchError::Transport(_))));
}
#[test]
fn fetch_rejects_credentials_in_url() {
let res = fetch_provenance_at("https://user:pass@pypi.org/integrity/x/1/x.whl/provenance");
assert!(matches!(res, Err(FetchError::Transport(_))));
}
}