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