Skip to main content

vanta_security/
lib.rs

1//! `vanta-security` — verification and policy (the fail-closed gate).
2//!
3//! Provides the checksum gate (SHA-256 / BLAKE3), Ed25519/minisign signature
4//! verification (see [`sign`]), and the organization policy model. An artifact
5//! that fails any required check is rejected rather than trusted. See
6//! `docs/15-security.md` and `docs/21-threat-model.md`.
7#![forbid(unsafe_code)]
8
9pub mod sign;
10pub mod trust;
11pub use sign::{minisign_verify, parse_minisign_pubkey, MinisignKey};
12
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use std::fs::File;
16use std::io::Read;
17use std::path::Path;
18use vanta_core::{Area, VtaError, VtaResult};
19
20/// Stream a file through SHA-256, returning lowercase hex.
21pub fn sha256_file(path: &Path) -> VtaResult<String> {
22    let mut hasher = Sha256::new();
23    hash_into(path, &mut |chunk| hasher.update(chunk))?;
24    Ok(hasher
25        .finalize()
26        .iter()
27        .map(|b| format!("{b:02x}"))
28        .collect())
29}
30
31/// Stream a file through BLAKE3, returning lowercase hex.
32pub fn blake3_file(path: &Path) -> VtaResult<String> {
33    let mut hasher = blake3::Hasher::new();
34    hash_into(path, &mut |chunk| {
35        hasher.update(chunk);
36    })?;
37    Ok(hasher.finalize().to_hex().to_string())
38}
39
40fn hash_into(path: &Path, sink: &mut dyn FnMut(&[u8])) -> VtaResult<()> {
41    let mut file = File::open(path)
42        .map_err(|e| VtaError::new(Area::Vrf, 1, format!("opening {}: {e}", path.display())))?;
43    let mut buf = [0u8; 65536];
44    loop {
45        let n = file
46            .read(&mut buf)
47            .map_err(|e| VtaError::new(Area::Vrf, 1, format!("reading {}: {e}", path.display())))?;
48        if n == 0 {
49            break;
50        }
51        sink(&buf[..n]);
52    }
53    Ok(())
54}
55
56/// Verify a file against an expected checksum, fail-closed. Unknown algorithms
57/// are rejected (never silently passed). `VTA-VRF-0001` on mismatch.
58pub fn verify_file(path: &Path, algo: &str, expected: &str) -> VtaResult<()> {
59    let got = match algo.to_ascii_lowercase().as_str() {
60        "sha256" => sha256_file(path)?,
61        "blake3" => blake3_file(path)?,
62        other => {
63            return Err(VtaError::new(
64                Area::Vrf,
65                2,
66                format!("unsupported checksum algorithm `{other}`"),
67            ))
68        }
69    };
70    if got.eq_ignore_ascii_case(expected) {
71        Ok(())
72    } else {
73        Err(VtaError::new(
74            Area::Vrf,
75            1,
76            format!("checksum mismatch ({algo}): expected {expected}, got {got}"),
77        ))
78    }
79}
80
81/// Org policy governing what may be installed (`docs/14-enterprise.md`).
82#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
83#[serde(default, deny_unknown_fields)]
84pub struct Policy {
85    pub require_signature: bool,
86    pub forbid_no_verify: bool,
87    pub allow_source_builds: Option<bool>,
88    pub min_slsa_level: Option<u8>,
89    /// Allowed tool name patterns (`*` suffix wildcard). Empty = allow all.
90    pub allow_tools: Vec<String>,
91    pub deny_tools: Vec<String>,
92    pub allow_licenses: Vec<String>,
93}
94
95impl Policy {
96    pub fn from_toml(src: &str) -> VtaResult<Policy> {
97        toml::from_str(src).map_err(|e| VtaError::new(Area::Vrf, 3, format!("parse policy: {e}")))
98    }
99
100    /// Whether a tool name is permitted (deny wins; empty allow = allow all).
101    pub fn allows_tool(&self, tool: &str) -> bool {
102        if self.deny_tools.iter().any(|p| matches_pattern(p, tool)) {
103            return false;
104        }
105        self.allow_tools.is_empty() || self.allow_tools.iter().any(|p| matches_pattern(p, tool))
106    }
107
108    /// Enforce the tool/license rules, returning a policy-denied error if violated.
109    pub fn check(&self, tool: &str, license: Option<&str>) -> VtaResult<()> {
110        if !self.allows_tool(tool) {
111            return Err(VtaError::new(
112                Area::Res,
113                6,
114                format!("tool `{tool}` is denied by org policy"),
115            ));
116        }
117        if let (false, Some(lic)) = (self.allow_licenses.is_empty(), license) {
118            if !self
119                .allow_licenses
120                .iter()
121                .any(|l| l.eq_ignore_ascii_case(lic))
122            {
123                return Err(VtaError::new(
124                    Area::Res,
125                    6,
126                    format!("license `{lic}` for `{tool}` is not in the policy allow-list"),
127                ));
128            }
129        }
130        Ok(())
131    }
132}
133
134fn matches_pattern(pattern: &str, name: &str) -> bool {
135    match pattern.strip_suffix('*') {
136        Some(prefix) => name.starts_with(prefix),
137        None => pattern == name,
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn verify_sha256_roundtrip() {
147        let path = std::env::temp_dir().join(format!("vanta-sec-{}.bin", std::process::id()));
148        std::fs::write(&path, b"hello world").unwrap();
149        let digest = sha256_file(&path).unwrap();
150        assert!(verify_file(&path, "sha256", &digest).is_ok());
151        assert!(verify_file(&path, "sha256", "deadbeef").is_err());
152        assert!(verify_file(&path, "md5", &digest).is_err()); // unsupported = fail closed
153        let _ = std::fs::remove_file(&path);
154    }
155
156    #[test]
157    fn policy_tool_rules() {
158        let p = Policy {
159            allow_tools: vec!["node".into(), "acme/*".into()],
160            deny_tools: vec!["leftpad".into()],
161            ..Default::default()
162        };
163        assert!(p.allows_tool("node"));
164        assert!(p.allows_tool("acme/deploy"));
165        assert!(!p.allows_tool("python")); // not in allow-list
166        assert!(!p.allows_tool("leftpad")); // denied
167        assert!(p.check("node", None).is_ok());
168        assert!(p.check("python", None).is_err());
169    }
170
171    #[test]
172    fn policy_license_allowlist() {
173        let p = Policy {
174            allow_licenses: vec!["MIT".into(), "Apache-2.0".into()],
175            ..Default::default()
176        };
177        assert!(p.check("node", Some("MIT")).is_ok());
178        assert!(p.check("node", Some("GPL-3.0")).is_err());
179    }
180}