diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a696d..c4dea1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +## 0.14.4 (2026-10-20) + +### Fixed + +- Apply consent shape and cross-field privacy checks to legacy GDPR, TCF, US Privacy and EID extension locations. Preserve input paths and honor the selected version's location, including empty or null values. Warn when canonical and legacy values conflict. +- Attribute a TCF contradiction to GPP when the decoded TCF core came from GPP rather than an unreadable user consent string. + ## 0.14.3 (2026-10-20) ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 4d1656f..b530e9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1154,7 +1154,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rtblint" -version = "0.14.3" +version = "0.14.4" dependencies = [ "rtblint-core", "rtblint-resolve", @@ -1164,7 +1164,7 @@ dependencies = [ [[package]] name = "rtblint-core" -version = "0.14.3" +version = "0.14.4" dependencies = [ "jsonschema", "serde", @@ -1173,7 +1173,7 @@ dependencies = [ [[package]] name = "rtblint-grpc" -version = "0.14.3" +version = "0.14.4" dependencies = [ "http", "mimalloc", @@ -1195,7 +1195,7 @@ dependencies = [ [[package]] name = "rtblint-mcp" -version = "0.14.3" +version = "0.14.4" dependencies = [ "rtblint-core", "serde_json", @@ -1203,7 +1203,7 @@ dependencies = [ [[package]] name = "rtblint-resolve" -version = "0.14.3" +version = "0.14.4" dependencies = [ "rtblint-core", "serde", @@ -1212,7 +1212,7 @@ dependencies = [ [[package]] name = "rtblint-wasm" -version = "0.14.3" +version = "0.14.4" dependencies = [ "rtblint-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index 2d9431f..6a77dfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ exclude = ["fuzz"] resolver = "2" [workspace.package] -version = "0.14.3" +version = "0.14.4" edition = "2021" rust-version = "1.74" authors = ["Aleks "] diff --git a/crates/rtblint-core/src/privacy.rs b/crates/rtblint-core/src/privacy.rs index 52dc80e..ca93e06 100644 --- a/crates/rtblint-core/src/privacy.rs +++ b/crates/rtblint-core/src/privacy.rs @@ -6,7 +6,7 @@ use serde_json::{Map, Value}; -use crate::{Issue, Severity}; +use crate::{Issue, OpenRtbVersion, Profile, Severity}; const SECTION_GPP: &str = "GPP Consent String Specification"; const SECTION_TCF: &str = "IAB TCF v2.2"; @@ -20,6 +20,144 @@ const KNOWN_GPP_SIDS: &[i64] = &[ const TCF_MIN_LEN: usize = 19; +/// A semantic view only. Validation and migration findings retain the input paths. +/// The selected contract's location wins even when its value is empty or null. +pub(crate) fn signal_view( + root: &Map, + version: OpenRtbVersion, + profile: Profile, + instance_path: &str, + issues: &mut Vec, +) -> (Map, Vec<(String, String)>) { + let mut view = root.clone(); + let mut paths = Vec::new(); + for (modern, legacy) in [ + ("regs.gdpr", "regs.ext.gdpr"), + ("user.consent", "user.ext.consent"), + ( + "regs.us_privacy", + if version <= OpenRtbVersion::V2_1 { + "user.ext.us_privacy" + } else { + "regs.ext.us_privacy" + }, + ), + ("user.eids", "user.ext.eids"), + ] { + let modern_value = at(root, modern); + let legacy_value = at(root, legacy); + let prefer_legacy = version <= OpenRtbVersion::V2_5 + || modern == "user.eids" && crate::canonical_field(version, "User", "eids").is_none(); + let (preferred, alternate) = if prefer_legacy { + ((legacy, legacy_value), (modern, modern_value)) + } else { + ((modern, modern_value), (legacy, legacy_value)) + }; + if let (Some(a), Some(b)) = (preferred.1, alternate.1) { + // These two representations carry the same GDPR flag in proto JSON. + let equal_flag = modern == "regs.gdpr" + && matches!( + (a.as_bool(), b.as_i64()), + (Some(false), Some(0)) | (Some(true), Some(1)) + ) + || modern == "regs.gdpr" + && matches!( + (b.as_bool(), a.as_i64()), + (Some(false), Some(0)) | (Some(true), Some(1)) + ); + if a != b && !equal_flag { + issues.push(issue( + "openrtb.privacy.signal_conflict", + Severity::Warning, + format!("{} and {} disagree; privacy checks use {} for OpenRTB {}. Reconcile the values before removing either field.", + preferred.0, alternate.0, preferred.0, version.id()), + join_path(instance_path, alternate.0), + )); + } + } + if let Some(value) = legacy_value { + // Vendor extension typing belongs to the selected profile's wire contract. + if profile == Profile::Spec + && matches!(modern, "user.consent" | "regs.us_privacy") + && !value.is_string() + { + issues.push(issue( + "openrtb.type.mismatch", + Severity::Error, + format!("{legacy} must be a string."), + join_path(instance_path, legacy), + )); + } else if profile == Profile::Spec + && modern == "regs.gdpr" + && !matches!(value.as_i64(), Some(0 | 1)) + && !value.is_boolean() + { + issues.push(issue( + "openrtb.value.invalid", + Severity::Error, + format!("{legacy} must be a 0/1 flag (or a protobuf boolean)."), + join_path(instance_path, legacy), + )); + } + if legacy.ends_with(".consent") { + if let Some(consent) = value.as_str().filter(|s| !s.is_empty()) { + if let Some(message) = tcf_shape_error(consent) { + issues.push(issue( + "openrtb.regs.tcf_malformed", + Severity::Warning, + format!("{legacy} {message}"), + join_path(instance_path, legacy), + )); + } + } + } else if legacy.ends_with(".us_privacy") { + if let Some(usp) = value.as_str().filter(|s| !s.is_empty()) { + if !us_privacy_shape(usp) { + issues.push(issue( + "openrtb.regs.us_privacy_malformed", + Severity::Warning, + format!( + "{legacy} must be 1 followed by three Y, N, or '-' characters." + ), + join_path(instance_path, legacy), + )); + } + } + } + } + let selected = if preferred.1.is_some() { + preferred + } else { + alternate + }; + if let Some(value) = selected.1 { + let (object, field) = modern.split_once('.').expect("known signal path"); + let target = view + .entry(object) + .or_insert_with(|| Value::Object(Map::new())); + if let Some(target) = target.as_object_mut() { + target.insert(field.to_owned(), value.clone()); + if selected.0 != modern { + paths.push(( + join_path(instance_path, modern), + join_path(instance_path, selected.0), + )); + } + } + } + } + (view, paths) +} + +fn at<'a>(root: &'a Map, path: &str) -> Option<&'a Value> { + let mut keys = path.split('.'); + let mut value = root.get(keys.next()?)?; + for key in keys { + value = value.get(key)?; + } + Some(value) +} + pub(crate) fn validate_gpp( gpp: &str, gpp_sid: Option<&[Value]>, @@ -361,6 +499,10 @@ fn issue(id: &'static str, severity: Severity, message: String, path: String) -> SECTION_TCF } else if id.contains("us_privacy") { "US Privacy String v1" + } else if matches!(id, "openrtb.type.mismatch" | "openrtb.value.invalid") { + "OpenRTB legacy privacy extensions" + } else if id == "openrtb.privacy.signal_conflict" { + "OpenRTB privacy signals" } else { SECTION_GPP })), diff --git a/crates/rtblint-core/src/privacy_signals.rs b/crates/rtblint-core/src/privacy_signals.rs index cb980bd..e3341ba 100644 --- a/crates/rtblint-core/src/privacy_signals.rs +++ b/crates/rtblint-core/src/privacy_signals.rs @@ -7,7 +7,7 @@ use serde_json::{Map, Value}; use crate::privacy::{decode_tcf_core, gpp_section}; -use crate::{Issue, Severity}; +use crate::{Issue, OpenRtbVersion, Profile, Severity}; const SECTION_REGS: &str = "3.2.3"; const SECTION_DEVICE: &str = "3.2.18"; @@ -18,10 +18,15 @@ const SECTION_DSA: &str = "DSA Transparency"; /// Cross-field privacy checks on a 2.x BidRequest or an AdCOM Context. pub(crate) fn validate_privacy_signals( + version: OpenRtbVersion, + profile: Profile, root: &Map, instance_path: &str, issues: &mut Vec, ) { + let (view, paths) = crate::privacy::signal_view(root, version, profile, instance_path, issues); + let start = issues.len(); + let root = &view; let regs = root.get("regs").and_then(Value::as_object); let device = root.get("device").and_then(Value::as_object); let user = root.get("user").and_then(Value::as_object); @@ -114,21 +119,17 @@ pub(crate) fn validate_privacy_signals( "openrtb.privacy.gdpr_without_consent", SECTION_REGS, join(instance_path, "regs.gdpr"), - "regs.gdpr is 1, no user.consent or GPP TCF section is present, and the \ - request still carries identifiers or user.data segments.", + "the GDPR flag is set, no consent string or GPP TCF section is present, and \ + the request still carries identifiers or user.data segments.", ); } - if let Some(core) = tcf_core_from(user, regs) { + if let Some((core, source)) = tcf_core_from(user, regs) { if !core.purpose1_consent && !core.purpose_one_treatment && has_storage_identifiers(user, device) { - let path = if user.is_some_and(|user| non_empty_str(user.get("consent"))) { - join(instance_path, "user.consent") - } else { - join(instance_path, "regs.gpp") - }; + let path = join(instance_path, source); push( issues, "openrtb.privacy.tcf_purpose1_identifiers", @@ -154,6 +155,28 @@ pub(crate) fn validate_privacy_signals( scan_pii_email(user, site, instance_path, issues); validate_request_dsa(regs, instance_path, issues); + for issue in &mut issues[start..] { + if let Some(path) = issue.path.as_mut() { + for (modern, original) in &paths { + if path == modern + || path + .strip_prefix(modern) + .is_some_and(|tail| tail.starts_with('[') || tail.starts_with('.')) + { + *path = format!("{original}{}", &path[modern.len()..]); + issue.message = issue.message.replace( + modern + .trim_start_matches(instance_path) + .trim_start_matches('.'), + original + .trim_start_matches(instance_path) + .trim_start_matches('.'), + ); + break; + } + } + } + } } /// `bid.ext.dsa` presence: the IAB DSA Transparency fields `behalf` and `paid`. @@ -284,13 +307,13 @@ fn scan_pii_email( fn tcf_core_from( user: Option<&Map>, regs: Option<&Map>, -) -> Option { +) -> Option<(crate::privacy::TcfCore, &'static str)> { if let Some(consent) = user .and_then(|user| user.get("consent")) .and_then(Value::as_str) { if let Some(core) = decode_tcf_core(consent) { - return Some(core); + return Some((core, "user.consent")); } } let gpp = regs @@ -299,7 +322,7 @@ fn tcf_core_from( for sid in [2_i64, 5] { if let Some(section) = gpp_section(gpp, sid) { if let Some(core) = decode_tcf_core(section) { - return Some(core); + return Some((core, "regs.gpp")); } } } @@ -587,7 +610,13 @@ mod tests { "device": { "ifa": "aebe4057-cebe-4301-8cc6-5e6e41c0bf47" } })); let mut issues = Vec::new(); - validate_privacy_signals(&object, "", &mut issues); + validate_privacy_signals( + OpenRtbVersion::V2_6_202606, + Profile::Spec, + &object, + "", + &mut issues, + ); assert!(issues .iter() .any(|issue| issue.id == "openrtb.privacy.coppa_identifier")); @@ -600,7 +629,13 @@ mod tests { "device": { "ifa": "00000000-0000-0000-0000-000000000000" } })); let mut issues = Vec::new(); - validate_privacy_signals(&object, "", &mut issues); + validate_privacy_signals( + OpenRtbVersion::V2_6_202606, + Profile::Spec, + &object, + "", + &mut issues, + ); assert!(!issues .iter() .any(|issue| issue.id == "openrtb.privacy.coppa_identifier")); @@ -613,7 +648,13 @@ mod tests { "device": { "ifa": "abc" } })); let mut issues = Vec::new(); - validate_privacy_signals(&object, "", &mut issues); + validate_privacy_signals( + OpenRtbVersion::V2_6_202606, + Profile::Spec, + &object, + "", + &mut issues, + ); assert!(issues .iter() .any(|issue| issue.id == "openrtb.privacy.coppa_identifier")); @@ -623,7 +664,13 @@ mod tests { fn email_in_user_id() { let object = root(json!({ "user": { "id": "buyer@example.com" } })); let mut issues = Vec::new(); - validate_privacy_signals(&object, "", &mut issues); + validate_privacy_signals( + OpenRtbVersion::V2_6_202606, + Profile::Spec, + &object, + "", + &mut issues, + ); assert!(issues .iter() .any(|issue| issue.id == "openrtb.privacy.pii_email")); @@ -636,7 +683,13 @@ mod tests { "user": { "eids": [{ "source": "liveramp.com", "uids": [{ "id": "abc", "atype": 3 }] }] } })); let mut issues = Vec::new(); - validate_privacy_signals(&object, "", &mut issues); + validate_privacy_signals( + OpenRtbVersion::V2_6_202606, + Profile::Spec, + &object, + "", + &mut issues, + ); assert!(issues .iter() .any(|issue| issue.id == "openrtb.privacy.us_privacy_sale_eids")); @@ -648,7 +701,13 @@ mod tests { "regs": { "ext": { "dsa": { "dsarequired": 2, "transparency": [{ "dsaparams": [1] }] } } } })); let mut issues = Vec::new(); - validate_privacy_signals(&object, "", &mut issues); + validate_privacy_signals( + OpenRtbVersion::V2_6_202606, + Profile::Spec, + &object, + "", + &mut issues, + ); assert!(issues .iter() .any(|issue| issue.id == "openrtb.privacy.dsa_transparency_domain")); @@ -673,7 +732,13 @@ mod tests { "device": { "ifa": "aebe4057-cebe-4301-8cc6-5e6e41c0bf47" } })); let mut issues = Vec::new(); - validate_privacy_signals(&object, "", &mut issues); + validate_privacy_signals( + OpenRtbVersion::V2_6_202606, + Profile::Spec, + &object, + "", + &mut issues, + ); assert!(issues .iter() .any(|issue| issue.id == "openrtb.privacy.tcf_purpose1_identifiers")); @@ -687,7 +752,13 @@ mod tests { "device": { "ifa": "aebe4057-cebe-4301-8cc6-5e6e41c0bf47" } })); let mut issues = Vec::new(); - validate_privacy_signals(&object, "", &mut issues); + validate_privacy_signals( + OpenRtbVersion::V2_6_202606, + Profile::Spec, + &object, + "", + &mut issues, + ); assert!(!issues .iter() .any(|issue| issue.id == "openrtb.privacy.tcf_purpose1_identifiers")); diff --git a/crates/rtblint-core/src/validator.rs b/crates/rtblint-core/src/validator.rs index 6f2c25e..edcb660 100644 --- a/crates/rtblint-core/src/validator.rs +++ b/crates/rtblint-core/src/validator.rs @@ -933,9 +933,14 @@ fn validate_object_semantics( && canonical_adcom_object(object_name).is_some(); match object_name { - "BidRequest" => { - validate_bid_request_semantics(object, instance_path, object_section, issues) - } + "BidRequest" => validate_bid_request_semantics( + version, + profile, + object, + instance_path, + object_section, + issues, + ), "BidResponse" => { validate_bid_response_semantics(profile, object, instance_path, object_section, issues) } @@ -1106,7 +1111,13 @@ fn validate_adcom_context_semantics( }); } - crate::privacy_signals::validate_privacy_signals(object, instance_path, issues); + crate::privacy_signals::validate_privacy_signals( + OpenRtbVersion::V3_0, + Profile::Spec, + object, + instance_path, + issues, + ); } fn validate_adcom_av_placement_semantics( @@ -1163,6 +1174,8 @@ fn validate_generic_exclusive_pairs( } fn validate_bid_request_semantics( + version: OpenRtbVersion, + profile: Profile, object: &Map, instance_path: &str, section: &str, @@ -1232,7 +1245,13 @@ fn validate_bid_request_semantics( } } - crate::privacy_signals::validate_privacy_signals(object, instance_path, issues); + crate::privacy_signals::validate_privacy_signals( + version, + profile, + object, + instance_path, + issues, + ); } fn validate_bid_response_semantics( diff --git a/crates/rtblint-core/tests/fixtures/dialects/README.md b/crates/rtblint-core/tests/fixtures/dialects/README.md index fcf3a4a..b1c8b6c 100644 --- a/crates/rtblint-core/tests/fixtures/dialects/README.md +++ b/crates/rtblint-core/tests/fixtures/dialects/README.md @@ -16,3 +16,14 @@ The wire alternatives and boolean fields follow the [IAB protobuf schema](https: The site domain control follows [OpenRTB 2.6, section 3.2.13](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#3213---object-site). Run `cargo test -p rtblint-core --test dialect_fixtures`. + +Privacy fixtures also cover legacy 2.5 GDPR and consent, the 2.0/2.1 User +extension location for US Privacy, and legacy Regs US Privacy. They pin malformed +strings and base-spec extension types, canonical/legacy conflict precedence, +null canonical consent, equal duplicates, legacy EIDs under COPPA and US +Privacy, Purpose 1 checks, and the actual GPP source path. Vendor extension +wire types remain governed by their profiles. No stored visitor values appear +in these fixtures. + +The legacy locations follow the [OpenRTB 2.6 migration notes](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md) +and [IAB US Privacy extension](https://github.com/InteractiveAdvertisingBureau/USPrivacy/blob/master/CCPA/OpenRTB%20Extension%20for%20USPrivacy.md). diff --git a/crates/rtblint-core/tests/fixtures/dialects/cases.json b/crates/rtblint-core/tests/fixtures/dialects/cases.json index beb3056..9328507 100644 --- a/crates/rtblint-core/tests/fixtures/dialects/cases.json +++ b/crates/rtblint-core/tests/fixtures/dialects/cases.json @@ -451,5 +451,319 @@ "path": null } ] + }, + { + "name": "privacy: legacy-gdpr-missing-consent", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-gdpr-missing-consent.json", + "valid": true, + "issues": [ + { + "id": "openrtb.privacy.gdpr_without_consent", + "severity": "warning", + "path": "regs.ext.gdpr" + } + ] + }, + { + "name": "privacy: legacy-gdpr-missing-consent-new-contract", + "mode": "request", + "version": "2.6-202606", + "dialect": "spec-json", + "payload": "privacy-legacy-gdpr-missing-consent-new-contract.json", + "valid": false, + "issues": [ + { + "id": "openrtb.field.moved", + "severity": "error", + "path": "regs.ext.gdpr" + }, + { + "id": "openrtb.privacy.gdpr_without_consent", + "severity": "warning", + "path": "regs.ext.gdpr" + } + ] + }, + { + "name": "privacy: legacy-tcf-malformed", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-tcf-malformed.json", + "valid": true, + "issues": [ + { + "id": "openrtb.regs.tcf_malformed", + "severity": "warning", + "path": "user.ext.consent" + } + ] + }, + { + "name": "privacy: legacy-tcf-malformed-new-contract", + "mode": "request", + "version": "2.6-202606", + "dialect": "spec-json", + "payload": "privacy-legacy-tcf-malformed-new-contract.json", + "valid": false, + "issues": [ + { + "id": "openrtb.field.moved", + "severity": "error", + "path": "user.ext.consent" + }, + { + "id": "openrtb.regs.tcf_malformed", + "severity": "warning", + "path": "user.ext.consent" + } + ] + }, + { + "name": "privacy: legacy-usp-malformed", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-usp-malformed.json", + "valid": true, + "issues": [ + { + "id": "openrtb.regs.us_privacy_malformed", + "severity": "warning", + "path": "regs.ext.us_privacy" + } + ] + }, + { + "name": "privacy: legacy-usp-user-2-1", + "mode": "request", + "version": "2.1", + "dialect": "spec-json", + "payload": "privacy-legacy-usp-user-2-1.json", + "valid": true, + "issues": [ + { + "id": "openrtb.regs.us_privacy_malformed", + "severity": "warning", + "path": "user.ext.us_privacy" + } + ] + }, + { + "name": "privacy: legacy-gdpr-type", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-gdpr-type.json", + "valid": false, + "issues": [ + { + "id": "openrtb.value.invalid", + "severity": "error", + "path": "regs.ext.gdpr" + } + ] + }, + { + "name": "privacy: legacy-consent-type", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-consent-type.json", + "valid": false, + "issues": [ + { + "id": "openrtb.type.mismatch", + "severity": "error", + "path": "user.ext.consent" + } + ] + }, + { + "name": "privacy: legacy-usp-type", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-usp-type.json", + "valid": false, + "issues": [ + { + "id": "openrtb.type.mismatch", + "severity": "error", + "path": "regs.ext.us_privacy" + } + ] + }, + { + "name": "privacy: legacy-gdpr-conflict", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-gdpr-conflict.json", + "valid": false, + "issues": [ + { + "id": "openrtb.field.not_yet_available", + "severity": "error", + "path": "regs.gdpr" + }, + { + "id": "openrtb.privacy.signal_conflict", + "severity": "warning", + "path": "regs.gdpr" + }, + { + "id": "openrtb.privacy.gdpr_without_consent", + "severity": "warning", + "path": "regs.ext.gdpr" + } + ] + }, + { + "name": "privacy: modern-gdpr-conflict", + "mode": "request", + "version": "2.6-202606", + "dialect": "spec-json", + "payload": "privacy-modern-gdpr-conflict.json", + "valid": false, + "issues": [ + { + "id": "openrtb.field.moved", + "severity": "error", + "path": "regs.ext.gdpr" + }, + { + "id": "openrtb.privacy.signal_conflict", + "severity": "warning", + "path": "regs.ext.gdpr" + } + ] + }, + { + "name": "privacy: equal-gdpr-duplicates", + "mode": "request", + "version": "2.6-202606", + "dialect": "spec-json", + "payload": "privacy-equal-gdpr-duplicates.json", + "valid": false, + "issues": [ + { + "id": "openrtb.field.moved", + "severity": "error", + "path": "regs.ext.gdpr" + } + ] + }, + { + "name": "privacy: null-canonical-consent", + "mode": "request", + "version": "2.6-202606", + "dialect": "spec-json", + "payload": "privacy-null-canonical-consent.json", + "valid": false, + "issues": [ + { + "id": "openrtb.type.mismatch", + "severity": "error", + "path": "user.consent" + }, + { + "id": "openrtb.field.moved", + "severity": "error", + "path": "user.ext.consent" + }, + { + "id": "openrtb.privacy.signal_conflict", + "severity": "warning", + "path": "user.ext.consent" + }, + { + "id": "openrtb.privacy.gdpr_without_consent", + "severity": "warning", + "path": "regs.gdpr" + } + ] + }, + { + "name": "privacy: legacy-eids-usp", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-eids-usp.json", + "valid": true, + "issues": [ + { + "id": "openrtb.privacy.coppa_eids", + "severity": "warning", + "path": "user.ext.eids" + }, + { + "id": "openrtb.privacy.us_privacy_sale_eids", + "severity": "warning", + "path": "user.ext.eids" + } + ] + }, + { + "name": "privacy: legacy-tcf-purpose1-off", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-tcf-purpose1-off.json", + "valid": true, + "issues": [ + { + "id": "openrtb.privacy.tcf_purpose1_identifiers", + "severity": "warning", + "path": "user.ext.consent" + } + ] + }, + { + "name": "privacy: legacy-gdpr-valid-consent", + "mode": "request", + "version": "2.5", + "dialect": "spec-json", + "payload": "privacy-legacy-gdpr-valid-consent.json", + "valid": true, + "issues": [] + }, + { + "name": "privacy: gpp-contradiction-source", + "mode": "request", + "version": "2.6-202606", + "dialect": "spec-json", + "payload": "privacy-gpp-contradiction-source.json", + "valid": true, + "issues": [ + { + "id": "openrtb.regs.tcf_malformed", + "severity": "warning", + "path": "user.consent" + }, + { + "id": "openrtb.privacy.tcf_purpose1_identifiers", + "severity": "warning", + "path": "regs.gpp" + } + ] + }, + { + "name": "privacy: legacy-proto-bool-gdpr", + "mode": "request", + "version": "2.5", + "dialect": "proto-json", + "payload": "privacy-legacy-proto-bool-gdpr.json", + "valid": true, + "issues": [ + { + "id": "openrtb.privacy.gdpr_without_consent", + "severity": "warning", + "path": "regs.ext.gdpr" + } + ] } ] diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-equal-gdpr-duplicates.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-equal-gdpr-duplicates.json new file mode 100644 index 0000000..e3d70dc --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-equal-gdpr-duplicates.json @@ -0,0 +1,18 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "gdpr": 0, + "ext": { + "gdpr": 0 + } + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-gpp-contradiction-source.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-gpp-contradiction-source.json new file mode 100644 index 0000000..b83306e --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-gpp-contradiction-source.json @@ -0,0 +1,24 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "gpp": "DBABM~CAAAAAAAAAAAAABABBENABCgAAAAAAAAAAAA", + "gpp_sid": [ + 2 + ] + }, + "user": { + "consent": "yes" + }, + "device": { + "ifa": "synthetic-ifa" + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-consent-type.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-consent-type.json new file mode 100644 index 0000000..72adf63 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-consent-type.json @@ -0,0 +1,19 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "user": { + "ext": { + "consent": { + "private": "synthetic" + } + } + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-eids-usp.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-eids-usp.json new file mode 100644 index 0000000..0997a8f --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-eids-usp.json @@ -0,0 +1,33 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "coppa": 1, + "ext": { + "us_privacy": "1YYN" + } + }, + "user": { + "ext": { + "eids": [ + { + "source": "synthetic.example", + "uids": [ + { + "id": "synthetic-uid", + "atype": 3 + } + ] + } + ] + } + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-conflict.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-conflict.json new file mode 100644 index 0000000..7d62550 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-conflict.json @@ -0,0 +1,21 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "gdpr": 0, + "ext": { + "gdpr": 1 + } + }, + "device": { + "ifa": "synthetic-ifa" + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-missing-consent-new-contract.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-missing-consent-new-contract.json new file mode 100644 index 0000000..ded31a1 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-missing-consent-new-contract.json @@ -0,0 +1,20 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "ext": { + "gdpr": 1 + } + }, + "device": { + "ifa": "synthetic-ifa" + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-missing-consent.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-missing-consent.json new file mode 100644 index 0000000..ded31a1 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-missing-consent.json @@ -0,0 +1,20 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "ext": { + "gdpr": 1 + } + }, + "device": { + "ifa": "synthetic-ifa" + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-type.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-type.json new file mode 100644 index 0000000..57cb8b7 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-type.json @@ -0,0 +1,17 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "ext": { + "gdpr": "true" + } + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-valid-consent.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-valid-consent.json new file mode 100644 index 0000000..c16edc1 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-gdpr-valid-consent.json @@ -0,0 +1,25 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "ext": { + "gdpr": 1 + } + }, + "user": { + "ext": { + "consent": "CAAAAAAAAAAAAABABBENABCgAIAAAAAAAAAA" + } + }, + "device": { + "ifa": "synthetic-ifa" + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-proto-bool-gdpr.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-proto-bool-gdpr.json new file mode 100644 index 0000000..6275eb9 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-proto-bool-gdpr.json @@ -0,0 +1,20 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "ext": { + "gdpr": true + } + }, + "device": { + "ifa": "synthetic-ifa" + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-tcf-malformed-new-contract.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-tcf-malformed-new-contract.json new file mode 100644 index 0000000..e41818f --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-tcf-malformed-new-contract.json @@ -0,0 +1,17 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "user": { + "ext": { + "consent": "yes" + } + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-tcf-malformed.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-tcf-malformed.json new file mode 100644 index 0000000..e41818f --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-tcf-malformed.json @@ -0,0 +1,17 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "user": { + "ext": { + "consent": "yes" + } + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-tcf-purpose1-off.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-tcf-purpose1-off.json new file mode 100644 index 0000000..6fbe119 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-tcf-purpose1-off.json @@ -0,0 +1,20 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "user": { + "ext": { + "consent": "CAAAAAAAAAAAAABABBENABCgAAAAAAAAAAAA" + } + }, + "device": { + "ifa": "synthetic-ifa" + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-usp-malformed.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-usp-malformed.json new file mode 100644 index 0000000..d067062 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-usp-malformed.json @@ -0,0 +1,17 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "ext": { + "us_privacy": "1Y" + } + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-usp-type.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-usp-type.json new file mode 100644 index 0000000..589ae3d --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-usp-type.json @@ -0,0 +1,17 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "ext": { + "us_privacy": 123 + } + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-usp-user-2-1.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-usp-user-2-1.json new file mode 100644 index 0000000..fcb43b3 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-legacy-usp-user-2-1.json @@ -0,0 +1,17 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "user": { + "ext": { + "us_privacy": "1Y" + } + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-modern-gdpr-conflict.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-modern-gdpr-conflict.json new file mode 100644 index 0000000..7d62550 --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-modern-gdpr-conflict.json @@ -0,0 +1,21 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "gdpr": 0, + "ext": { + "gdpr": 1 + } + }, + "device": { + "ifa": "synthetic-ifa" + } +} diff --git a/crates/rtblint-core/tests/fixtures/dialects/privacy-null-canonical-consent.json b/crates/rtblint-core/tests/fixtures/dialects/privacy-null-canonical-consent.json new file mode 100644 index 0000000..0deb82a --- /dev/null +++ b/crates/rtblint-core/tests/fixtures/dialects/privacy-null-canonical-consent.json @@ -0,0 +1,24 @@ +{ + "id": "synthetic-privacy-fixture", + "imp": [ + { + "id": "synthetic-imp", + "banner": { + "w": 300, + "h": 250 + } + } + ], + "regs": { + "gdpr": 1 + }, + "user": { + "consent": null, + "ext": { + "consent": "CAAAAAAAAAAAAABABBENABCgAAAAAAAAAAAA" + } + }, + "device": { + "ifa": "synthetic-ifa" + } +} diff --git a/crates/rtblint-core/tests/legacy_privacy.rs b/crates/rtblint-core/tests/legacy_privacy.rs new file mode 100644 index 0000000..96ec4b4 --- /dev/null +++ b/crates/rtblint-core/tests/legacy_privacy.rs @@ -0,0 +1,117 @@ +use rtblint_core::{validate_bid_request_with_dialect, Dialect, OpenRtbVersion}; +use serde_json::{json, Value}; + +fn findings(version: OpenRtbVersion, extra: Value) -> Vec<(String, String)> { + let mut request = + json!({"id":"privacy-regression", "imp":[{"id":"1", "banner":{"w":300,"h":250}}]}); + request + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + validate_bid_request_with_dialect(version, Dialect::SpecJson, &request.to_string()) + .issues + .into_iter() + .filter(|i| i.id.starts_with("openrtb.privacy.") || i.id.ends_with("_malformed")) + .map(|i| (i.id, i.path.unwrap_or_default())) + .collect() +} + +#[test] +fn legacy_and_modern_signals_receive_the_same_checks_at_their_input_paths() { + for (version, extra, path, code) in [ + ( + OpenRtbVersion::V2_5, + json!({"regs":{"ext":{"gdpr":1}},"device":{"ifa":"synthetic-ifa"}}), + "regs.ext.gdpr", + "gdpr_without_consent", + ), + ( + OpenRtbVersion::V2_6_202606, + json!({"regs":{"gdpr":1},"device":{"ifa":"synthetic-ifa"}}), + "regs.gdpr", + "gdpr_without_consent", + ), + ( + OpenRtbVersion::V2_5, + json!({"user":{"ext":{"consent":"yes"}}}), + "user.ext.consent", + "tcf_malformed", + ), + ( + OpenRtbVersion::V2_6_202606, + json!({"user":{"consent":"yes"}}), + "user.consent", + "tcf_malformed", + ), + ( + OpenRtbVersion::V2_5, + json!({"regs":{"ext":{"us_privacy":"1Y"}}}), + "regs.ext.us_privacy", + "us_privacy_malformed", + ), + ( + OpenRtbVersion::V2_1, + json!({"user":{"ext":{"us_privacy":"1Y"}}}), + "user.ext.us_privacy", + "us_privacy_malformed", + ), + ] { + let actual = findings(version, extra); + assert_eq!(actual.len(), 1, "{version:?}: {actual:?}"); + assert!(actual[0].0.ends_with(code)); + assert_eq!(actual[0].1, path); + } +} + +#[test] +fn selected_contract_wins_conflicts_including_empty_or_null_values() { + let input = json!({"regs":{"gdpr":0,"ext":{"gdpr":1}},"device":{"ifa":"synthetic"}}); + let old = findings(OpenRtbVersion::V2_5, input.clone()); + assert!(old + .iter() + .any(|(id, p)| id.ends_with("signal_conflict") && p == "regs.gdpr")); + assert!(old + .iter() + .any(|(id, p)| id.ends_with("gdpr_without_consent") && p == "regs.ext.gdpr")); + let modern = findings(OpenRtbVersion::V2_6_202606, input); + assert_eq!( + modern, + vec![( + "openrtb.privacy.signal_conflict".into(), + "regs.ext.gdpr".into() + )] + ); + for value in [Value::Null, json!("")] { + let actual = findings( + OpenRtbVersion::V2_6_202606, + json!({"regs":{"gdpr":1},"device":{"ifa":"synthetic"}, "user":{"consent":value,"ext":{"consent":"synthetic-present"}}}), + ); + assert!(actual.iter().any(|(id, _)| id.ends_with("signal_conflict"))); + assert!(actual + .iter() + .any(|(id, _)| id.ends_with("gdpr_without_consent"))); + } +} + +#[test] +fn legacy_extended_identifiers_participate_in_contradiction_checks() { + let actual = findings( + OpenRtbVersion::V2_5, + json!({"regs":{"coppa":1,"ext":{"us_privacy":"1YYN"}},"user":{"ext":{"eids":[{"source":"synthetic.example","uids":[{"id":"synthetic","atype":3}]}]}}}), + ); + assert!(actual + .iter() + .any(|(id, p)| id.ends_with("coppa_eids") && p == "user.ext.eids")); + assert!(actual + .iter() + .any(|(id, p)| id.ends_with("us_privacy_sale_eids") && p == "user.ext.eids")); +} + +#[test] +fn equal_duplicates_and_unrelated_extensions_do_not_add_conflicts() { + assert!(findings( + OpenRtbVersion::V2_5, + json!({"regs":{"gdpr":0,"ext":{"gdpr":0}},"user":{"ext":{"vendor":{"consent":"opaque"}}}}) + ) + .is_empty()); +} diff --git a/crates/rtblint-grpc/Cargo.toml b/crates/rtblint-grpc/Cargo.toml index ede411f..adfd355 100644 --- a/crates/rtblint-grpc/Cargo.toml +++ b/crates/rtblint-grpc/Cargo.toml @@ -17,7 +17,7 @@ name = "rtblint-grpc" path = "src/main.rs" [dependencies] -rtblint-core = { path = "../rtblint-core", version = "0.14.3" } +rtblint-core = { path = "../rtblint-core", version = "0.14.4" } tonic = "0.14" tonic-prost = "0.14" diff --git a/crates/rtblint-mcp/Cargo.toml b/crates/rtblint-mcp/Cargo.toml index c25e409..c1c0085 100644 --- a/crates/rtblint-mcp/Cargo.toml +++ b/crates/rtblint-mcp/Cargo.toml @@ -17,5 +17,5 @@ name = "rtblint-mcp" path = "src/main.rs" [dependencies] -rtblint-core = { path = "../rtblint-core", version = "0.14.3" } +rtblint-core = { path = "../rtblint-core", version = "0.14.4" } serde_json = "1" diff --git a/crates/rtblint-mcp/server.json b/crates/rtblint-mcp/server.json index 241c680..16cccd6 100644 --- a/crates/rtblint-mcp/server.json +++ b/crates/rtblint-mcp/server.json @@ -10,7 +10,7 @@ }, "documentation": "https://rtblint.org/docs/mcp/", "license": "Apache-2.0", - "version": "0.14.3", + "version": "0.14.4", "remotes": [ { "type": "streamable-http", diff --git a/crates/rtblint-resolve/Cargo.toml b/crates/rtblint-resolve/Cargo.toml index c8ba64f..69724f7 100644 --- a/crates/rtblint-resolve/Cargo.toml +++ b/crates/rtblint-resolve/Cargo.toml @@ -13,6 +13,6 @@ categories = ["development-tools"] readme = "../../README.md" [dependencies] -rtblint-core = { path = "../rtblint-core", version = "0.14.3" } +rtblint-core = { path = "../rtblint-core", version = "0.14.4" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/rtblint-wasm/Cargo.toml b/crates/rtblint-wasm/Cargo.toml index d73d761..55b2d7a 100644 --- a/crates/rtblint-wasm/Cargo.toml +++ b/crates/rtblint-wasm/Cargo.toml @@ -15,7 +15,7 @@ categories = ["parser-implementations", "wasm"] crate-type = ["cdylib"] [dependencies] -rtblint-core = { path = "../rtblint-core", version = "0.14.3" } +rtblint-core = { path = "../rtblint-core", version = "0.14.4" } wasm-bindgen = "0.2" serde = { version = "1", features = ["derive"] } serde-wasm-bindgen = "0.6" diff --git a/crates/rtblint/Cargo.toml b/crates/rtblint/Cargo.toml index e1349a4..2317f56 100644 --- a/crates/rtblint/Cargo.toml +++ b/crates/rtblint/Cargo.toml @@ -17,7 +17,7 @@ name = "rtblint" path = "src/main.rs" [dependencies] -rtblint-core = { path = "../rtblint-core", version = "0.14.3" } -rtblint-resolve = { path = "../rtblint-resolve", version = "0.14.3" } +rtblint-core = { path = "../rtblint-core", version = "0.14.4" } +rtblint-resolve = { path = "../rtblint-resolve", version = "0.14.4" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/npm/package.json b/npm/package.json index 34292b0..ea8c298 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "rtblint-core", - "version": "0.14.3", + "version": "0.14.4", "description": "OpenRTB linter: validates OpenRTB 2.x bid requests and bid responses against versioned IAB spec snapshots", "license": "Apache-2.0", "author": "Aleks ", diff --git a/npm/wasm/rtblint_wasm_bg.wasm b/npm/wasm/rtblint_wasm_bg.wasm index 78a52bd..418a195 100644 Binary files a/npm/wasm/rtblint_wasm_bg.wasm and b/npm/wasm/rtblint_wasm_bg.wasm differ