#![forbid(unsafe_code)]
#![warn(missing_docs, rust_2018_idioms)]
#![cfg_attr(docsrs, feature(doc_cfg))]
use x509_cert::Certificate;
pub use pkix_path::{Profile, ValidatedPath, ValidationPolicy};
#[cfg(feature = "serde")]
pub(crate) fn de_static_str<'de, D>(deserializer: D) -> Result<&'static str, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize as _;
let s = String::deserialize(deserializer)?;
Ok(Box::leak(s.into_boxed_str()))
}
#[cfg(feature = "serde")]
pub(crate) fn de_cow_static<'de, D>(
deserializer: D,
) -> Result<std::borrow::Cow<'static, str>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize as _;
let s = String::deserialize(deserializer)?;
Ok(std::borrow::Cow::Owned(s))
}
pub mod cabf_tls_br;
pub mod deviation;
pub mod report;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum Severity {
Info,
Warn,
Error,
Fatal,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Scope {
Certificate,
Path,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SubjectKind {
Leaf,
IntermediateCa,
AnchorIssued,
Any,
}
impl SubjectKind {
#[must_use]
pub fn matches(self, filter: Self) -> bool {
match filter {
Self::Any => true,
Self::IntermediateCa => self == Self::IntermediateCa || self == Self::AnchorIssued,
other => self == other,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(bound(deserialize = "")))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum LintResult {
Pass,
NotApplicable,
Warn(#[cfg_attr(feature = "serde", serde(deserialize_with = "de_static_str"))] &'static str),
Error(#[cfg_attr(feature = "serde", serde(deserialize_with = "de_static_str"))] &'static str),
Fatal(#[cfg_attr(feature = "serde", serde(deserialize_with = "de_static_str"))] &'static str),
}
impl LintResult {
#[must_use]
pub const fn is_pass(&self) -> bool {
matches!(self, Self::Pass)
}
#[must_use]
pub const fn is_finding(&self) -> bool {
matches!(self, Self::Warn(_) | Self::Error(_) | Self::Fatal(_))
}
#[must_use]
pub const fn is_fatal(&self) -> bool {
matches!(self, Self::Fatal(_))
}
#[must_use]
pub const fn detail(&self) -> Option<&'static str> {
match self {
Self::Warn(d) | Self::Error(d) | Self::Fatal(d) => Some(d),
_ => None,
}
}
}
impl core::fmt::Display for Severity {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Info => f.write_str("info"),
Self::Warn => f.write_str("warn"),
Self::Error => f.write_str("error"),
Self::Fatal => f.write_str("fatal"),
}
}
}
impl core::fmt::Display for LintResult {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Pass => f.write_str("Pass"),
Self::NotApplicable => f.write_str("NotApplicable"),
Self::Warn(msg) => write!(f, "Warn: {msg}"),
Self::Error(msg) => write!(f, "Error: {msg}"),
Self::Fatal(msg) => write!(f, "Fatal: {msg}"),
}
}
}
impl core::fmt::Display for Finding {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let severity_label = match &self.result {
LintResult::Warn(_) => "warn",
LintResult::Error(_) => "error",
LintResult::Fatal(_) => "fatal",
LintResult::Pass => "pass",
LintResult::NotApplicable => "n/a",
};
match self.result.detail() {
Some(msg) => write!(f, "{} [{}]: {}", self.lint_id, severity_label, msg),
None => write!(f, "{} [{}]", self.lint_id, severity_label),
}
}
}
pub trait Lint: Send + Sync {
fn id(&self) -> &'static str;
fn citation(&self) -> &'static str;
fn severity(&self) -> Severity;
fn scope(&self) -> Scope;
fn applies_to(&self) -> SubjectKind;
#[allow(unused_variables)]
fn check_cert(&self, cert: &Certificate, kind: SubjectKind, now_unix: u64) -> LintResult {
LintResult::NotApplicable
}
#[allow(unused_variables)]
fn check_path(&self, chain: &[Certificate], path: &ValidatedPath, now_unix: u64) -> LintResult {
LintResult::NotApplicable
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(bound(deserialize = "'de: 'static")))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Finding {
#[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
pub lint_id: std::borrow::Cow<'static, str>,
#[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
pub citation: std::borrow::Cow<'static, str>,
#[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
pub rule_bundle_version: std::borrow::Cow<'static, str>,
pub result: LintResult,
pub cert_index: Option<usize>,
pub evaluated_at_unix: u64,
}
impl Finding {
#[must_use]
pub const fn is_finding(&self) -> bool {
self.result.is_finding()
}
}
pub struct LintRunner {
lints: Vec<Box<dyn Lint>>,
bundle_version: std::borrow::Cow<'static, str>,
}
impl core::fmt::Debug for LintRunner {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("LintRunner")
.field("lint_count", &self.lints.len())
.field("bundle_version", &self.bundle_version)
.finish()
}
}
impl LintRunner {
#[must_use]
pub fn new(lints: Vec<Box<dyn Lint>>) -> Self {
#[cfg(debug_assertions)]
{
let mut ids: Vec<_> = lints.iter().map(|l| l.id()).collect();
let original_len = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(
ids.len(),
original_len,
"duplicate lint IDs will produce confusing deviation behavior"
);
}
Self {
lints,
bundle_version: std::borrow::Cow::Borrowed(""),
}
}
#[must_use]
pub fn with_bundle_version(
lints: Vec<Box<dyn Lint>>,
version: impl Into<std::borrow::Cow<'static, str>>,
) -> Self {
Self {
lints,
bundle_version: version.into(),
}
}
#[must_use]
pub fn lints(&self) -> &[Box<dyn Lint>] {
&self.lints
}
#[must_use]
pub fn bundle_version(&self) -> &str {
&self.bundle_version
}
#[must_use]
pub fn run_cert(
&self,
cert: &Certificate,
kind: SubjectKind,
cert_index: usize,
now_unix: u64,
) -> Vec<Finding> {
let mut findings = Vec::new();
for lint in &self.lints {
if lint.scope() != Scope::Certificate {
continue;
}
let result = if kind.matches(lint.applies_to()) {
lint.check_cert(cert, kind, now_unix)
} else {
LintResult::NotApplicable
};
let is_fatal = result.is_fatal();
findings.push(Finding {
lint_id: std::borrow::Cow::Borrowed(lint.id()),
citation: std::borrow::Cow::Borrowed(lint.citation()),
rule_bundle_version: self.bundle_version.clone(),
result,
cert_index: Some(cert_index),
evaluated_at_unix: now_unix,
});
if is_fatal {
break;
}
}
findings
}
#[must_use]
pub fn run_cert_at_issuance(
&self,
cert: &Certificate,
kind: SubjectKind,
cert_index: usize,
) -> Vec<Finding> {
let issuance_unix = cert
.tbs_certificate
.validity
.not_before
.to_unix_duration()
.as_secs();
self.run_cert(cert, kind, cert_index, issuance_unix)
}
#[must_use]
pub fn run_chain(
&self,
chain: &[Certificate],
kinds: &[SubjectKind],
now_unix: u64,
) -> Vec<Finding> {
let mut all = Vec::new();
for (i, cert) in chain.iter().enumerate() {
let kind = kinds.get(i).copied().unwrap_or(SubjectKind::IntermediateCa);
all.extend(self.run_cert(cert, kind, i, now_unix));
}
all
}
#[must_use]
pub fn run_path(
&self,
chain: &[Certificate],
path: &ValidatedPath,
now_unix: u64,
) -> Vec<Finding> {
let mut findings = Vec::new();
for lint in &self.lints {
if lint.scope() != Scope::Path {
continue;
}
let result = lint.check_path(chain, path, now_unix);
let is_fatal = result.is_fatal();
findings.push(Finding {
lint_id: std::borrow::Cow::Borrowed(lint.id()),
citation: std::borrow::Cow::Borrowed(lint.citation()),
rule_bundle_version: self.bundle_version.clone(),
result,
cert_index: None,
evaluated_at_unix: now_unix,
});
if is_fatal {
break;
}
}
findings
}
}
pub trait LintProfile: Profile {
fn lints(&self) -> &[Box<dyn Lint>];
#[must_use]
fn lint_runner(&self) -> LintRunner;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subject_kind_any_matches_all() {
for &kind in &[
SubjectKind::Leaf,
SubjectKind::IntermediateCa,
SubjectKind::AnchorIssued,
SubjectKind::Any,
] {
assert!(
kind.matches(SubjectKind::Any),
"{kind:?} must match filter Any"
);
}
}
#[test]
fn subject_kind_exact_matches_self() {
assert!(SubjectKind::Leaf.matches(SubjectKind::Leaf));
assert!(SubjectKind::IntermediateCa.matches(SubjectKind::IntermediateCa));
assert!(SubjectKind::AnchorIssued.matches(SubjectKind::AnchorIssued));
}
#[test]
fn subject_kind_intermediate_filter_includes_anchor_issued() {
assert!(SubjectKind::AnchorIssued.matches(SubjectKind::IntermediateCa));
}
#[test]
fn subject_kind_leaf_does_not_match_intermediate() {
assert!(!SubjectKind::Leaf.matches(SubjectKind::IntermediateCa));
assert!(!SubjectKind::Leaf.matches(SubjectKind::AnchorIssued));
}
#[test]
fn subject_kind_intermediate_does_not_match_leaf() {
assert!(!SubjectKind::IntermediateCa.matches(SubjectKind::Leaf));
}
#[test]
fn lint_result_pass_is_pass() {
assert!(LintResult::Pass.is_pass());
assert!(!LintResult::Pass.is_finding());
assert!(!LintResult::Pass.is_fatal());
assert_eq!(LintResult::Pass.detail(), None);
}
#[test]
fn lint_result_not_applicable_is_not_pass_not_finding() {
assert!(!LintResult::NotApplicable.is_pass());
assert!(!LintResult::NotApplicable.is_finding());
assert_eq!(LintResult::NotApplicable.detail(), None);
}
#[test]
fn lint_result_warn_is_finding() {
let r = LintResult::Warn("test warning");
assert!(!r.is_pass());
assert!(r.is_finding());
assert!(!r.is_fatal());
assert_eq!(r.detail(), Some("test warning"));
}
#[test]
fn lint_result_error_is_finding() {
let r = LintResult::Error("test error");
assert!(!r.is_pass());
assert!(r.is_finding());
assert!(!r.is_fatal());
assert_eq!(r.detail(), Some("test error"));
}
#[test]
fn lint_result_fatal_is_fatal_and_finding() {
let r = LintResult::Fatal("fatal error");
assert!(!r.is_pass());
assert!(r.is_finding());
assert!(r.is_fatal());
assert_eq!(r.detail(), Some("fatal error"));
}
struct AlwaysPass;
impl Lint for AlwaysPass {
fn id(&self) -> &'static str {
"test.always_pass"
}
fn citation(&self) -> &'static str {
"test"
}
fn severity(&self) -> Severity {
Severity::Info
}
fn scope(&self) -> Scope {
Scope::Certificate
}
fn applies_to(&self) -> SubjectKind {
SubjectKind::Any
}
fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
LintResult::Pass
}
}
struct AlwaysWarn;
impl Lint for AlwaysWarn {
fn id(&self) -> &'static str {
"test.always_warn"
}
fn citation(&self) -> &'static str {
"test"
}
fn severity(&self) -> Severity {
Severity::Warn
}
fn scope(&self) -> Scope {
Scope::Certificate
}
fn applies_to(&self) -> SubjectKind {
SubjectKind::Any
}
fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
LintResult::Warn("always warns")
}
}
struct AlwaysFatal;
impl Lint for AlwaysFatal {
fn id(&self) -> &'static str {
"test.always_fatal"
}
fn citation(&self) -> &'static str {
"test"
}
fn severity(&self) -> Severity {
Severity::Fatal
}
fn scope(&self) -> Scope {
Scope::Certificate
}
fn applies_to(&self) -> SubjectKind {
SubjectKind::Any
}
fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
LintResult::Fatal("always fatal")
}
}
struct LeafOnlyLint;
impl Lint for LeafOnlyLint {
fn id(&self) -> &'static str {
"test.leaf_only"
}
fn citation(&self) -> &'static str {
"test"
}
fn severity(&self) -> Severity {
Severity::Warn
}
fn scope(&self) -> Scope {
Scope::Certificate
}
fn applies_to(&self) -> SubjectKind {
SubjectKind::Leaf
}
fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
LintResult::Warn("leaf lint fires")
}
}
struct PathDepthLint;
impl Lint for PathDepthLint {
fn id(&self) -> &'static str {
"test.path_depth"
}
fn citation(&self) -> &'static str {
"test"
}
fn severity(&self) -> Severity {
Severity::Warn
}
fn scope(&self) -> Scope {
Scope::Path
}
fn applies_to(&self) -> SubjectKind {
SubjectKind::Any
}
fn check_path(
&self,
_chain: &[Certificate],
path: &ValidatedPath,
_now: u64,
) -> LintResult {
if path.depth > 5 {
LintResult::Warn("chain depth exceeds 5")
} else {
LintResult::Pass
}
}
}
fn load_fixture_cert() -> Certificate {
use der::Decode as _;
Certificate::from_der(include_bytes!(
"../../pkix-path/tests/fixtures/policy-checks/webpki-self-signed-365d.der"
))
.expect("fixture is valid DER")
}
#[test]
fn runner_records_pass_finding() {
let cert = load_fixture_cert();
let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].lint_id, "test.always_pass");
assert_eq!(findings[0].result, LintResult::Pass);
assert_eq!(findings[0].cert_index, Some(0));
}
#[test]
fn runner_records_warn_finding() {
let cert = load_fixture_cert();
let runner = LintRunner::new(vec![Box::new(AlwaysWarn)]);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].lint_id, "test.always_warn");
assert!(matches!(findings[0].result, LintResult::Warn(_)));
assert!(findings[0].is_finding());
}
#[test]
fn runner_stops_after_fatal() {
let cert = load_fixture_cert();
let runner = LintRunner::new(vec![
Box::new(AlwaysFatal),
Box::new(AlwaysWarn), ]);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
assert_eq!(findings.len(), 1, "runner must stop after Fatal");
assert_eq!(findings[0].lint_id, "test.always_fatal");
assert!(findings[0].result.is_fatal());
}
#[test]
fn runner_skips_non_applicable_scope_kind() {
let cert = load_fixture_cert();
let runner = LintRunner::new(vec![Box::new(LeafOnlyLint)]);
let findings = runner.run_cert(&cert, SubjectKind::IntermediateCa, 1, 0);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].result, LintResult::NotApplicable);
}
#[test]
fn runner_applies_leaf_lint_to_leaf() {
let cert = load_fixture_cert();
let runner = LintRunner::new(vec![Box::new(LeafOnlyLint)]);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
assert_eq!(findings.len(), 1);
assert!(matches!(findings[0].result, LintResult::Warn(_)));
}
fn validated_path_for_self_signed() -> (Vec<Certificate>, ValidatedPath) {
use pkix_path::{EcdsaP256Verifier, TrustAnchor, ValidationPolicy};
let cert = load_fixture_cert();
let anchor = TrustAnchor::from_cert(cert.clone());
let policy = ValidationPolicy::new(1_767_225_600);
let path = pkix_path::validate_path(
std::slice::from_ref(&cert),
&[anchor],
&policy,
&EcdsaP256Verifier,
)
.expect("fixture cert must validate");
(vec![cert], path)
}
#[test]
fn runner_skips_cert_lints_in_run_path() {
let (chain, path) = validated_path_for_self_signed();
let runner = LintRunner::new(vec![Box::new(AlwaysWarn)]);
let findings = runner.run_path(&chain, &path, 0);
assert!(
findings.is_empty(),
"run_path must not invoke Certificate-scope lints"
);
}
#[test]
fn runner_invokes_path_lint_in_run_path() {
let (chain, path) = validated_path_for_self_signed();
let runner = LintRunner::new(vec![Box::new(PathDepthLint)]);
let findings = runner.run_path(&chain, &path, 0);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].lint_id, "test.path_depth");
assert_eq!(findings[0].result, LintResult::Pass);
assert_eq!(
findings[0].cert_index, None,
"path findings have no cert_index"
);
}
#[test]
fn runner_run_chain_sets_cert_index() {
let cert = load_fixture_cert();
let chain = vec![cert.clone(), cert.clone(), cert];
let kinds = vec![
SubjectKind::Leaf,
SubjectKind::IntermediateCa,
SubjectKind::AnchorIssued,
];
let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
let findings = runner.run_chain(&chain, &kinds, 0);
assert_eq!(findings.len(), 3);
assert_eq!(findings[0].cert_index, Some(0));
assert_eq!(findings[1].cert_index, Some(1));
assert_eq!(findings[2].cert_index, Some(2));
}
#[test]
fn finding_is_finding_reflects_result() {
let f_pass = Finding {
lint_id: std::borrow::Cow::Borrowed("x"),
citation: std::borrow::Cow::Borrowed("test"),
rule_bundle_version: std::borrow::Cow::Borrowed(""),
result: LintResult::Pass,
cert_index: None,
evaluated_at_unix: 0,
};
let f_warn = Finding {
lint_id: std::borrow::Cow::Borrowed("x"),
citation: std::borrow::Cow::Borrowed("test"),
rule_bundle_version: std::borrow::Cow::Borrowed(""),
result: LintResult::Warn("w"),
cert_index: None,
evaluated_at_unix: 0,
};
assert!(!f_pass.is_finding());
assert!(f_warn.is_finding());
}
#[test]
fn finding_citation_is_threaded_from_lint() {
let cert = load_fixture_cert();
let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 12345);
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].citation, "test",
"citation must be threaded from Lint::citation()"
);
assert_eq!(
findings[0].evaluated_at_unix, 12345,
"evaluated_at_unix must be the passed now_unix"
);
}
#[test]
fn run_cert_at_issuance_uses_not_before() {
let cert = load_fixture_cert();
let expected_unix = cert
.tbs_certificate
.validity
.not_before
.to_unix_duration()
.as_secs();
let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
let findings = runner.run_cert_at_issuance(&cert, SubjectKind::Leaf, 0);
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].evaluated_at_unix, expected_unix,
"run_cert_at_issuance must use cert notBefore as evaluated_at_unix"
);
}
#[test]
fn bundle_version_stamped_into_findings() {
let cert = load_fixture_cert();
let runner = LintRunner::with_bundle_version(
vec![Box::new(AlwaysPass)],
"pkix-lint/cabf_tls_br v0.2.0",
);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].rule_bundle_version.as_ref(),
"pkix-lint/cabf_tls_br v0.2.0",
"rule_bundle_version must be stamped from runner into Finding"
);
}
#[test]
fn bundle_version_empty_by_default() {
let cert = load_fixture_cert();
let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
assert_eq!(findings[0].rule_bundle_version.as_ref(), "");
}
}