1use regex::Regex;
13use serde::Serialize;
14
15use super::ResolvedPolicy;
16
17pub const BENCHMARK_ID: &str = "cgb-v1.0-draft";
19pub const CONTROLS_TOTAL: usize = 32;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24#[serde(rename_all = "lowercase")]
25pub enum CheckStatus {
26 Pass,
28 Fail,
30 Inconclusive,
32}
33
34#[derive(Debug, Clone, Serialize)]
36pub struct CoverageCheck {
37 pub control: &'static str,
39 pub title: &'static str,
41 pub status: CheckStatus,
42 pub detail: String,
44}
45
46pub(crate) const CREDENTIAL_FIXTURES: &[(&str, &str)] = &[
51 ("private key block", "-----BEGIN RSA PRIVATE KEY-----"),
52 ("cloud access key", "AKIAIOSFODNN7EXAMPLE"),
53 (
54 "credential assignment",
55 "api_key = \"sk-supersecretvalue1234\"",
56 ),
57 (
58 "bearer token",
59 "Authorization: Bearer abcdefghij0123456789xyz",
60 ),
61];
62
63pub(crate) const DOMAIN_FIXTURES: &[(&str, &str)] = &[
67 ("IBAN", "DE89 3704 0044 0532 0130 00"),
68 ("payment card", "4111 1111 1111 1111"),
69 ("US SSN", "SSN: 123-45-6789"),
70 ("date of birth", "DOB: 03/14/1975"),
71];
72
73const EGRESS_TOOL_HINTS: &[&str] = &["url", "web", "fetch", "http"];
76
77pub fn assess(policy: &ResolvedPolicy) -> Vec<CoverageCheck> {
79 let patterns: Vec<(String, Regex)> = policy
80 .redaction
81 .iter()
82 .filter_map(|(name, raw)| Regex::new(raw).ok().map(|re| (name.clone(), re)))
83 .collect();
84
85 let matches = |fixture: &str| patterns.iter().any(|(_, re)| re.is_match(fixture));
86
87 let mut checks = Vec::new();
88
89 let missing: Vec<&str> = CREDENTIAL_FIXTURES
91 .iter()
92 .filter(|(_, fixture)| !matches(fixture))
93 .map(|(class, _)| *class)
94 .collect();
95 checks.push(if missing.is_empty() {
96 CoverageCheck {
97 control: "CGB-1.1",
98 title: "credential redaction",
99 status: CheckStatus::Pass,
100 detail: format!(
101 "{}/{} credential fixture classes matched by redaction patterns",
102 CREDENTIAL_FIXTURES.len(),
103 CREDENTIAL_FIXTURES.len()
104 ),
105 }
106 } else {
107 CoverageCheck {
108 control: "CGB-1.1",
109 title: "credential redaction",
110 status: CheckStatus::Fail,
111 detail: format!("unredacted credential classes: {}", missing.join(", ")),
112 }
113 });
114
115 checks.push(if policy.redaction.is_empty() {
117 CoverageCheck {
118 control: "CGB-1.2",
119 title: "declarative redaction rules",
120 status: CheckStatus::Fail,
121 detail: "pack declares no named redaction patterns".to_string(),
122 }
123 } else {
124 CoverageCheck {
125 control: "CGB-1.2",
126 title: "declarative redaction rules",
127 status: CheckStatus::Pass,
128 detail: format!(
129 "{} named, versioned patterns (chain: {})",
130 policy.redaction.len(),
131 if policy.chain.is_empty() {
132 "root pack".to_string()
133 } else {
134 policy.chain.join(" → ")
135 }
136 ),
137 }
138 });
139
140 let domain_hits: Vec<&str> = DOMAIN_FIXTURES
142 .iter()
143 .filter(|(_, fixture)| matches(fixture))
144 .map(|(class, _)| *class)
145 .collect();
146 checks.push(if domain_hits.is_empty() {
147 CoverageCheck {
148 control: "CGB-1.3",
149 title: "beyond-secret classification",
150 status: CheckStatus::Inconclusive,
151 detail:
152 "no regulated-identifier patterns declared — acceptable outside regulated workloads"
153 .to_string(),
154 }
155 } else {
156 CoverageCheck {
157 control: "CGB-1.3",
158 title: "beyond-secret classification",
159 status: CheckStatus::Pass,
160 detail: format!("regulated classes redacted: {}", domain_hits.join(", ")),
161 }
162 });
163
164 checks.push(match policy.max_context_tokens {
166 Some(cap) => CoverageCheck {
167 control: "CGB-3.2",
168 title: "context budget cap",
169 status: CheckStatus::Pass,
170 detail: format!("max_context_tokens = {cap}"),
171 },
172 None => CoverageCheck {
173 control: "CGB-3.2",
174 title: "context budget cap",
175 status: CheckStatus::Inconclusive,
176 detail: "no cap in pack — verify budget enforcement elsewhere".to_string(),
177 },
178 });
179
180 checks.push(match policy.audit_retention_days {
182 Some(days) => CoverageCheck {
183 control: "CGB-4.3",
184 title: "audit retention declared",
185 status: CheckStatus::Pass,
186 detail: format!("audit_retention_days = {days}"),
187 },
188 None => CoverageCheck {
189 control: "CGB-4.3",
190 title: "audit retention declared",
191 status: CheckStatus::Inconclusive,
192 detail: "no retention expectation in pack".to_string(),
193 },
194 });
195
196 let denies = policy.deny_tools.len();
198 checks.push(match (&policy.allow_tools, denies) {
199 (Some(allow), _) => CoverageCheck {
200 control: "CGB-5.4",
201 title: "tool surface scoped",
202 status: CheckStatus::Pass,
203 detail: format!(
204 "allowlist posture: {} tools permitted, rest denied",
205 allow.len()
206 ),
207 },
208 (None, d) if d > 0 => CoverageCheck {
209 control: "CGB-5.4",
210 title: "tool surface scoped",
211 status: CheckStatus::Pass,
212 detail: format!("denylist posture: {d} denied tool(s)"),
213 },
214 _ => CoverageCheck {
215 control: "CGB-5.4",
216 title: "tool surface scoped",
217 status: CheckStatus::Inconclusive,
218 detail: "pack neither allows nor denies tools — engine defaults apply".to_string(),
219 },
220 });
221
222 let egress_denied: Vec<&str> = policy
224 .deny_tools
225 .iter()
226 .filter(|t| {
227 let t = t.to_lowercase();
228 EGRESS_TOOL_HINTS.iter().any(|h| t.contains(h))
229 })
230 .map(String::as_str)
231 .collect();
232 let egress_allowed = policy.allow_tools.as_ref().map(|allow| {
233 allow
234 .iter()
235 .filter(|t| {
236 let t = t.to_lowercase();
237 EGRESS_TOOL_HINTS.iter().any(|h| t.contains(h))
238 })
239 .count()
240 });
241 checks.push(if !egress_denied.is_empty() {
242 CoverageCheck {
243 control: "CGB-5.5",
244 title: "egress restricted",
245 status: CheckStatus::Pass,
246 detail: format!("egress tools denied: {}", egress_denied.join(", ")),
247 }
248 } else if egress_allowed == Some(0) {
249 CoverageCheck {
250 control: "CGB-5.5",
251 title: "egress restricted",
252 status: CheckStatus::Pass,
253 detail: "allowlist contains no egress-capable tools".to_string(),
254 }
255 } else {
256 CoverageCheck {
257 control: "CGB-5.5",
258 title: "egress restricted",
259 status: CheckStatus::Inconclusive,
260 detail: "pack does not restrict egress tools — verify via roles/network policy"
261 .to_string(),
262 }
263 });
264
265 checks
266}
267
268#[derive(Debug, Serialize)]
270pub struct CoverageSummary {
271 pub pass: usize,
272 pub fail: usize,
273 pub inconclusive: usize,
274 pub controls_covered: usize,
276 pub controls_total: usize,
277}
278
279pub fn summarize(checks: &[CoverageCheck]) -> CoverageSummary {
281 let mut covered: Vec<&str> = checks.iter().map(|c| c.control).collect();
282 covered.dedup();
283 CoverageSummary {
284 pass: checks
285 .iter()
286 .filter(|c| c.status == CheckStatus::Pass)
287 .count(),
288 fail: checks
289 .iter()
290 .filter(|c| c.status == CheckStatus::Fail)
291 .count(),
292 inconclusive: checks
293 .iter()
294 .filter(|c| c.status == CheckStatus::Inconclusive)
295 .count(),
296 controls_covered: covered.len(),
297 controls_total: CONTROLS_TOTAL,
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::super::builtin;
304 use super::*;
305
306 fn resolved(name: &str) -> ResolvedPolicy {
307 let pack = builtin::get(name).expect("built-in exists");
308 super::super::resolve(&pack).expect("resolves")
309 }
310
311 fn status_of(checks: &[CoverageCheck], control: &str) -> CheckStatus {
312 checks
313 .iter()
314 .find(|c| c.control == control)
315 .expect("control checked")
316 .status
317 }
318
319 #[test]
320 fn baseline_passes_credential_redaction() {
321 let checks = assess(&resolved("baseline"));
322 assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Pass);
323 assert_eq!(status_of(&checks, "CGB-1.2"), CheckStatus::Pass);
324 assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Inconclusive);
326 assert_eq!(status_of(&checks, "CGB-5.4"), CheckStatus::Inconclusive);
327 }
328
329 #[test]
330 fn finance_eu_demonstrates_domain_classes_and_egress_denial() {
331 let checks = assess(&resolved("finance-eu"));
332 assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Pass);
333 assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Pass);
334 assert_eq!(status_of(&checks, "CGB-3.2"), CheckStatus::Pass);
335 assert_eq!(status_of(&checks, "CGB-4.3"), CheckStatus::Pass);
336 assert_eq!(status_of(&checks, "CGB-5.5"), CheckStatus::Pass);
337 }
338
339 #[test]
340 fn healthcare_demonstrates_phi_classes() {
341 let checks = assess(&resolved("healthcare"));
342 assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Pass);
343 }
344
345 #[test]
346 fn empty_policy_fails_credential_checks() {
347 let empty = ResolvedPolicy {
348 name: "empty".into(),
349 version: "0.0.1".into(),
350 description: String::new(),
351 chain: vec![],
352 default_read_mode: None,
353 allow_tools: None,
354 deny_tools: vec![],
355 max_context_tokens: None,
356 audit_retention_days: None,
357 redaction: std::collections::BTreeMap::new(),
358 filters: crate::core::policy::FilterRules::default(),
359 egress: crate::core::policy::EgressRules::default(),
360 };
361 let checks = assess(&empty);
362 assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Fail);
363 assert_eq!(status_of(&checks, "CGB-1.2"), CheckStatus::Fail);
364 }
365
366 #[test]
367 fn summary_counts_are_consistent() {
368 let checks = assess(&resolved("finance-eu"));
369 let s = summarize(&checks);
370 assert_eq!(s.pass + s.fail + s.inconclusive, checks.len());
371 assert_eq!(s.controls_total, CONTROLS_TOTAL);
372 assert!(s.controls_covered <= checks.len());
373 }
374}