signalscreen-checker 0.3.0

Windows code-signing hygiene checker. Reads the Authenticode signature in a PE file and grades it A-F. Pure Rust, no Windows dependency.
Documentation
//! Run the hygiene checks over decoupled `Facts` (easy to unit-test without parsing).
use serde::Serialize;

#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum Status {
    Pass,
    Warn,
    Fail,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CheckResult {
    pub id: String,
    pub status: Status,
    pub detail: String,
}

/// Inputs the checks reason over — decoupled from the parsers.
#[derive(Debug, Clone)]
pub struct Facts {
    pub signed: bool,
    pub signature_valid: bool,
    pub digest_algo_oid: String,
    pub has_timestamp: bool,
    pub self_signed: bool,
    pub not_after_unix: i64,
    pub now_unix: i64,
    /// Whether the file's bytes match the digest the signature was made over.
    pub image_hash: crate::signature::ImageHash,
}

const OID_SHA1: &str = "1.3.14.3.2.26";

pub fn run(f: &Facts) -> Vec<CheckResult> {
    let r = |id: &str, s: Status, d: &str| CheckResult {
        id: id.into(),
        status: s,
        detail: d.into(),
    };
    let mut out = Vec::new();

    if !f.signed {
        out.push(r(
            "signature",
            Status::Fail,
            "No Authenticode signature present.",
        ));
        return out; // nothing else is meaningful on an unsigned file
    }
    // The strongest signal is whether the file's bytes still match the digest the
    // signature was made over. A mismatch means the file was altered after signing
    // — the signature is invalid, an outright F. When the hash could not be
    // recomputed (unsupported digest, e.g. SHA-1) the check states that honestly
    // rather than failing a signature that may be perfectly good.
    out.push(match f.image_hash {
        crate::signature::ImageHash::Mismatch => r(
            "signature",
            Status::Fail,
            "Signature is present but does not match the file — the bytes were altered after it was signed.",
        ),
        _ if !f.signature_valid => r(
            "signature",
            Status::Fail,
            "Signature present but the signer certificate could not be resolved from the bundle.",
        ),
        crate::signature::ImageHash::Match => r(
            "signature",
            Status::Pass,
            "Authenticode signature is present and the file matches its signed hash.",
        ),
        crate::signature::ImageHash::Unverified => r(
            "signature",
            Status::Pass,
            "Authenticode signature is present and well-formed; image hash not verified (unsupported digest).",
        ),
    });
    out.push(if f.has_timestamp {
        r("timestamp", Status::Pass, "RFC-3161 timestamp present.")
    } else {
        r(
            "timestamp",
            Status::Fail,
            "No RFC-3161 timestamp; the signature stops validating once the certificate expires.",
        )
    });
    out.push(if f.digest_algo_oid == OID_SHA1 {
        r(
            "digest",
            Status::Fail,
            "SHA-1 digest is deprecated and distrusted; re-sign with SHA-256.",
        )
    } else {
        r("digest", Status::Pass, "Modern digest algorithm.")
    });
    if f.self_signed {
        out.push(r(
            "chain",
            Status::Fail,
            "Certificate is self-signed (no issuing CA); it has no reputation.",
        ));
    }
    // A timestamped signature stays valid past the signing certificate's own
    // expiry — that is the whole point of timestamping, and Authenticode still
    // accepts it with no warning. So certificate expiry (past or upcoming) does
    // not lower the grade when the file is timestamped; the report still states
    // the fact under this check so nobody is surprised later. Grade the
    // signature that was made, not the calendar.
    let days_left = (f.not_after_unix - f.now_unix) / 86_400;
    if days_left < 0 {
        out.push(if f.has_timestamp {
            r(
                "expiry",
                Status::Pass,
                "Signing certificate has expired, but the signature is timestamped, so it stays valid.",
            )
        } else {
            r("expiry", Status::Fail, "Signing certificate has expired.")
        });
    } else if days_left < 30 {
        out.push(if f.has_timestamp {
            r(
                "expiry",
                Status::Pass,
                "Signing certificate expires within 30 days, but the signature is timestamped, so its validity is unaffected.",
            )
        } else {
            r(
                "expiry",
                Status::Warn,
                "Signing certificate expires within 30 days.",
            )
        });
    } else {
        out.push(r(
            "expiry",
            Status::Pass,
            "Certificate validity is healthy.",
        ));
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn base() -> Facts {
        Facts {
            signed: true,
            signature_valid: true,
            digest_algo_oid: "2.16.840.1.101.3.4.2.1".into(),
            has_timestamp: true,
            self_signed: false,
            not_after_unix: 10_000_000_000,
            now_unix: 0,
            image_hash: crate::signature::ImageHash::Match,
        }
    }

    fn sig_check(f: &Facts) -> CheckResult {
        run(f).into_iter().find(|c| c.id == "signature").unwrap()
    }

    #[test]
    fn image_hash_mismatch_fails_the_signature() {
        // Altered bytes: the signature no longer matches the file — an outright F.
        let mut f = base();
        f.image_hash = crate::signature::ImageHash::Mismatch;
        f.signature_valid = false; // lib sets this when the hash mismatches
        let c = sig_check(&f);
        assert_eq!(c.status, Status::Fail);
        assert!(c.detail.contains("altered"), "detail: {}", c.detail);
    }

    #[test]
    fn image_hash_match_passes_and_says_so() {
        let c = sig_check(&base()); // base has Match
        assert_eq!(c.status, Status::Pass);
        assert!(
            c.detail.contains("matches its signed hash"),
            "detail: {}",
            c.detail
        );
    }

    #[test]
    fn image_hash_unverified_passes_with_an_honest_note() {
        // A digest we can't recompute (e.g. SHA-1) is not a mismatch — say so.
        let mut f = base();
        f.image_hash = crate::signature::ImageHash::Unverified;
        let c = sig_check(&f);
        assert_eq!(c.status, Status::Pass);
        assert!(
            c.detail.to_lowercase().contains("not verified"),
            "detail: {}",
            c.detail
        );
    }

    #[test]
    fn unsigned_short_circuits() {
        let mut f = base();
        f.signed = false;
        let res = run(&f);
        assert_eq!(res.len(), 1);
        assert_eq!(res[0].id, "signature");
        assert_eq!(res[0].status, Status::Fail);
    }

    #[test]
    fn missing_timestamp_fails_that_check() {
        let mut f = base();
        f.has_timestamp = false;
        let ts = run(&f).into_iter().find(|c| c.id == "timestamp").unwrap();
        assert_eq!(ts.status, Status::Fail);
    }

    #[test]
    fn sha1_fails_digest() {
        let mut f = base();
        f.digest_algo_oid = "1.3.14.3.2.26".into();
        let d = run(&f).into_iter().find(|c| c.id == "digest").unwrap();
        assert_eq!(d.status, Status::Fail);
    }

    #[test]
    fn expired_cert_with_timestamp_is_not_penalized() {
        // A timestamped signature stays valid past the signing cert's expiry —
        // that is the whole point of timestamping. So an expired cert must not
        // fail the expiry check when the file is timestamped; the report still
        // states the fact under the check.
        let mut f = base(); // has_timestamp = true
        f.not_after_unix = 1_000;
        f.now_unix = 1_000 + 100 * 86_400; // expired ~100 days ago
        let e = run(&f).into_iter().find(|c| c.id == "expiry").unwrap();
        assert_eq!(e.status, Status::Pass, "detail: {}", e.detail);
        assert!(
            e.detail.contains("expired"),
            "must still state the fact: {}",
            e.detail
        );
        assert!(
            e.detail.to_lowercase().contains("timestamp"),
            "must explain why it does not count: {}",
            e.detail
        );
    }

    #[test]
    fn expired_cert_without_timestamp_still_fails() {
        let mut f = base();
        f.has_timestamp = false;
        f.not_after_unix = 1_000;
        f.now_unix = 1_000 + 100 * 86_400; // expired ~100 days ago
        let e = run(&f).into_iter().find(|c| c.id == "expiry").unwrap();
        assert_eq!(e.status, Status::Fail);
    }

    #[test]
    fn timestamped_but_expired_still_grades_a() {
        // The Reddit thread's argument, end to end: an otherwise-clean file whose
        // signing cert has expired but which carries a valid timestamp keeps its
        // full score, because the signature is still valid.
        let mut f = base();
        f.not_after_unix = 1_000;
        f.now_unix = 1_000 + 100 * 86_400; // expired ~100 days ago
        let results = run(&f);
        assert_eq!(
            crate::score::score(&results),
            100,
            "expiry must cost nothing when the signature is timestamped"
        );
    }

    #[test]
    fn self_signed_adds_chain_fail() {
        let mut f = base();
        f.self_signed = true;
        assert!(run(&f)
            .iter()
            .any(|c| c.id == "chain" && c.status == Status::Fail));
    }
}