relay_event_schema/protocol/
security_report.rs

1//! Contains definitions for the security report interfaces.
2//!
3//! The security interfaces are CSP, HPKP, ExpectCT and ExpectStaple.
4
5use std::borrow::Cow;
6use std::collections::BTreeMap;
7use std::fmt::{self, Write};
8
9use chrono::{DateTime, Utc};
10use relay_protocol::{Annotated, Array, Empty, FromValue, IntoValue, Object, Value};
11use serde::de::{self, Error, IgnoredAny};
12use serde::{Deserialize, Deserializer, Serialize};
13use url::Url;
14
15use crate::processor::ProcessValue;
16use crate::protocol::{
17    Event, HeaderName, HeaderValue, Headers, LogEntry, PairList, Request, TagEntry, Tags,
18};
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub struct InvalidSecurityError;
22
23impl fmt::Display for InvalidSecurityError {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        write!(f, "invalid security report")
26    }
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum CspDirective {
31    BaseUri,
32    ChildSrc,
33    ConnectSrc,
34    DefaultSrc,
35    FontSrc,
36    FormAction,
37    FrameAncestors,
38    FrameSrc,
39    ImgSrc,
40    ManifestSrc,
41    MediaSrc,
42    ObjectSrc,
43    PluginTypes,
44    PrefetchSrc,
45    Referrer,
46    ScriptSrc,
47    ScriptSrcAttr,
48    ScriptSrcElem,
49    StyleSrc,
50    StyleSrcElem,
51    StyleSrcAttr,
52    UpgradeInsecureRequests,
53    WorkerSrc,
54    Sandbox,
55    NavigateTo,
56    ReportUri,
57    ReportTo,
58    BlockAllMixedContent,
59    RequireSriFor,
60    RequireTrustedTypesFor,
61    TrustedTypes,
62}
63
64relay_common::derive_fromstr_and_display!(CspDirective, InvalidSecurityError, {
65    CspDirective::BaseUri => "base-uri",
66    CspDirective::ChildSrc => "child-src",
67    CspDirective::ConnectSrc => "connect-src",
68    CspDirective::DefaultSrc => "default-src",
69    CspDirective::FontSrc => "font-src",
70    CspDirective::FormAction => "form-action",
71    CspDirective::FrameAncestors => "frame-ancestors",
72    CspDirective::FrameSrc => "frame-src",
73    CspDirective::ImgSrc => "img-src",
74    CspDirective::ManifestSrc => "manifest-src",
75    CspDirective::MediaSrc => "media-src",
76    CspDirective::ObjectSrc => "object-src",
77    CspDirective::PluginTypes => "plugin-types",
78    CspDirective::PrefetchSrc => "prefetch-src",
79    CspDirective::Referrer => "referrer",
80    CspDirective::ScriptSrc => "script-src",
81    CspDirective::ScriptSrcAttr => "script-src-attr",
82    CspDirective::ScriptSrcElem => "script-src-elem",
83    CspDirective::StyleSrc => "style-src",
84    CspDirective::StyleSrcElem => "style-src-elem",
85    CspDirective::StyleSrcAttr => "style-src-attr",
86    CspDirective::UpgradeInsecureRequests => "upgrade-insecure-requests",
87    CspDirective::WorkerSrc => "worker-src",
88    CspDirective::Sandbox => "sandbox",
89    CspDirective::NavigateTo => "navigate-to",
90    CspDirective::ReportUri => "report-uri",
91    CspDirective::ReportTo => "report-to",
92    CspDirective::BlockAllMixedContent => "block-all-mixed-content",
93    CspDirective::RequireSriFor => "require-sri-for",
94    CspDirective::RequireTrustedTypesFor => "require-trusted-types-for",
95    CspDirective::TrustedTypes => "trusted-types",
96});
97
98relay_common::impl_str_serde!(CspDirective, "a csp directive");
99
100fn is_local(uri: &str) -> bool {
101    matches!(uri, "" | "self" | "'self'")
102}
103
104fn schema_uses_host(schema: &str) -> bool {
105    // List of schemas with host (netloc) from Python's urlunsplit:
106    // see <https://github.com/python/cpython/blob/1eac437e8da106a626efffe9fce1cb47dbf5be35/Lib/urllib/parse.py#L51>
107    //
108    // Only modification: "" is set to false, since there is a separate check in the urlunsplit
109    // implementation that omits the leading "//" in that case.
110    matches!(
111        schema,
112        "ftp"
113            | "http"
114            | "gopher"
115            | "nntp"
116            | "telnet"
117            | "imap"
118            | "wais"
119            | "file"
120            | "mms"
121            | "https"
122            | "shttp"
123            | "snews"
124            | "prospero"
125            | "rtsp"
126            | "rtspu"
127            | "rsync"
128            | "svn"
129            | "svn+ssh"
130            | "sftp"
131            | "nfs"
132            | "git"
133            | "git+ssh"
134            | "ws"
135            | "wss"
136    )
137}
138
139/// Mimicks Python's urlunsplit with all its quirks.
140fn unsplit_uri(schema: &str, host: &str) -> String {
141    if !host.is_empty() || schema_uses_host(schema) {
142        format!("{schema}://{host}")
143    } else if !schema.is_empty() {
144        format!("{schema}:{host}")
145    } else {
146        String::new()
147    }
148}
149
150fn normalize_uri(value: &str) -> Cow<'_, str> {
151    if is_local(value) {
152        return Cow::Borrowed("'self'");
153    }
154
155    // A lot of these values get reported as literally just the scheme. So a value like 'data'
156    // or 'blob', which are valid schemes, just not a uri. So we want to normalize it into a
157    // URI.
158
159    if !value.contains(':') {
160        return Cow::Owned(unsplit_uri(value, ""));
161    }
162
163    let url = match Url::parse(value) {
164        Ok(url) => url,
165        Err(_) => return Cow::Borrowed(value),
166    };
167
168    let normalized = match url.scheme() {
169        "http" | "https" => Cow::Borrowed(url.host_str().unwrap_or_default()),
170        scheme => Cow::Owned(unsplit_uri(scheme, url.host_str().unwrap_or_default())),
171    };
172
173    Cow::Owned(match url.port() {
174        Some(port) => format!("{normalized}:{port}"),
175        None => normalized.into_owned(),
176    })
177}
178
179/// Inner (useful) part of a CSP report.
180///
181/// See `Csp` for meaning of fields.
182#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
183struct CspRaw {
184    #[serde(
185        skip_serializing_if = "Option::is_none",
186        alias = "effective-directive",
187        alias = "effectiveDirective"
188    )]
189    effective_directive: Option<String>,
190    #[serde(
191        default = "CspRaw::default_blocked_uri",
192        alias = "blockedURL",
193        alias = "blocked-uri"
194    )]
195    blocked_uri: String,
196    #[serde(
197        skip_serializing_if = "Option::is_none",
198        alias = "documentURL",
199        alias = "document-uri"
200    )]
201    document_uri: Option<String>,
202    #[serde(
203        skip_serializing_if = "Option::is_none",
204        alias = "originalPolicy",
205        alias = "original-policy"
206    )]
207    original_policy: Option<String>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    referrer: Option<String>,
210    #[serde(
211        default,
212        skip_serializing_if = "Option::is_none",
213        alias = "statusCode",
214        alias = "status-code",
215        deserialize_with = "de_opt_num_or_str"
216    )]
217    status_code: Option<u64>,
218    #[serde(
219        default = "String::new",
220        alias = "violatedDirective",
221        alias = "violated-directive"
222    )]
223    violated_directive: String,
224    #[serde(
225        skip_serializing_if = "Option::is_none",
226        alias = "sourceFile",
227        alias = "source-file"
228    )]
229    source_file: Option<String>,
230    #[serde(
231        default,
232        skip_serializing_if = "Option::is_none",
233        alias = "lineNumber",
234        alias = "line-number",
235        deserialize_with = "de_opt_num_or_str"
236    )]
237    line_number: Option<u64>,
238    #[serde(
239        default,
240        skip_serializing_if = "Option::is_none",
241        alias = "columnNumber",
242        alias = "column-number",
243        deserialize_with = "de_opt_num_or_str"
244    )]
245    column_number: Option<u64>,
246    #[serde(
247        skip_serializing_if = "Option::is_none",
248        alias = "scriptSample",
249        alias = "script-sample"
250    )]
251    script_sample: Option<String>,
252    #[serde(skip_serializing_if = "Option::is_none")]
253    disposition: Option<String>,
254
255    #[serde(flatten)]
256    other: BTreeMap<String, serde_json::Value>,
257}
258
259fn de_opt_num_or_str<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
260where
261    D: Deserializer<'de>,
262{
263    #[derive(Deserialize)]
264    #[serde(untagged)]
265    enum NumOrStr<'a> {
266        Num(u64),
267        Str(Cow<'a, str>),
268    }
269
270    Option::<NumOrStr>::deserialize(deserializer)?
271        .map(|status_code| match status_code {
272            NumOrStr::Num(num) => Ok(num),
273            NumOrStr::Str(s) => s.parse(),
274        })
275        .transpose()
276        .map_err(de::Error::custom)
277}
278
279impl CspRaw {
280    fn default_blocked_uri() -> String {
281        "self".to_string()
282    }
283
284    fn effective_directive(&self) -> Result<CspDirective, InvalidSecurityError> {
285        // Firefox doesn't send effective-directive, so parse it from
286        // violated-directive but prefer effective-directive when present.
287        // refs: https://bugzil.la/1192684#c8
288
289        if let Some(directive) = &self.effective_directive {
290            // In C2P1 and CSP2, violated_directive and possibly effective_directive might contain
291            // more information than just the CSP-directive.
292            if let Ok(parsed_directive) = directive
293                .split_once(' ')
294                .map_or(directive.as_str(), |s| s.0)
295                .parse()
296            {
297                return Ok(parsed_directive);
298            }
299        }
300
301        if let Ok(parsed_directive) = self
302            .violated_directive
303            .split_once(' ')
304            .map_or(self.violated_directive.as_str(), |s| s.0)
305            .parse()
306        {
307            Ok(parsed_directive)
308        } else {
309            Err(InvalidSecurityError)
310        }
311    }
312
313    fn get_message(&self, effective_directive: CspDirective) -> String {
314        if is_local(&self.blocked_uri) {
315            match effective_directive {
316                CspDirective::ChildSrc => "Blocked inline 'child'".to_string(),
317                CspDirective::ConnectSrc => "Blocked inline 'connect'".to_string(),
318                CspDirective::FontSrc => "Blocked inline 'font'".to_string(),
319                CspDirective::ImgSrc => "Blocked inline 'image'".to_string(),
320                CspDirective::ManifestSrc => "Blocked inline 'manifest'".to_string(),
321                CspDirective::MediaSrc => "Blocked inline 'media'".to_string(),
322                CspDirective::ObjectSrc => "Blocked inline 'object'".to_string(),
323                CspDirective::ScriptSrcAttr => "Blocked unsafe 'script' element".to_string(),
324                CspDirective::ScriptSrcElem => "Blocked inline script attribute".to_string(),
325                CspDirective::StyleSrc => "Blocked inline 'style'".to_string(),
326                CspDirective::StyleSrcElem => "Blocked 'style' or 'link' element".to_string(),
327                CspDirective::StyleSrcAttr => "Blocked style attribute".to_string(),
328                CspDirective::ScriptSrc => {
329                    if self.violated_directive.contains("'unsafe-inline'") {
330                        "Blocked unsafe inline 'script'".to_string()
331                    } else if self.violated_directive.contains("'unsafe-eval'") {
332                        "Blocked unsafe eval() 'script'".to_string()
333                    } else {
334                        "Blocked unsafe (eval() or inline) 'script'".to_string()
335                    }
336                }
337                directive => format!("Blocked inline '{directive}'"),
338            }
339        } else {
340            let uri = normalize_uri(&self.blocked_uri);
341
342            match effective_directive {
343                CspDirective::ChildSrc => format!("Blocked 'child' from '{uri}'"),
344                CspDirective::ConnectSrc => format!("Blocked 'connect' from '{uri}'"),
345                CspDirective::FontSrc => format!("Blocked 'font' from '{uri}'"),
346                CspDirective::FormAction => format!("Blocked 'form' action to '{uri}'"),
347                CspDirective::ImgSrc => format!("Blocked 'image' from '{uri}'"),
348                CspDirective::ManifestSrc => format!("Blocked 'manifest' from '{uri}'"),
349                CspDirective::MediaSrc => format!("Blocked 'media' from '{uri}'"),
350                CspDirective::ObjectSrc => format!("Blocked 'object' from '{uri}'"),
351                CspDirective::ScriptSrc => format!("Blocked 'script' from '{uri}'"),
352                CspDirective::ScriptSrcAttr => {
353                    format!("Blocked inline script attribute from '{uri}'")
354                }
355                CspDirective::ScriptSrcElem => format!("Blocked 'script' from '{uri}'"),
356                CspDirective::StyleSrc => format!("Blocked 'style' from '{uri}'"),
357                CspDirective::StyleSrcElem => format!("Blocked 'style' from '{uri}'"),
358                CspDirective::StyleSrcAttr => format!("Blocked style attribute from '{uri}'"),
359                directive => format!("Blocked '{directive}' from '{uri}'"),
360            }
361        }
362    }
363
364    fn into_protocol(self, effective_directive: CspDirective) -> Csp {
365        Csp {
366            effective_directive: Annotated::from(effective_directive.to_string()),
367            blocked_uri: Annotated::from(self.blocked_uri),
368            document_uri: Annotated::from(self.document_uri),
369            original_policy: Annotated::from(self.original_policy),
370            referrer: Annotated::from(self.referrer),
371            status_code: Annotated::from(self.status_code),
372            violated_directive: Annotated::from(self.violated_directive),
373            source_file: Annotated::from(self.source_file),
374            line_number: Annotated::from(self.line_number),
375            column_number: Annotated::from(self.column_number),
376            script_sample: Annotated::from(self.script_sample),
377            disposition: Annotated::from(self.disposition),
378            other: self
379                .other
380                .into_iter()
381                .map(|(k, v)| (k, Annotated::from(v)))
382                .collect(),
383        }
384    }
385
386    fn sanitized_blocked_uri(&self) -> String {
387        // HACK: This is 100% to work around Stripe urls that will casually put extremely sensitive
388        // information in querystrings. The real solution is to apply data scrubbing to all tags
389        // generically.
390        //
391        //    if netloc == 'api.stripe.com':
392        //      query = '' fragment = ''
393
394        let mut uri = self.blocked_uri.clone();
395
396        if uri.starts_with("https://api.stripe.com/") {
397            if let Some(index) = uri.find(&['#', '?'][..]) {
398                uri.truncate(index);
399            }
400        }
401
402        uri
403    }
404
405    fn normalize_value<'a>(&self, value: &'a str, document_uri: &str) -> Cow<'a, str> {
406        // > If no scheme is specified, the same scheme as the one used to access the protected
407        // > document is assumed.
408        // Source: https://developer.mozilla.org/en-US/docs/Web/Security/CSP/CSP_policy_directives
409        if let "'none'" | "'self'" | "'unsafe-inline'" | "'unsafe-eval'" = value {
410            return Cow::Borrowed(value);
411        }
412
413        // Normalize a value down to 'self' if it matches the origin of document-uri FireFox
414        // transforms a 'self' value into the spelled out origin, so we want to reverse this and
415        // bring it back.
416        if value.starts_with("data:")
417            || value.starts_with("mediastream:")
418            || value.starts_with("blob:")
419            || value.starts_with("filesystem:")
420            || value.starts_with("http:")
421            || value.starts_with("https:")
422            || value.starts_with("file:")
423        {
424            if document_uri == normalize_uri(value) {
425                return Cow::Borrowed("'self'");
426            }
427
428            // Their rule had an explicit scheme, so let's respect that
429            return Cow::Borrowed(value);
430        }
431
432        // Value doesn't have a scheme, but let's see if their hostnames match at least, if so,
433        // they're the same.
434        if value == document_uri {
435            return Cow::Borrowed("'self'");
436        }
437
438        // Now we need to stitch on a scheme to the value, but let's not stitch on the boring
439        // values.
440        let original_uri = self.document_uri.as_deref().unwrap_or_default();
441        match original_uri.split_once(':').map(|x| x.0) {
442            None | Some("http" | "https") => Cow::Borrowed(value),
443            Some(scheme) => Cow::Owned(unsplit_uri(scheme, value)),
444        }
445    }
446
447    fn get_culprit(&self) -> String {
448        if self.violated_directive.is_empty() {
449            return String::new();
450        }
451
452        let mut bits = self.violated_directive.split_ascii_whitespace();
453        let mut culprit = bits.next().unwrap_or_default().to_owned();
454
455        let document_uri = self.document_uri.as_deref().unwrap_or("");
456        let normalized_uri = normalize_uri(document_uri);
457
458        for bit in bits {
459            write!(culprit, " {}", self.normalize_value(bit, &normalized_uri)).ok();
460        }
461
462        culprit
463    }
464
465    fn get_tags(&self, effective_directive: CspDirective) -> Tags {
466        let mut tags = vec![
467            Annotated::new(TagEntry(
468                Annotated::new("effective-directive".to_string()),
469                Annotated::new(effective_directive.to_string()),
470            )),
471            Annotated::new(TagEntry(
472                Annotated::new("blocked-uri".to_string()),
473                Annotated::new(self.sanitized_blocked_uri()),
474            )),
475        ];
476
477        if let Ok(url) = Url::parse(&self.blocked_uri) {
478            if let ("http" | "https", Some(host)) = (url.scheme(), url.host_str()) {
479                tags.push(Annotated::new(TagEntry(
480                    Annotated::new("blocked-host".to_string()),
481                    Annotated::new(host.to_owned()),
482                )));
483            }
484        }
485
486        Tags(PairList::from(tags))
487    }
488
489    fn get_request(&self) -> Request {
490        let headers = match self.referrer {
491            Some(ref referrer) if !referrer.is_empty() => {
492                Annotated::new(Headers(PairList(vec![Annotated::new((
493                    Annotated::new(HeaderName::new("Referer")),
494                    Annotated::new(HeaderValue::new(referrer.clone())),
495                ))])))
496            }
497            Some(_) | None => Annotated::empty(),
498        };
499
500        Request {
501            url: Annotated::from(self.document_uri.clone()),
502            headers,
503            ..Request::default()
504        }
505    }
506}
507
508/// Defines external, RFC-defined schema we accept, while `Csp` defines our own schema.
509///
510/// See `Csp` for meaning of fields.
511#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
512#[serde(rename_all = "kebab-case")]
513struct CspReportRaw {
514    csp_report: CspRaw,
515}
516
517#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
518#[serde(untagged)]
519enum CspVariant {
520    Csp {
521        #[serde(rename = "csp-report")]
522        csp_report: CspRaw,
523    },
524    /// Defines CSP report sent through the [Reporting API](https://developer.mozilla.org/en-US/docs/Web/API/Reporting_API).
525    ///
526    /// This contains the [body](https://developer.mozilla.org/en-US/docs/Web/API/CSPViolationReportBody)
527    /// with actual report. We currently ignore the additional fields.
528    /// Reporting API has [slightly different format](https://csplite.com/csp66/#sample-violation-report) for the CSP report body,
529    /// but the biggest difference that browser sends the CSP reports in batches.
530    CspViolation { body: CspRaw },
531}
532
533/// The type of the CSP report which comes through the Reporting API.
534#[derive(Clone, Debug, PartialEq, Deserialize)]
535#[serde(rename_all = "kebab-case")]
536enum CspViolationType {
537    CspViolation,
538    #[serde(other)]
539    Other,
540}
541
542/// Models the content of a CSP report.
543///
544/// Note this models the older CSP reports (report-uri policy directive).
545/// The new CSP reports (using report-to policy directive) are different.
546///
547/// NOTE: This is the structure used inside the Event (serialization is based on Annotated
548/// infrastructure). We also use a version of this structure to deserialize from raw JSON
549/// via serde.
550///
551///
552/// See <https://www.w3.org/TR/CSP3/>
553#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
554pub struct Csp {
555    /// The directive whose enforcement caused the violation.
556    #[metastructure(pii = "true")]
557    pub effective_directive: Annotated<String>,
558    /// The URI of the resource that was blocked from loading by the Content Security Policy.
559    #[metastructure(pii = "true")]
560    pub blocked_uri: Annotated<String>,
561    /// The URI of the document in which the violation occurred.
562    #[metastructure(pii = "true")]
563    pub document_uri: Annotated<String>,
564    /// The original policy as specified by the Content-Security-Policy HTTP header.
565    pub original_policy: Annotated<String>,
566    /// The referrer of the document in which the violation occurred.
567    #[metastructure(pii = "true")]
568    pub referrer: Annotated<String>,
569    /// The HTTP status code of the resource on which the global object was instantiated.
570    pub status_code: Annotated<u64>,
571    /// The name of the policy section that was violated.
572    pub violated_directive: Annotated<String>,
573    /// The URL of the resource where the violation occurred.
574    pub source_file: Annotated<String>,
575    /// The line number in source-file on which the violation occurred.
576    pub line_number: Annotated<u64>,
577    /// The column number in source-file on which the violation occurred.
578    pub column_number: Annotated<u64>,
579    /// The first 40 characters of the inline script, event handler, or style that caused the
580    /// violation.
581    pub script_sample: Annotated<String>,
582    /// Policy disposition (enforce or report).
583    pub disposition: Annotated<String>,
584    /// Additional arbitrary fields for forwards compatibility.
585    #[metastructure(pii = "true", additional_properties)]
586    pub other: Object<Value>,
587}
588
589impl Csp {
590    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
591        let variant = serde_json::from_slice::<CspVariant>(data)?;
592        match variant {
593            CspVariant::Csp { csp_report } => Csp::extract_report(event, csp_report)?,
594            CspVariant::CspViolation { body } => Csp::extract_report(event, body)?,
595        }
596
597        Ok(())
598    }
599
600    fn extract_report(event: &mut Event, raw_csp: CspRaw) -> Result<(), serde_json::Error> {
601        let effective_directive = raw_csp
602            .effective_directive()
603            .map_err(serde::de::Error::custom)?;
604
605        event.logentry = Annotated::new(LogEntry::from(raw_csp.get_message(effective_directive)));
606        event.culprit = Annotated::new(raw_csp.get_culprit());
607        event.tags = Annotated::new(raw_csp.get_tags(effective_directive));
608        event.request = Annotated::new(raw_csp.get_request());
609        event.csp = Annotated::new(raw_csp.into_protocol(effective_directive));
610
611        Ok(())
612    }
613}
614
615#[derive(Clone, Copy, Debug, PartialEq, Eq)]
616enum ExpectCtStatus {
617    Unknown,
618    Valid,
619    Invalid,
620}
621
622relay_common::derive_fromstr_and_display!(ExpectCtStatus, InvalidSecurityError, {
623    ExpectCtStatus::Unknown => "unknown",
624    ExpectCtStatus::Valid => "valid",
625    ExpectCtStatus::Invalid => "invalid",
626});
627
628relay_common::impl_str_serde!(ExpectCtStatus, "an expect-ct status");
629
630#[derive(Clone, Copy, Debug, PartialEq, Eq)]
631enum ExpectCtSource {
632    TlsExtension,
633    Ocsp,
634    Embedded,
635}
636
637relay_common::derive_fromstr_and_display!(ExpectCtSource, InvalidSecurityError, {
638            ExpectCtSource::TlsExtension => "tls-extension",
639            ExpectCtSource::Ocsp => "ocsp",
640            ExpectCtSource::Embedded => "embedded",
641});
642
643relay_common::impl_str_serde!(ExpectCtSource, "an expect-ct source");
644
645#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
646struct SingleCertificateTimestampRaw {
647    version: Option<i64>,
648    status: Option<ExpectCtStatus>,
649    source: Option<ExpectCtSource>,
650    serialized_sct: Option<String>, // NOT kebab-case!
651}
652
653impl SingleCertificateTimestampRaw {
654    fn into_protocol(self) -> SingleCertificateTimestamp {
655        SingleCertificateTimestamp {
656            version: Annotated::from(self.version),
657            status: Annotated::from(self.status.map(|s| s.to_string())),
658            source: Annotated::from(self.source.map(|s| s.to_string())),
659            serialized_sct: Annotated::from(self.serialized_sct),
660        }
661    }
662}
663
664#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
665#[serde(rename_all = "kebab-case")]
666struct ExpectCtRaw {
667    #[serde(with = "serde_date_time_3339")]
668    date_time: Option<DateTime<Utc>>,
669    hostname: String,
670    port: Option<i64>,
671    scheme: Option<String>,
672    #[serde(with = "serde_date_time_3339")]
673    effective_expiration_date: Option<DateTime<Utc>>,
674    served_certificate_chain: Option<Vec<String>>,
675    validated_certificate_chain: Option<Vec<String>>,
676    scts: Option<Vec<SingleCertificateTimestampRaw>>,
677    failure_mode: Option<String>,
678    test_report: Option<bool>,
679}
680
681mod serde_date_time_3339 {
682    use serde::de::Visitor;
683
684    use super::*;
685
686    pub fn serialize<S>(date_time: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
687    where
688        S: serde::Serializer,
689    {
690        match date_time {
691            None => serializer.serialize_none(),
692            Some(d) => serializer.serialize_str(&d.to_rfc3339()),
693        }
694    }
695
696    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
697    where
698        D: serde::Deserializer<'de>,
699    {
700        struct DateTimeVisitor;
701
702        impl Visitor<'_> for DateTimeVisitor {
703            type Value = Option<DateTime<Utc>>;
704
705            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
706                formatter.write_str("expected a date-time in RFC 3339 format")
707            }
708
709            fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
710            where
711                E: serde::de::Error,
712            {
713                DateTime::parse_from_rfc3339(s)
714                    .map(|d| Some(d.with_timezone(&Utc)))
715                    .map_err(serde::de::Error::custom)
716            }
717
718            fn visit_none<E>(self) -> Result<Self::Value, E>
719            where
720                E: Error,
721            {
722                Ok(None)
723            }
724        }
725
726        deserializer.deserialize_any(DateTimeVisitor)
727    }
728}
729
730impl ExpectCtRaw {
731    fn get_message(&self) -> String {
732        format!("Expect-CT failed for '{}'", self.hostname)
733    }
734
735    fn into_protocol(self) -> ExpectCt {
736        ExpectCt {
737            date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
738            hostname: Annotated::from(self.hostname),
739            port: Annotated::from(self.port),
740            scheme: Annotated::from(self.scheme),
741            effective_expiration_date: Annotated::from(
742                self.effective_expiration_date.map(|d| d.to_rfc3339()),
743            ),
744            served_certificate_chain: Annotated::new(
745                self.served_certificate_chain
746                    .map(|s| s.into_iter().map(Annotated::from).collect())
747                    .unwrap_or_default(),
748            ),
749
750            validated_certificate_chain: Annotated::new(
751                self.validated_certificate_chain
752                    .map(|v| v.into_iter().map(Annotated::from).collect())
753                    .unwrap_or_default(),
754            ),
755            scts: Annotated::from(self.scts.map(|scts| {
756                scts.into_iter()
757                    .map(|elm| Annotated::from(elm.into_protocol()))
758                    .collect()
759            })),
760            failure_mode: Annotated::from(self.failure_mode),
761            test_report: Annotated::from(self.test_report),
762        }
763    }
764
765    fn get_culprit(&self) -> String {
766        self.hostname.clone()
767    }
768
769    fn get_tags(&self) -> Tags {
770        let mut tags = vec![Annotated::new(TagEntry(
771            Annotated::new("hostname".to_string()),
772            Annotated::new(self.hostname.clone()),
773        ))];
774
775        if let Some(port) = self.port {
776            tags.push(Annotated::new(TagEntry(
777                Annotated::new("port".to_string()),
778                Annotated::new(port.to_string()),
779            )));
780        }
781
782        Tags(PairList::from(tags))
783    }
784
785    fn get_request(&self) -> Request {
786        Request {
787            url: Annotated::from(self.hostname.clone()),
788            ..Request::default()
789        }
790    }
791}
792
793#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
794#[serde(rename_all = "kebab-case")]
795struct ExpectCtReportRaw {
796    expect_ct_report: ExpectCtRaw,
797}
798
799/// Object used in ExpectCt reports
800///
801/// See <https://tools.ietf.org/html/draft-ietf-httpbis-expect-ct-07#section-3.1>.
802#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
803pub struct SingleCertificateTimestamp {
804    pub version: Annotated<i64>,
805    pub status: Annotated<String>,
806    pub source: Annotated<String>,
807    pub serialized_sct: Annotated<String>,
808}
809
810/// Expect CT security report sent by user agent (browser).
811///
812/// See <https://tools.ietf.org/html/draft-ietf-httpbis-expect-ct-07#section-3.1>
813#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
814pub struct ExpectCt {
815    /// Date time in rfc3339 format YYYY-MM-DDTHH:MM:DD{.FFFFFF}(Z|+/-HH:MM)
816    /// UTC time that the UA observed the CT compliance failure
817    pub date_time: Annotated<String>,
818    /// The hostname to which the UA made the original request that failed the CT compliance check.
819    pub hostname: Annotated<String>,
820    pub port: Annotated<i64>,
821    pub scheme: Annotated<String>,
822    /// Date time in rfc3339 format
823    pub effective_expiration_date: Annotated<String>,
824    pub served_certificate_chain: Annotated<Array<String>>,
825    pub validated_certificate_chain: Annotated<Array<String>>,
826    pub scts: Annotated<Array<SingleCertificateTimestamp>>,
827    pub failure_mode: Annotated<String>,
828    pub test_report: Annotated<bool>,
829}
830
831impl ExpectCt {
832    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
833        let raw_report = serde_json::from_slice::<ExpectCtReportRaw>(data)?;
834        let raw_expect_ct = raw_report.expect_ct_report;
835
836        event.logentry = Annotated::new(LogEntry::from(raw_expect_ct.get_message()));
837        event.culprit = Annotated::new(raw_expect_ct.get_culprit());
838        event.tags = Annotated::new(raw_expect_ct.get_tags());
839        event.request = Annotated::new(raw_expect_ct.get_request());
840        event.expectct = Annotated::new(raw_expect_ct.into_protocol());
841
842        Ok(())
843    }
844}
845
846/// Defines external, RFC-defined schema we accept, while `Hpkp` defines our own schema.
847///
848/// See `Hpkp` for meaning of fields.
849#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
850#[serde(rename_all = "kebab-case")]
851struct HpkpRaw {
852    #[serde(skip_serializing_if = "Option::is_none")]
853    date_time: Option<DateTime<Utc>>,
854    hostname: String,
855    #[serde(skip_serializing_if = "Option::is_none")]
856    port: Option<u64>,
857    #[serde(skip_serializing_if = "Option::is_none")]
858    effective_expiration_date: Option<DateTime<Utc>>,
859    #[serde(skip_serializing_if = "Option::is_none")]
860    include_subdomains: Option<bool>,
861    #[serde(skip_serializing_if = "Option::is_none")]
862    noted_hostname: Option<String>,
863    #[serde(skip_serializing_if = "Option::is_none")]
864    served_certificate_chain: Option<Vec<String>>,
865    #[serde(skip_serializing_if = "Option::is_none")]
866    validated_certificate_chain: Option<Vec<String>>,
867    known_pins: Vec<String>,
868    #[serde(flatten)]
869    other: BTreeMap<String, serde_json::Value>,
870}
871
872impl HpkpRaw {
873    fn get_message(&self) -> String {
874        format!(
875            "Public key pinning validation failed for '{}'",
876            self.hostname
877        )
878    }
879
880    fn into_protocol(self) -> Hpkp {
881        Hpkp {
882            date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
883            hostname: Annotated::new(self.hostname),
884            port: Annotated::from(self.port),
885            effective_expiration_date: Annotated::from(
886                self.effective_expiration_date.map(|d| d.to_rfc3339()),
887            ),
888            include_subdomains: Annotated::from(self.include_subdomains),
889            noted_hostname: Annotated::from(self.noted_hostname),
890            served_certificate_chain: Annotated::from(
891                self.served_certificate_chain
892                    .map(|chain| chain.into_iter().map(Annotated::from).collect()),
893            ),
894            validated_certificate_chain: Annotated::from(
895                self.validated_certificate_chain
896                    .map(|chain| chain.into_iter().map(Annotated::from).collect()),
897            ),
898            known_pins: Annotated::new(self.known_pins.into_iter().map(Annotated::from).collect()),
899            other: self
900                .other
901                .into_iter()
902                .map(|(k, v)| (k, Annotated::from(v)))
903                .collect(),
904        }
905    }
906
907    fn get_tags(&self) -> Tags {
908        let mut tags = vec![Annotated::new(TagEntry(
909            Annotated::new("hostname".to_string()),
910            Annotated::new(self.hostname.clone()),
911        ))];
912
913        if let Some(port) = self.port {
914            tags.push(Annotated::new(TagEntry(
915                Annotated::new("port".to_string()),
916                Annotated::new(port.to_string()),
917            )));
918        }
919
920        if let Some(include_subdomains) = self.include_subdomains {
921            tags.push(Annotated::new(TagEntry(
922                Annotated::new("include-subdomains".to_string()),
923                Annotated::new(include_subdomains.to_string()),
924            )));
925        }
926
927        Tags(PairList::from(tags))
928    }
929
930    fn get_request(&self) -> Request {
931        Request {
932            url: Annotated::from(self.hostname.clone()),
933            ..Request::default()
934        }
935    }
936}
937
938/// Schema as defined in RFC7469, Section 3
939#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
940pub struct Hpkp {
941    /// Indicates the time the UA observed the Pin Validation failure.
942    pub date_time: Annotated<String>,
943    /// Hostname to which the UA made the original request that failed Pin Validation.
944    pub hostname: Annotated<String>,
945    /// The port to which the UA made the original request that failed Pin Validation.
946    pub port: Annotated<u64>,
947    /// Effective Expiration Date for the noted pins.
948    pub effective_expiration_date: Annotated<String>,
949    /// Indicates whether or not the UA has noted the includeSubDomains directive for the Known
950    /// Pinned Host.
951    pub include_subdomains: Annotated<bool>,
952
953    /// Indicates the hostname that the UA noted when it noted the Known Pinned Host.  This field
954    /// allows operators to understand why Pin Validation was performed for, e.g., foo.example.com
955    /// when the noted Known Pinned Host was example.com with includeSubDomains set.
956    pub noted_hostname: Annotated<String>,
957    /// The certificate chain, as served by the Known Pinned Host during TLS session setup.  It
958    /// is provided as an array of strings; each string pem1, ... pemN is the Privacy-Enhanced Mail
959    /// (PEM) representation of each X.509 certificate as described in [RFC7468].
960    ///
961    /// [RFC7468]: https://tools.ietf.org/html/rfc7468
962    pub served_certificate_chain: Annotated<Array<String>>,
963    /// The certificate chain, as constructed by the UA during certificate chain verification.
964    pub validated_certificate_chain: Annotated<Array<String>>,
965
966    /// Pins that the UA has noted for the Known Pinned Host.
967    // TODO: regex this string for 'pin-sha256="ABC123"' syntax
968    #[metastructure(required = true)]
969    pub known_pins: Annotated<Array<String>>,
970
971    #[metastructure(pii = "true", additional_properties)]
972    pub other: Object<Value>,
973}
974
975impl Hpkp {
976    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
977        let raw_hpkp = serde_json::from_slice::<HpkpRaw>(data)?;
978
979        event.logentry = Annotated::new(LogEntry::from(raw_hpkp.get_message()));
980        event.tags = Annotated::new(raw_hpkp.get_tags());
981        event.request = Annotated::new(raw_hpkp.get_request());
982        event.hpkp = Annotated::new(raw_hpkp.into_protocol());
983
984        Ok(())
985    }
986}
987
988/// Defines external, RFC-defined schema we accept, while `ExpectStaple` defines our own schema.
989///
990/// See `ExpectStaple` for meaning of fields.
991#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
992#[serde(rename_all = "kebab-case")]
993struct ExpectStapleReportRaw {
994    expect_staple_report: ExpectStapleRaw,
995}
996
997#[derive(Clone, Copy, Debug, PartialEq, Eq)]
998pub enum ExpectStapleResponseStatus {
999    Missing,
1000    Provided,
1001    ErrorResponse,
1002    BadProducedAt,
1003    NoMatchingResponse,
1004    InvalidDate,
1005    ParseResponseError,
1006    ParseResponseDataError,
1007}
1008
1009relay_common::derive_fromstr_and_display!(ExpectStapleResponseStatus, InvalidSecurityError, {
1010    ExpectStapleResponseStatus::Missing => "MISSING",
1011    ExpectStapleResponseStatus::Provided => "PROVIDED",
1012    ExpectStapleResponseStatus::ErrorResponse => "ERROR_RESPONSE",
1013    ExpectStapleResponseStatus::BadProducedAt => "BAD_PRODUCED_AT",
1014    ExpectStapleResponseStatus::NoMatchingResponse => "NO_MATCHING_RESPONSE",
1015    ExpectStapleResponseStatus::InvalidDate => "INVALID_DATE",
1016    ExpectStapleResponseStatus::ParseResponseError => "PARSE_RESPONSE_ERROR",
1017    ExpectStapleResponseStatus::ParseResponseDataError => "PARSE_RESPONSE_DATA_ERROR",
1018});
1019
1020relay_common::impl_str_serde!(ExpectStapleResponseStatus, "an expect-ct response status");
1021
1022#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1023pub enum ExpectStapleCertStatus {
1024    Good,
1025    Revoked,
1026    Unknown,
1027}
1028
1029relay_common::derive_fromstr_and_display!(ExpectStapleCertStatus, InvalidSecurityError, {
1030    ExpectStapleCertStatus::Good => "GOOD",
1031    ExpectStapleCertStatus::Revoked => "REVOKED",
1032    ExpectStapleCertStatus::Unknown => "UNKNOWN",
1033});
1034
1035relay_common::impl_str_serde!(ExpectStapleCertStatus, "an expect-staple cert status");
1036
1037/// Inner (useful) part of a Expect Stable report as sent by a user agent ( browser)
1038#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
1039#[serde(rename_all = "kebab-case")]
1040struct ExpectStapleRaw {
1041    #[serde(skip_serializing_if = "Option::is_none")]
1042    date_time: Option<DateTime<Utc>>,
1043    hostname: String,
1044    #[serde(skip_serializing_if = "Option::is_none")]
1045    port: Option<i64>,
1046    #[serde(skip_serializing_if = "Option::is_none")]
1047    effective_expiration_date: Option<DateTime<Utc>>,
1048    #[serde(skip_serializing_if = "Option::is_none")]
1049    response_status: Option<ExpectStapleResponseStatus>,
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    ocsp_response: Option<Value>,
1052    #[serde(skip_serializing_if = "Option::is_none")]
1053    cert_status: Option<ExpectStapleCertStatus>,
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    served_certificate_chain: Option<Vec<String>>,
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    validated_certificate_chain: Option<Vec<String>>,
1058}
1059
1060impl ExpectStapleRaw {
1061    fn get_message(&self) -> String {
1062        format!("Expect-Staple failed for '{}'", self.hostname)
1063    }
1064
1065    fn into_protocol(self) -> ExpectStaple {
1066        ExpectStaple {
1067            date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
1068            hostname: Annotated::from(self.hostname),
1069            port: Annotated::from(self.port),
1070            effective_expiration_date: Annotated::from(
1071                self.effective_expiration_date.map(|d| d.to_rfc3339()),
1072            ),
1073            response_status: Annotated::from(self.response_status.map(|rs| rs.to_string())),
1074            cert_status: Annotated::from(self.cert_status.map(|cs| cs.to_string())),
1075            served_certificate_chain: Annotated::from(
1076                self.served_certificate_chain
1077                    .map(|cert_chain| cert_chain.into_iter().map(Annotated::from).collect()),
1078            ),
1079            validated_certificate_chain: Annotated::from(
1080                self.validated_certificate_chain
1081                    .map(|cert_chain| cert_chain.into_iter().map(Annotated::from).collect()),
1082            ),
1083            ocsp_response: Annotated::from(self.ocsp_response),
1084        }
1085    }
1086
1087    fn get_culprit(&self) -> String {
1088        self.hostname.clone()
1089    }
1090
1091    fn get_tags(&self) -> Tags {
1092        let mut tags = vec![Annotated::new(TagEntry(
1093            Annotated::new("hostname".to_string()),
1094            Annotated::new(self.hostname.clone()),
1095        ))];
1096
1097        if let Some(port) = self.port {
1098            tags.push(Annotated::new(TagEntry(
1099                Annotated::new("port".to_string()),
1100                Annotated::new(port.to_string()),
1101            )));
1102        }
1103
1104        if let Some(response_status) = self.response_status {
1105            tags.push(Annotated::new(TagEntry(
1106                Annotated::new("response_status".to_string()),
1107                Annotated::new(response_status.to_string()),
1108            )));
1109        }
1110
1111        if let Some(cert_status) = self.cert_status {
1112            tags.push(Annotated::new(TagEntry(
1113                Annotated::new("cert_status".to_string()),
1114                Annotated::new(cert_status.to_string()),
1115            )));
1116        }
1117
1118        Tags(PairList::from(tags))
1119    }
1120
1121    fn get_request(&self) -> Request {
1122        Request {
1123            url: Annotated::from(self.hostname.clone()),
1124            ..Request::default()
1125        }
1126    }
1127}
1128
1129/// Represents an Expect Staple security report.
1130///
1131/// See <https://scotthelme.co.uk/ocsp-expect-staple/> for specification.
1132#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
1133pub struct ExpectStaple {
1134    date_time: Annotated<String>,
1135    hostname: Annotated<String>,
1136    port: Annotated<i64>,
1137    effective_expiration_date: Annotated<String>,
1138    response_status: Annotated<String>,
1139    cert_status: Annotated<String>,
1140    served_certificate_chain: Annotated<Array<String>>,
1141    validated_certificate_chain: Annotated<Array<String>>,
1142    ocsp_response: Annotated<Value>,
1143}
1144
1145impl ExpectStaple {
1146    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
1147        let raw_report = serde_json::from_slice::<ExpectStapleReportRaw>(data)?;
1148        let raw_expect_staple = raw_report.expect_staple_report;
1149
1150        event.logentry = Annotated::new(LogEntry::from(raw_expect_staple.get_message()));
1151        event.culprit = Annotated::new(raw_expect_staple.get_culprit());
1152        event.tags = Annotated::new(raw_expect_staple.get_tags());
1153        event.request = Annotated::new(raw_expect_staple.get_request());
1154        event.expectstaple = Annotated::new(raw_expect_staple.into_protocol());
1155
1156        Ok(())
1157    }
1158}
1159
1160#[derive(Clone, Debug, PartialEq, Eq)]
1161pub enum SecurityReportType {
1162    Csp,
1163    ExpectCt,
1164    ExpectStaple,
1165    Hpkp,
1166    Unsupported,
1167}
1168
1169impl SecurityReportType {
1170    /// Infers the type of a security report from its payload.
1171    ///
1172    /// This looks into the JSON payload and tries to infer the type from keys. If no report
1173    /// matches, an error is returned.
1174    pub fn from_json(data: &[u8]) -> Result<Option<Self>, serde_json::Error> {
1175        #[derive(Deserialize)]
1176        #[serde(rename_all = "kebab-case")]
1177        struct SecurityReport {
1178            #[serde(rename = "type")]
1179            ty: Option<CspViolationType>,
1180            csp_report: Option<IgnoredAny>,
1181            known_pins: Option<IgnoredAny>,
1182            expect_staple_report: Option<IgnoredAny>,
1183            expect_ct_report: Option<IgnoredAny>,
1184        }
1185
1186        let helper: SecurityReport = serde_json::from_slice(data)?;
1187
1188        Ok(if helper.csp_report.is_some() {
1189            Some(SecurityReportType::Csp)
1190        } else if let Some(CspViolationType::CspViolation) = helper.ty {
1191            Some(SecurityReportType::Csp)
1192        } else if let Some(CspViolationType::Other) = helper.ty {
1193            Some(SecurityReportType::Unsupported)
1194        } else if helper.known_pins.is_some() {
1195            Some(SecurityReportType::Hpkp)
1196        } else if helper.expect_staple_report.is_some() {
1197            Some(SecurityReportType::ExpectStaple)
1198        } else if helper.expect_ct_report.is_some() {
1199            Some(SecurityReportType::ExpectCt)
1200        } else {
1201            None
1202        })
1203    }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209    use relay_protocol::assert_annotated_snapshot;
1210
1211    #[test]
1212    fn test_unsplit_uri() {
1213        assert_eq!(unsplit_uri("", ""), "");
1214        assert_eq!(unsplit_uri("data", ""), "data:");
1215        assert_eq!(unsplit_uri("data", "foo"), "data://foo");
1216        assert_eq!(unsplit_uri("http", ""), "http://");
1217        assert_eq!(unsplit_uri("http", "foo"), "http://foo");
1218    }
1219
1220    #[test]
1221    fn test_normalize_uri() {
1222        // Special handling for self URIs
1223        assert_eq!(normalize_uri(""), "'self'");
1224        assert_eq!(normalize_uri("self"), "'self'");
1225
1226        // Special handling for schema-only URIs
1227        assert_eq!(normalize_uri("data"), "data:");
1228        assert_eq!(normalize_uri("http"), "http://");
1229
1230        // URIs without port
1231        assert_eq!(normalize_uri("http://notlocalhost/"), "notlocalhost");
1232        assert_eq!(normalize_uri("https://notlocalhost/"), "notlocalhost");
1233        assert_eq!(normalize_uri("data://notlocalhost/"), "data://notlocalhost");
1234        assert_eq!(normalize_uri("http://notlocalhost/lol.css"), "notlocalhost");
1235
1236        // URIs with port
1237        assert_eq!(
1238            normalize_uri("http://notlocalhost:8000/"),
1239            "notlocalhost:8000"
1240        );
1241        assert_eq!(
1242            normalize_uri("http://notlocalhost:8000/lol.css"),
1243            "notlocalhost:8000"
1244        );
1245
1246        // Invalid URIs
1247        assert_eq!(normalize_uri("xyz://notlocalhost/"), "xyz://notlocalhost");
1248    }
1249
1250    #[test]
1251    fn test_csp_basic() {
1252        let json = r#"{
1253            "csp-report": {
1254                "document-uri": "http://example.com",
1255                "violated-directive": "style-src cdn.example.com",
1256                "blocked-uri": "http://example.com/lol.css",
1257                "effective-directive": "style-src",
1258                "status-code": "200"
1259            }
1260        }"#;
1261
1262        let mut event = Event::default();
1263        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1264
1265        assert_annotated_snapshot!(Annotated::new(event), @r###"
1266        {
1267          "culprit": "style-src cdn.example.com",
1268          "logentry": {
1269            "formatted": "Blocked 'style' from 'example.com'"
1270          },
1271          "request": {
1272            "url": "http://example.com"
1273          },
1274          "tags": [
1275            [
1276              "effective-directive",
1277              "style-src"
1278            ],
1279            [
1280              "blocked-uri",
1281              "http://example.com/lol.css"
1282            ],
1283            [
1284              "blocked-host",
1285              "example.com"
1286            ]
1287          ],
1288          "csp": {
1289            "effective_directive": "style-src",
1290            "blocked_uri": "http://example.com/lol.css",
1291            "document_uri": "http://example.com",
1292            "status_code": 200,
1293            "violated_directive": "style-src cdn.example.com"
1294          }
1295        }
1296        "###);
1297    }
1298
1299    #[test]
1300    fn test_csp_coerce_blocked_uri_if_missing() {
1301        let json = r#"{
1302            "csp-report": {
1303                "document-uri": "http://example.com",
1304                "effective-directive": "script-src"
1305            }
1306        }"#;
1307
1308        let mut event = Event::default();
1309        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1310
1311        assert_annotated_snapshot!(Annotated::new(event), @r#"
1312        {
1313          "culprit": "",
1314          "logentry": {
1315            "formatted": "Blocked unsafe (eval() or inline) 'script'"
1316          },
1317          "request": {
1318            "url": "http://example.com"
1319          },
1320          "tags": [
1321            [
1322              "effective-directive",
1323              "script-src"
1324            ],
1325            [
1326              "blocked-uri",
1327              "self"
1328            ]
1329          ],
1330          "csp": {
1331            "effective_directive": "script-src",
1332            "blocked_uri": "self",
1333            "document_uri": "http://example.com",
1334            "violated_directive": ""
1335          }
1336        }
1337        "#);
1338    }
1339
1340    #[test]
1341    fn test_csp_msdn() {
1342        let json = r#"{
1343            "csp-report": {
1344                "document-uri": "https://example.com/foo/bar",
1345                "referrer": "https://www.google.com/",
1346                "violated-directive": "default-src self",
1347                "original-policy": "default-src self; report-uri /csp-hotline.php",
1348                "blocked-uri": "http://evilhackerscripts.com"
1349            }
1350        }"#;
1351
1352        let mut event = Event::default();
1353        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1354
1355        assert_annotated_snapshot!(Annotated::new(event), @r###"
1356        {
1357          "culprit": "default-src self",
1358          "logentry": {
1359            "formatted": "Blocked 'default-src' from 'evilhackerscripts.com'"
1360          },
1361          "request": {
1362            "url": "https://example.com/foo/bar",
1363            "headers": [
1364              [
1365                "Referer",
1366                "https://www.google.com/"
1367              ]
1368            ]
1369          },
1370          "tags": [
1371            [
1372              "effective-directive",
1373              "default-src"
1374            ],
1375            [
1376              "blocked-uri",
1377              "http://evilhackerscripts.com"
1378            ],
1379            [
1380              "blocked-host",
1381              "evilhackerscripts.com"
1382            ]
1383          ],
1384          "csp": {
1385            "effective_directive": "default-src",
1386            "blocked_uri": "http://evilhackerscripts.com",
1387            "document_uri": "https://example.com/foo/bar",
1388            "original_policy": "default-src self; report-uri /csp-hotline.php",
1389            "referrer": "https://www.google.com/",
1390            "violated_directive": "default-src self"
1391          }
1392        }
1393        "###);
1394    }
1395
1396    #[test]
1397    fn test_csp_real() {
1398        let json = r#"{
1399            "csp-report": {
1400                "document-uri": "https://sentry.io/sentry/csp/issues/88513416/",
1401                "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
1402                "violated-directive": "script-src",
1403                "effective-directive": "script-src",
1404                "original-policy": "default-src *; script-src 'make_csp_snapshot' 'unsafe-eval' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net cdn.ravenjs.com assets.zendesk.com ajax.googleapis.com ssl.google-analytics.com www.googleadservices.com analytics.twitter.com platform.twitter.com *.pingdom.net js.stripe.com api.stripe.com statuspage-production.s3.amazonaws.com s3.amazonaws.com *.google.com www.gstatic.com aui-cdn.atlassian.com *.atlassian.net *.jira.com *.zopim.com; font-src * data:; connect-src * wss://*.zopim.com; style-src 'make_csp_snapshot' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net s3.amazonaws.com aui-cdn.atlassian.com fonts.googleapis.com; img-src * data: blob:; report-uri https://sentry.io/api/54785/csp-report/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=39662eb9734f68e56b7f202260bb706be2f4cee7",
1405                "disposition": "enforce",
1406                "blocked-uri": "http://baddomain.com/test.js?_=1515535030116",
1407                "line-number": 24,
1408                "column-number": 66270,
1409                "source-file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
1410                "status-code": 0,
1411                "script-sample": ""
1412            }
1413        }"#;
1414
1415        let mut event = Event::default();
1416        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1417
1418        assert_annotated_snapshot!(Annotated::new(event), @r###"
1419        {
1420          "culprit": "script-src",
1421          "logentry": {
1422            "formatted": "Blocked 'script' from 'baddomain.com'"
1423          },
1424          "request": {
1425            "url": "https://sentry.io/sentry/csp/issues/88513416/",
1426            "headers": [
1427              [
1428                "Referer",
1429                "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/"
1430              ]
1431            ]
1432          },
1433          "tags": [
1434            [
1435              "effective-directive",
1436              "script-src"
1437            ],
1438            [
1439              "blocked-uri",
1440              "http://baddomain.com/test.js?_=1515535030116"
1441            ],
1442            [
1443              "blocked-host",
1444              "baddomain.com"
1445            ]
1446          ],
1447          "csp": {
1448            "effective_directive": "script-src",
1449            "blocked_uri": "http://baddomain.com/test.js?_=1515535030116",
1450            "document_uri": "https://sentry.io/sentry/csp/issues/88513416/",
1451            "original_policy": "default-src *; script-src 'make_csp_snapshot' 'unsafe-eval' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net cdn.ravenjs.com assets.zendesk.com ajax.googleapis.com ssl.google-analytics.com www.googleadservices.com analytics.twitter.com platform.twitter.com *.pingdom.net js.stripe.com api.stripe.com statuspage-production.s3.amazonaws.com s3.amazonaws.com *.google.com www.gstatic.com aui-cdn.atlassian.com *.atlassian.net *.jira.com *.zopim.com; font-src * data:; connect-src * wss://*.zopim.com; style-src 'make_csp_snapshot' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net s3.amazonaws.com aui-cdn.atlassian.com fonts.googleapis.com; img-src * data: blob:; report-uri https://sentry.io/api/54785/csp-report/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=39662eb9734f68e56b7f202260bb706be2f4cee7",
1452            "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
1453            "status_code": 0,
1454            "violated_directive": "script-src",
1455            "source_file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
1456            "line_number": 24,
1457            "column_number": 66270,
1458            "script_sample": "",
1459            "disposition": "enforce"
1460          }
1461        }
1462        "###);
1463    }
1464
1465    #[test]
1466    fn test_csp_culprit_0() {
1467        let json = r#"{
1468            "csp-report": {
1469                "document-uri": "http://example.com/foo",
1470                "violated-directive": "style-src http://cdn.example.com",
1471                "effective-directive": "style-src"
1472            }
1473        }"#;
1474
1475        let mut event = Event::default();
1476        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1477        insta::assert_debug_snapshot!(event.culprit, @r#""style-src http://cdn.example.com""#);
1478    }
1479
1480    #[test]
1481    fn test_csp_culprit_1() {
1482        let json = r#"{
1483            "csp-report": {
1484                "document-uri": "http://example.com/foo",
1485                "violated-directive": "style-src cdn.example.com",
1486                "effective-directive": "style-src"
1487            }
1488        }"#;
1489
1490        let mut event = Event::default();
1491        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1492        insta::assert_debug_snapshot!(event.culprit, @r#""style-src cdn.example.com""#);
1493    }
1494
1495    #[test]
1496    fn test_csp_culprit_2() {
1497        let json = r#"{
1498            "csp-report": {
1499                "document-uri": "https://example.com/foo",
1500                "violated-directive": "style-src cdn.example.com",
1501                "effective-directive": "style-src"
1502            }
1503        }"#;
1504
1505        let mut event = Event::default();
1506        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1507        insta::assert_debug_snapshot!(event.culprit, @r#""style-src cdn.example.com""#);
1508    }
1509
1510    #[test]
1511    fn test_csp_culprit_3() {
1512        let json = r#"{
1513            "csp-report": {
1514                "document-uri": "http://example.com/foo",
1515                "violated-directive": "style-src https://cdn.example.com",
1516                "effective-directive": "style-src"
1517            }
1518        }"#;
1519
1520        let mut event = Event::default();
1521        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1522        insta::assert_debug_snapshot!(event.culprit, @r#""style-src https://cdn.example.com""#);
1523    }
1524
1525    #[test]
1526    fn test_csp_culprit_4() {
1527        let json = r#"{
1528            "csp-report": {
1529                "document-uri": "http://example.com/foo",
1530                "violated-directive": "style-src http://example.com",
1531                "effective-directive": "style-src"
1532            }
1533        }"#;
1534
1535        let mut event = Event::default();
1536        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1537        insta::assert_debug_snapshot!(event.culprit, @r#""style-src 'self'""#);
1538    }
1539
1540    #[test]
1541    fn test_csp_culprit_5() {
1542        let json = r#"{
1543            "csp-report": {
1544                "document-uri": "http://example.com/foo",
1545                "violated-directive": "style-src http://example2.com example.com",
1546                "effective-directive": "style-src"
1547            }
1548        }"#;
1549
1550        let mut event = Event::default();
1551        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1552        insta::assert_debug_snapshot!(event.culprit, @r#""style-src http://example2.com 'self'""#);
1553    }
1554
1555    #[test]
1556    fn test_csp_culprit_uri_without_scheme() {
1557        // Not sure if this is a real-world example, but let's cover it anyway
1558        let json = r#"{
1559            "csp-report": {
1560                "document-uri": "example.com",
1561                "violated-directive": "style-src example2.com"
1562            }
1563        }"#;
1564
1565        let mut event = Event::default();
1566        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1567        insta::assert_debug_snapshot!(event.culprit, @r#""style-src example2.com""#);
1568    }
1569
1570    #[test]
1571    fn test_csp_tags_stripe() {
1572        // This is a regression test for potential PII in stripe URLs. PII stripping used to skip
1573        // report interfaces, which is why there is special handling.
1574
1575        let json = r#"{
1576            "csp-report": {
1577                "document-uri": "https://example.com",
1578                "blocked-uri": "https://api.stripe.com/v1/tokens?card[number]=xxx",
1579                "effective-directive": "script-src"
1580            }
1581        }"#;
1582
1583        let mut event = Event::default();
1584        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1585        insta::assert_debug_snapshot!(event.tags, @r###"
1586        Tags(
1587            PairList(
1588                [
1589                    TagEntry(
1590                        "effective-directive",
1591                        "script-src",
1592                    ),
1593                    TagEntry(
1594                        "blocked-uri",
1595                        "https://api.stripe.com/v1/tokens",
1596                    ),
1597                    TagEntry(
1598                        "blocked-host",
1599                        "api.stripe.com",
1600                    ),
1601                ],
1602            ),
1603        )
1604        "###);
1605    }
1606
1607    #[test]
1608    fn test_csp_get_message_0() {
1609        let json = r#"{
1610            "csp-report": {
1611                "document-uri": "http://example.com/foo",
1612                "effective-directive": "img-src",
1613                "blocked-uri": "http://google.com/foo"
1614            }
1615        }"#;
1616
1617        let mut event = Event::default();
1618        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1619        let message = &event.logentry.value().unwrap().formatted;
1620        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'image' from 'google.com'""#);
1621    }
1622
1623    #[test]
1624    fn test_csp_get_message_1() {
1625        let json = r#"{
1626            "csp-report": {
1627                "document-uri": "http://example.com/foo",
1628                "effective-directive": "style-src",
1629                "blocked-uri": ""
1630            }
1631        }"#;
1632
1633        let mut event = Event::default();
1634        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1635        let message = &event.logentry.value().unwrap().formatted;
1636        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked inline 'style'""#);
1637    }
1638
1639    #[test]
1640    fn test_csp_get_message_2() {
1641        let json = r#"{
1642            "csp-report": {
1643                "document-uri": "http://example.com/foo",
1644                "effective-directive": "script-src",
1645                "blocked-uri": "",
1646                "violated-directive": "script-src 'unsafe-inline'"
1647            }
1648        }"#;
1649
1650        let mut event = Event::default();
1651        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1652        let message = &event.logentry.value().unwrap().formatted;
1653        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked unsafe inline 'script'""#);
1654    }
1655
1656    #[test]
1657    fn test_csp_get_message_3() {
1658        let json = r#"{
1659            "csp-report": {
1660                "document-uri": "http://example.com/foo",
1661                "effective-directive": "script-src",
1662                "blocked-uri": "",
1663                "violated-directive": "script-src 'unsafe-eval'"
1664            }
1665        }"#;
1666
1667        let mut event = Event::default();
1668        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1669        let message = &event.logentry.value().unwrap().formatted;
1670        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked unsafe eval() 'script'""#);
1671    }
1672
1673    #[test]
1674    fn test_csp_get_message_4() {
1675        let json = r#"{
1676            "csp-report": {
1677                "document-uri": "http://example.com/foo",
1678                "effective-directive": "script-src",
1679                "blocked-uri": "",
1680                "violated-directive": "script-src example.com"
1681            }
1682        }"#;
1683
1684        let mut event = Event::default();
1685        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1686        let message = &event.logentry.value().unwrap().formatted;
1687        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked unsafe (eval() or inline) 'script'""#);
1688    }
1689
1690    #[test]
1691    fn test_csp_get_message_5() {
1692        let json = r#"{
1693            "csp-report": {
1694                "document-uri": "http://example.com/foo",
1695                "effective-directive": "script-src",
1696                "blocked-uri": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D"
1697            }
1698        }"#;
1699
1700        let mut event = Event::default();
1701        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1702        let message = &event.logentry.value().unwrap().formatted;
1703        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'script' from 'data:'""#);
1704    }
1705
1706    #[test]
1707    fn test_csp_get_message_6() {
1708        let json = r#"{
1709            "csp-report": {
1710                "document-uri": "http://example.com/foo",
1711                "effective-directive": "script-src",
1712                "blocked-uri": "data"
1713            }
1714        }"#;
1715
1716        let mut event = Event::default();
1717        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1718        let message = &event.logentry.value().unwrap().formatted;
1719        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'script' from 'data:'""#);
1720    }
1721
1722    #[test]
1723    fn test_csp_get_message_7() {
1724        let json = r#"{
1725            "csp-report": {
1726                "document-uri": "http://example.com/foo",
1727                "effective-directive": "style-src-elem",
1728                "blocked-uri": "http://fonts.google.com/foo"
1729            }
1730        }"#;
1731
1732        let mut event = Event::default();
1733        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1734        let message = &event.logentry.value().unwrap().formatted;
1735        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'style' from 'fonts.google.com'""#);
1736    }
1737
1738    #[test]
1739    fn test_csp_get_message_8() {
1740        let json = r#"{
1741            "csp-report": {
1742                "document-uri": "http://example.com/foo",
1743                "effective-directive": "script-src-elem",
1744                "blocked-uri": "http://cdn.ajaxapis.com/foo"
1745            }
1746        }"#;
1747
1748        let mut event = Event::default();
1749        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1750        let message = &event.logentry.value().unwrap().formatted;
1751        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'script' from 'cdn.ajaxapis.com'""#);
1752    }
1753
1754    #[test]
1755    fn test_csp_get_message_9() {
1756        let json = r#"{
1757            "csp-report": {
1758                "document-uri": "http://notlocalhost:8000/",
1759                "effective-directive": "style-src",
1760                "blocked-uri": "http://notlocalhost:8000/lol.css"
1761            }
1762        }"#;
1763
1764        let mut event = Event::default();
1765        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1766        let message = &event.logentry.value().unwrap().formatted;
1767        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'style' from 'notlocalhost:8000'""#);
1768    }
1769
1770    #[test]
1771    fn test_expectct_basic() {
1772        let json = r#"{
1773            "expect-ct-report": {
1774                "date-time": "2014-04-06T13:00:50Z",
1775                "hostname": "www.example.com",
1776                "port": 443,
1777                "scheme": "https",
1778                "effective-expiration-date": "2014-05-01T12:40:50Z",
1779                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1780                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1781                "scts": [
1782                    {
1783                        "version": 1,
1784                        "status": "invalid",
1785                        "source": "embedded",
1786                        "serialized_sct": "ABCD=="
1787                    }
1788                ],
1789                "failure-mode": "enforce",
1790                "test-report": false
1791            }
1792        }"#;
1793
1794        let mut event = Event::default();
1795        ExpectCt::apply_to_event(json.as_bytes(), &mut event).unwrap();
1796        assert_annotated_snapshot!(Annotated::new(event), @r#"
1797        {
1798          "culprit": "www.example.com",
1799          "logentry": {
1800            "formatted": "Expect-CT failed for 'www.example.com'"
1801          },
1802          "request": {
1803            "url": "www.example.com"
1804          },
1805          "tags": [
1806            [
1807              "hostname",
1808              "www.example.com"
1809            ],
1810            [
1811              "port",
1812              "443"
1813            ]
1814          ],
1815          "expectct": {
1816            "date_time": "2014-04-06T13:00:50+00:00",
1817            "hostname": "www.example.com",
1818            "port": 443,
1819            "scheme": "https",
1820            "effective_expiration_date": "2014-05-01T12:40:50+00:00",
1821            "served_certificate_chain": [
1822              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1823            ],
1824            "validated_certificate_chain": [
1825              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1826            ],
1827            "scts": [
1828              {
1829                "version": 1,
1830                "status": "invalid",
1831                "source": "embedded",
1832                "serialized_sct": "ABCD=="
1833              }
1834            ],
1835            "failure_mode": "enforce",
1836            "test_report": false
1837          }
1838        }
1839        "#);
1840    }
1841
1842    #[test]
1843    fn test_expectct_invalid() {
1844        let json = r#"{
1845            "hostname": "www.example.com",
1846            "date_time": "Not an RFC3339 datetime"
1847        }"#;
1848
1849        let mut event = Event::default();
1850        ExpectCt::apply_to_event(json.as_bytes(), &mut event)
1851            .expect_err("date_time should fail to parse");
1852    }
1853
1854    #[test]
1855    fn test_expectstaple_basic() {
1856        let json = r#"{
1857            "expect-staple-report": {
1858                "date-time": "2014-04-06T13:00:50Z",
1859                "hostname": "www.example.com",
1860                "port": 443,
1861                "response-status": "ERROR_RESPONSE",
1862                "cert-status": "REVOKED",
1863                "effective-expiration-date": "2014-05-01T12:40:50Z",
1864                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1865                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
1866            }
1867        }"#;
1868
1869        let mut event = Event::default();
1870        ExpectStaple::apply_to_event(json.as_bytes(), &mut event).unwrap();
1871        assert_annotated_snapshot!(Annotated::new(event), @r#"
1872        {
1873          "culprit": "www.example.com",
1874          "logentry": {
1875            "formatted": "Expect-Staple failed for 'www.example.com'"
1876          },
1877          "request": {
1878            "url": "www.example.com"
1879          },
1880          "tags": [
1881            [
1882              "hostname",
1883              "www.example.com"
1884            ],
1885            [
1886              "port",
1887              "443"
1888            ],
1889            [
1890              "response_status",
1891              "ERROR_RESPONSE"
1892            ],
1893            [
1894              "cert_status",
1895              "REVOKED"
1896            ]
1897          ],
1898          "expectstaple": {
1899            "date_time": "2014-04-06T13:00:50+00:00",
1900            "hostname": "www.example.com",
1901            "port": 443,
1902            "effective_expiration_date": "2014-05-01T12:40:50+00:00",
1903            "response_status": "ERROR_RESPONSE",
1904            "cert_status": "REVOKED",
1905            "served_certificate_chain": [
1906              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1907            ],
1908            "validated_certificate_chain": [
1909              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1910            ]
1911          }
1912        }
1913        "#);
1914    }
1915
1916    #[test]
1917    fn test_hpkp_basic() {
1918        let json = r#"{
1919            "date-time": "2014-04-06T13:00:50Z",
1920            "hostname": "example.com",
1921            "port": 443,
1922            "effective-expiration-date": "2014-05-01T12:40:50Z",
1923            "include-subdomains": false,
1924            "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1925            "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1926            "known-pins": ["pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""]
1927        }"#;
1928
1929        let mut event = Event::default();
1930        Hpkp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1931        assert_annotated_snapshot!(Annotated::new(event), @r#"
1932        {
1933          "logentry": {
1934            "formatted": "Public key pinning validation failed for 'example.com'"
1935          },
1936          "request": {
1937            "url": "example.com"
1938          },
1939          "tags": [
1940            [
1941              "hostname",
1942              "example.com"
1943            ],
1944            [
1945              "port",
1946              "443"
1947            ],
1948            [
1949              "include-subdomains",
1950              "false"
1951            ]
1952          ],
1953          "hpkp": {
1954            "date_time": "2014-04-06T13:00:50+00:00",
1955            "hostname": "example.com",
1956            "port": 443,
1957            "effective_expiration_date": "2014-05-01T12:40:50+00:00",
1958            "include_subdomains": false,
1959            "served_certificate_chain": [
1960              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1961            ],
1962            "validated_certificate_chain": [
1963              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1964            ],
1965            "known_pins": [
1966              "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
1967            ]
1968          }
1969        }
1970        "#);
1971    }
1972
1973    #[test]
1974    fn test_security_report_type_deserializer_recognizes_csp_reports() {
1975        let csp_report_text = r#"{
1976            "csp-report": {
1977                "document-uri": "https://example.com/foo/bar",
1978                "referrer": "https://www.google.com/",
1979                "violated-directive": "default-src self",
1980                "original-policy": "default-src self; report-uri /csp-hotline.php",
1981                "blocked-uri": "http://evilhackerscripts.com"
1982            }
1983        }"#;
1984
1985        let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
1986        assert_eq!(report_type, Some(SecurityReportType::Csp));
1987    }
1988
1989    #[test]
1990    fn test_security_report_type_deserializer_recognizes_csp_violations_reports() {
1991        let csp_report_text = r#"{
1992          "age":0,
1993          "body":{
1994            "blockedURL":"https://example.com/tst/media/7_del.png",
1995            "disposition":"enforce",
1996            "documentURL":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
1997            "effectiveDirective":"img-src",
1998            "lineNumber":9,
1999            "originalPolicy":"default-src 'none'; report-to endpoint-csp;",
2000            "referrer":"https://example.com/test229/",
2001            "sourceFile":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
2002            "statusCode":0
2003            },
2004          "type":"csp-violation",
2005          "url":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
2006          "user_agent":"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36"
2007        }"#;
2008
2009        let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
2010        assert_eq!(report_type, Some(SecurityReportType::Csp));
2011    }
2012
2013    #[test]
2014    fn test_security_report_type_deserializer_recognizes_expect_ct_reports() {
2015        let expect_ct_report_text = r#"{
2016            "expect-ct-report": {
2017                "date-time": "2014-04-06T13:00:50Z",
2018                "hostname": "www.example.com",
2019                "port": 443,
2020                "effective-expiration-date": "2014-05-01T12:40:50Z",
2021                "served-certificate-chain": [
2022                    "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
2023                ],
2024                "validated-certificate-chain": [
2025                    "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
2026                ],
2027                "scts": [
2028                    {
2029                        "version": 1,
2030                        "status": "invalid",
2031                        "source": "embedded",
2032                        "serialized_sct": "ABCD=="
2033                    }
2034                ]
2035            }
2036        }"#;
2037
2038        let report_type = SecurityReportType::from_json(expect_ct_report_text.as_bytes()).unwrap();
2039        assert_eq!(report_type, Some(SecurityReportType::ExpectCt));
2040    }
2041
2042    #[test]
2043    fn test_security_report_type_deserializer_recognizes_expect_staple_reports() {
2044        let expect_staple_report_text = r#"{
2045             "expect-staple-report": {
2046                "date-time": "2014-04-06T13:00:50Z",
2047                "hostname": "www.example.com",
2048                "port": 443,
2049                "response-status": "ERROR_RESPONSE",
2050                "cert-status": "REVOKED",
2051                "effective-expiration-date": "2014-05-01T12:40:50Z",
2052                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
2053                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
2054            }
2055        }"#;
2056        let report_type =
2057            SecurityReportType::from_json(expect_staple_report_text.as_bytes()).unwrap();
2058        assert_eq!(report_type, Some(SecurityReportType::ExpectStaple));
2059    }
2060
2061    #[test]
2062    fn test_security_report_type_deserializer_recognizes_hpkp_reports() {
2063        let hpkp_report_text = r#"{
2064            "date-time": "2014-04-06T13:00:50Z",
2065            "hostname": "www.example.com",
2066            "port": 443,
2067            "effective-expiration-date": "2014-05-01T12:40:50Z",
2068            "include-subdomains": false,
2069            "served-certificate-chain": [
2070              "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
2071            ],
2072            "validated-certificate-chain": [
2073              "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
2074            ],
2075            "known-pins": [
2076              "pin-sha256=\"d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=\"",
2077              "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
2078            ]
2079          }"#;
2080
2081        let report_type = SecurityReportType::from_json(hpkp_report_text.as_bytes()).unwrap();
2082        assert_eq!(report_type, Some(SecurityReportType::Hpkp));
2083    }
2084
2085    #[test]
2086    fn test_effective_directive_from_violated_directive_single() {
2087        // Example from Firefox:
2088        let csp_raw: CspRaw =
2089            serde_json::from_str(r#"{"violated-directive":"default-src"}"#).unwrap();
2090        assert!(matches!(
2091            csp_raw.effective_directive(),
2092            Ok(CspDirective::DefaultSrc)
2093        ));
2094    }
2095
2096    #[test]
2097    fn test_extract_effective_directive_from_long_form() {
2098        // First try from 'effective-directive' field
2099        let json = r#"{
2100            "csp-report": {
2101                "document-uri": "http://example.com/foo",
2102                "effective-directive": "script-src 'report-sample' 'strict-dynamic' 'unsafe-eval' 'nonce-random" ,
2103                "blocked-uri": "data"
2104            }
2105        }"#;
2106
2107        let raw_report = serde_json::from_slice::<CspReportRaw>(json.as_bytes()).unwrap();
2108        let raw_csp = raw_report.csp_report;
2109
2110        let effective_directive = raw_csp.effective_directive().unwrap();
2111
2112        assert_eq!(effective_directive, CspDirective::ScriptSrc);
2113
2114        // Then from 'violated-directive' field
2115        let json = r#"{
2116            "csp-report": {
2117                "document-uri": "http://example.com/foo",
2118                "violated-directive": "script-src 'report-sample' 'strict-dynamic' 'unsafe-eval' 'nonce-random" ,
2119                "blocked-uri": "data"
2120            }
2121        }"#;
2122
2123        let raw_report = serde_json::from_slice::<CspReportRaw>(json.as_bytes()).unwrap();
2124        let raw_csp = raw_report.csp_report;
2125
2126        let effective_directive = raw_csp.effective_directive().unwrap();
2127
2128        assert_eq!(effective_directive, CspDirective::ScriptSrc);
2129    }
2130}