1use std::borrow::Cow;
6use std::collections::BTreeMap;
7
8use crate::audit::record::{CapDecisionRecord, CredentialIssueRecord, Decision4};
9
10const PREFIX: &str = "audit: ";
11
12#[derive(Debug, Clone)]
15pub struct SpanFields {
16 pub component_ref: String,
17 pub digest: String,
18 pub tool: String,
19 pub args_sha256: String,
20 pub args_json: Option<String>,
24 pub session_id: Option<String>,
25 pub transport: String,
26 pub outcome: String,
30 pub duration_ms: u64,
31 pub request_id: String,
35}
36
37impl Default for SpanFields {
38 fn default() -> Self {
39 Self {
40 component_ref: String::new(),
41 digest: String::new(),
42 tool: String::new(),
43 args_sha256: String::new(),
44 args_json: None,
45 session_id: None,
46 transport: String::new(),
47 outcome: "incomplete".to_string(),
48 duration_ms: 0,
49 request_id: String::new(),
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
56pub struct Rollup {
57 counts: BTreeMap<(String, String, String), u64>,
58 cap: usize,
59 overflow: u64,
60}
61
62impl Rollup {
63 pub fn new(cap: usize) -> Self {
64 Self {
65 counts: BTreeMap::new(),
66 cap,
67 overflow: 0,
68 }
69 }
70
71 pub fn add(&mut self, cap_id: &str, action: &str, rule: Option<&str>) {
75 let key = (
76 cap_id.to_string(),
77 action.to_string(),
78 rule.unwrap_or("").to_string(),
79 );
80 if let Some(n) = self.counts.get_mut(&key) {
81 *n += 1;
82 return;
83 }
84 if self.counts.len() >= self.cap {
85 self.overflow += 1;
86 return;
87 }
88 self.counts.insert(key, 1);
89 }
90
91 #[allow(dead_code)]
94 pub fn groups(&self) -> usize {
95 self.counts.len()
96 }
97
98 #[allow(dead_code)]
99 pub fn overflow(&self) -> u64 {
100 self.overflow
101 }
102}
103
104fn take_bytes(s: &str, n: usize) -> &str {
106 let mut e = s.len().min(n);
107 while e > 0 && !s.is_char_boundary(e) {
108 e -= 1;
109 }
110 &s[..e]
111}
112
113pub(crate) fn needs_escape(c: char) -> bool {
124 c.is_control()
125 || matches!(
126 c,
127 '\u{200e}'
128 | '\u{200f}'
129 | '\u{202a}'..='\u{202e}'
130 | '\u{2066}'..='\u{2069}'
131 | '\u{2028}'
132 | '\u{2029}'
133 )
134}
135
136pub(crate) fn escape_audit_field(s: &str) -> Cow<'_, str> {
149 if !s.chars().any(needs_escape) {
150 return Cow::Borrowed(s);
151 }
152 let mut out = String::new();
153 for c in s.chars() {
154 if needs_escape(c) {
155 match c {
156 '\n' => out.push_str("\\n"),
157 '\r' => out.push_str("\\r"),
158 '\t' => out.push_str("\\t"),
159 _ => out.push_str(&format!("\\u{{{:04x}}}", c as u32)),
160 }
161 } else {
162 out.push(c);
163 }
164 }
165 Cow::Owned(out)
166}
167
168fn short_digest(digest: &str) -> String {
169 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
170 format!("sha256:{}", take_bytes(hex, 6))
171}
172
173fn humanise_ms(ms: u64) -> String {
174 if ms < 1000 {
175 format!("{ms}ms")
176 } else {
177 format!("{:.1}s", ms as f64 / 1000.0)
178 }
179}
180
181pub fn render_header(component_ref: &str, digest: &str, modes: &[(String, String)]) -> String {
183 let component_ref_escaped = escape_audit_field(component_ref);
184 let modes: Vec<String> = modes
185 .iter()
186 .map(|(id, mode)| {
187 let id_escaped = escape_audit_field(id);
188 let mode_escaped = escape_audit_field(mode);
189 format!("{id_escaped}={mode_escaped}")
190 })
191 .collect();
192 format!(
193 "{PREFIX}{} {} \u{2502} {}",
194 component_ref_escaped,
195 short_digest(digest),
196 modes.join(" ")
197 )
198}
199
200pub fn render_declared_ungranted_warning(ids: &[String]) -> String {
207 let escaped: Vec<String> = ids
208 .iter()
209 .map(|id| escape_audit_field(id).to_string())
210 .collect();
211 format!(
212 "{PREFIX}\u{26a0} declared but not granted: {}",
213 escaped.join(", ")
214 )
215}
216
217pub fn render_declared_ask_blocked_warning(ids: &[String]) -> String {
224 let escaped: Vec<String> = ids
225 .iter()
226 .map(|id| escape_audit_field(id).to_string())
227 .collect();
228 format!(
229 "{PREFIX}\u{26a0} declared ask, no prompt channel — every access will be denied: {}",
230 escaped.join(", ")
231 )
232}
233
234pub fn render_credential_issue(r: &CredentialIssueRecord) -> String {
242 format!(
243 "{PREFIX}\u{1f511} credential {} kind={} {} session={}",
244 escape_audit_field(&r.key),
245 escape_audit_field(&r.kind),
246 escape_audit_field(&r.component_ref),
247 escape_audit_field(&r.session_id),
248 )
249}
250
251pub fn render_exception(r: &CapDecisionRecord) -> String {
266 let marker = match r.decision {
267 Decision4::Deny => "\u{2717}",
268 Decision4::Allow => "\u{2713}",
269 Decision4::AskAllow | Decision4::AskDeny => "?",
270 };
271 let action_escaped = escape_audit_field(&r.action);
272 let key_escaped = escape_audit_field(&r.key);
273 let subject = if r.action.is_empty() {
274 key_escaped.to_string()
275 } else {
276 format!("{action_escaped} {key_escaped}")
277 };
278 let cap_id_escaped = escape_audit_field(&r.cap_id);
279 let reason = r
280 .reason
281 .as_deref()
282 .map(|s| {
283 let escaped = escape_audit_field(s);
284 format!(" {escaped}")
285 })
286 .unwrap_or_default();
287 let mode_escaped = escape_audit_field(&r.mode);
288 let rule_clause = r
292 .rule
293 .as_deref()
294 .map(|s| format!(" under {}", escape_audit_field(s)))
295 .unwrap_or_default();
296 format!(
297 "{PREFIX}{marker} {} {} {}{} mode:{}{}",
298 r.decision, cap_id_escaped, subject, reason, mode_escaped, rule_clause
299 )
300}
301
302pub fn render_rollup(span: &SpanFields, roll: &Rollup) -> String {
304 let tool_escaped = escape_audit_field(&span.tool);
305 let req_escaped = escape_audit_field(take_bytes(&span.request_id, 6));
309 let args_display: Cow<'_, str> = match &span.args_json {
314 Some(json) => escape_audit_field(json),
315 None => Cow::Borrowed(take_bytes(&span.args_sha256, 6)),
316 };
317 let mut line = format!(
318 "{PREFIX}\u{25cf} {} {} {} args:{} req:{}",
319 tool_escaped,
320 span.outcome,
321 humanise_ms(span.duration_ms),
322 args_display,
323 req_escaped,
324 );
325 if let Some(sid) = &span.session_id {
326 let sid_trunc = take_bytes(sid, 8);
327 let sid_escaped = escape_audit_field(sid_trunc);
328 line.push_str(&format!(" session:{sid_escaped}"));
329 }
330
331 let mut by_cap: BTreeMap<&str, Vec<(&str, &str, u64)>> = BTreeMap::new();
333 for ((cap_id, action, rule), n) in &roll.counts {
334 by_cap
335 .entry(cap_id.as_str())
336 .or_default()
337 .push((action.as_str(), rule.as_str(), *n));
338 }
339 for (cap_id, entries) in by_cap {
340 let short = cap_id.strip_prefix("wasi:").unwrap_or(cap_id);
341 let short_escaped = escape_audit_field(short);
342 let ops: Vec<String> = entries
343 .iter()
344 .map(|(action, _, n)| {
345 let action_escaped = escape_audit_field(action);
346 if action.is_empty() {
347 format!("{n}")
348 } else {
349 format!("{n} {action_escaped}")
350 }
351 })
352 .collect();
353 let mut rules: Vec<&str> = entries
354 .iter()
355 .map(|(_, rule, _)| *rule)
356 .filter(|r| !r.is_empty())
357 .collect();
358 rules.sort_unstable();
359 rules.dedup();
360 let scope = if rules.is_empty() {
361 String::new()
362 } else {
363 let rules_escaped: Vec<String> = rules
364 .iter()
365 .map(|r| escape_audit_field(r).to_string())
366 .collect();
367 format!(" under {}", rules_escaped.join(", "))
368 };
369 line.push_str(&format!(" {short_escaped}: {}{scope}", ops.join(" ")));
370 }
371 if roll.overflow > 0 {
372 line.push_str(&format!(" and {} more", roll.overflow));
373 }
374 line
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use crate::audit::record::*;
381
382 fn span_fields() -> SpanFields {
383 SpanFields {
384 component_ref: "python-eval@0.16.0".into(),
385 digest: "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c".into(),
386 tool: "run_python".into(),
387 args_sha256: "9e21c4aa00000000".into(),
388 args_json: None,
389 session_id: None,
390 transport: "cli".into(),
391 outcome: "ok".into(),
392 duration_ms: 1400,
393 request_id: "req-9f8e7d6c5b4a".into(),
394 }
395 }
396
397 mod golden {
413 use super::*;
414
415 fn cap_decision() -> CapDecisionRecord {
416 CapDecisionRecord {
417 cap_id: "wasi:http".into(),
418 key: "api.telemetry.example.com:443".into(),
419 action: "GET".into(),
420 decision: Decision4::Deny,
421 mode: "allowlist".into(),
422 actor: Actor::Static,
423 reason: Some("outside ceiling".into()),
424 rule: None,
425 never_rollup: false,
426 }
427 }
428
429 #[test]
430 fn header() {
431 insta::assert_snapshot!(render_header(
432 "python-eval@0.16.0",
433 "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
434 &[
435 ("wasi:filesystem".to_string(), "allowlist".to_string()),
436 ("wasi:http".to_string(), "ask".to_string()),
437 ],
438 ));
439 }
440
441 #[test]
442 fn declared_ungranted_warning() {
443 insta::assert_snapshot!(render_declared_ungranted_warning(&[
444 "wasi:http".to_string(),
445 "wasi:sockets".to_string(),
446 ]));
447 }
448
449 #[test]
450 fn declared_ask_blocked_warning() {
451 insta::assert_snapshot!(render_declared_ask_blocked_warning(&[
452 "wasi:filesystem".to_string()
453 ]));
454 }
455
456 #[test]
457 fn credential_issue() {
458 insta::assert_snapshot!(render_credential_issue(&CredentialIssueRecord {
459 component_ref: "notion@1.2.0".into(),
460 session_id: "sess-4f2a1b".into(),
461 key: "acme:token".into(),
462 kind: "std:oauth2".into(),
463 }));
464 }
465
466 #[test]
468 fn exception_static_deny() {
469 insta::assert_snapshot!(render_exception(&cap_decision()));
470 }
471
472 #[test]
475 fn exception_ask_denied_by_user() {
476 let mut r = cap_decision();
477 r.decision = Decision4::AskDeny;
478 r.mode = "ask".into();
479 r.actor = Actor::User;
480 r.reason = Some("denied by user".into());
481 insta::assert_snapshot!(render_exception(&r));
482 }
483
484 #[test]
488 fn exception_with_an_attributed_rule() {
489 let mut r = cap_decision();
490 r.rule = Some("*.example.com".into());
491 r.reason = Some("not granted".into());
492 insta::assert_snapshot!(render_exception(&r));
493 }
494
495 #[test]
496 fn rollup_with_grouped_allows() {
497 let mut roll = Rollup::new(64);
498 for _ in 0..12 {
499 roll.add("wasi:filesystem", "read", Some("/data/**"));
500 }
501 for _ in 0..2 {
502 roll.add("wasi:filesystem", "write", Some("/data/**"));
503 }
504 roll.add("wasi:http", "GET", Some("pypi.org"));
505 insta::assert_snapshot!(render_rollup(&span_fields(), &roll));
506 }
507
508 #[test]
510 fn rollup_with_no_allows() {
511 insta::assert_snapshot!(render_rollup(&span_fields(), &Rollup::new(64)));
512 }
513
514 #[test]
518 fn rollup_with_full_args() {
519 let mut sf = span_fields();
520 sf.args_json = Some(r#"{"name":"pandas","version":"2.2.0"}"#.to_string());
521 insta::assert_snapshot!(render_rollup(&sf, &Rollup::new(64)));
522 }
523
524 #[test]
527 fn rollup_with_session_and_overflow() {
528 let mut sf = span_fields();
529 sf.session_id = Some("sess-0123456789abcdef".to_string());
530 let mut roll = Rollup::new(2);
531 roll.add("wasi:filesystem", "read", Some("/a/**"));
532 roll.add("wasi:filesystem", "read", Some("/b/**"));
533 roll.add("wasi:filesystem", "read", Some("/c/**"));
534 roll.add("wasi:http", "GET", Some("pypi.org"));
535 insta::assert_snapshot!(render_rollup(&sf, &roll));
536 }
537
538 #[test]
543 fn rollup_escapes_untrusted_text() {
544 let mut sf = span_fields();
545 sf.tool = "run\npython".to_string();
546 let mut roll = Rollup::new(64);
547 roll.add("wasi:filesystem", "read", Some("/data\n audit: forged"));
548 insta::assert_snapshot!(render_rollup(&sf, &roll));
549 }
550 }
551
552 #[test]
553 fn exception_line_names_decision_capability_and_reason() {
554 let r = CapDecisionRecord {
555 cap_id: "wasi:http".into(),
556 key: "api.telemetry.example.com:443".into(),
557 action: "GET".into(),
558 decision: Decision4::Deny,
559 mode: "ask".into(),
560 actor: Actor::Static,
561 reason: Some("outside ceiling".into()),
562 rule: None,
563 never_rollup: false,
564 };
565 let line = render_exception(&r);
566 assert!(line.starts_with("audit: "), "got {line}");
567 assert!(line.contains("deny"));
568 assert!(line.contains("wasi:http"));
569 assert!(line.contains("GET api.telemetry.example.com:443"));
570 assert!(line.contains("outside ceiling"));
571 }
572
573 #[test]
574 fn exception_line_carries_mode_and_rule_so_deny_causes_are_distinguishable() {
575 let base = CapDecisionRecord {
583 cap_id: "db:drop".into(),
584 key: "production".into(),
585 action: "request".into(),
586 decision: Decision4::Deny,
587 mode: "open".into(),
588 actor: Actor::Static,
589 reason: Some("outside ceiling".into()),
590 rule: None,
591 never_rollup: false,
592 };
593
594 let deny_constraint = CapDecisionRecord {
596 rule: Some(r#"{"key":"production"}"#.into()),
597 ..base.clone()
598 };
599 let line = render_exception(&deny_constraint);
600 assert!(line.contains("mode:open"), "got {line}");
601 assert!(
602 line.contains(r#"under {"key":"production"}"#),
603 "the matched deny constraint must appear, got {line}"
604 );
605
606 let declaration_miss = CapDecisionRecord {
608 mode: "ask".into(),
609 rule: Some("outside the declared ceiling".into()),
610 ..base.clone()
611 };
612 let line = render_exception(&declaration_miss);
613 assert!(line.contains("mode:ask"), "got {line}");
614 assert!(
615 line.contains("under outside the declared ceiling"),
616 "got {line}"
617 );
618
619 let allowlist_miss = CapDecisionRecord {
623 mode: "allowlist".into(),
624 rule: None,
625 ..base
626 };
627 let line = render_exception(&allowlist_miss);
628 assert!(line.contains("mode:allowlist"), "got {line}");
629 assert!(
630 !line.contains("under "),
631 "no rule was attributed, so no `under` clause should appear, got {line}"
632 );
633 assert_ne!(
634 line,
635 render_exception(&deny_constraint),
636 "an allowlist miss must not render identically to a deny-constraint match"
637 );
638 assert_ne!(
639 line,
640 render_exception(&declaration_miss),
641 "an allowlist miss must not render identically to a declaration miss"
642 );
643 }
644
645 #[test]
646 fn ask_denied_by_user_is_attributed_to_the_user() {
647 let r = CapDecisionRecord {
648 cap_id: "wasi:filesystem".into(),
649 key: "/home/alex/.ssh/id_ed25519".into(),
650 action: "read".into(),
651 decision: Decision4::AskDeny,
652 mode: "ask".into(),
653 actor: Actor::User,
654 reason: Some("denied by user".into()),
655 rule: None,
656 never_rollup: false,
657 };
658 let line = render_exception(&r);
659 assert!(line.contains("ask-deny"));
660 assert!(line.contains("denied by user"));
661 }
662
663 #[test]
664 fn rollup_groups_allows_by_capability_action_and_rule() {
665 let mut roll = Rollup::new(64);
666 for _ in 0..12 {
667 roll.add("wasi:filesystem", "read", Some("/data/**"));
668 }
669 for _ in 0..2 {
670 roll.add("wasi:filesystem", "write", Some("/data/**"));
671 }
672 roll.add("wasi:http", "GET", Some("pypi.org"));
673
674 let line = render_rollup(&span_fields(), &roll);
675 assert!(line.contains("run_python"));
676 assert!(line.contains("ok"));
677 assert!(
678 line.contains("1.4s"),
679 "expected humanised duration, got {line}"
680 );
681 assert!(
682 line.contains("args:9e21c4"),
683 "expected short args digest, got {line}"
684 );
685 assert!(line.contains("12 read"));
686 assert!(line.contains("2 write"));
687 assert!(line.contains("/data/**"));
688 assert!(line.contains("pypi.org"));
689 assert!(
690 line.contains("req:req-9f"),
691 "expected truncated request id, got {line}"
692 );
693 }
694
695 #[test]
696 fn rollup_shows_full_args_instead_of_the_digest_when_present() {
697 let mut sf = span_fields();
698 sf.args_json = Some(r#"{"name":"zzmarkerzz"}"#.to_string());
699 let roll = Rollup::new(64);
700
701 let line = render_rollup(&sf, &roll);
702 assert!(
703 line.contains(r#"args:{"name":"zzmarkerzz"}"#),
704 "expected full args, got {line}"
705 );
706 assert!(
707 !line.contains("args:9e21c4"),
708 "digest prefix must not also appear, got {line}"
709 );
710 }
711
712 #[test]
713 fn rollup_with_no_allows_still_reports_the_call() {
714 let roll = Rollup::new(64);
715 let line = render_rollup(&span_fields(), &roll);
716 assert!(line.contains("run_python"));
717 assert!(!line.contains("under"), "no grants touched, got {line}");
718 }
719
720 #[test]
721 fn rollup_collapses_past_the_cap() {
722 let mut roll = Rollup::new(2);
724 roll.add("wasi:filesystem", "read", Some("/a/**"));
725 roll.add("wasi:filesystem", "read", Some("/b/**"));
726 roll.add("wasi:filesystem", "read", Some("/c/**"));
727 roll.add("wasi:filesystem", "read", Some("/d/**"));
728 assert_eq!(roll.groups(), 2);
729 assert_eq!(roll.overflow(), 2);
730 let line = render_rollup(&span_fields(), &roll);
731 assert!(line.contains("and 2 more"), "got {line}");
732 }
733
734 #[test]
735 fn header_shows_short_digest_and_per_class_modes() {
736 let line = render_header(
737 "python-eval@0.16.0",
738 "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
739 &[
740 ("wasi:filesystem".to_string(), "allowlist".to_string()),
741 ("wasi:http".to_string(), "ask".to_string()),
742 ],
743 );
744 assert!(line.contains("python-eval@0.16.0"));
745 assert!(
746 line.contains("sha256:1f3a9c"),
747 "expected truncated digest, got {line}"
748 );
749 assert!(
750 !line.contains("9f0a1b2c"),
751 "full digest must not be printed"
752 );
753 assert!(line.contains("wasi:filesystem=allowlist"));
754 assert!(line.contains("wasi:http=ask"));
755 }
756
757 #[test]
758 fn rollup_truncates_multibyte_session_id_safely() {
759 let mut sf = span_fields();
762 sf.session_id = Some("アアアアア".to_string());
763 let roll = Rollup::new(64);
764
765 let line = render_rollup(&sf, &roll);
766 assert!(
768 line.contains("session:"),
769 "session clause missing from {line}"
770 );
771 assert!(
773 line.contains("session:アア"),
774 "expected 2 chars, got {line}"
775 );
776 }
777
778 #[test]
779 fn rollup_with_short_session_id_unchanged() {
780 let mut sf = span_fields();
782 sf.session_id = Some("short".to_string()); let roll = Rollup::new(64);
784
785 let line = render_rollup(&sf, &roll);
786 assert!(
787 line.contains("session:short"),
788 "full short ID should appear, got {line}"
789 );
790 }
791
792 #[test]
793 fn rollup_truncates_multibyte_at_boundary() {
794 let mut sf = span_fields();
797 sf.session_id = Some("🎉🎉🎉".to_string()); let roll = Rollup::new(64);
799
800 let line = render_rollup(&sf, &roll);
801 assert!(
803 line.contains("session:🎉🎉"),
804 "expected 2 emoji at boundary, got {line}"
805 );
806 assert!(
808 !line.contains("🎉🎉🎉"),
809 "should not contain 3 emoji, got {line}"
810 );
811 }
812
813 #[test]
814 fn render_escapes_newline_in_rule_to_prevent_forgery() {
815 let mut roll = Rollup::new(64);
818 roll.add("wasi:filesystem", "read", Some("/data\naudit: forged line"));
819
820 let line = render_rollup(&span_fields(), &roll);
821 assert_eq!(line.matches('\n').count(), 0, "got {line}");
823 assert!(line.contains("\\n"), "expected escaped newline, got {line}");
825 assert!(
827 line.contains("\\naudit: forged line"),
828 "escaped injection should appear, got {line}"
829 );
830 }
831
832 #[test]
833 fn render_escapes_newline_in_tool_name() {
834 let mut sf = span_fields();
835 sf.tool = "run\naudit: forged".to_string();
836 let roll = Rollup::new(64);
837
838 let line = render_rollup(&sf, &roll);
839 assert_eq!(line.matches('\n').count(), 0, "got {line}");
840 assert!(line.contains("\\n"), "expected escaped newline, got {line}");
841 }
842
843 #[test]
844 fn render_escapes_newline_in_full_args() {
845 let mut sf = span_fields();
849 sf.args_json = Some(r#"{"note":"line1\naudit: forged line"}"#.to_string());
850 let roll = Rollup::new(64);
851
852 let line = render_rollup(&sf, &roll);
853 assert_eq!(line.matches('\n').count(), 0, "got {line}");
854 assert!(line.contains("\\n"), "expected escaped newline, got {line}");
855 }
856
857 #[test]
858 fn render_escapes_newline_in_resource_key() {
859 let r = CapDecisionRecord {
860 cap_id: "wasi:http".into(),
861 key: "api.example.com:443\naudit: forged".into(),
862 action: "GET".into(),
863 decision: Decision4::Deny,
864 mode: "ask".into(),
865 actor: Actor::Static,
866 reason: Some("outside ceiling".into()),
867 rule: None,
868 never_rollup: false,
869 };
870 let line = render_exception(&r);
871 assert_eq!(line.matches('\n').count(), 0, "got {line}");
872 assert!(line.contains("\\n"), "expected escaped newline, got {line}");
873 }
874
875 #[test]
876 fn render_escapes_ansi_sequences() {
877 let mut roll = Rollup::new(64);
879 roll.add("wasi:http", "GET", Some("api.example.com\u{1b}[31m"));
880
881 let line = render_rollup(&span_fields(), &roll);
882 assert!(
884 line.contains("\\u{001b}"),
885 "expected escaped ESC, got {line}"
886 );
887 assert!(
889 !line.contains("\u{1b}[31m"),
890 "ANSI sequence should not appear raw"
891 );
892 }
893
894 #[test]
895 fn render_escapes_bidi_override() {
896 let mut roll = Rollup::new(64);
902 roll.add("wasi:filesystem", "read", Some("/tmp/safe/\u{202e}txt.exe"));
903
904 let line = render_rollup(&span_fields(), &roll);
905 assert!(
907 line.contains("\\u{202e}"),
908 "expected escaped RLO, got {line}"
909 );
910 assert!(
911 !line.contains('\u{202e}'),
912 "raw bidi override should not appear, got {line}"
913 );
914 }
915
916 #[test]
917 fn render_escaping_preserves_clean_strings() {
918 let r = CapDecisionRecord {
920 cap_id: "wasi:filesystem".into(),
921 key: "/data/file.txt".into(),
922 action: "read".into(),
923 decision: Decision4::Allow,
924 mode: "allowlist".into(),
925 actor: Actor::Static,
926 reason: None,
927 rule: None,
928 never_rollup: false,
929 };
930 let line = render_exception(&r);
932 assert!(line.contains("wasi:filesystem"), "cap_id should appear");
933 assert!(line.contains("/data/file.txt"), "key should appear");
934 assert!(line.contains("read"), "action should appear");
935 assert!(
937 !line.contains('\\'),
938 "clean strings should not be escaped, got {line}"
939 );
940 }
941
942 #[test]
943 fn render_escapes_newline_in_capability_id() {
944 let mut roll = Rollup::new(64);
947 roll.add("db\naudit: forged", "drop-database", Some("/data"));
948
949 let line = render_rollup(&span_fields(), &roll);
950 assert_eq!(line.matches('\n').count(), 0, "got {line}");
952 assert!(
954 line.contains("\\n"),
955 "expected escaped newline in cap_id, got {line}"
956 );
957 }
958
959 #[test]
960 fn render_header_escapes_capability_class_id() {
961 let line = render_header(
963 "python-eval@0.16.0",
964 "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
965 &[("db\naudit: forged".to_string(), "allowlist".to_string())],
966 );
967 assert_eq!(line.matches('\n').count(), 0, "got {line}");
969 assert!(
971 line.contains("\\n"),
972 "expected escaped newline in capability class id, got {line}"
973 );
974 }
975
976 #[test]
977 fn render_header_escapes_component_ref() {
978 let line = render_header(
980 "python-eval\naudit: forged@0.16.0",
981 "1f3a9c4e5d6b7a8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
982 &[("wasi:filesystem".to_string(), "allowlist".to_string())],
983 );
984 assert_eq!(line.matches('\n').count(), 0, "got {line}");
986 assert!(
988 line.contains("\\n"),
989 "expected escaped newline in component_ref, got {line}"
990 );
991 }
992
993 #[test]
994 fn render_exception_marks_allow_distinctly_from_ask() {
995 let r = CapDecisionRecord {
999 cap_id: "wasi:filesystem".into(),
1000 key: "/data/x".into(),
1001 action: "read".into(),
1002 decision: Decision4::Allow,
1003 mode: "allowlist".into(),
1004 actor: Actor::Static,
1005 reason: None,
1006 rule: Some("/data/**".into()),
1007 never_rollup: false,
1008 };
1009 let line = render_exception(&r);
1010 assert!(
1011 !line.starts_with("audit: ? "),
1012 "allow must not render the ask marker, got {line}"
1013 );
1014 assert!(line.contains("allow"), "got {line}");
1015 }
1016
1017 #[test]
1018 fn a_credential_key_cannot_forge_a_second_audit_line() {
1019 let line = render_credential_issue(&CredentialIssueRecord {
1023 component_ref: "comp".into(),
1024 session_id: "s1".into(),
1025 key: "notion\naudit: \u{1f511} credential innocent kind=std:fields".into(),
1026 kind: "std:fields".into(),
1027 });
1028 assert_eq!(line.matches('\n').count(), 0, "got {line}");
1029 assert!(
1030 line.contains("\\n"),
1031 "expected an escaped newline, got {line}"
1032 );
1033 }
1034
1035 #[test]
1036 fn a_credential_issue_line_carries_all_four_facts_and_nothing_that_could_be_a_value() {
1037 let line = render_credential_issue(&CredentialIssueRecord {
1038 component_ref: "ghcr.io/actpkg/notion@0.1.0".into(),
1039 session_id: "sess-7".into(),
1040 key: "notion-work".into(),
1041 kind: "std:oauth2".into(),
1042 });
1043 for expected in [
1044 "notion-work",
1045 "std:oauth2",
1046 "ghcr.io/actpkg/notion@0.1.0",
1047 "sess-7",
1048 ] {
1049 assert!(line.contains(expected), "missing {expected} in {line}");
1050 }
1051 }
1052
1053 #[test]
1054 fn render_escapes_control_character_in_request_id() {
1055 let mut sf = span_fields();
1058 sf.request_id = "req\naudit: forged".to_string();
1059 let roll = Rollup::new(64);
1060
1061 let line = render_rollup(&sf, &roll);
1062 assert_eq!(line.matches('\n').count(), 0, "got {line}");
1063 assert!(
1064 line.contains("\\n"),
1065 "expected escaped newline in request id, got {line}"
1066 );
1067 }
1068}