use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use chromiumoxide::Page;
use reqwest::Client;
use tracing::debug;
use c2pa::assertions::{labels, Actions, SoftwareAgent};
use c2pa::{format_from_path, Context, DigitalSourceType, Reader, ValidationState};
use crate::error::{AuditError, Result};
use super::{AiProvenanceKind, AiTransparencyAnalysis, ImageProvenanceFinding, ManifestValidation};
const RULE_ID: &str = "ai_transparency.c2pa_provenance";
const MAX_IMAGES_CHECKED: usize = 15;
const MAX_IMAGE_FETCH_BYTES: u64 = 8_000_000;
const FETCH_TIMEOUT_SECS: u64 = 8;
const EXTRACT_JS: &str = include_str!("extract.js");
#[derive(Debug, Default, serde::Deserialize)]
struct RawImageUrls {
#[serde(default, rename = "imageUrls")]
image_urls: Vec<String>,
}
pub(super) async fn analyze_ai_transparency(page: &Page) -> Result<AiTransparencyAnalysis> {
let js_code = format!("(() => {{ {EXTRACT_JS} }})();");
let eval_result = page.evaluate(js_code.as_str()).await.map_err(|e| {
AuditError::CdpError(format!("AI transparency image extraction failed: {e}"))
})?;
let raw: RawImageUrls = match eval_result.value() {
Some(value) => serde_json::from_value(value.clone()).unwrap_or_default(),
None => RawImageUrls::default(),
};
let mut findings = Vec::new();
let mut images_checked = 0usize;
for url in raw.image_urls.iter().take(MAX_IMAGES_CHECKED) {
let Some((bytes, content_type)) = safe_fetch_image(url).await else {
continue;
};
images_checked += 1;
if let Some(finding) = inspect_image_manifest(url, &content_type, bytes) {
findings.push(finding);
}
}
Ok(AiTransparencyAnalysis {
findings,
images_checked,
})
}
async fn safe_fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
let parsed = url::Url::parse(url).ok()?;
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return None;
}
let host = parsed.host_str()?.to_string();
let port = parsed.port_or_known_default()?;
let mut addrs = tokio::net::lookup_host((host.as_str(), port)).await.ok()?;
let safe_addr: SocketAddr = addrs.find(|a| is_public_ip(&a.ip()))?;
let client = Client::builder()
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.user_agent("auditmysite/1.0")
.resolve(&host, safe_addr)
.build()
.ok()?;
let resp = client.get(url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
if let Some(len) = resp.content_length() {
if len > MAX_IMAGE_FETCH_BYTES {
debug!("Skipping oversized image (Content-Length {len}): {url}");
return None;
}
}
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let bytes = resp.bytes().await.ok()?;
if bytes.len() as u64 > MAX_IMAGE_FETCH_BYTES {
debug!(
"Skipping oversized image (actual {} bytes): {url}",
bytes.len()
);
return None;
}
Some((bytes.to_vec(), content_type))
}
fn is_public_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
!(v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_unspecified())
}
IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_multicast() || v6.is_unspecified() {
return false;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return is_public_ip(&IpAddr::V4(v4));
}
let seg0 = v6.segments()[0];
let is_unique_local = seg0 & 0xfe00 == 0xfc00; let is_link_local = seg0 & 0xffc0 == 0xfe80; !(is_unique_local || is_link_local)
}
}
}
fn inspect_image_manifest(
image_url: &str,
content_type: &str,
bytes: Vec<u8>,
) -> Option<ImageProvenanceFinding> {
let format = if content_type.is_empty() {
url::Url::parse(image_url)
.ok()
.and_then(|u| format_from_path(u.path()))
.unwrap_or_default()
} else {
content_type.to_string()
};
if format.is_empty() {
return None;
}
let cursor = std::io::Cursor::new(bytes);
let reader = Reader::from_context(Context::new())
.with_stream(&format, cursor)
.ok()?;
let manifest = reader.active_manifest()?;
let actions: Actions = manifest.find_assertion(labels::ACTIONS).ok()?;
let (provenance, generator) = actions.actions().iter().find_map(|action| {
let provenance = map_source_type(action.source_type()?)?;
let generator = action
.software_agent()
.and_then(software_agent_name)
.or_else(|| manifest.claim_generator().map(str::to_string));
Some((provenance, generator))
})?;
let validation = map_validation(reader.validation_state());
Some(ImageProvenanceFinding {
rule_id: RULE_ID.to_string(),
image_url: image_url.to_string(),
provenance,
validation,
generator: generator.clone(),
measurement_type: "c2pa_manifest".to_string(),
evidence: format!(
"C2PA manifest asserts digitalSourceType '{}'{}, manifest validation: {}.",
provenance.as_str(),
generator
.as_ref()
.map(|g| format!(" (generator: {g})"))
.unwrap_or_default(),
validation.as_str(),
),
message: super::finding_message_text(RULE_ID, true),
})
}
fn map_source_type(dst: &DigitalSourceType) -> Option<AiProvenanceKind> {
match dst {
DigitalSourceType::TrainedAlgorithmicMedia => {
Some(AiProvenanceKind::TrainedAlgorithmicMedia)
}
DigitalSourceType::CompositeWithTrainedAlgorithmicMedia => {
Some(AiProvenanceKind::CompositeWithTrainedAlgorithmicMedia)
}
DigitalSourceType::CompositeSynthetic => Some(AiProvenanceKind::CompositeSynthetic),
DigitalSourceType::VirtualRecording => Some(AiProvenanceKind::VirtualRecording),
DigitalSourceType::TrainedAlgorithmicData => Some(AiProvenanceKind::TrainedAlgorithmicData),
_ => None,
}
}
fn map_validation(state: ValidationState) -> ManifestValidation {
match state {
ValidationState::Invalid => ManifestValidation::Invalid,
ValidationState::Valid => ManifestValidation::Valid,
ValidationState::Trusted => ManifestValidation::Trusted,
}
}
fn software_agent_name(agent: &SoftwareAgent) -> Option<String> {
match agent {
SoftwareAgent::String(s) => Some(s.clone()),
SoftwareAgent::ClaimGeneratorInfo(info) => Some(match &info.version {
Some(v) => format!("{} {}", info.name, v),
None => info.name.clone(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
const BLANK_PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x04, 0x00, 0x00, 0x00, 0xb5,
0x1c, 0x0c, 0x02, 0x00, 0x00, 0x00, 0x0b, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0x64,
0xf8, 0x0f, 0x00, 0x01, 0x05, 0x01, 0x01, 0x27, 0x18, 0xe3, 0x66, 0x00, 0x00, 0x00, 0x00,
0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
fn ai_generated_png_bytes() -> Vec<u8> {
use c2pa::{Builder, EphemeralSigner};
let mut builder = Builder::default();
builder
.add_action(serde_json::json!({
"action": "c2pa.created",
"digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
"softwareAgent": "Test Generative Tool 1.0",
}))
.expect("add_action");
let signer = EphemeralSigner::new("ai-transparency-test.local").expect("ephemeral signer");
let mut source = std::io::Cursor::new(BLANK_PNG.to_vec());
let mut dest = std::io::Cursor::new(Vec::new());
builder
.sign(&signer, "image/png", &mut source, &mut dest)
.expect("sign");
dest.into_inner()
}
#[test]
fn inspects_ai_generated_image_and_finds_provenance() {
let bytes = ai_generated_png_bytes();
let finding = inspect_image_manifest("https://example.com/hero.png", "image/png", bytes)
.expect("finding expected for AI-generated image");
assert_eq!(
finding.provenance,
AiProvenanceKind::TrainedAlgorithmicMedia
);
assert_eq!(
finding.generator.as_deref(),
Some("Test Generative Tool 1.0")
);
assert_eq!(finding.validation, ManifestValidation::Valid);
assert!(finding.evidence.contains("trained_algorithmic_media"));
}
#[test]
fn plain_image_without_manifest_yields_no_finding() {
let finding = inspect_image_manifest(
"https://example.com/plain.png",
"image/png",
BLANK_PNG.to_vec(),
);
assert!(finding.is_none());
}
#[test]
fn public_ip_v4_accepted() {
assert!(is_public_ip(&"93.184.216.34".parse().unwrap()));
}
#[test]
fn private_ip_v4_rejected() {
for ip in [
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"127.0.0.1",
"169.254.1.1",
] {
assert!(!is_public_ip(&ip.parse().unwrap()), "{ip} must be rejected");
}
}
#[test]
fn loopback_and_unique_local_ipv6_rejected() {
for ip in ["::1", "fc00::1", "fd12:3456:789a::1", "fe80::1"] {
assert!(!is_public_ip(&ip.parse().unwrap()), "{ip} must be rejected");
}
}
#[test]
fn ipv4_mapped_private_ipv6_rejected() {
assert!(!is_public_ip(&"::ffff:127.0.0.1".parse().unwrap()));
}
#[test]
fn public_ipv6_accepted() {
assert!(is_public_ip(&"2606:4700:4700::1111".parse().unwrap()));
}
}