1use std::collections::{BTreeMap, HashSet};
24use std::os::fd::{AsFd, BorrowedFd};
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28use anyhow::{Context, anyhow, bail};
29use async_trait::async_trait;
30use serde_json::{Value, json};
31use talon_core::config::TalonConfig;
32use talon_http::HttpConfig;
33use talon_http::validate::{
34 is_valid_customer_code, is_valid_investigation_id, is_valid_template_name,
35};
36use talon_intel::eval::{
37 CaseRequest, GoldenCase, Outcome, Summary, is_valid_case_id, score_case, summarize,
38};
39use tokio::time::Instant;
40
41use crate::cli::{EvalCommonArgs, EvalRunArgs, EvalScoreArgs};
42use crate::safe_fs::{OpenRefusal, open_regular_at, open_root, read_capped};
43
44pub const DEFAULT_CASES_DIR: &str = "evals/golden";
46pub const MAX_CASE_BYTES: u64 = 1024 * 1024;
48pub const MAX_REPORT_BYTES: u64 = 2 * 1024 * 1024;
50pub const TRIGGERED_BY: &str = "manual";
53pub const MAX_POLL_ERRORS: u32 = 3;
55const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
57
58pub const EXIT_BELOW_THRESHOLD: i32 = 1;
60pub const EXIT_USAGE: i32 = 2;
62
63fn read_regular(dir: BorrowedFd<'_>, name: &str, max: u64) -> Result<Option<Vec<u8>>, String> {
64 match open_regular_at(dir, name, max) {
65 Ok((file, _)) => read_capped(file, max)
66 .map(Some)
67 .map_err(|e| format!("cannot read: {e}")),
68 Err(OpenRefusal::Missing) => Ok(None),
69 Err(OpenRefusal::Rejected(reason)) => Err(reason),
70 }
71}
72
73pub fn load_cases(dir: &Path) -> anyhow::Result<Vec<GoldenCase>> {
80 let mut names = Vec::new();
81 for entry in std::fs::read_dir(dir)
82 .with_context(|| format!("cannot read the golden-case directory {}", dir.display()))?
83 {
84 let name = entry?.file_name();
85 if let Some(name) = name.to_str().filter(|n| n.ends_with(".json")) {
86 names.push(name.to_string());
87 }
88 }
89 names.sort();
90 if names.is_empty() {
91 bail!("no golden cases (*.json) in {}", dir.display());
92 }
93 let root = open_root(dir).with_context(|| format!("cannot open {}", dir.display()))?;
94 let mut cases = Vec::new();
95 let mut problems = Vec::new();
96 for name in &names {
97 let stem = name.trim_end_matches(".json");
98 let raw = match read_regular(root.as_fd(), name, MAX_CASE_BYTES) {
99 Ok(Some(raw)) => raw,
100 Ok(None) => continue,
101 Err(reason) => {
102 problems.push(format!("{name}: {reason}"));
103 continue;
104 }
105 };
106 let case = match std::str::from_utf8(&raw)
107 .map_err(|e| format!("not UTF-8: {e}"))
108 .and_then(GoldenCase::from_json_str)
109 {
110 Ok(case) => case,
111 Err(e) => {
112 problems.push(format!("{name}: {e}"));
113 continue;
114 }
115 };
116 let mut issues = case.validate();
117 if case.id != stem {
118 issues.push(format!("id {:?} does not match the file name", case.id));
119 }
120 problems.extend(issues.into_iter().map(|i| format!("{name}: {i}")));
121 cases.push(case);
122 }
123 if !problems.is_empty() {
124 bail!("invalid golden cases:\n {}", problems.join("\n "));
125 }
126 Ok(cases)
127}
128
129pub fn load_outcome(reports: BorrowedFd<'_>, case_id: &str) -> Outcome {
131 if !is_valid_case_id(case_id) {
132 return Outcome::Missing("invalid case id".to_string());
133 }
134 let name = format!("{case_id}.json");
135 match read_regular(reports, &name, MAX_REPORT_BYTES) {
136 Ok(Some(raw)) => match serde_json::from_slice::<Value>(&raw) {
137 Ok(doc) => Outcome::from_document(doc),
138 Err(e) => Outcome::Failed(format!("{name} is not valid JSON: {e}")),
139 },
140 Ok(None) => Outcome::Missing(format!("no {name} in the reports directory")),
141 Err(reason) => Outcome::Failed(format!("{name} was not read: {reason}")),
142 }
143}
144
145pub fn score_reports(cases: &[GoldenCase], reports_dir: &Path) -> anyhow::Result<Summary> {
150 let reports = open_root(reports_dir).with_context(|| {
151 format!(
152 "cannot open the reports directory {}",
153 reports_dir.display()
154 )
155 })?;
156 Ok(summarize(
157 cases
158 .iter()
159 .map(|case| score_case(case, &load_outcome(reports.as_fd(), &case.id)))
160 .collect(),
161 ))
162}
163
164fn cases_dir(root: &Path, common: &EvalCommonArgs) -> PathBuf {
165 match &common.cases {
166 Some(dir) => dir.clone(),
167 None => root.join(DEFAULT_CASES_DIR),
168 }
169}
170
171fn report(summary: &Summary, common: &EvalCommonArgs) -> i32 {
173 if common.json {
174 match serde_json::to_string_pretty(summary) {
175 Ok(text) => println!("{text}"),
176 Err(e) => {
177 eprintln!("talahon eval: cannot serialize the summary: {e}");
178 return EXIT_USAGE;
179 }
180 }
181 } else {
182 print!("{}", summary.render_table());
183 }
184 if summary.meets(common.min_pass_rate) {
185 0
186 } else {
187 eprintln!(
188 "talahon eval: pass rate {:.1}% is below the required {:.1}%",
189 summary.pass_rate * 100.0,
190 common.min_pass_rate * 100.0
191 );
192 EXIT_BELOW_THRESHOLD
193 }
194}
195
196fn usage_error(e: &anyhow::Error) -> i32 {
197 eprintln!("talahon eval: {e:#}");
198 EXIT_USAGE
199}
200
201pub fn score(root: &Path, args: &EvalScoreArgs) -> i32 {
203 let result = load_cases(&cases_dir(root, &args.common))
204 .and_then(|cases| score_reports(&cases, &args.reports));
205 match result {
206 Ok(summary) => report(&summary, &args.common),
207 Err(e) => usage_error(&e),
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Submission {
218 pub job_id: String,
220 pub deduplicated: bool,
222}
223
224#[async_trait]
227pub trait InvestigationApi: Send + Sync {
228 async fn submit(&self, request: &CaseRequest) -> anyhow::Result<Submission>;
230 async fn fetch(&self, job_id: &str) -> anyhow::Result<Value>;
232}
233
234pub struct HttpInvestigationApi {
236 client: reqwest::Client,
237 base: String,
238 api_key: String,
239}
240
241impl HttpInvestigationApi {
242 pub fn new(base_url: &str, api_key: impl Into<String>) -> anyhow::Result<Self> {
250 let url = reqwest::Url::parse(base_url.trim())
251 .map_err(|e| anyhow!("invalid Talahon URL {base_url:?}: {e}"))?;
252 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
253 bail!("the Talahon URL must be http(s)://host[:port], got {base_url:?}");
254 }
255 let client = reqwest::Client::builder()
256 .timeout(REQUEST_TIMEOUT)
257 .connect_timeout(REQUEST_TIMEOUT)
258 .redirect(reqwest::redirect::Policy::none())
259 .build()
260 .context("cannot build the HTTP client")?;
261 Ok(Self {
262 client,
263 base: url.as_str().trim_end_matches('/').to_string(),
264 api_key: api_key.into(),
265 })
266 }
267
268 async fn json_body(mut response: reqwest::Response, what: &str) -> anyhow::Result<Value> {
271 let status = response.status();
272 let mut body = Vec::new();
273 while let Some(chunk) = response
274 .chunk()
275 .await
276 .with_context(|| format!("{what}: reading the response failed"))?
277 {
278 body.extend_from_slice(&chunk);
279 if u64::try_from(body.len()).unwrap_or(u64::MAX) > MAX_REPORT_BYTES {
280 bail!("{what}: response exceeds {MAX_REPORT_BYTES} bytes");
281 }
282 }
283 let parsed = serde_json::from_slice::<Value>(&body);
284 if !status.is_success() {
285 let message = parsed
286 .ok()
287 .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_string))
288 .unwrap_or_default();
289 let message: String = message
290 .chars()
291 .filter(|c| !c.is_control())
292 .take(200)
293 .collect();
294 bail!("{what}: HTTP {status} {message}");
295 }
296 parsed.with_context(|| format!("{what}: the response is not JSON"))
297 }
298}
299
300pub fn investigate_body(request: &CaseRequest) -> Value {
302 let mut body = json!({
303 "alert_id": request.alert_id,
304 "customer_code": request.customer_code,
305 "triggered_by": TRIGGERED_BY,
306 });
307 if let Some(template) = &request.template_override {
308 body["template_override"] = json!(template);
309 }
310 body
311}
312
313#[async_trait]
314impl InvestigationApi for HttpInvestigationApi {
315 async fn submit(&self, request: &CaseRequest) -> anyhow::Result<Submission> {
316 let response = self
317 .client
318 .post(format!("{}/investigate", self.base))
319 .header("x-api-key", &self.api_key)
320 .json(&investigate_body(request))
321 .send()
322 .await
323 .map_err(|e| anyhow!("POST /investigate: {}", e.without_url()))?;
324 let body = Self::json_body(response, "POST /investigate").await?;
325 let job_id = body
326 .get("job_id")
327 .and_then(Value::as_str)
328 .filter(|id| is_valid_investigation_id(id))
329 .ok_or_else(|| anyhow!("POST /investigate: the response has no valid job_id"))?;
330 Ok(Submission {
331 job_id: job_id.to_string(),
332 deduplicated: body.get("deduplicated").and_then(Value::as_bool) == Some(true),
333 })
334 }
335
336 async fn fetch(&self, job_id: &str) -> anyhow::Result<Value> {
337 if !is_valid_investigation_id(job_id) {
338 bail!("invalid investigation id");
339 }
340 let response = self
341 .client
342 .get(format!("{}/investigations/{job_id}", self.base))
343 .header("x-api-key", &self.api_key)
344 .send()
345 .await
346 .map_err(|e| anyhow!("GET /investigations/{job_id}: {}", e.without_url()))?;
347 Self::json_body(response, "GET /investigations/{id}").await
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub struct RunOptions {
354 pub timeout: Duration,
356 pub poll: Duration,
358 pub allow_deduplicated: bool,
361}
362
363#[derive(Debug, Clone, PartialEq)]
365pub struct CaseRun {
366 pub job_id: Option<String>,
368 pub record: Option<Value>,
371 pub outcome: Outcome,
373}
374
375impl CaseRun {
376 fn failed(job_id: Option<String>, reason: String) -> Self {
377 Self {
378 job_id,
379 record: None,
380 outcome: Outcome::Failed(reason),
381 }
382 }
383}
384
385pub async fn investigate_case(
392 api: &dyn InvestigationApi,
393 request: &CaseRequest,
394 options: RunOptions,
395) -> CaseRun {
396 let submission = match api.submit(request).await {
397 Ok(s) => s,
398 Err(e) => return CaseRun::failed(None, format!("submission failed: {e:#}")),
399 };
400 let job_id = submission.job_id;
401 if submission.deduplicated && !options.allow_deduplicated {
402 return CaseRun::failed(
403 Some(job_id.clone()),
404 format!(
405 "Talahon de-duplicated the trigger to the existing job {job_id}; its verdict may \
406 predate the change under test (set COPILOT_INVESTIGATION_DEDUP_MINUTES=0 on \
407 the eval instance, or pass --allow-deduplicated)"
408 ),
409 );
410 }
411 let deadline = Instant::now() + options.timeout;
412 let mut last_status = "unknown".to_string();
413 let mut errors = 0;
414 loop {
415 match api.fetch(&job_id).await {
416 Ok(record) => {
417 errors = 0;
418 let status = record
419 .get("status")
420 .and_then(Value::as_str)
421 .unwrap_or("unknown");
422 if matches!(status, "completed" | "failed") {
423 return CaseRun {
424 job_id: Some(job_id),
425 outcome: Outcome::from_document(record.clone()),
426 record: Some(record),
427 };
428 }
429 last_status = status
430 .chars()
431 .filter(|c| !c.is_control())
432 .take(32)
433 .collect();
434 }
435 Err(e) => {
436 errors += 1;
437 if errors >= MAX_POLL_ERRORS {
438 return CaseRun::failed(
439 Some(job_id),
440 format!("status polling failed {errors} times in a row: {e:#}"),
441 );
442 }
443 }
444 }
445 if Instant::now() + options.poll > deadline {
446 return CaseRun::failed(
447 Some(job_id.clone()),
448 format!(
449 "no verdict within {}s (last status: {last_status}); job {job_id} may \
450 still be running in Talahon",
451 options.timeout.as_secs()
452 ),
453 );
454 }
455 tokio::time::sleep(options.poll).await;
456 }
457}
458
459pub fn parse_alert_map(text: &str) -> anyhow::Result<BTreeMap<String, u64>> {
464 let map: BTreeMap<String, u64> = serde_json::from_str(text)
465 .context("the alert map must be a JSON object of case id -> alert id")?;
466 if let Some((id, _)) = map.iter().find(|(_, alert)| **alert == 0) {
467 bail!("alert map: the alert id of {id:?} must be positive");
468 }
469 Ok(map)
470}
471
472pub fn prepare_requests(
480 cases: &[GoldenCase],
481 alert_map: &BTreeMap<String, u64>,
482 customer_code: Option<&str>,
483) -> anyhow::Result<Vec<CaseRequest>> {
484 let known: HashSet<&str> = cases.iter().map(|c| c.id.as_str()).collect();
485 if let Some(unknown) = alert_map.keys().find(|id| !known.contains(id.as_str())) {
486 bail!("alert map: no golden case named {unknown:?}");
487 }
488 cases
489 .iter()
490 .map(|case| {
491 let mut request = case.request.clone();
492 if let Some(alert_id) = alert_map.get(&case.id) {
493 request.alert_id = *alert_id;
494 }
495 if let Some(code) = customer_code {
496 request.customer_code = code.to_string();
497 }
498 if request.alert_id == 0 || request.alert_id > talon_http::validate::MAX_ALERT_ID {
499 bail!("{}: alert_id is out of range", case.id);
500 }
501 if !is_valid_customer_code(&request.customer_code) {
502 bail!(
503 "{}: customer_code must match ^[A-Za-z0-9_-]{{1,64}}$",
504 case.id
505 );
506 }
507 if let Some(template) = &request.template_override
508 && !is_valid_template_name(template)
509 {
510 bail!(
511 "{}: template_override must be a plain .txt filename",
512 case.id
513 );
514 }
515 Ok(request)
516 })
517 .collect()
518}
519
520pub async fn run_cases(
524 api: &dyn InvestigationApi,
525 cases: &[GoldenCase],
526 requests: &[CaseRequest],
527 options: RunOptions,
528 mut progress: impl FnMut(&str) + Send,
529) -> (Summary, Vec<CaseRun>) {
530 let mut results = Vec::new();
531 let mut runs = Vec::new();
532 for (i, (case, request)) in cases.iter().zip(requests).enumerate() {
533 progress(&format!(
534 "[{}/{}] {}: investigating alert {} ({})",
535 i + 1,
536 cases.len(),
537 case.id,
538 request.alert_id,
539 request.customer_code
540 ));
541 let run = investigate_case(api, request, options).await;
542 let result = score_case(case, &run.outcome);
543 progress(&format!(
544 "[{}/{}] {}: {} (job {}, verdict {})",
545 i + 1,
546 cases.len(),
547 case.id,
548 if result.passed { "PASS" } else { "FAIL" },
549 run.job_id.as_deref().unwrap_or("-"),
550 result.actual_verdict
551 ));
552 results.push(result);
553 runs.push(run);
554 }
555 (summarize(results), runs)
556}
557
558fn save_records(dir: &Path, cases: &[GoldenCase], runs: &[CaseRun]) -> anyhow::Result<()> {
563 std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
564 for (case, run) in cases.iter().zip(runs) {
565 let Some(record) = &run.record else { continue };
566 let path = dir.join(format!("{}.json", case.id));
567 let text = serde_json::to_vec_pretty(record)?;
568 std::fs::write(&path, text).with_context(|| format!("cannot write {}", path.display()))?;
569 }
570 Ok(())
571}
572
573fn default_base_url(http: &HttpConfig) -> String {
574 let host = match http.bind {
575 ip if ip.is_unspecified() => "127.0.0.1".to_string(),
576 std::net::IpAddr::V6(ip) => format!("[{ip}]"),
577 std::net::IpAddr::V4(ip) => ip.to_string(),
578 };
579 format!("http://{host}:{}", http.port)
580}
581
582fn is_cleartext_remote(url: &str) -> bool {
585 let Ok(url) = reqwest::Url::parse(url) else {
586 return false;
587 };
588 let host = url.host_str().unwrap_or_default();
589 let loopback = match host.trim_matches(['[', ']']).parse::<std::net::IpAddr>() {
590 Ok(ip) => ip.is_loopback(),
591 Err(_) => host.eq_ignore_ascii_case("localhost"),
592 };
593 url.scheme() == "http" && !loopback
594}
595
596async fn run_inner(root: &Path, config: &TalonConfig, args: &EvalRunArgs) -> anyhow::Result<i32> {
597 let cases = load_cases(&cases_dir(root, &args.common))?;
598 let alert_map = match &args.alert_map {
599 Some(path) => parse_alert_map(
600 &std::fs::read_to_string(path)
601 .with_context(|| format!("cannot read {}", path.display()))?,
602 )?,
603 None => BTreeMap::new(),
604 };
605 let requests = prepare_requests(&cases, &alert_map, args.customer_code.as_deref())?;
606
607 let http = HttpConfig::from_talon_config(config).map_err(|e| anyhow!("{e}"))?;
608 if !http.api_key.is_configured() {
609 bail!(
610 "HTTP_API_KEY is not set in the project's .env; the Talahon HTTP API rejects every request without it"
611 );
612 }
613 let base = match &args.url {
614 Some(url) => url.clone(),
615 None => default_base_url(&http),
616 };
617 if is_cleartext_remote(&base) {
618 eprintln!(
619 "talahon eval: warning: sending the API key over plain HTTP to a non-loopback host"
620 );
621 }
622 let api = HttpInvestigationApi::new(&base, http.api_key.expose())?;
623 let options = RunOptions {
624 timeout: Duration::from_secs(args.timeout_secs),
625 poll: Duration::from_secs(args.poll_secs),
626 allow_deduplicated: args.allow_deduplicated,
627 };
628 let (summary, runs) =
629 run_cases(&api, &cases, &requests, options, |line| eprintln!("{line}")).await;
630 if let Some(dir) = &args.save_dir {
631 save_records(dir, &cases, &runs)?;
632 }
633 Ok(report(&summary, &args.common))
634}
635
636pub async fn run(root: &Path, config: &TalonConfig, args: &EvalRunArgs) -> i32 {
638 match run_inner(root, config, args).await {
639 Ok(code) => code,
640 Err(e) => usage_error(&e),
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use std::collections::VecDeque;
647 use std::sync::Mutex;
648
649 use talon_intel::eval::{ACTUAL_FAILED, ACTUAL_MISSING};
650 use wiremock::matchers::{body_json, header, method, path};
651 use wiremock::{Mock, MockServer, ResponseTemplate};
652
653 use super::*;
654
655 fn case_json(id: &str, alert_id: u64) -> Value {
656 json!({
657 "id": id,
658 "description": "test",
659 "request": {"alert_id": alert_id, "customer_code": "golden"},
660 "alert": {},
661 "expect": {
662 "verdict": "true_positive",
663 "severity": {"min": "medium", "max": "critical"},
664 "required_iocs": [{"type": "ip", "value": "203.0.113.10"}],
665 "forbidden_strings": ["USER_1"]
666 }
667 })
668 }
669
670 fn case(id: &str, alert_id: u64) -> GoldenCase {
671 serde_json::from_value(case_json(id, alert_id)).unwrap()
672 }
673
674 fn good_report() -> Value {
675 json!({
676 "alert_id": "1", "verdict": "true_positive", "confidence": 90, "severity": "high",
677 "summary": "Beaconing to 203.0.113.10.",
678 "iocs": [{"value": "203.0.113.10", "type": "ip", "verdict": "malicious", "details": "c2"}],
679 "mitre": [], "timeline": [], "evidence": [],
680 "recommended_actions": [{"action": "Isolate the host", "priority": "immediate", "rationale": "c2"}]
681 })
682 }
683
684 fn record(status: &str, verdict: Value) -> Value {
685 json!({"id": "copilot-inv-1-2", "alert_id": "1", "customer_code": "golden",
686 "triggered_by": "manual", "status": status, "template": null,
687 "created_at": "t", "updated_at": "t", "verdict": verdict, "error": null})
688 }
689
690 fn write(dir: &Path, name: &str, v: &Value) {
691 std::fs::write(dir.join(name), serde_json::to_vec(v).unwrap()).unwrap();
692 }
693
694 #[test]
697 fn load_cases_sorted_and_validated() {
698 let dir = tempfile::tempdir().unwrap();
699 write(dir.path(), "b-case.json", &case_json("b-case", 2));
700 write(dir.path(), "a-case.json", &case_json("a-case", 1));
701 std::fs::write(dir.path().join("README.md"), "docs").unwrap();
702 let cases = load_cases(dir.path()).unwrap();
703 assert_eq!(
704 cases.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
705 ["a-case", "b-case"]
706 );
707 }
708
709 #[test]
710 fn load_cases_lists_every_problem() {
711 let dir = tempfile::tempdir().unwrap();
712 assert!(
713 load_cases(dir.path())
714 .unwrap_err()
715 .to_string()
716 .contains("no golden cases")
717 );
718 assert!(load_cases(&dir.path().join("absent")).is_err());
719
720 write(dir.path(), "renamed.json", &case_json("other", 1));
721 std::fs::write(dir.path().join("broken.json"), "{").unwrap();
722 let mut bad = case_json("bad", 1);
723 bad["expect"]["severity"] = json!({"min": "high", "max": "low"});
724 write(dir.path(), "bad.json", &bad);
725 std::os::unix::fs::symlink(dir.path().join("bad.json"), dir.path().join("link.json"))
726 .unwrap();
727 let err = load_cases(dir.path()).unwrap_err().to_string();
728 for needle in [
729 "renamed.json: id \"other\" does not match",
730 "broken.json: not a golden case",
731 "bad.json: expect.severity.min",
732 "link.json: symbolic link",
733 ] {
734 assert!(err.contains(needle), "{needle} missing in {err}");
735 }
736 }
737
738 #[test]
741 fn score_reports_handles_reports_records_and_bad_files() {
742 let reports = tempfile::tempdir().unwrap();
743 let cases: Vec<GoldenCase> = [
744 "bare",
745 "record",
746 "failed-run",
747 "no-verdict",
748 "absent",
749 "garbage",
750 "leaky",
751 "linked",
752 ]
753 .iter()
754 .map(|id| case(id, 1))
755 .collect();
756 write(reports.path(), "bare.json", &good_report());
757 write(
758 reports.path(),
759 "record.json",
760 &record("completed", good_report()),
761 );
762 let mut failed = record("failed", json!(null));
763 failed["error"] = json!("container exited 137");
764 write(reports.path(), "failed-run.json", &failed);
765 write(
766 reports.path(),
767 "no-verdict.json",
768 &record("completed", json!(null)),
769 );
770 std::fs::write(reports.path().join("garbage.json"), "<html>").unwrap();
771 let mut leaky = good_report();
772 leaky["summary"] = json!("USER_1 beaconed to 203.0.113.10.");
773 write(reports.path(), "leaky.json", &leaky);
774 std::os::unix::fs::symlink(
775 reports.path().join("bare.json"),
776 reports.path().join("linked.json"),
777 )
778 .unwrap();
779
780 let s = score_reports(&cases, reports.path()).unwrap();
781 let by_id = |id: &str| s.cases.iter().find(|c| c.case_id == id).unwrap();
782 assert!(by_id("bare").passed && by_id("record").passed);
783 assert_eq!(by_id("failed-run").actual_verdict, ACTUAL_FAILED);
784 assert!(
785 by_id("failed-run").checks[0]
786 .detail
787 .contains("container exited 137")
788 );
789 assert_eq!(by_id("no-verdict").actual_verdict, ACTUAL_MISSING);
790 assert_eq!(by_id("absent").actual_verdict, ACTUAL_MISSING);
791 assert_eq!(by_id("garbage").actual_verdict, ACTUAL_FAILED);
792 assert_eq!(by_id("leaky").actual_verdict, "true_positive");
793 assert!(!by_id("leaky").passed);
794 assert_eq!(by_id("linked").actual_verdict, ACTUAL_FAILED);
796 assert!(by_id("linked").checks[0].detail.contains("symbolic link"));
797 assert_eq!((s.total, s.passed), (8, 2));
798
799 assert!(score_reports(&cases, &reports.path().join("absent")).is_err());
800 }
801
802 #[test]
803 fn score_exit_codes() {
804 let root = tempfile::tempdir().unwrap();
805 let golden = root.path().join(DEFAULT_CASES_DIR);
806 std::fs::create_dir_all(&golden).unwrap();
807 write(&golden, "one.json", &case_json("one", 1));
808 write(&golden, "two.json", &case_json("two", 2));
809 let reports = tempfile::tempdir().unwrap();
810 write(reports.path(), "one.json", &good_report());
811 let args = |min: f64, json: bool| EvalScoreArgs {
812 reports: reports.path().to_path_buf(),
813 common: EvalCommonArgs {
814 cases: None,
815 json,
816 min_pass_rate: min,
817 },
818 };
819 assert_eq!(score(root.path(), &args(1.0, false)), EXIT_BELOW_THRESHOLD);
820 assert_eq!(score(root.path(), &args(0.5, true)), 0);
821 write(reports.path(), "two.json", &good_report());
822 assert_eq!(score(root.path(), &args(1.0, false)), 0);
823
824 let empty = tempfile::tempdir().unwrap();
826 assert_eq!(score(empty.path(), &args(1.0, false)), EXIT_USAGE);
827 let mut missing = args(1.0, false);
828 missing.reports = reports.path().join("absent");
829 assert_eq!(score(root.path(), &missing), EXIT_USAGE);
830 }
831
832 #[test]
835 fn alert_map_and_customer_override() {
836 let cases = vec![case("a", 1), case("b", 2)];
837 let map = parse_alert_map(r#"{"b": 4711}"#).unwrap();
838 let reqs = prepare_requests(&cases, &map, Some("lab_01")).unwrap();
839 assert_eq!((reqs[0].alert_id, reqs[1].alert_id), (1, 4711));
840 assert!(reqs.iter().all(|r| r.customer_code == "lab_01"));
841
842 assert!(prepare_requests(&cases, &parse_alert_map(r#"{"c": 1}"#).unwrap(), None).is_err());
843 assert!(prepare_requests(&cases, &BTreeMap::new(), Some("bad code")).is_err());
844 let too_big = BTreeMap::from([("a".to_string(), u64::MAX)]);
845 assert!(prepare_requests(&cases, &too_big, None).is_err());
846 let mut templated = case("t", 1);
847 templated.request.template_override = Some("../x.txt".into());
848 assert!(prepare_requests(&[templated], &BTreeMap::new(), None).is_err());
849
850 for bad in [r#"{"a": 0}"#, r#"{"a": "1"}"#, r#"{"a": -1}"#, "[]", "{"] {
851 assert!(parse_alert_map(bad).is_err(), "{bad}");
852 }
853 }
854
855 #[test]
856 fn investigate_body_matches_the_http_contract() {
857 let mut request = case("a", 7).request;
858 assert_eq!(
859 investigate_body(&request),
860 json!({"alert_id": 7, "customer_code": "golden", "triggered_by": "manual"})
861 );
862 request.template_override = Some("sysmon_event_1.txt".into());
863 assert_eq!(
864 investigate_body(&request)["template_override"],
865 "sysmon_event_1.txt"
866 );
867 assert!(talon_http::validate::is_valid_triggered_by(TRIGGERED_BY));
869 }
870
871 struct FakeApi {
874 submit: Mutex<VecDeque<anyhow::Result<Submission>>>,
875 fetch: Mutex<VecDeque<anyhow::Result<Value>>>,
876 fetched: Mutex<Vec<String>>,
877 }
878
879 impl FakeApi {
880 fn new(submit: Vec<anyhow::Result<Submission>>, fetch: Vec<anyhow::Result<Value>>) -> Self {
881 Self {
882 submit: Mutex::new(submit.into()),
883 fetch: Mutex::new(fetch.into()),
884 fetched: Mutex::new(Vec::new()),
885 }
886 }
887 }
888
889 #[async_trait]
890 impl InvestigationApi for FakeApi {
891 async fn submit(&self, _request: &CaseRequest) -> anyhow::Result<Submission> {
892 self.submit.lock().unwrap().pop_front().unwrap()
893 }
894 async fn fetch(&self, job_id: &str) -> anyhow::Result<Value> {
895 self.fetched.lock().unwrap().push(job_id.to_string());
896 self.fetch
898 .lock()
899 .unwrap()
900 .pop_front()
901 .unwrap_or_else(|| Ok(record("running", json!(null))))
902 }
903 }
904
905 fn submitted(job: &str, deduplicated: bool) -> anyhow::Result<Submission> {
906 Ok(Submission {
907 job_id: job.into(),
908 deduplicated,
909 })
910 }
911
912 const FAST: RunOptions = RunOptions {
913 timeout: Duration::from_millis(200),
914 poll: Duration::from_millis(1),
915 allow_deduplicated: false,
916 };
917
918 #[tokio::test]
919 async fn waits_through_queued_and_running() {
920 let api = FakeApi::new(
921 vec![submitted("job-1", false)],
922 vec![
923 Ok(record("queued", json!(null))),
924 Err(anyhow!("connection reset")),
925 Ok(record("running", json!(null))),
926 Ok(record("completed", good_report())),
927 ],
928 );
929 let run = investigate_case(&api, &case("a", 1).request, FAST).await;
930 assert_eq!(run.job_id.as_deref(), Some("job-1"));
931 assert_eq!(run.outcome, Outcome::Report(good_report()));
932 assert_eq!(run.record.unwrap()["status"], "completed");
933 assert_eq!(api.fetched.lock().unwrap().len(), 4);
934 }
935
936 #[tokio::test]
937 async fn failure_modes_become_failed_outcomes() {
938 let request = case("a", 1).request;
939 let reason = |run: CaseRun| match run.outcome {
940 Outcome::Failed(r) => r,
941 other => panic!("expected a failure, got {other:?}"),
942 };
943
944 let api = FakeApi::new(vec![Err(anyhow!("HTTP 401 Unauthorized"))], vec![]);
946 let run = investigate_case(&api, &request, FAST).await;
947 assert_eq!(run.job_id, None);
948 assert!(reason(run).contains("submission failed: HTTP 401"));
949
950 let api = FakeApi::new(vec![submitted("old-job", true)], vec![]);
952 let run = investigate_case(&api, &request, FAST).await;
953 assert!(reason(run).contains("de-duplicated the trigger to the existing job old-job"));
954 assert!(api.fetched.lock().unwrap().is_empty());
955
956 let api = FakeApi::new(
958 vec![submitted("old-job", true)],
959 vec![Ok(record("completed", good_report()))],
960 );
961 let allow = RunOptions {
962 allow_deduplicated: true,
963 ..FAST
964 };
965 let run = investigate_case(&api, &request, allow).await;
966 assert!(matches!(run.outcome, Outcome::Report(_)));
967
968 let mut failed = record("failed", json!(null));
970 failed["error"] = json!("agent crashed");
971 let api = FakeApi::new(vec![submitted("job-2", false)], vec![Ok(failed)]);
972 let run = investigate_case(&api, &request, FAST).await;
973 assert!(run.record.is_some());
974 assert!(reason(run).contains("agent crashed"));
975
976 let api = FakeApi::new(
978 vec![submitted("job-3", false)],
979 (0..5).map(|_| Err(anyhow!("HTTP 500"))).collect(),
980 );
981 let run = investigate_case(&api, &request, FAST).await;
982 assert!(reason(run).contains("status polling failed 3 times in a row"));
983 assert_eq!(api.fetched.lock().unwrap().len(), 3);
984
985 let api = FakeApi::new(vec![submitted("job-4", false)], vec![]);
987 let quick = RunOptions {
988 timeout: Duration::from_millis(20),
989 ..FAST
990 };
991 let started = std::time::Instant::now();
992 let run = investigate_case(&api, &request, quick).await;
993 assert!(started.elapsed() < Duration::from_secs(5));
994 assert_eq!(run.record, None);
995 let reason = reason(run);
996 assert!(
997 reason.contains("no verdict within 0s (last status: running)"),
998 "{reason}"
999 );
1000 assert!(reason.contains("job-4"));
1001 }
1002
1003 #[tokio::test]
1004 async fn completed_without_verdict_is_missing() {
1005 let api = FakeApi::new(
1006 vec![submitted("job-1", false)],
1007 vec![Ok(record("completed", json!(null)))],
1008 );
1009 let run = investigate_case(&api, &case("a", 1).request, FAST).await;
1010 assert!(matches!(run.outcome, Outcome::Missing(_)));
1011 }
1012
1013 #[tokio::test]
1014 async fn run_cases_scores_and_round_trips_through_score() {
1015 let cases = vec![case("good", 1), case("wrong", 2), case("dead", 3)];
1016 let requests = prepare_requests(&cases, &BTreeMap::new(), None).unwrap();
1017 let mut wrong = good_report();
1018 wrong["verdict"] = json!("false_positive");
1019 let api = FakeApi::new(
1020 vec![
1021 submitted("job-1", false),
1022 submitted("job-2", false),
1023 Err(anyhow!("HTTP 503")),
1024 ],
1025 vec![
1026 Ok(record("completed", good_report())),
1027 Ok(record("completed", wrong)),
1028 ],
1029 );
1030 let mut lines = Vec::new();
1031 let (summary, runs) =
1032 run_cases(&api, &cases, &requests, FAST, |l| lines.push(l.to_string())).await;
1033 assert_eq!((summary.total, summary.passed), (3, 1));
1034 assert_eq!(summary.confusion["true_positive"]["false_positive"], 1);
1035 assert_eq!(summary.confusion["true_positive"][ACTUAL_FAILED], 1);
1036 assert_eq!(lines.len(), 6);
1037 assert!(lines[1].contains("good: PASS (job job-1, verdict true_positive)"));
1038 assert!(lines[5].contains("dead: FAIL (job -, verdict failed)"));
1039
1040 let dir = tempfile::tempdir().unwrap();
1043 let save = dir.path().join("out");
1044 save_records(&save, &cases, &runs).unwrap();
1045 let rescored = score_reports(&cases, &save).unwrap();
1046 assert_eq!((rescored.total, rescored.passed), (3, 1));
1047 assert_eq!(rescored.cases[0], summary.cases[0]);
1048 assert_eq!(rescored.cases[1], summary.cases[1]);
1049 assert_eq!(rescored.cases[2].actual_verdict, ACTUAL_MISSING);
1050 }
1051
1052 #[tokio::test]
1055 async fn http_api_speaks_the_copilot_contract() {
1056 let server = MockServer::start().await;
1057 Mock::given(method("POST"))
1058 .and(path("/investigate"))
1059 .and(header("x-api-key", "s3cret"))
1060 .and(body_json(json!({
1061 "alert_id": 7, "customer_code": "golden", "triggered_by": "manual"
1062 })))
1063 .respond_with(ResponseTemplate::new(202).set_body_json(json!({
1064 "job_id": "copilot-inv-7-1700000000000", "status": "queued",
1065 "template_override": null, "deduplicated": false
1066 })))
1067 .expect(1)
1068 .mount(&server)
1069 .await;
1070 Mock::given(method("GET"))
1071 .and(path("/investigations/copilot-inv-7-1700000000000"))
1072 .and(header("x-api-key", "s3cret"))
1073 .respond_with(
1074 ResponseTemplate::new(200).set_body_json(record("completed", good_report())),
1075 )
1076 .expect(1)
1077 .mount(&server)
1078 .await;
1079
1080 let api = HttpInvestigationApi::new(&format!("{}/", server.uri()), "s3cret").unwrap();
1082 let run = investigate_case(&api, &case("a", 7).request, FAST).await;
1083 assert_eq!(run.job_id.as_deref(), Some("copilot-inv-7-1700000000000"));
1084 assert_eq!(run.outcome, Outcome::Report(good_report()));
1085 }
1086
1087 #[tokio::test]
1088 async fn http_api_reports_errors_without_leaking_the_key() {
1089 let server = MockServer::start().await;
1090 Mock::given(method("POST"))
1091 .and(path("/investigate"))
1092 .respond_with(
1093 ResponseTemplate::new(401).set_body_json(json!({"error": "Unauthorized\u{1b}[2J"})),
1094 )
1095 .mount(&server)
1096 .await;
1097 Mock::given(method("GET"))
1098 .and(path("/investigations/job-1"))
1099 .respond_with(ResponseTemplate::new(200).set_body_string("<html>"))
1100 .mount(&server)
1101 .await;
1102 let api = HttpInvestigationApi::new(&server.uri(), "s3cret-key").unwrap();
1103 let err = api
1104 .submit(&case("a", 7).request)
1105 .await
1106 .unwrap_err()
1107 .to_string();
1108 assert!(
1109 err.contains("HTTP 401") && err.contains("Unauthorized"),
1110 "{err}"
1111 );
1112 assert!(!err.contains('\u{1b}') && !err.contains("s3cret-key"));
1113 let err = format!("{:#}", api.fetch("job-1").await.unwrap_err());
1114 assert!(err.contains("not JSON"), "{err}");
1115 assert!(api.fetch("../status").await.is_err());
1117
1118 let server = MockServer::start().await;
1120 Mock::given(method("POST"))
1121 .and(path("/investigate"))
1122 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1123 "job_id": "copilot-inv-7-1", "status": "completed", "deduplicated": true
1124 })))
1125 .up_to_n_times(1)
1126 .mount(&server)
1127 .await;
1128 Mock::given(method("POST"))
1129 .and(path("/investigate"))
1130 .respond_with(ResponseTemplate::new(202).set_body_json(json!({"job_id": "a/b"})))
1131 .mount(&server)
1132 .await;
1133 let api = HttpInvestigationApi::new(&server.uri(), "k").unwrap();
1134 let first = api.submit(&case("a", 7).request).await.unwrap();
1135 assert!(first.deduplicated);
1136 assert!(api.submit(&case("a", 7).request).await.is_err());
1137
1138 let api = HttpInvestigationApi::new("http://127.0.0.1:1", "s3cret-key").unwrap();
1140 let err = format!("{:#}", api.submit(&case("a", 7).request).await.unwrap_err());
1141 assert!(err.starts_with("POST /investigate:"), "{err}");
1142 }
1143
1144 #[test]
1145 fn url_handling() {
1146 for bad in [
1147 "",
1148 "ftp://x",
1149 "localhost:3100",
1150 "http://",
1151 "file:///etc/passwd",
1152 ] {
1153 assert!(HttpInvestigationApi::new(bad, "k").is_err(), "{bad}");
1154 }
1155 assert!(HttpInvestigationApi::new("https://talon.example.com", "k").is_ok());
1156 assert!(is_cleartext_remote("http://talon.example.com:3100"));
1157 assert!(is_cleartext_remote("http://192.0.2.10:3100"));
1158 for fine in [
1159 "http://127.0.0.1:3100",
1160 "http://localhost:3100",
1161 "http://[::1]:3100",
1162 "https://talon.example.com",
1163 ] {
1164 assert!(!is_cleartext_remote(fine), "{fine}");
1165 }
1166 let mut http = HttpConfig::new("/srv/talahon");
1167 assert_eq!(default_base_url(&http), "http://127.0.0.1:3100");
1168 http.bind = "0.0.0.0".parse().unwrap();
1169 http.port = 3200;
1170 assert_eq!(default_base_url(&http), "http://127.0.0.1:3200");
1171 http.bind = "::1".parse().unwrap();
1172 assert_eq!(default_base_url(&http), "http://[::1]:3200");
1173 }
1174
1175 fn repo_root() -> PathBuf {
1178 Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
1179 }
1180
1181 fn ideal_report(case: &GoldenCase) -> Value {
1183 let e = &case.expect;
1184 let iocs: Vec<Value> = e
1185 .required_iocs
1186 .iter()
1187 .map(|i| {
1188 let ioc_type = match i.ioc_type {
1191 Some(t) => t.as_str(),
1192 None if i.value.parse::<std::net::IpAddr>().is_ok() => "ip",
1193 None => "domain",
1194 };
1195 json!({"value": i.value, "type": ioc_type,
1196 "verdict": "unknown", "details": "from the alert"})
1197 })
1198 .collect();
1199 let mitre: Vec<Value> = e
1200 .required_mitre
1201 .iter()
1202 .map(|id| {
1203 let t = talon_intel::mitre::technique(id).unwrap_or_else(|| {
1204 panic!("{}: {id} is not in the embedded ATT&CK table", case.id)
1205 });
1206 json!({"technique_id": t.id, "name": t.full_name(),
1207 "tactic": t.tactics[0], "rationale": "observed"})
1208 })
1209 .collect();
1210 json!({
1211 "alert_id": case.request.alert_id.to_string(),
1212 "verdict": talon_intel::eval::verdict_str(e.verdict),
1213 "confidence": 80,
1214 "severity": e.severity.min.as_str(),
1215 "summary": "Synthetic ideal answer.",
1216 "iocs": iocs, "mitre": mitre, "timeline": [], "evidence": [],
1217 "recommended_actions": [
1218 {"action": "Review", "priority": "medium", "rationale": "golden"}
1219 ]
1220 })
1221 }
1222
1223 #[test]
1224 fn shipped_golden_cases_are_valid_and_self_consistent() {
1225 let root = repo_root();
1226 if !root.join("Cargo.lock").exists() {
1227 return;
1230 }
1231 let cases = load_cases(&root.join(DEFAULT_CASES_DIR)).unwrap();
1232 assert!((5..=8).contains(&cases.len()), "{} cases", cases.len());
1233 prepare_requests(&cases, &BTreeMap::new(), None).unwrap();
1234
1235 let mut alert_ids = HashSet::new();
1236 let mut verdicts = HashSet::new();
1237 let mut types = HashSet::new();
1238 for case in &cases {
1239 let id = &case.id;
1240 assert!(
1241 alert_ids.insert(case.request.alert_id),
1242 "{id}: duplicate alert_id"
1243 );
1244 verdicts.insert(case.expect.verdict);
1245 assert!(case.alert.is_object(), "{id}: no alert document");
1246 assert!(!case.description.is_empty(), "{id}: no description");
1247
1248 let alert_type = case
1250 .alert_type
1251 .as_deref()
1252 .unwrap_or_else(|| panic!("{id}: no alert_type"));
1253 types.insert(alert_type.to_string());
1254 let template = root
1255 .join("groups/copilot/prompts")
1256 .join(format!("{alert_type}.txt"));
1257 assert!(template.is_file(), "{id}: no template {alert_type}.txt");
1258
1259 let alert_text = case.alert.to_string().replace("\\\\", "\\").to_lowercase();
1262 for ioc in &case.expect.required_iocs {
1263 assert!(
1264 alert_text.contains(&ioc.value.to_lowercase()),
1265 "{id}: required IOC {} is not in the alert",
1266 ioc.value
1267 );
1268 }
1269 for forbidden in case
1270 .expect
1271 .forbidden_iocs
1272 .iter()
1273 .chain(&case.expect.forbidden_strings)
1274 {
1275 assert!(
1276 !alert_text.contains(&forbidden.to_lowercase()),
1277 "{id}: forbidden {forbidden} occurs in the alert"
1278 );
1279 }
1280 assert!(
1282 !case.expect.forbidden_patterns.is_empty(),
1283 "{id}: no forbidden_patterns"
1284 );
1285
1286 let ideal = score_case(case, &Outcome::Report(ideal_report(case)));
1288 assert!(
1289 ideal.passed,
1290 "{id}: {:?}",
1291 ideal.failures().collect::<Vec<_>>()
1292 );
1293 assert!(!score_case(case, &Outcome::Report(json!({}))).passed);
1294 let mut leaky = ideal_report(case);
1295 leaky["summary"] = json!("USER_1 on HOST_2 did it.");
1296 assert!(!score_case(case, &Outcome::Report(leaky)).passed, "{id}");
1297 }
1298 assert!(verdicts.len() >= 3, "verdict classes covered: {verdicts:?}");
1299 assert!(types.len() >= 4, "alert types covered: {types:?}");
1300 }
1301}