Skip to content

Commit dfa8a29

Browse files
Address audit review feedback
1 parent bdb9284 commit dfa8a29

7 files changed

Lines changed: 175 additions & 130 deletions

File tree

crates/trusted-server-cli/src/audit.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -421,16 +421,20 @@ mod tests {
421421
requested_url: "https://publisher.example/page".to_string(),
422422
final_url: "https://publisher.example/page".to_string(),
423423
page_title: Some("Example Publisher".to_string()),
424-
html: r#"<html><head><title>Example Publisher</title><script src="https://securepubads.g.doubleclick.net/tag/js/gpt.js"></script></head></html>"#.to_string(),
425-
script_tags: vec![CollectedScriptTag {
426-
src: Some("https://www.googletagmanager.com/gtm.js?id=GTM-ABC123".to_string()),
427-
inline_text: None,
428-
}],
424+
html: r#"<html><head><title>Example Publisher</title></head></html>"#.to_string(),
425+
script_tags: vec![
426+
CollectedScriptTag {
427+
src: Some("https://www.googletagmanager.com/gtm.js?id=GTM-ABC123".to_string()),
428+
inline_text: None,
429+
},
430+
CollectedScriptTag {
431+
src: Some("https://securepubads.g.doubleclick.net/tag/js/gpt.js".to_string()),
432+
inline_text: None,
433+
},
434+
],
429435
network_requests: vec![CollectedRequest {
430436
url: "https://cdn.publisher.example/app.js".to_string(),
431-
method: "GET".to_string(),
432437
resource_type: Some("script".to_string()),
433-
status: None,
434438
}],
435439
warnings: Vec::new(),
436440
}

crates/trusted-server-cli/src/audit/analyzer.rs

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ use crate::error::{report_error, CliResult};
1111

1212
static GTM_REGEX: LazyLock<Regex> =
1313
LazyLock::new(|| Regex::new(r"\bGTM-[A-Z0-9]+\b").expect("should compile GTM regex"));
14+
static GPT_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
15+
Regex::new(r"(?i)\b(?:googletag|gpt\.js|googletagservices|securepubads)\b")
16+
.expect("should compile GPT inline regex")
17+
});
18+
static DIDOMI_INLINE_REGEX: LazyLock<Regex> =
19+
LazyLock::new(|| Regex::new(r"(?i)\bdidomi\b").expect("should compile Didomi inline regex"));
20+
static DATADOME_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
21+
Regex::new(r"(?i)\bdatadome\b").expect("should compile DataDome inline regex")
22+
});
23+
static PERMUTIVE_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
24+
Regex::new(r"(?i)\bpermutive\b").expect("should compile Permutive inline regex")
25+
});
26+
static LOCKR_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
27+
Regex::new(r"(?i)(?:\blockr\b|\bloc\.kr\b)").expect("should compile Lockr inline regex")
28+
});
29+
static PREBID_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
30+
Regex::new(r"(?i)(?:\bprebid\b|\bpbjs\b)").expect("should compile Prebid inline regex")
31+
});
1432

1533
pub(crate) fn analyze_collected_page(collected: &CollectedPage) -> CliResult<AuditArtifact> {
1634
let final_url = collected
@@ -22,7 +40,6 @@ pub(crate) fn analyze_collected_page(collected: &CollectedPage) -> CliResult<Aud
2240

2341
let document = Html::parse_document(&collected.html);
2442
let title_selector = Selector::parse("title").expect("should parse title selector");
25-
let script_selector = Selector::parse("script").expect("should parse script selector");
2643
let derived_title = document
2744
.select(&title_selector)
2845
.next()
@@ -46,30 +63,15 @@ pub(crate) fn analyze_collected_page(collected: &CollectedPage) -> CliResult<Aud
4663
));
4764
}
4865

