Add Subject Alt Name (SAN) attribute for certificate assertions.

Co-authored-by: Rajiv Aaron Manglani <rajiv@alum.mit.edu>
This commit is contained in:
Rajiv Aaron Manglani 2025-12-03 21:28:00 -05:00 committed by Jean-Christophe Amiel
parent 27d529b6c2
commit c6e73e0892
No known key found for this signature in database
GPG Key ID: 07FF11CFD55356CC
12 changed files with 66 additions and 12 deletions

View File

@ -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)

View File

@ -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
[UUID v4]: https://en.wikipedia.org/wiki/Universally_unique_identifier

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -28,6 +28,7 @@ pub struct Certificate {
pub start_date: DateTime<Utc>,
pub expire_date: DateTime<Utc>,
pub serial_number: String,
pub subject_alt_name: Option<String>,
}
impl TryFrom<CertInfo> for Certificate {
@ -44,12 +45,14 @@ impl TryFrom<CertInfo> 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<String, String>) -> Result<String, S
Ok(normalized_value)
}
fn parse_subject_alt_name(attributes: &HashMap<String, String>) -> Result<String, String> {
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<String>) -> HashMap<String, String> {
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!(

View File

@ -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<String>,
}
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(),
}
}
}

View File

@ -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);
}

View File

@ -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()
},

View File

@ -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",
}
}
}

View File

@ -219,9 +219,11 @@ fn certificate_field(reader: &mut Reader) -> ParseResult<CertificateAttributeNam
Ok(CertificateAttributeName::ExpireDate)
} else if try_literal(r#"Serial-Number""#, reader).is_ok() {
Ok(CertificateAttributeName::SerialNumber)
} else if try_literal(r#"Subject-Alt-Name""#, reader).is_ok() {
Ok(CertificateAttributeName::SubjectAltName)
} else {
let value =
"Field <Subject>, <Issuer>, <Start-Date>, <Expire-Date> or <Serial-Number>".to_string();
"Field <Subject>, <Issuer>, <Start-Date>, <Expire-Date>, <Serial-Number>, or <Subject-Alt-Name>".to_string();
let kind = ParseErrorKind::Expecting { value };
let cur = reader.cursor();
Err(ParseError::new(cur.pos, false, kind))