1use std::fmt;
45use std::io::{Read as _, Write as _};
46use std::process::{Child, Command, Stdio};
47use std::time::{Duration, Instant};
48
49use serde::Deserialize;
50
51use crate::ids::EvidenceId;
52use crate::model::{Evidence, EvidenceKind, FactEvent, FactValue};
53
54pub const CONTENT_CHECK_PROTOCOL: &str = "dent8.content-check/1";
56
57pub const CONTENT_FLAG_LOCATOR_PREFIX: &str = "content-check:";
62
63const MAX_REASON_LEN: usize = 256;
66
67const POLL_INTERVAL: Duration = Duration::from_millis(10);
69
70#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
73pub enum FailurePolicy {
74 #[default]
76 FailClosed,
77 FailOpen,
80}
81
82#[derive(Clone, Debug)]
85pub struct ContentCheckConfig {
86 command: Vec<String>,
88 pub timeout: Duration,
91 pub failure_policy: FailurePolicy,
92}
93
94impl ContentCheckConfig {
95 pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
97
98 pub fn new(command: Vec<String>) -> Result<Self, String> {
101 if command
102 .first()
103 .is_none_or(|program| program.trim().is_empty())
104 {
105 return Err("content check command cannot be empty".to_string());
106 }
107 Ok(Self {
108 command,
109 timeout: Self::DEFAULT_TIMEOUT,
110 failure_policy: FailurePolicy::default(),
111 })
112 }
113
114 #[must_use]
116 pub fn scanner_name(&self) -> &str {
117 &self.command[0]
118 }
119}
120
121#[derive(Clone, Debug, Eq, PartialEq)]
123pub enum Verdict {
124 Allow,
126 Reject { reason: Option<String> },
128 Taint { reason: Option<String> },
130}
131
132#[derive(Clone, Debug)]
134pub enum ScanError {
135 Spawn(String),
137 Io(String),
139 Timeout(Duration),
141 NonZeroExit(String),
143 MalformedVerdict(String),
145}
146
147impl fmt::Display for ScanError {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match self {
150 Self::Spawn(error) => write!(f, "could not spawn the scanner: {error}"),
151 Self::Io(error) => write!(f, "scanner I/O failed: {error}"),
152 Self::Timeout(timeout) => write!(
153 f,
154 "the scanner did not answer within {} ms and was killed",
155 timeout.as_millis()
156 ),
157 Self::NonZeroExit(detail) => write!(f, "the scanner exited non-zero ({detail})"),
158 Self::MalformedVerdict(detail) => {
159 write!(f, "the scanner returned a malformed verdict: {detail}")
160 }
161 }
162 }
163}
164
165impl std::error::Error for ScanError {}
166
167#[derive(Clone, Debug)]
170pub enum ContentCheckRefusal {
171 Rejected {
173 subject: String,
174 predicate: String,
175 reason: Option<String>,
176 },
177 ScannerUnavailable(ScanError),
180}
181
182impl fmt::Display for ContentCheckRefusal {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 match self {
185 Self::Rejected {
186 subject,
187 predicate,
188 reason,
189 } => {
190 write!(f, "content check rejected {subject} {predicate}")?;
191 if let Some(reason) = reason {
192 write!(f, ": {reason}")?;
193 }
194 Ok(())
195 }
196 Self::ScannerUnavailable(error) => write!(
197 f,
198 "content check failed closed — {error} (the configured scanner must \
199 answer, or set DENT8_CONTENT_CHECK_FAIL_OPEN=1 to admit-but-flag)"
200 ),
201 }
202 }
203}
204
205impl std::error::Error for ContentCheckRefusal {}
206
207#[derive(Clone, Debug, Eq, PartialEq)]
210pub struct ContentFlag {
211 pub scanner: String,
212 pub reason: String,
213}
214
215#[must_use]
218pub fn content_flag(event: &FactEvent) -> Option<ContentFlag> {
219 event.evidence.iter().find_map(|item| {
220 let scanner = item.locator.strip_prefix(CONTENT_FLAG_LOCATOR_PREFIX)?;
221 Some(ContentFlag {
222 scanner: scanner.to_string(),
223 reason: item.summary.clone().unwrap_or_default(),
224 })
225 })
226}
227
228#[must_use]
232pub fn candidate_payload(event: &FactEvent) -> serde_json::Value {
233 let value = match &event.value {
234 Some(FactValue::Text(text)) => serde_json::json!({ "kind": "text", "text": text }),
235 Some(FactValue::Json(json)) => serde_json::json!({ "kind": "json", "json": json.as_str() }),
236 Some(FactValue::Redacted) => serde_json::json!({ "kind": "redacted" }),
237 None => serde_json::Value::Null,
238 };
239 let evidence: Vec<serde_json::Value> = event
240 .evidence
241 .iter()
242 .map(|item| {
243 serde_json::json!({
244 "kind": format!("{:?}", item.kind),
245 "locator": item.locator,
246 "summary": item.summary,
247 })
248 })
249 .collect();
250 serde_json::json!({
251 "protocol": CONTENT_CHECK_PROTOCOL,
252 "event_id": event.event_id.as_str(),
253 "fact_id": event.fact_id.as_str(),
254 "event_type": event.kind.name(),
255 "subject": { "kind": event.subject.kind(), "key": event.subject.key() },
256 "predicate": event.predicate.as_str(),
257 "value": value,
258 "source": event.provenance.source.as_str(),
259 "authority": event.authority.level.name(),
260 "evidence": evidence,
261 })
262}
263
264#[derive(Deserialize)]
267struct WireVerdict {
268 verdict: String,
269 #[serde(default)]
270 reason: Option<String>,
271}
272
273pub fn scan_event(config: &ContentCheckConfig, event: &FactEvent) -> Result<Verdict, ScanError> {
275 let payload = candidate_payload(event).to_string();
276 let stdout = run_scanner(config, &payload)?;
277 parse_verdict(&stdout)
278}
279
280fn parse_verdict(stdout: &str) -> Result<Verdict, ScanError> {
281 let trimmed = stdout.trim();
282 let wire: WireVerdict = serde_json::from_str(trimmed).map_err(|error| {
283 ScanError::MalformedVerdict(format!(
284 "{error} (stdout: {})",
285 truncate_chars(trimmed, 120)
286 ))
287 })?;
288 let reason = wire
289 .reason
290 .map(|reason| truncate_chars(&reason, MAX_REASON_LEN));
291 match wire.verdict.as_str() {
292 "allow" => Ok(Verdict::Allow),
293 "reject" => Ok(Verdict::Reject { reason }),
294 "taint" => Ok(Verdict::Taint { reason }),
295 other => Err(ScanError::MalformedVerdict(format!(
296 "unknown verdict {other:?} (allow|reject|taint)"
297 ))),
298 }
299}
300
301fn truncate_chars(text: &str, max: usize) -> String {
303 if text.chars().count() <= max {
304 return text.to_string();
305 }
306 let mut out: String = text.chars().take(max).collect();
307 out.push('…');
308 out
309}
310
311fn run_scanner(config: &ContentCheckConfig, payload: &str) -> Result<String, ScanError> {
317 let mut child = Command::new(&config.command[0])
318 .args(&config.command[1..])
319 .stdin(Stdio::piped())
320 .stdout(Stdio::piped())
321 .stderr(Stdio::null())
322 .spawn()
323 .map_err(|error| ScanError::Spawn(format!("{}: {error}", config.command[0])))?;
324
325 let mut stdin = child
326 .stdin
327 .take()
328 .ok_or_else(|| ScanError::Io("scanner stdin unavailable".to_string()))?;
329 let payload = payload.to_string();
330 let writer = std::thread::spawn(move || {
333 let _ = stdin.write_all(payload.as_bytes());
334 drop(stdin);
335 });
336 let mut stdout_pipe = child
337 .stdout
338 .take()
339 .ok_or_else(|| ScanError::Io("scanner stdout unavailable".to_string()))?;
340 let (send_stdout, recv_stdout) = std::sync::mpsc::channel();
341 let reader = std::thread::spawn(move || {
342 let mut out = String::new();
343 let read = stdout_pipe.read_to_string(&mut out).map(|_| ());
344 let _ = send_stdout.send((read, out));
345 });
346
347 let status = match wait_with_timeout(&mut child, config.timeout) {
348 Ok(status) => status,
349 Err(error) => {
350 drop(writer);
355 drop(reader);
356 return Err(error);
357 }
358 };
359 drop(writer);
363 let (read_result, stdout) = recv_stdout
364 .recv_timeout(config.timeout)
365 .map_err(|_| ScanError::Timeout(config.timeout))?;
366 let _ = reader.join();
367 read_result.map_err(|error| ScanError::Io(format!("reading scanner stdout: {error}")))?;
368 if !status.success() {
369 return Err(ScanError::NonZeroExit(format!(
370 "{status}; stdout: {}",
371 stdout.trim().chars().take(120).collect::<String>()
372 )));
373 }
374 Ok(stdout)
375}
376
377fn wait_with_timeout(
380 child: &mut Child,
381 timeout: Duration,
382) -> Result<std::process::ExitStatus, ScanError> {
383 let deadline = Instant::now() + timeout;
384 loop {
385 match child.try_wait() {
386 Ok(Some(status)) => return Ok(status),
387 Ok(None) => {
388 if Instant::now() >= deadline {
389 let _ = child.kill();
390 let _ = child.wait();
391 return Err(ScanError::Timeout(timeout));
392 }
393 std::thread::sleep(POLL_INTERVAL);
394 }
395 Err(error) => {
396 let _ = child.kill();
397 let _ = child.wait();
398 return Err(ScanError::Io(format!("waiting for the scanner: {error}")));
399 }
400 }
401 }
402}
403
404fn flag_evidence(event: &FactEvent, scanner: &str, reason: &str) -> Evidence {
406 Evidence {
407 id: EvidenceId::new(format!(
408 "evidence:content-check:{}",
409 event.event_id.as_str()
410 ))
411 .expect("a content-flag evidence id built from a non-empty event id is non-empty"),
412 kind: EvidenceKind::ToolOutput,
413 locator: format!("{CONTENT_FLAG_LOCATOR_PREFIX}{scanner}"),
414 digest: None,
415 summary: Some(reason.to_string()),
416 }
417}
418
419pub fn enforce(
426 config: &ContentCheckConfig,
427 events: &mut [FactEvent],
428) -> Result<(), ContentCheckRefusal> {
429 for event in events.iter_mut() {
430 if event.value.is_none() {
431 continue;
432 }
433 match scan_event(config, event) {
434 Ok(Verdict::Allow) => {}
435 Ok(Verdict::Reject { reason }) => {
436 return Err(ContentCheckRefusal::Rejected {
437 subject: format!("{}:{}", event.subject.kind(), event.subject.key()),
438 predicate: event.predicate.as_str().to_string(),
439 reason,
440 });
441 }
442 Ok(Verdict::Taint { reason }) => {
443 let reason = reason.unwrap_or_else(|| "flagged by the content check".to_string());
444 let flag = flag_evidence(event, config.scanner_name(), &reason);
445 event.evidence.push(flag);
446 }
447 Err(error) => match config.failure_policy {
448 FailurePolicy::FailClosed => {
449 return Err(ContentCheckRefusal::ScannerUnavailable(error));
450 }
451 FailurePolicy::FailOpen => {
453 let reason = format!("content check unavailable (fail-open): {error}");
454 let reason = truncate_chars(&reason, MAX_REASON_LEN);
455 let flag = flag_evidence(event, config.scanner_name(), &reason);
456 event.evidence.push(flag);
457 }
458 },
459 }
460 }
461 Ok(())
462}
463
464#[cfg(test)]
465mod tests {
466 use super::{
467 CONTENT_FLAG_LOCATOR_PREFIX, ContentCheckConfig, ContentCheckRefusal, FailurePolicy,
468 ScanError, Verdict, candidate_payload, content_flag, enforce, parse_verdict,
469 };
470 use crate::ids::{ActorId, EvidenceId, FactEventId, FactId, SourceId, TimestampMillis};
471 use crate::model::{
472 Authority, AuthorityLevel, Confidence, Evidence, EvidenceKind, FactEvent, FactEventKind,
473 FactValue, Predicate, Provenance, Subject, Ttl,
474 };
475
476 fn event(value: Option<&str>) -> FactEvent {
477 FactEvent {
478 event_id: FactEventId::new("event:1").expect("event id"),
479 fact_id: FactId::new("fact:x").expect("fact id"),
480 kind: FactEventKind::Asserted,
481 subject: Subject::new("repo", "app").expect("subject"),
482 predicate: Predicate::new("build_command").expect("predicate"),
483 value: value.map(|text| FactValue::Text(text.to_string())),
484 confidence: Confidence::ASSERTED,
485 authority: Authority {
486 level: AuthorityLevel::Low,
487 issuer: None,
488 scope: None,
489 },
490 ttl: Ttl::Never,
491 provenance: Provenance {
492 source: SourceId::new("source:agent").expect("source"),
493 actor: ActorId::new("actor:test").expect("actor"),
494 tool: None,
495 run_id: None,
496 input_digest: None,
497 recorded_at: TimestampMillis::from_unix_millis(1),
498 attestation: None,
499 },
500 evidence: vec![Evidence {
501 id: EvidenceId::new("evidence:test").expect("evidence id"),
502 kind: EvidenceKind::UserStatement,
503 locator: "test".to_string(),
504 digest: None,
505 summary: None,
506 }],
507 observed_at: None,
508 valid_from: None,
509 valid_to: None,
510 }
511 }
512
513 #[test]
514 fn verdict_parsing_accepts_the_three_verdicts_and_rejects_the_rest() {
515 assert_eq!(
516 parse_verdict(r#"{"verdict":"allow"}"#).unwrap(),
517 Verdict::Allow
518 );
519 assert_eq!(
520 parse_verdict(r#"{"verdict":"reject","reason":"why"}"#).unwrap(),
521 Verdict::Reject {
522 reason: Some("why".to_string())
523 }
524 );
525 assert_eq!(
526 parse_verdict(" {\"verdict\":\"taint\"}\n").unwrap(),
527 Verdict::Taint { reason: None }
528 );
529 assert_eq!(
531 parse_verdict(r#"{"verdict":"allow","score":0.1}"#).unwrap(),
532 Verdict::Allow
533 );
534 assert!(matches!(
536 parse_verdict(r#"{"verdict":"maybe"}"#),
537 Err(ScanError::MalformedVerdict(_))
538 ));
539 assert!(matches!(
540 parse_verdict("yes"),
541 Err(ScanError::MalformedVerdict(_))
542 ));
543 }
544
545 #[test]
546 fn a_multibyte_malformed_verdict_is_reported_without_panicking() {
547 let garbage = format!("a{}", "€".repeat(50));
550 let error = parse_verdict(&garbage).expect_err("non-JSON must be malformed");
551 assert!(matches!(error, ScanError::MalformedVerdict(_)));
552 }
553
554 #[test]
555 fn the_candidate_payload_carries_the_content_and_the_protocol_marker() {
556 let payload = candidate_payload(&event(Some("make test")));
557 assert_eq!(payload["protocol"], super::CONTENT_CHECK_PROTOCOL);
558 assert_eq!(payload["event_type"], "fact.asserted");
559 assert_eq!(payload["subject"]["kind"], "repo");
560 assert_eq!(payload["predicate"], "build_command");
561 assert_eq!(payload["value"]["kind"], "text");
562 assert_eq!(payload["value"]["text"], "make test");
563 assert_eq!(payload["authority"], "low");
564 assert_eq!(payload["evidence"][0]["locator"], "test");
565 }
566
567 #[test]
568 fn a_scanner_reason_is_bounded_before_it_is_persisted() {
569 let long = "x".repeat(10_000);
570 let Verdict::Taint {
571 reason: Some(reason),
572 } = parse_verdict(&format!(r#"{{"verdict":"taint","reason":"{long}"}}"#)).unwrap()
573 else {
574 panic!("expected a taint verdict with a reason");
575 };
576 assert!(reason.chars().count() <= super::MAX_REASON_LEN + 1);
577 }
578
579 #[test]
580 fn content_flags_round_trip_through_the_evidence_marker() {
581 let mut flagged = event(Some("suspicious"));
582 flagged
583 .evidence
584 .push(super::flag_evidence(&flagged, "demo.sh", "matched a rule"));
585 let flag = content_flag(&flagged).expect("flag present");
586 assert_eq!(flag.scanner, "demo.sh");
587 assert_eq!(flag.reason, "matched a rule");
588 assert!(
589 flagged.evidence[1]
590 .locator
591 .starts_with(CONTENT_FLAG_LOCATOR_PREFIX)
592 );
593 assert!(content_flag(&event(Some("clean"))).is_none());
594 }
595
596 #[test]
597 fn an_empty_command_is_refused_at_construction() {
598 assert!(ContentCheckConfig::new(vec![]).is_err());
599 assert!(ContentCheckConfig::new(vec![String::new()]).is_err());
600 assert!(ContentCheckConfig::new(vec!["scanner".to_string()]).is_ok());
601 }
602
603 #[test]
604 fn a_missing_scanner_fails_closed_by_default_and_flags_on_fail_open() {
605 let mut config =
606 ContentCheckConfig::new(vec!["/nonexistent/dent8-test-scanner".to_string()])
607 .expect("config");
608 let mut events = [event(Some("anything"))];
609 let refusal = enforce(&config, &mut events).expect_err("fail-closed must refuse");
610 assert!(matches!(
611 refusal,
612 ContentCheckRefusal::ScannerUnavailable(ScanError::Spawn(_))
613 ));
614
615 config.failure_policy = FailurePolicy::FailOpen;
616 enforce(&config, &mut events).expect("fail-open admits");
617 let flag = content_flag(&events[0]).expect("fail-open must mark the admit");
618 assert!(flag.reason.contains("fail-open"), "{}", flag.reason);
619 }
620
621 #[test]
622 fn value_less_events_are_not_scanned() {
623 let config = ContentCheckConfig::new(vec!["/nonexistent/dent8-test-scanner".to_string()])
625 .expect("config");
626 let mut events = [event(None)];
627 enforce(&config, &mut events).expect("no value, no scan, no failure");
628 }
629
630 #[cfg(unix)]
632 mod subprocess {
633 use std::io::Write as _;
634 use std::os::unix::fs::PermissionsExt as _;
635 use std::time::Duration;
636
637 use super::super::{
638 ContentCheckConfig, ContentCheckRefusal, FailurePolicy, ScanError, Verdict,
639 content_flag, enforce, scan_event,
640 };
641 use super::event;
642
643 fn serialized() -> std::sync::MutexGuard<'static, ()> {
647 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
648 LOCK.lock()
649 .unwrap_or_else(std::sync::PoisonError::into_inner)
650 }
651
652 fn scanner_script(body: &str) -> (std::path::PathBuf, tempdir::Guard) {
654 let dir = tempdir::create();
655 let path = dir.path.join("scanner.sh");
656 let mut file = std::fs::File::create(&path).expect("create script");
657 writeln!(file, "#!/bin/sh\n{body}").expect("write script");
658 drop(file);
659 let mut perms = std::fs::metadata(&path).expect("stat").permissions();
660 perms.set_mode(0o755);
661 std::fs::set_permissions(&path, perms).expect("chmod");
662 (path, dir)
663 }
664
665 mod tempdir {
667 use std::path::PathBuf;
668 use std::sync::atomic::{AtomicU32, Ordering};
669
670 pub struct Guard {
671 pub path: PathBuf,
672 }
673
674 impl Drop for Guard {
675 fn drop(&mut self) {
676 let _ = std::fs::remove_dir_all(&self.path);
677 }
678 }
679
680 pub fn create() -> Guard {
681 static COUNTER: AtomicU32 = AtomicU32::new(0);
682 let path = std::env::temp_dir().join(format!(
683 "dent8-content-check-{}-{}",
684 std::process::id(),
685 COUNTER.fetch_add(1, Ordering::Relaxed)
686 ));
687 std::fs::create_dir_all(&path).expect("create temp dir");
688 Guard { path }
689 }
690 }
691
692 fn config_for(path: &std::path::Path) -> ContentCheckConfig {
693 ContentCheckConfig::new(vec![path.to_string_lossy().into_owned()]).expect("config")
694 }
695
696 #[test]
697 fn allow_reject_and_taint_verdicts_drive_enforcement() {
698 let _guard = serialized();
699 let (path, _dir) = scanner_script(
700 r#"input="$(cat)"
701case "$input" in
702 *poison*) printf '{"verdict":"reject","reason":"matched poison"}\n' ;;
703 *odd*) printf '{"verdict":"taint","reason":"looks odd"}\n' ;;
704 *) printf '{"verdict":"allow"}\n' ;;
705esac"#,
706 );
707 let config = config_for(&path);
708
709 assert_eq!(
710 scan_event(&config, &event(Some("a clean fact"))).unwrap(),
711 Verdict::Allow
712 );
713
714 let mut rejected = [event(Some("this is poison"))];
715 let refusal = enforce(&config, &mut rejected).expect_err("reject verdict refuses");
716 let ContentCheckRefusal::Rejected {
717 subject,
718 predicate,
719 reason,
720 } = refusal
721 else {
722 panic!("expected a rejection");
723 };
724 assert_eq!(subject, "repo:app");
725 assert_eq!(predicate, "build_command");
726 assert_eq!(reason.as_deref(), Some("matched poison"));
727
728 let mut tainted = [event(Some("something odd"))];
729 enforce(&config, &mut tainted).expect("taint admits");
730 let flag = content_flag(&tainted[0]).expect("taint must mark");
731 assert_eq!(flag.reason, "looks odd");
732
733 let mut clean = [event(Some("a clean fact"))];
734 enforce(&config, &mut clean).expect("allow admits");
735 assert!(content_flag(&clean[0]).is_none(), "allow must not mark");
736 }
737
738 #[test]
739 fn a_hung_scanner_is_killed_at_the_timeout_and_fails_closed() {
740 let _guard = serialized();
741 let (path, _dir) = scanner_script("cat > /dev/null\nsleep 60");
742 let mut config = config_for(&path);
743 config.timeout = Duration::from_millis(200);
744
745 let started = std::time::Instant::now();
746 let error = scan_event(&config, &event(Some("anything"))).expect_err("must time out");
747 assert!(matches!(error, ScanError::Timeout(_)), "{error}");
748 assert!(
749 started.elapsed() < Duration::from_secs(10),
750 "the kill must not wait for the scanner's sleep"
751 );
752
753 let mut events = [event(Some("anything"))];
754 let refusal = enforce(&config, &mut events).expect_err("fail-closed refuses");
755 assert!(matches!(
756 refusal,
757 ContentCheckRefusal::ScannerUnavailable(ScanError::Timeout(_))
758 ));
759 }
760
761 #[test]
762 fn a_non_zero_exit_and_garbage_stdout_are_scanner_failures() {
763 let _guard = serialized();
764 let (crash, _dir_a) = scanner_script("cat > /dev/null\nexit 3");
765 let error =
766 scan_event(&config_for(&crash), &event(Some("x"))).expect_err("non-zero exit");
767 assert!(matches!(error, ScanError::NonZeroExit(_)), "{error}");
768
769 let (garbage, _dir_b) = scanner_script("cat > /dev/null\necho not-json");
770 let error =
771 scan_event(&config_for(&garbage), &event(Some("x"))).expect_err("garbage stdout");
772 assert!(matches!(error, ScanError::MalformedVerdict(_)), "{error}");
773 }
774
775 #[test]
776 fn fail_open_admits_a_broken_scanner_run_but_marks_it() {
777 let _guard = serialized();
778 let (path, _dir) = scanner_script("cat > /dev/null\nexit 3");
779 let mut config = config_for(&path);
780 config.failure_policy = FailurePolicy::FailOpen;
781 let mut events = [event(Some("anything"))];
782 enforce(&config, &mut events).expect("fail-open admits");
783 let flag = content_flag(&events[0]).expect("fail-open must mark");
784 assert!(flag.reason.contains("fail-open"), "{}", flag.reason);
785 }
786 }
787}