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)] = &[
50 ("private key block", "-----BEGIN RSA PRIVATE KEY-----"),
51 ("cloud access key", concat!("AK", "IAIOSFODNN7EXAMPLE")),
52 (
53 "credential assignment",
54 concat!("api_key = \"sk", "-supersecretvalue1234\""),
55 ),
56 (
57 "bearer token",
58 "Authorization: Bearer abcdefghij0123456789xyz",
59 ),
60];
61
62pub(crate) const DOMAIN_FIXTURES: &[(&str, &str)] = &[
65 ("IBAN", "DE89 3704 0044 0532 0130 00"),
66 ("payment card", "4111 1111 1111 1111"),
67 ("US SSN", "SSN: 123-45-6789"),
68 ("date of birth", "DOB: 03/14/1975"),
69];
70
71pub(crate) const EGRESS_CAPABLE_TOOLS: &[&str] = &["ctx_url_read", "ctx_provider", "ctx_tools"];
77
78pub(crate) fn egress_tools_permitted(policy: &ResolvedPolicy) -> Vec<&'static str> {
81 EGRESS_CAPABLE_TOOLS
82 .iter()
83 .copied()
84 .filter(|tool| {
85 !policy.deny_tools.iter().any(|t| t == tool)
86 && policy
87 .allow_tools
88 .as_ref()
89 .is_none_or(|allow| allow.iter().any(|t| t == tool))
90 })
91 .collect()
92}
93
94pub fn assess(policy: &ResolvedPolicy) -> Vec<CoverageCheck> {
96 let patterns: Vec<(String, Regex)> = policy
97 .redaction
98 .iter()
99 .filter_map(|(name, raw)| Regex::new(raw).ok().map(|re| (name.clone(), re)))
100 .collect();
101
102 let matches = |fixture: &str| patterns.iter().any(|(_, re)| re.is_match(fixture));
103
104 let mut checks = Vec::new();
105
106 let missing: Vec<&str> = CREDENTIAL_FIXTURES
108 .iter()
109 .filter(|(_, fixture)| !matches(fixture))
110 .map(|(class, _)| *class)
111 .collect();
112 checks.push(if missing.is_empty() {
113 CoverageCheck {
114 control: "CGB-1.1",
115 title: "credential redaction",
116 status: CheckStatus::Pass,
117 detail: format!(
118 "{}/{} credential fixture classes matched by redaction patterns",
119 CREDENTIAL_FIXTURES.len(),
120 CREDENTIAL_FIXTURES.len()
121 ),
122 }
123 } else {
124 CoverageCheck {
125 control: "CGB-1.1",
126 title: "credential redaction",
127 status: CheckStatus::Fail,
128 detail: format!("unredacted credential classes: {}", missing.join(", ")),
129 }
130 });
131
132 checks.push(if policy.redaction.is_empty() {
134 CoverageCheck {
135 control: "CGB-1.2",
136 title: "declarative redaction rules",
137 status: CheckStatus::Fail,
138 detail: "pack declares no named redaction patterns".to_string(),
139 }
140 } else {
141 CoverageCheck {
142 control: "CGB-1.2",
143 title: "declarative redaction rules",
144 status: CheckStatus::Pass,
145 detail: format!(
146 "{} named, versioned patterns (chain: {})",
147 policy.redaction.len(),
148 if policy.chain.is_empty() {
149 "root pack".to_string()
150 } else {
151 policy.chain.join(" → ")
152 }
153 ),
154 }
155 });
156
157 let domain_hits: Vec<&str> = DOMAIN_FIXTURES
159 .iter()
160 .filter(|(_, fixture)| matches(fixture))
161 .map(|(class, _)| *class)
162 .collect();
163 checks.push(if domain_hits.is_empty() {
164 CoverageCheck {
165 control: "CGB-1.3",
166 title: "beyond-secret classification",
167 status: CheckStatus::Inconclusive,
168 detail:
169 "no regulated-identifier patterns declared — acceptable outside regulated workloads"
170 .to_string(),
171 }
172 } else {
173 CoverageCheck {
174 control: "CGB-1.3",
175 title: "beyond-secret classification",
176 status: CheckStatus::Pass,
177 detail: format!("regulated classes redacted: {}", domain_hits.join(", ")),
178 }
179 });
180
181 checks.push(match policy.max_context_tokens {
183 Some(cap) => CoverageCheck {
184 control: "CGB-3.2",
185 title: "context budget cap",
186 status: CheckStatus::Pass,
187 detail: format!("max_context_tokens = {cap}"),
188 },
189 None => CoverageCheck {
190 control: "CGB-3.2",
191 title: "context budget cap",
192 status: CheckStatus::Inconclusive,
193 detail: "no cap in pack — verify budget enforcement elsewhere".to_string(),
194 },
195 });
196
197 checks.push(match policy.audit_retention_days {
199 Some(days) => CoverageCheck {
200 control: "CGB-4.3",
201 title: "audit retention declared",
202 status: CheckStatus::Pass,
203 detail: format!("audit_retention_days = {days}"),
204 },
205 None => CoverageCheck {
206 control: "CGB-4.3",
207 title: "audit retention declared",
208 status: CheckStatus::Inconclusive,
209 detail: "no retention expectation in pack".to_string(),
210 },
211 });
212
213 let denies = policy.deny_tools.len();
215 checks.push(match (&policy.allow_tools, denies) {
216 (Some(allow), _) => CoverageCheck {
217 control: "CGB-5.4",
218 title: "tool surface scoped",
219 status: CheckStatus::Pass,
220 detail: format!(
221 "allowlist posture: {} tools permitted, rest denied",
222 allow.len()
223 ),
224 },
225 (None, d) if d > 0 => CoverageCheck {
226 control: "CGB-5.4",
227 title: "tool surface scoped",
228 status: CheckStatus::Pass,
229 detail: format!("denylist posture: {d} denied tool(s)"),
230 },
231 _ => CoverageCheck {
232 control: "CGB-5.4",
233 title: "tool surface scoped",
234 status: CheckStatus::Inconclusive,
235 detail: "pack neither allows nor denies tools — instance defaults apply".to_string(),
236 },
237 });
238
239 let still_open = egress_tools_permitted(policy);
243 checks.push(if still_open.is_empty() {
244 CoverageCheck {
245 control: "CGB-5.5",
246 title: "egress restricted",
247 status: CheckStatus::Pass,
248 detail: format!(
249 "all egress-capable tools blocked: {}",
250 EGRESS_CAPABLE_TOOLS.join(", ")
251 ),
252 }
253 } else if still_open.len() < EGRESS_CAPABLE_TOOLS.len() {
254 CoverageCheck {
255 control: "CGB-5.5",
256 title: "egress restricted",
257 status: CheckStatus::Fail,
258 detail: format!(
259 "egress only partially restricted — still permitted: {}",
260 still_open.join(", ")
261 ),
262 }
263 } else {
264 CoverageCheck {
265 control: "CGB-5.5",
266 title: "egress restricted",
267 status: CheckStatus::Inconclusive,
268 detail: "pack does not restrict egress tools — verify via roles/network policy"
269 .to_string(),
270 }
271 });
272
273 checks
274}
275
276#[derive(Debug, Serialize)]
278pub struct CoverageSummary {
279 pub pass: usize,
280 pub fail: usize,
281 pub inconclusive: usize,
282 pub controls_covered: usize,
284 pub controls_total: usize,
285}
286
287pub fn summarize(checks: &[CoverageCheck]) -> CoverageSummary {
289 let mut covered: Vec<&str> = checks.iter().map(|c| c.control).collect();
290 covered.dedup();
291 CoverageSummary {
292 pass: checks
293 .iter()
294 .filter(|c| c.status == CheckStatus::Pass)
295 .count(),
296 fail: checks
297 .iter()
298 .filter(|c| c.status == CheckStatus::Fail)
299 .count(),
300 inconclusive: checks
301 .iter()
302 .filter(|c| c.status == CheckStatus::Inconclusive)
303 .count(),
304 controls_covered: covered.len(),
305 controls_total: CONTROLS_TOTAL,
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::super::builtin;
312 use super::*;
313
314 fn resolved(name: &str) -> ResolvedPolicy {
315 let pack = builtin::get(name).expect("built-in exists");
316 super::super::resolve(&pack).expect("resolves")
317 }
318
319 fn regulated() -> ResolvedPolicy {
322 let pack = super::super::parse(
323 r#"
324name = "regulated-test"
325version = "1.0.0"
326description = "test pack with own identifier classes"
327extends = "strict-redaction"
328
329[context]
330deny_tools = ["ctx_url_read", "ctx_provider", "ctx_tools"]
331max_context_tokens = 12000
332audit_retention_days = 365
333
334[redaction]
335iban = '\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b'
336us_ssn = '\b\d{3}-\d{2}-\d{4}\b'
337"#,
338 )
339 .expect("parses");
340 super::super::resolve(&pack).expect("resolves")
341 }
342
343 fn status_of(checks: &[CoverageCheck], control: &str) -> CheckStatus {
344 checks
345 .iter()
346 .find(|c| c.control == control)
347 .expect("control checked")
348 .status
349 }
350
351 #[test]
352 fn baseline_passes_credential_redaction() {
353 let checks = assess(&resolved("baseline"));
354 assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Pass);
355 assert_eq!(status_of(&checks, "CGB-1.2"), CheckStatus::Pass);
356 assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Inconclusive);
358 assert_eq!(status_of(&checks, "CGB-5.4"), CheckStatus::Inconclusive);
359 }
360
361 #[test]
362 fn regulated_pack_demonstrates_domain_classes_and_egress_denial() {
363 let checks = assess(®ulated());
364 assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Pass);
365 assert_eq!(status_of(&checks, "CGB-1.3"), CheckStatus::Pass);
366 assert_eq!(status_of(&checks, "CGB-3.2"), CheckStatus::Pass);
367 assert_eq!(status_of(&checks, "CGB-4.3"), CheckStatus::Pass);
368 assert_eq!(status_of(&checks, "CGB-5.5"), CheckStatus::Pass);
369 }
370
371 #[test]
374 fn partial_egress_denial_fails_and_names_open_tools() {
375 let mut policy = regulated();
376 assert_eq!(status_of(&assess(&policy), "CGB-5.5"), CheckStatus::Pass);
377 policy.deny_tools = vec!["ctx_url_read".into()];
378 let checks = assess(&policy);
379 let check = checks.iter().find(|c| c.control == "CGB-5.5").unwrap();
380 assert_eq!(check.status, CheckStatus::Fail);
381 assert!(check.detail.contains("ctx_provider"), "{}", check.detail);
382 assert!(check.detail.contains("ctx_tools"), "{}", check.detail);
383 policy.deny_tools.clear();
384 policy.allow_tools = Some(vec!["ctx_read".into()]);
385 assert_eq!(status_of(&assess(&policy), "CGB-5.5"), CheckStatus::Pass);
386 }
387
388 #[test]
389 fn empty_policy_fails_credential_checks() {
390 let empty = ResolvedPolicy {
391 name: "empty".into(),
392 version: "0.0.1".into(),
393 description: String::new(),
394 chain: vec![],
395 default_read_mode: None,
396 allow_tools: None,
397 deny_tools: vec![],
398 max_context_tokens: None,
399 audit_retention_days: None,
400 redaction: std::collections::BTreeMap::new(),
401 filters: crate::core::policy::FilterRules::default(),
402 egress: crate::core::policy::EgressRules::default(),
403 routing: crate::core::policy::RoutingPolicyRules::default(),
404 budgets: crate::core::policy::BudgetRules::default(),
405 };
406 let checks = assess(&empty);
407 assert_eq!(status_of(&checks, "CGB-1.1"), CheckStatus::Fail);
408 assert_eq!(status_of(&checks, "CGB-1.2"), CheckStatus::Fail);
409 }
410
411 #[test]
412 fn summary_counts_are_consistent() {
413 let checks = assess(®ulated());
414 let s = summarize(&checks);
415 assert_eq!(s.pass + s.fail + s.inconclusive, checks.len());
416 assert_eq!(s.controls_total, CONTROLS_TOTAL);
417 assert!(s.controls_covered <= checks.len());
418 }
419}