From c6e73e0892db09e06b0d44e19a64cadd161f4fb4 Mon Sep 17 00:00:00 2001 From: Rajiv Aaron Manglani Date: Wed, 3 Dec 2025 21:28:00 -0500 Subject: [PATCH] Add Subject Alt Name (SAN) attribute for certificate assertions. Co-authored-by: Rajiv Aaron Manglani --- README.md | 2 ++ docs/asserting-response.md | 6 ++-- docs/manual/hurl.1 | 2 +- docs/manual/hurlfmt.1 | 2 +- docs/samples.md | 2 ++ packages/hurl/README.md | 2 ++ packages/hurl/src/http/certificate.rs | 38 ++++++++++++++++++++++---- packages/hurl/src/json/result.rs | 3 ++ packages/hurl/src/report/html/run.rs | 6 +++- packages/hurl/src/runner/query.rs | 9 +++++- packages/hurl_core/src/ast/section.rs | 2 ++ packages/hurl_core/src/parser/query.rs | 4 ++- 12 files changed, 66 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6e177371d7..715e553cc8 100644 --- a/README.md +++ b/README.md @@ -780,6 +780,8 @@ certificate "Subject" == "CN=example.org" certificate "Issuer" == "C=US, O=Let's Encrypt, CN=R3" certificate "Expire-Date" daysAfterNow > 15 certificate "Serial-Number" matches /[\da-f]+/ +certificate "Subject-Alt-Name" contains "DNS:example.org" +certificate "Subject-Alt-Name" split "," count == 2 ``` [Doc](https://hurl.dev/docs/asserting-response.html#ssl-certificate-assert) diff --git a/docs/asserting-response.md b/docs/asserting-response.md index f22eea94ca..92555a39a9 100644 --- a/docs/asserting-response.md +++ b/docs/asserting-response.md @@ -960,7 +960,7 @@ duration < 1000 # Check that response time is less than one second Check the SSL certificate properties. Certificate assert consists of the keyword `certificate`, followed by the certificate attribute value. -The following attributes are supported: `Subject`, `Issuer`, `Start-Date`, `Expire-Date` and `Serial-Number`. +The following attributes are supported: `Subject`, `Issuer`, `Start-Date`, `Expire-Date`, `Serial-Number`, and `Subject-Alt-Name`. ```hurl GET https://example.org @@ -970,6 +970,8 @@ certificate "Subject" == "CN=example.org" certificate "Issuer" == "C=US, O=Let's Encrypt, CN=R3" certificate "Expire-Date" daysAfterNow > 15 certificate "Serial-Number" matches "[0-9af]+" +certificate "Subject-Alt-Name" contains "DNS:example.org" +certificate "Subject-Alt-Name" split "," count == 2 ``` [predicates]: #predicates @@ -1000,4 +1002,4 @@ certificate "Serial-Number" matches "[0-9af]+" [`Content-Type` header]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type [`body` assert]: #body-assert [`location` filter]: /docs/filters.md#location -[UUID v4]: https://en.wikipedia.org/wiki/Universally_unique_identifier \ No newline at end of file +[UUID v4]: https://en.wikipedia.org/wiki/Universally_unique_identifier diff --git a/docs/manual/hurl.1 b/docs/manual/hurl.1 index 466f38c4a5..e69463aec8 100644 --- a/docs/manual/hurl.1 +++ b/docs/manual/hurl.1 @@ -1,4 +1,4 @@ -.TH hurl 1 "20 Nov 2025" "hurl 7.2.0-SNAPSHOT" " Hurl Manual" +.TH hurl 1 "07 Dec 2025" "hurl 7.2.0-SNAPSHOT" " Hurl Manual" .SH NAME hurl - run and test HTTP requests. diff --git a/docs/manual/hurlfmt.1 b/docs/manual/hurlfmt.1 index d08dac9cc1..dbeada14aa 100644 --- a/docs/manual/hurlfmt.1 +++ b/docs/manual/hurlfmt.1 @@ -1,4 +1,4 @@ -.TH hurl 1 "20 Nov 2025" "hurl 7.2.0-SNAPSHOT" " Hurl Manual" +.TH hurl 1 "07 Dec 2025" "hurl 7.2.0-SNAPSHOT" " Hurl Manual" .SH NAME hurlfmt - format Hurl files diff --git a/docs/samples.md b/docs/samples.md index 999e8bf7ee..dbac55affa 100644 --- a/docs/samples.md +++ b/docs/samples.md @@ -505,6 +505,8 @@ certificate "Subject" == "CN=example.org" certificate "Issuer" == "C=US, O=Let's Encrypt, CN=R3" certificate "Expire-Date" daysAfterNow > 15 certificate "Serial-Number" matches /[\da-f]+/ +certificate "Subject-Alt-Name" contains "DNS:example.org" +certificate "Subject-Alt-Name" split "," count == 2 ``` [Doc](/docs/asserting-response.md#ssl-certificate-assert) diff --git a/packages/hurl/README.md b/packages/hurl/README.md index 8d1d19308b..603df33b4e 100644 --- a/packages/hurl/README.md +++ b/packages/hurl/README.md @@ -780,6 +780,8 @@ certificate "Subject" == "CN=example.org" certificate "Issuer" == "C=US, O=Let's Encrypt, CN=R3" certificate "Expire-Date" daysAfterNow > 15 certificate "Serial-Number" matches /[\da-f]+/ +certificate "Subject-Alt-Name" contains "DNS:example.org" +certificate "Subject-Alt-Name" split "," count == 2 ``` [Doc](https://hurl.dev/docs/asserting-response.html#ssl-certificate-assert) diff --git a/packages/hurl/src/http/certificate.rs b/packages/hurl/src/http/certificate.rs index 1610372f25..1d901a1868 100644 --- a/packages/hurl/src/http/certificate.rs +++ b/packages/hurl/src/http/certificate.rs @@ -28,6 +28,7 @@ pub struct Certificate { pub start_date: DateTime, pub expire_date: DateTime, pub serial_number: String, + pub subject_alt_name: Option, } impl TryFrom for Certificate { @@ -44,12 +45,14 @@ impl TryFrom for Certificate { let start_date = parse_start_date(&attributes)?; let expire_date = parse_expire_date(&attributes)?; let serial_number = parse_serial_number(&attributes)?; + let subject_alt_name = parse_subject_alt_name(&attributes).ok(); Ok(Certificate { subject, issuer, start_date, expire_date, serial_number, + subject_alt_name, }) } } @@ -133,6 +136,15 @@ fn parse_serial_number(attributes: &HashMap) -> Result) -> Result { + match attributes.get("x509v3 subject alternative name") { + None => Err(format!( + "missing x509v3 subject alternative name attribute in {attributes:?}" + )), + Some(value) => Ok(value.clone()), + } +} + fn parse_attributes(data: &Vec) -> HashMap { let mut map = HashMap::new(); for s in data { @@ -182,7 +194,7 @@ mod tests { parse_start_date(&attributes).unwrap(), chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() - .with_timezone(&chrono::Utc) + .with_timezone(&Utc) ); let mut attributes = HashMap::new(); @@ -194,7 +206,7 @@ mod tests { parse_start_date(&attributes).unwrap(), chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() - .with_timezone(&chrono::Utc) + .with_timezone(&Utc) ); } @@ -221,6 +233,19 @@ mod tests { ); } + #[test] + fn test_parse_subject_alt_name() { + let mut attributes = HashMap::new(); + attributes.insert( + "x509v3 subject alternative name".to_string(), + "DNS:localhost, IP address:127.0.0.1, IP adddress:0:0:0:0:0:0:0:1".to_string(), + ); + assert_eq!( + parse_subject_alt_name(&attributes).unwrap(), + "DNS:localhost, IP address:127.0.0.1, IP adddress:0:0:0:0:0:0:0:1".to_string() + ); + } + #[test] fn test_try_from() { assert_eq!( @@ -233,6 +258,8 @@ mod tests { "Serial Number:1ee8b17f1b64d8d6b3de870103d2a4f533535ab0".to_string(), "Start date:Jan 10 08:29:52 2023 GMT".to_string(), "Expire date:Oct 30 08:29:52 2025 GMT".to_string(), + "x509v3 subject alternative name:DNS:localhost, IP address:127.0.0.1, IP adddress:0:0:0:0:0:0:0:1" + .to_string(), ] }) .unwrap(), @@ -242,12 +269,13 @@ mod tests { issuer: "C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost".to_string(), start_date: chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() - .with_timezone(&chrono::Utc), + .with_timezone(&Utc), expire_date: chrono::DateTime::parse_from_rfc2822("Thu, 30 Oct 2025 08:29:52 GMT") .unwrap() - .with_timezone(&chrono::Utc), + .with_timezone(&Utc), serial_number: "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0" - .to_string() + .to_string(), + subject_alt_name: Some("DNS:localhost, IP address:127.0.0.1, IP adddress:0:0:0:0:0:0:0:1".to_string()) } ); assert_eq!( diff --git a/packages/hurl/src/json/result.rs b/packages/hurl/src/json/result.rs index 4cd584bd20..901c702a15 100644 --- a/packages/hurl/src/json/result.rs +++ b/packages/hurl/src/json/result.rs @@ -194,6 +194,8 @@ struct CertificateJson { start_date: String, expire_date: String, serial_number: String, + #[serde(skip_serializing_if = "Option::is_none")] + subject_alt_name: Option, } impl HurlResultJson { @@ -448,6 +450,7 @@ impl CertificateJson { start_date: c.start_date.to_string(), expire_date: c.expire_date.to_string(), serial_number: c.serial_number.to_string(), + subject_alt_name: c.subject_alt_name.clone(), } } } diff --git a/packages/hurl/src/report/html/run.rs b/packages/hurl/src/report/html/run.rs index 30ec3b6b3e..4816b6d590 100644 --- a/packages/hurl/src/report/html/run.rs +++ b/packages/hurl/src/report/html/run.rs @@ -133,13 +133,17 @@ fn get_call_html( if let Some(certificate) = &call.response.certificate { let start_date = certificate.start_date.to_string(); let end_date = certificate.expire_date.to_string(); - let values = vec![ + let mut values = vec![ ("Subject", certificate.subject.as_str()), ("Issuer", certificate.issuer.as_str()), ("Start Date", start_date.as_str()), ("Expire Date", end_date.as_str()), ("Serial Number", certificate.serial_number.as_str()), ]; + if let Some(subject_alt_name) = certificate.subject_alt_name.as_ref() { + values.push(("Subject Alt Name", subject_alt_name.as_str())); + } + let table = new_table("Certificate", &values); text.push_str(&table); } diff --git a/packages/hurl/src/runner/query.rs b/packages/hurl/src/runner/query.rs index 89f1bb552b..859d628c62 100644 --- a/packages/hurl/src/runner/query.rs +++ b/packages/hurl/src/runner/query.rs @@ -391,6 +391,12 @@ fn eval_query_certificate( CertificateAttributeName::SerialNumber => { Value::String(certificate.serial_number.clone()) } + CertificateAttributeName::SubjectAltName => { + match certificate.subject_alt_name.as_ref() { + Some(s) => Value::String(s.clone()), + None => return Ok(None), + } + } }; Ok(Some(value)) } else { @@ -1484,7 +1490,8 @@ pub mod tests { issuer: String::new(), start_date: Default::default(), expire_date: Default::default(), - serial_number: String::new() + serial_number: String::new(), + subject_alt_name: Some(String::new()) }), ..default_response() }, diff --git a/packages/hurl_core/src/ast/section.rs b/packages/hurl_core/src/ast/section.rs index eb6c73f698..c8605f5f3e 100644 --- a/packages/hurl_core/src/ast/section.rs +++ b/packages/hurl_core/src/ast/section.rs @@ -296,6 +296,7 @@ pub enum CertificateAttributeName { StartDate, ExpireDate, SerialNumber, + SubjectAltName, } impl CertificateAttributeName { @@ -307,6 +308,7 @@ impl CertificateAttributeName { CertificateAttributeName::StartDate => "Start-Date", CertificateAttributeName::ExpireDate => "Expire-Date", CertificateAttributeName::SerialNumber => "Serial-Number", + CertificateAttributeName::SubjectAltName => "Subject-Alt-Name", } } } diff --git a/packages/hurl_core/src/parser/query.rs b/packages/hurl_core/src/parser/query.rs index 58d5a7f31b..2832b983f5 100644 --- a/packages/hurl_core/src/parser/query.rs +++ b/packages/hurl_core/src/parser/query.rs @@ -219,9 +219,11 @@ fn certificate_field(reader: &mut Reader) -> ParseResult, , , or ".to_string(); + "Field , , , , , or ".to_string(); let kind = ParseErrorKind::Expecting { value }; let cur = reader.cursor(); Err(ParseError::new(cur.pos, false, kind))