49-
for element in document.select(&script_selector) {
50-
if let Some(src) = element.value().attr("src") {
66+
for tag in &collected.script_tags {
67+
if let Some(src) = &tag.src {
5168
if let Ok(asset_url) = final_url.join(src) {
5269
let integration = detect_integration_from_url(&asset_url);
5370
record_integration(&mut integrations, &integration, asset_url.as_str());
5471
insert_asset(&mut assets_by_url, &final_url, &asset_url, integration);
5572
} else {
5673
warnings.push(format!("could not resolve script URL `{src}`"));
5774
}
58-
} else {
59-
let inline_text = element.text().collect::<Vec<_>>().join(" ");
60-
for (integration_id, evidence) in detect_integrations_from_inline_script(&inline_text) {
61-
integrations.entry(integration_id).or_insert(evidence);
62-
}
63-
}
64-
}
65-
66-
for tag in &collected.script_tags {
67-
if let Some(src) = &tag.src {
68-
if let Ok(asset_url) = Url::parse(src) {
69-
let integration = detect_integration_from_url(&asset_url);
70-
record_integration(&mut integrations, &integration, asset_url.as_str());
71-
insert_asset(&mut assets_by_url, &final_url, &asset_url, integration);
72-
}
7375
}
7476

7577
if let Some(inline_text) = &tag.inline_text {
@@ -167,6 +169,7 @@ pub(crate) fn classify_party(page_url: &Url, asset_url: &Url) -> AssetParty {
167169
}
168170

169171
fn host_matches(page_host: &str, asset_host: &str) -> bool {
172+
// This is an advisory heuristic, not public-suffix-aware eTLD+1 classification.
170173
asset_host == page_host
171174
|| asset_host
172175
.strip_suffix(page_host)
@@ -213,9 +216,15 @@ pub(crate) fn detect_integrations_from_inline_script(script: &str) -> Vec<(Strin
213216
));
214217
}
215218

216-
let lowered = script.to_ascii_lowercase();
217-
for integration in ["gpt", "didomi", "datadome", "permutive", "lockr", "prebid"] {
218-
if lowered.contains(integration) {
219+
for (integration, regex) in [
220+
("gpt", &*GPT_INLINE_REGEX),
221+
("didomi", &*DIDOMI_INLINE_REGEX),
222+
("datadome", &*DATADOME_INLINE_REGEX),
223+
("permutive", &*PERMUTIVE_INLINE_REGEX),
224+
("lockr", &*LOCKR_INLINE_REGEX),
225+
("prebid", &*PREBID_INLINE_REGEX),
226+
] {
227+
if regex.is_match(script) {
219228
matches.push((
220229
integration.to_string(),
221230
format!("inline script matched `{integration}`"),
@@ -259,16 +268,20 @@ mod tests {
259268
requested_url: "https://publisher.example/page".to_string(),
260269
final_url: "https://publisher.example/page".to_string(),
261270
page_title: Some("Browser Title".to_string()),
262-
html: r#"<html><head><title>HTML Title</title><script src="https://www.googletagmanager.com/gtm.js?id=GTM-ABCD123"></script></head></html>"#.to_string(),
263-
script_tags: vec![CollectedScriptTag {
264-
src: Some("https://securepubads.g.doubleclick.net/tag/js/gpt.js".to_string()),
265-
inline_text: None,
266-
}],
271+
html: r#"<html><head><title>HTML Title</title></head></html>"#.to_string(),
272+
script_tags: vec![
273+
CollectedScriptTag {
274+
src: Some("https://www.googletagmanager.com/gtm.js?id=GTM-ABCD123".to_string()),
275+
inline_text: None,
276+
},
277+
CollectedScriptTag {
278+
src: Some("https://securepubads.g.doubleclick.net/tag/js/gpt.js".to_string()),
279+
inline_text: None,
280+
},
281+
],
267282
network_requests: vec![CollectedRequest {
268283
url: "https://cdn.example.com/dynamic.js".to_string(),
269-
method: "GET".to_string(),
270284
resource_type: Some("Script".to_string()),
271-
status: Some(200),
272285
}],
273286
warnings: vec!["partial settle".to_string()],
274287
};
@@ -345,9 +358,7 @@ mod tests {
345358
}],
346359
network_requests: vec![CollectedRequest {
347360
url: "https://cdn.example.com/prebid.js".to_string(),
348-
method: "GET".to_string(),
349361
resource_type: Some("script".to_string()),
350-
status: Some(200),
351362
}],
352363
warnings: Vec::new(),
353364
};
@@ -371,8 +382,17 @@ mod tests {
371382
requested_url: "https://publisher.example/page".to_string(),
372383
final_url: "https://publisher.example/path/page".to_string(),
373384
page_title: None,
374-
html: r#"<html><head><script src="/static/app.js"></script><script src="http://[invalid"></script></head></html>"#.to_string(),
375-
script_tags: Vec::new(),
385+
html: "<html><head></head></html>".to_string(),
386+
script_tags: vec![
387+
CollectedScriptTag {
388+
src: Some("/static/app.js".to_string()),
389+
inline_text: None,
390+
},
391+
CollectedScriptTag {
392+
src: Some("http://[invalid".to_string()),
393+
inline_text: None,
394+
},
395+
],
376396
network_requests: Vec::new(),
377397
warnings: Vec::new(),
378398
};
@@ -463,6 +483,22 @@ mod tests {
463483
.any(|(integration, _)| integration == "didomi"));
464484
}
465485

486+
#[test]
487+
fn detect_integrations_from_inline_script_avoids_short_substring_matches() {
488+
let matches = detect_integrations_from_inline_script("const svgptimize = blockrResult;");
489+
490+
assert!(
491+
!matches.iter().any(|(integration, _)| integration == "gpt"),
492+
"should not match incidental GPT substrings"
493+
);
494+
assert!(
495+
!matches
496+
.iter()
497+
.any(|(integration, _)| integration == "lockr"),
498+
"should not match lockr inside a larger token"
499+
);
500+
}
501+
466502
#[test]
467503
fn detect_integration_from_url_recognizes_known_patterns() {
468504
let cases = [

crates/trusted-server-cli/src/audit/browser_collector.rs

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use futures::StreamExt as _;
77
use serde::Deserialize;
88
use tempfile::TempDir;
99
use tokio::runtime::Builder;
10-
use tokio::time::sleep;
10+
use tokio::time::{sleep, timeout};
1111
use url::Url;
1212
use which::which;
1313

@@ -19,6 +19,9 @@ use crate::error::{report_error, CliResult};
1919
const SETTLE_QUIET_PERIOD: Duration = Duration::from_millis(750);
2020
const SETTLE_POLL_INTERVAL: Duration = Duration::from_millis(250);
2121
const SETTLE_MAX_WAIT: Duration = Duration::from_secs(6);
22+
const NAVIGATION_TIMEOUT: Duration = Duration::from_secs(30);
23+
const BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
24+
const RESOURCE_TIMING_BUFFER_WARNING_THRESHOLD: usize = 250;
2225

2326
#[derive(Default)]
2427
pub(crate) struct BrowserAuditCollector;
@@ -72,10 +75,14 @@ async fn collect_page_via_browser_async(target_url: &Url) -> CliResult<Collected
7275

7376
let result = collect_page_from_browser(&mut browser, target_url).await;
7477

75-
let close_result = browser
76-
.close()
78+
let close_result = timeout(BROWSER_CLOSE_TIMEOUT, browser.close())
7779
.await
78-
.map_err(|error| report_error(format!("failed to close browser after audit: {error}")));
80+
.map_err(|_| report_error("timed out closing browser after audit"))
81+
.and_then(|result| {
82+
result.map_err(|error| {
83+
report_error(format!("failed to close browser after audit: {error}"))
84+
})
85+
});
7986
if close_result.is_err() {
8087
handler_task.abort();
8188
}
@@ -95,18 +102,28 @@ async fn collect_page_from_browser(
95102
report_error(format!("failed to create browser page for audit: {error}"))
96103
})?;
97104

98-
page.goto(target_url.as_str())
105+
timeout(NAVIGATION_TIMEOUT, page.goto(target_url.as_str()))
99106
.await
107+
.map_err(|_| report_error(format!("timed out navigating to `{target_url}`")))?
100108
.map_err(|error| report_error(format!("failed to navigate to `{target_url}`: {error}")))?;
101109

102-
let navigation_response = page.wait_for_navigation_response().await.map_err(|error| {
103-
report_error(format!(
104-
"failed to read main document navigation response: {error}"
105-
))
106-
})?;
107-
validate_navigation_response(navigation_response)?;
110+
let navigation_response = timeout(NAVIGATION_TIMEOUT, page.wait_for_navigation_response())
111+
.await
112+
.map_err(|_| {
113+
report_error(format!(
114+
"timed out waiting for main document navigation response from `{target_url}`"
115+
))
116+
})?
117+
.map_err(|error| {
118+
report_error(format!(
119+
"failed to read main document navigation response: {error}"
120+
))
121+
})?;
108122

109123
let mut warnings = Vec::new();
124+
if let Some(warning) = validate_navigation_response(navigation_response)? {
125+
warnings.push(warning);
126+
}
110127
if !wait_for_page_settle(&page).await? {
111128
warnings.push(
112129
"browser audit timed out while waiting for the page to settle; results may be partial"
@@ -164,6 +181,13 @@ async fn collect_page_from_browser(
164181
))
165182
})?;
166183

184+
if network_requests.len() >= RESOURCE_TIMING_BUFFER_WARNING_THRESHOLD {
185+
warnings.push(
186+
"browser resource timing buffer reached its default size; some network assets may be missing"
187+
.to_string(),
188+
);
189+
}
190+
167191
Ok(CollectedPage {
168192
requested_url: target_url.to_string(),
169193
final_url,
@@ -180,9 +204,7 @@ async fn collect_page_from_browser(
180204
.into_iter()
181205
.map(|entry| CollectedRequest {
182206
url: entry.url,
183-
method: "GET".to_string(),
184207
resource_type: entry.initiator_type,
185-
status: None,
186208
})
187209
.collect(),
188210
warnings,
@@ -230,7 +252,7 @@ async fn wait_for_page_settle(page: &chromiumoxide::Page) -> CliResult<bool> {
230252
Ok(false)
231253
}
232254

233-
fn validate_navigation_response(navigation_response: ArcHttpRequest) -> CliResult<()> {
255+
fn validate_navigation_response(navigation_response: ArcHttpRequest) -> CliResult<Option<String>> {
234256
let request = navigation_response
235257
.ok_or_else(|| report_error("browser audit did not capture the main document response"))?;
236258

@@ -245,11 +267,11 @@ fn validate_navigation_response(navigation_response: ArcHttpRequest) -> CliResul
245267
})?;
246268

247269
if is_successful_navigation_status(response.status) {
248-
return Ok(());
270+
return Ok(None);
249271
}
250272

251-
Err(report_error(format!(
252-
"audit request returned HTTP {} {} for `{}`",
273+
Ok(Some(format!(
274+
"audit request returned HTTP {} {} for `{}`; results may be partial",
253275
response.status, response.status_text, response.url
254276
)))
255277
}

crates/trusted-server-cli/src/audit/collector.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,7 @@ pub(crate) struct CollectedScriptTag {
2727
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
2828
pub(crate) struct CollectedRequest {
2929
pub(crate) url: String,
30-
pub(crate) method: String,
3130
pub(crate) resource_type: Option<String>,
32-
pub(crate) status: Option<u16>,
3331
}
3432

3533
impl CollectedPage {

crates/trusted-server-cli/src/error.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ pub(crate) fn cli_error<T>(message: impl Into<String>) -> CliResult<T> {
55
}
66

77
pub(crate) fn report_error(message: impl Into<String>) -> String {
8-
message.into()
8+
let message = message.into();
9+
log::error!("{message}");
10+
message
911
}

0 commit comments

Comments
 (0)