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