diff --git a/app/src/app.rs b/app/src/app.rs index f4e7d8a..254d3cf 100644 --- a/app/src/app.rs +++ b/app/src/app.rs @@ -20,7 +20,7 @@ use crate::ui::status_bar::StatusBarView; use crate::ui::title_bar::TitleBarView; use crate::ui::workspace::Workspace; use crate::ui::{ - ActivateTab, AddSource, CloseTab, NewGraph, NewQuery, NewSqlQuery, NextTab, OpenDashboard, OpenGraph, PreviousTab, + ActivateTab, AddSource, CloseTab, NewGraph, NewQuery, NewSqlQuery, NextTab, OpenDashboard, OpenGraph, OpenInvestigation, PreviousTab, ToggleSidebar, }; @@ -136,8 +136,11 @@ impl KhonsuApp { } else if crate::spec::tabs::is_spec(&path) { self.workspace.update(cx, |ws, cx| ws.open_dashboard(path.clone(), window, cx)); opened.push(json!({"dashboard": path.display().to_string()})); + } else if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case(khonsu_core::investigation::EXTENSION)) { + crate::ui::reopen_investigation(self.state.clone(), self.workspace.clone(), &path, window, cx); + opened.push(json!({"investigation": path.display().to_string()})); } else { - return json!({"error": {"kind": "not_openable", "message": format!("{} is neither a graph nor a .dash file", path.display())}}); + return json!({"error": {"kind": "not_openable", "message": format!("{} is not a graph, a .dash file or an investigation", path.display())}}); } } if let Some(text) = request.text { @@ -193,6 +196,9 @@ impl Render for KhonsuApp { .on_action(cx.listener(|this, _: &OpenDashboard, window, cx| { this.workspace.update(cx, |ws, cx| ws.pick_dashboard_file(window, cx)); })) + .on_action(cx.listener(|this, _: &OpenInvestigation, window, cx| { + crate::ui::pick_investigation(this.state.clone(), this.workspace.clone(), window, cx); + })) .on_action(cx.listener(|this, _: &NewGraph, window, cx| { crate::ui::pick_graph(this.state.clone(), true, window, cx); })) diff --git a/app/src/db.rs b/app/src/db.rs index a5ba9dd..10c2699 100644 --- a/app/src/db.rs +++ b/app/src/db.rs @@ -2,8 +2,13 @@ //! blocks on the engine; UI code reaches it through `smol::unblock`. use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; use std::sync::{LazyLock, Mutex, PoisonError}; +use khonsu_core::explore::{Exploration, ExploreRequest}; +use khonsu_core::investigation::{Capture, Investigation, Reopened}; +use khonsu_core::plugin::PluginError; +use khonsu_core::plugin_analysis::{self, Offered, Report}; use khonsu_core::{AccessMode, Graph, GraphData, GraphSchema, Language, OpenError, QueryError, QueryOptions, QueryResult}; use serde_json::Map; @@ -91,6 +96,39 @@ pub fn neighbors(id: i64) -> Result { with_graph(|g| g.neighbors(id, 200)) } +/// Holds the graph for the whole walk, as any call here does; `cancel` stops +/// it between queries. +pub fn explore(request: &ExploreRequest, cancel: &AtomicBool) -> Result { + with_graph(|g| g.explore(request, cancel)) +} + +/// Records `capture` against the open graph, which holds it while it reads. +pub fn capture_investigation(capture: Capture) -> Result { + let sources = khonsu_core::settings::load_sources(); + with_graph(|g| { + let path = g.path().to_path_buf(); + g.capture_investigation(&path, capture, &sources, &AtomicBool::new(false)) + }) +} + +/// Runs a saved investigation again on the open graph. +pub fn reopen_investigation(saved: &Investigation) -> Result { + let sources = khonsu_core::settings::load_sources(); + with_graph(|g| g.reopen_investigation(saved, &sources, &AtomicBool::new(false))) +} + +/// Runs an analysis a plugin offers. It holds the graph only while reading, not +/// while the plugin works. `Ok(None)` when `cancel` stopped it. +pub fn run_analysis(offered: &Offered, cancel: &AtomicBool) -> Result, String> { + let sources = khonsu_core::settings::load_sources(); + let input = with_graph(|g| g.read_analysis_input(&offered.info)).map_err(|error| error.message)?; + match plugin_analysis::run(offered, &input, &sources, cancel, |_| {}) { + Ok(report) => Ok(Some(report)), + Err(PluginError::Cancelled) => Ok(None), + Err(error) => Err(error.to_string()), + } +} + pub fn nodes_by_id(ids: &[i64]) -> Result { with_graph(|g| g.nodes_by_id(ids)) } diff --git a/app/src/i18n.rs b/app/src/i18n.rs index db784bf..2c2f45e 100644 --- a/app/src/i18n.rs +++ b/app/src/i18n.rs @@ -332,6 +332,77 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("canvas.compact_hint", "滚轮缩放,悬停查看名称", "Scroll to zoom; hover to see names"), ("canvas.truncated", "显示 {} / {} 个节点", "Showing {} of {} nodes"), ("canvas.limit", "画布最多显示 {} 个节点,没有展开", "The canvas holds at most {} nodes; not expanded"), + ("evidence.title", "证据", "Evidence"), + ("evidence.id", "证据 ID", "Evidence ID"), + ("evidence.source", "数据源", "Source"), + ("evidence.plugin", "插件", "Plugin"), + ("evidence.written", "记录最后写入", "Record last written"), + ("evidence.last_sync", "数据源最后成功同步", "Source last synced"), + ("evidence.snapshot", "数据源快照", "Source snapshot"), + ("evidence.coverage", "最近一次同步覆盖范围", "Last sync's coverage"), + ("evidence.coverage.complete", "完整", "Complete"), + ("evidence.coverage.partial", "部分", "Partial"), + ("evidence.coverage.failed", "失败", "Failed"), + ("evidence.coverage.cancelled", "已取消", "Cancelled"), + ("evidence.assertion", "断言", "Assertion"), + ("evidence.explicit", "数据源报告", "Reported by the source"), + ("evidence.derived", "由其他记录推导", "Derived from other records"), + ("evidence.unknown", "未提供", "Not supplied"), + ("evidence.not_stable", "内部 ID,重建图后可能改变", "An internal ID, which a rebuild can change"), + ("evidence.redacted", "已遮盖疑似机密:{}", "Masked as possible secrets: {}"), + ("evidence.copy", "复制证据 JSON", "Copy evidence as JSON"), + ("evidence.copy_path", "复制路径证据 JSON", "Copy path evidence as JSON"), + ("investigation.open", "打开调查…", "Open investigation…"), + ("investigation.save", "保存调查…", "Save investigation…"), + ("investigation.saved", "已保存调查:{}", "Investigation saved: {}"), + ("investigation.save_failed", "无法保存调查:{}", "The investigation could not be saved: {}"), + ("investigation.open_failed", "无法打开调查:{}", "The investigation could not be opened: {}"), + ("investigation.graph_missing", "这个调查的图不在 {}", "This investigation's graph is not at {}"), + ("investigation.reopened", "已重新打开调查“{}”,保存于 {}", "Reopened investigation \"{}\", saved {}"), + ("investigation.changed", "图在保存后发生了变化,结果可能与保存时不同", "The graph has changed since this was saved, so the answer may differ"), + ("investigation.not_compared", "图太大,无法比较内容,只确认了记录数相同", "The graph is too large to compare, so only its record counts were checked"), + ("investigation.missing_seeds", "保存的起点已不在图中:{}", "Saved starting points are no longer in the graph: {}"), + ("investigation.target_missing", "保存的路径终点已不在图中", "The saved path's end is no longer in the graph"), + ("investigation.path_differs", "路径不再基于保存时的记录", "The path no longer rests on the saved records"), + ("investigation.unsure", "这些记录靠内部 ID 找到,图已变化,可能已是别的记录:{}", "Found by internal ID on a changed graph, so these may now be other records: {}"), + ("analysis.check", "运行分析", "Run analysis"), + ("analysis.recheck", "重新检查", "Check again"), + ("analysis.stop", "停止", "Stop"), + ("analysis.failed", "检查失败:{}", "The check failed: {}"), + ("analysis.incomplete", "图太大未能读完,或有数据源最近一次同步不完整,所以缺少发现不说明任何问题。", "The graph was too large to read whole, or a source's last sync was not complete, so a missing finding means nothing."), + ("analysis.none", "没有关于此节点的发现。如果刚同步过,请重新检查。", "No findings about this one. If you just synced, check again."), + ("analysis.status.met", "满足", "Met"), + ("analysis.status.not_met", "不满足", "Not met"), + ("analysis.status.unknown", "未知", "Unknown"), + ("analysis.assumptions", "未检查", "Not checked"), + ("analysis.show_path", "在画布上显示路径", "Show path on canvas"), + ("analysis.copy", "复制发现 JSON", "Copy finding as JSON"), + ("explore.title", "路径探索", "Path Explorer"), + ("explore.out", "沿箭头", "Outbound"), + ("explore.in", "逆箭头", "Inbound"), + ("explore.both", "双向", "Both"), + ("explore.hops", "最多 {} 跳", "Up to {} hops"), + ("explore.fewer_hops", "减少跳数", "Fewer hops"), + ("explore.more_hops", "增加跳数", "More hops"), + ("explore.types_all", "全部关系类型", "All relationship types"), + ("explore.types_some", "关系类型:{} 种", "Relationship types: {}"), + ("explore.add_start", "加为起点", "Add as a start"), + ("explore.remove_start", "移出起点", "Remove from starts"), + ("explore.starts", "起点:{} 个", "Starts: {}"), + ("explore.run", "探索", "Explore"), + ("explore.cancel", "取消", "Cancel"), + ("explore.back", "返回上一步", "Back"), + ("explore.show_path", "只看这条路径", "Show only this path"), + ("explore.path", "到此节点的路径", "Path to this node"), + ("explore.found", "节点:{},关系:{},深度:{}", "Nodes: {}, relationships: {}, depth: {}"), + ("explore.none", "没有符合筛选条件的关系", "No relationships match these filters"), + ("explore.limit.hops", "已到跳数上限,可能还有更远的节点", "Stopped at the hop limit; there is more beyond it"), + ("explore.limit.nodes", "已到节点上限,结果不完整", "Stopped at the node limit; this result is incomplete"), + ("explore.limit.edges", "已到关系上限,结果不完整", "Stopped at the relationship limit; this result is incomplete"), + ("explore.cancelled", "已取消,结果不完整", "Cancelled; this result is incomplete"), + ("explore.missing_start", "有起点已不在图中", "A start is no longer in the graph"), + ("explore.failed", "探索失败:{}", "Exploring failed: {}"), + ("explore.candidate", "路径来自配置数据,不代表实际可达", "A path in the data, not proof that it can be reached"), // ── status bar ────────────────────────────────────────────────────── ("status_bar.opening", "正在打开…", "Opening…"), @@ -378,6 +449,7 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("sources.read_only", "图以只读方式打开,不能同步", "The graph is open read-only; it can't be synced"), ("sources.last_ok", "{}同步,{} 个节点,{} 条关系", "Synced {}, {} nodes, {} relationships"), ("sources.last_failed", "{}同步失败:{}", "Sync failed {}: {}"), + ("sources.last_partial", "{} 同步不完整,未删除任何记录:{}", "{} incomplete, nothing removed: {}"), ("sources.never", "从未同步", "Never synced"), ("sources.remove", "移除", "Remove"), ("sources.remove_title", "移除数据源 {}?", "Remove source {}?"), diff --git a/app/src/setup.rs b/app/src/setup.rs index a559f9a..495d4aa 100644 --- a/app/src/setup.rs +++ b/app/src/setup.rs @@ -312,6 +312,8 @@ pub fn agent_prompt() -> String { `{command} schema --db `.\n\ - HawDB's Cypher has no labels(), and path variables only with shortestPath; \ return whole nodes and relationships to get a graph back.\n\ + - To follow a suspected chain within caps: `{command} explore --db --from --to `; \ + check `complete` before treating a missing path as an answer.\n\ - Validate a dashboard with `{command} check --db `; \ answer my comments on it with `{command} comments `.\n\ - Reads are the default. Answer from the values it returns, check `truncated`, \ diff --git a/app/src/ui/canvas.rs b/app/src/ui/canvas.rs index 7c7e387..11062f2 100644 --- a/app/src/ui/canvas.rs +++ b/app/src/ui/canvas.rs @@ -10,6 +10,8 @@ //! collapsed group to open it again. use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use gpui_graph::{ColorBy, Edge, GraphData as Graph, LabelMode, NetworkGraph, NetworkGraphEvent, Node, Selection}; use gpui_kit::component::button::{Button, ButtonVariants}; @@ -19,10 +21,17 @@ use gpui_kit::assets::IconName as AssetIcon; use gpui_kit::component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, TITLE_BAR_HEIGHT}; use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; +use khonsu_core::evidence::{self, Evidence}; +use khonsu_core::explore::{Direction, Exploration}; +use khonsu_core::investigation::{Capture, Investigation, Reopened, SavedQuery, EXTENSION}; +use khonsu_core::plugin_analysis::Offered; +use khonsu_core::protocol::{ConditionStatus, VerdictTone}; +use khonsu_core::settings::{self, Source}; use khonsu_core::{GraphData, GraphEdge, GraphNode}; use serde_json::Value as Json; use crate::i18n::{tr, trf}; +use crate::ui::explorer::{self, Explorer, View}; use crate::ui::palette; /// Above this many nodes the canvas shows the first ones and says so. @@ -127,6 +136,14 @@ pub struct GraphCanvas { /// Nodes the result held beyond [`NODE_LIMIT`]. hidden: usize, expanding: bool, + explorer: Explorer, + /// Configured sources, read when a selection changes, for the inspector's + /// evidence. + sources: Vec, + /// The canvas shows only one path of the last walk. + path_view: bool, + /// The query behind this result, to keep with a saved investigation. + query: Option, /// The node property shown as each node's caption, before the usual /// name-like ones: a dashboard `graph`'s `caption`. caption_key: Option, @@ -167,6 +184,10 @@ impl GraphCanvas { selected: None, hidden: 0, expanding: false, + explorer: Explorer::default(), + sources: Vec::new(), + path_view: false, + query: None, caption_key: None, group_key: None, label_mode: LabelMode::Auto, @@ -276,6 +297,9 @@ impl GraphCanvas { /// Show a new result. Relationship endpoints the result left out are /// fetched so every edge has both ends on the canvas. pub fn set_data(&mut self, data: GraphData, window: &mut Window, cx: &mut Context) { + self.explorer.forget_walks(); + self.discover_analyses(window, cx); + self.set_path_view(false, cx); self.nodes.clear(); self.edges.clear(); self.selected = None; @@ -348,6 +372,7 @@ impl GraphCanvas { ) { match event { NetworkGraphEvent::SelectionChanged(selection) => { + self.sources = settings::load_sources(); self.selected = match selection { Selection::Node(node) => node.id.parse().ok().map(Selected::Node), Selection::Edge(edge) => edge_id(edge).map(Selected::Edge), @@ -358,6 +383,7 @@ impl GraphCanvas { if let Some(node) = self.nodes.get(&id) { cx.emit(CanvasEvent::NodeSelected(node.clone())); } + self.load_relationship_types(window, cx); } cx.notify(); } @@ -406,6 +432,633 @@ impl GraphCanvas { .detach(); } + /// Offer the graph's relationship types as filters, once a node is picked. + fn load_relationship_types(&mut self, window: &mut Window, cx: &mut Context) { + if self.compact || !self.explorer.available.is_empty() { + return; + } + cx.spawn_in(window, async move |this, cx| { + let schema = smol::unblock(crate::db::schema).await; + this.update(cx, |this, cx| { + if let Ok(schema) = schema { + this.explorer.available = schema.relationship_types.into_iter().map(|rel| rel.name).collect(); + cx.notify(); + } + }) + .ok(); + }) + .detach(); + } + + /// By ID, so that a view comes back in the order it would have anyway. + fn view(&self) -> View { + let mut nodes: Vec = self.nodes.values().cloned().collect(); + let mut edges: Vec = self.edges.values().cloned().collect(); + nodes.sort_by_key(|node| node.id); + edges.sort_by_key(|edge| edge.id); + View { + nodes, + edges, + walk: self.explorer.walk.clone(), + selected: self.selected_node(), + } + } + + fn selected_node(&self) -> Option { + match self.selected { + Some(Selected::Node(id)) => Some(id), + _ => None, + } + } + + /// Walk from the chosen starts and add what it finds to the canvas. A walk + /// that would not fit adds nothing and says so, as expanding does. + fn explore(&mut self, window: &mut Window, cx: &mut Context) { + if self.explorer.running.is_some() { + return; + } + let Some(request) = self.explorer.request(self.selected_node()) else { + return; + }; + let cancel = Arc::new(AtomicBool::new(false)); + self.explorer.running = Some(cancel.clone()); + self.explorer.error = None; + cx.notify(); + cx.spawn_in(window, async move |this, cx| { + let anchor = request.starts[0].to_string(); + let result = smol::unblock(move || crate::db::explore(&request, &cancel)).await; + this.update_in(cx, |this, window, cx| { + this.explorer.running = None; + match result { + Ok(walk) => this.show_walk(walk, &anchor, window, cx), + Err(error) => this.explorer.error = Some(error.message), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn show_walk(&mut self, walk: Exploration, anchor: &str, window: &mut Window, cx: &mut Context) { + let fresh = walk.graph.nodes.iter().filter(|node| !self.nodes.contains_key(&node.id)).count(); + if self.nodes.len() + fresh > NODE_LIMIT { + window.push_notification(Notification::warning(trf("canvas.limit", &[&NODE_LIMIT.to_string()])), cx); + return; + } + self.explorer.push_view(self.view()); + self.set_path_view(false, cx); + let data = self.admit(walk.graph.nodes.clone(), walk.graph.edges.clone(), cx); + self.graph.update(cx, |graph, cx| graph.merge(data, Some(anchor), cx)); + self.explorer.walk = Some(walk); + } + + pub fn set_query(&mut self, query: Option) { + self.query = query; + } + + /// Save the walk, filters and chosen path to a file the person picks. The + /// graph is not touched. + fn save_investigation(&mut self, node: i64, window: &mut Window, cx: &mut Context) { + let Some(mut request) = self.explorer.request(Some(node)) else { + return; + }; + // After a walk, its starts and the path to this node are what is on + // screen; the selection only says which path. + let walk = self.explorer.walk.as_ref(); + let target = walk.filter(|walk| walk.path_to(node).is_some_and(|path| !path.is_empty())).map(|_| node); + if let Some(walk) = walk.filter(|walk| !walk.starts.is_empty()) { + request.starts = walk.starts.clone(); + } + let path_refs = self.explorer.exact_path_to(node); + let query = self.query.clone(); + let suggested = format!("investigation.{EXTENSION}"); + let directory = directories::UserDirs::new().map(|dirs| dirs.home_dir().to_path_buf()).unwrap_or_else(std::env::temp_dir); + let rx = cx.prompt_for_new_path(&directory, Some(&suggested)); + window + .spawn(cx, async move |cx| { + let Ok(Ok(Some(path))) = rx.await else { + return; + }; + let written = smol::unblock({ + let path = path.clone(); + move || { + let capture = Capture { + name: path.file_stem().map(|stem| stem.to_string_lossy().into_owned()).unwrap_or_default(), + query, + walk: Some(request), + target, + path_refs, + dashboard: None, + }; + let saved = crate::db::capture_investigation(capture).map_err(|error| error.message)?; + // The dialog has already asked about replacing a file. + saved.save(&path, true).map_err(|error| error.message) + } + }) + .await; + cx.update(|window, cx| match written { + Ok(()) => window.push_notification(Notification::success(trf("investigation.saved", &[&path.display().to_string()])), cx), + Err(error) => window.push_notification(Notification::error(trf("investigation.save_failed", &[&error])), cx), + }) + .ok(); + }) + .detach(); + } + + /// Show a reopened investigation: its filters, walk and chosen path, and + /// everything that differs from when it was saved. + pub fn restore_investigation(&mut self, saved: &Investigation, reopened: Reopened, window: &mut Window, cx: &mut Context) { + let graph = reopened.walk.as_ref().map(|walk| walk.graph.clone()).unwrap_or_default(); + self.set_data(graph, window, cx); + self.query = saved.query.clone(); + if let Some(plan) = &saved.walk { + self.explorer.direction = plan.direction; + self.explorer.types = plan.types.iter().cloned().collect(); + self.explorer.max_hops = plan.max_hops.clamp(1, khonsu_core::explore::MAX_HOPS); + } + self.explorer.restored = Some(explorer::Restored::new(saved, &reopened)); + self.explorer.walk = reopened.walk; + self.load_relationship_types(window, cx); + if reopened.exact_path { + // The canvas already holds that path and nothing else. + self.set_path_view(true, cx); + } else if let Some(target) = reopened.target.filter(|_| reopened.path.is_some()) { + self.show_path(target, cx); + } + cx.notify(); + } + + /// Ask the plugins what they offer. A plugin not approved as it is now is + /// not started to find out. + fn discover_analyses(&mut self, window: &mut Window, cx: &mut Context) { + cx.spawn_in(window, async move |this, cx| { + let analyses = smol::unblock(khonsu_core::plugin_analysis::offered).await; + this.update(cx, |this, cx| { + this.explorer.analyses = analyses; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Run an analysis over the whole graph, not just the canvas, or stop it if + /// it is running. + fn check_analysis(&mut self, offered: Offered, window: &mut Window, cx: &mut Context) { + let key = explorer::analysis_key(&offered); + if let Some(cancel) = self.explorer.checking.remove(&key) { + cancel.store(true, Ordering::Relaxed); + cx.notify(); + return; + } + let cancel = Arc::new(AtomicBool::new(false)); + self.explorer.checking.insert(key.clone(), cancel.clone()); + self.explorer.analysis_errors.remove(&key); + cx.notify(); + cx.spawn_in(window, async move |this, cx| { + let stop = cancel.clone(); + let result = smol::unblock(move || crate::db::run_analysis(&offered, &stop)).await; + this.update(cx, |this, cx| { + // Stopped, or the canvas moved on: this says nothing about it now. + if cancel.load(Ordering::Relaxed) { + return; + } + this.explorer.checking.remove(&key); + match result { + Ok(Some(report)) => { + this.explorer.reports.insert(key, report); + } + Ok(None) => {} + Err(error) => { + this.explorer.analysis_errors.insert(key, error); + } + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Show a finding's candidate path, as a walk Path Explorer can go on from. + fn show_finding(&mut self, key: &str, finding_id: &str, target: i64, cx: &mut Context) { + let report = self.explorer.reports.get(key); + let finding = report.and_then(|report| report.findings.iter().find(|finding| finding.finding_id == finding_id)); + let hint = finding.and_then(|finding| finding.walk.clone()); + let saved_as = finding.and_then(|finding| Some((finding.hops.clone(), finding.path_refs.clone()?))); + let Some(walk) = finding.and_then(|finding| finding.exploration()) else { + return; + }; + self.explorer.push_view(self.view()); + self.replace_canvas(walk.graph.nodes.clone(), walk.graph.edges.clone(), cx); + self.explorer.walk = Some(walk); + self.explorer.finding_path = saved_as; + // The filters that find this path again, so that saving or walking on + // from here means the same thing. + if let Some(hint) = hint { + self.explorer.direction = hint.direction; + self.explorer.types = hint.types.into_iter().collect(); + self.explorer.max_hops = hint.max_hops.clamp(1, khonsu_core::explore::MAX_HOPS); + } + self.set_path_view(true, cx); + let id = target.to_string(); + self.graph.update(cx, |graph, cx| graph.select_node(Some(&id), false, cx)); + } + + /// Narrow the canvas to the path the last walk took to `target`. + fn show_path(&mut self, target: i64, cx: &mut Context) { + let Some(path) = self.explorer.walk.as_ref().and_then(|walk| explorer::path_graph(walk, target)) else { + return; + }; + self.explorer.push_view(self.view()); + self.replace_canvas(path.nodes, path.edges, cx); + self.set_path_view(true, cx); + let id = target.to_string(); + self.graph.update(cx, |graph, cx| graph.select_node(Some(&id), false, cx)); + } + + /// Return to the canvas as it was before the last walk or path. + fn go_back(&mut self, cx: &mut Context) { + if let Some(view) = self.explorer.pop_view() { + self.explorer.walk = view.walk; + self.explorer.error = None; + self.replace_canvas(view.nodes, view.edges, cx); + self.set_path_view(false, cx); + // Back where the person was, with the panel still in reach. + let id = view.selected.map(|id| id.to_string()); + self.graph.update(cx, |graph, cx| graph.select_node(id.as_deref(), false, cx)); + } + } + + /// On a path every node is the point, so a selected one must not fade the + /// rest; elsewhere the canvas's usual setting applies. + fn set_path_view(&mut self, on: bool, cx: &mut Context) { + self.path_view = on; + let dimming = self.focus_dimming && !on; + self.graph.update(cx, |graph, cx| graph.set_focus_dimming(dimming, cx)); + } + + fn replace_canvas(&mut self, nodes: Vec, edges: Vec, cx: &mut Context) { + self.nodes.clear(); + self.edges.clear(); + self.selected = None; + self.hidden = 0; + self.load(nodes, edges, cx); + } + + /// Where the selected record came from, with what is missing stated. + fn render_evidence(&self, record: Evidence, cx: &mut Context) -> AnyElement { + let muted = cx.theme().muted_foreground; + let warning = cx.theme().warning; + let mono = cx.theme().mono_font_family.clone(); + let unknown = tr("evidence.unknown"); + let copy = serde_json::to_string_pretty(&record).expect("evidence serializes"); + v_flex() + .gap_1() + .child(div().text_sm().font_semibold().child(tr("evidence.title"))) + .children(explorer::evidence_rows(&record).into_iter().map(|(key, value)| { + let missing = value == unknown; + v_flex() + .child(div().text_xs().text_color(muted).child(tr(key))) + .child( + div() + .text_xs() + .font_family(mono.clone()) + .whitespace_normal() + .when(missing, |this| this.text_color(muted)) + .child(value), + ) + })) + .when(!record.stable, |this| { + this.child(div().text_xs().whitespace_normal().text_color(warning).child(tr("evidence.not_stable"))) + }) + .when(!record.redacted.is_empty(), |this| { + this.child( + div() + .text_xs() + .whitespace_normal() + .text_color(warning) + .child(trf("evidence.redacted", &[&record.redacted.join(", ")])), + ) + }) + .child( + Button::new("evidence-copy") + .xsmall() + .outline() + .label(tr("evidence.copy")) + .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()))), + ) + .into_any_element() + } + + /// What the analyses that apply say about a selected node, and why. None + /// apply unless a plugin declared this node's label. + fn render_analyses(&self, id: i64, cx: &mut Context) -> Option { + let node = self.nodes.get(&id)?; + let sections: Vec = self + .explorer + .analyses + .iter() + .enumerate() + .filter(|(_, offered)| offered.applies_to(&node.labels)) + .map(|(ix, offered)| self.render_analysis(ix, offered, node, id, cx)) + .collect(); + (!sections.is_empty()).then(|| v_flex().gap_2().children(sections).into_any_element()) + } + + /// One analysis on a node. All its words come from the plugin. + fn render_analysis(&self, section: usize, offered: &Offered, node: &GraphNode, id: i64, cx: &mut Context) -> AnyElement { + let muted = cx.theme().muted_foreground; + let warning = cx.theme().warning; + let mono = cx.theme().mono_font_family.clone(); + let explorer = &self.explorer; + let info = &offered.info; + let key = explorer::analysis_key(offered); + let report = explorer.reports.get(&key); + let findings = report.map(|report| explorer::findings_for(report, node)); + + let mut list = Vec::new(); + for (ix, finding) in findings.iter().flatten().enumerate() { + let button = section * 1000 + ix; + let copy = serde_json::to_string_pretty(finding).expect("a finding serializes"); + let finding_id = finding.finding_id.clone(); + let finding_key = key.clone(); + // Offered again only when another path is showing: Back would otherwise + // step through the same view twice. + let showing = self.path_view && explorer.walk.as_ref().is_some_and(|walk| walk.hops == finding.hops); + let drawable = !finding.hops.is_empty() && !showing; + let conditions = finding.conditions.iter().map(|condition| { + let statement = explorer::analysis_text(info, &format!("condition.{}", condition.id), &condition.statement); + let color = if condition.status == ConditionStatus::Met { muted } else { warning }; + let fields = condition.fields.iter().filter_map(|field| { + let value = field.value.known()?; + Some( + div() + .text_xs() + .whitespace_normal() + .font_family(mono.clone()) + .text_color(muted) + .child(format!("{} = {}", field.source_field, crate::ui::results::cell_text(value))), + ) + }); + v_flex() + .child( + div() + .text_xs() + .whitespace_normal() + .child(format!("{} · {statement}", tr(explorer::status_key(condition.status)))), + ) + .child(div().text_xs().whitespace_normal().text_color(color).child(condition.detail.clone())) + .children(fields.collect::>()) + }); + let tone = info.verdicts.iter().find(|verdict| verdict.id == finding.verdict).map(|verdict| verdict.tone); + let verdict_color = if tone == Some(VerdictTone::Quiet) { muted } else { warning }; + list.push( + v_flex() + .gap_1() + .pt_1() + .child(div().text_xs().font_semibold().text_color(verdict_color).child(explorer::verdict_text(info, &finding.verdict))) + .child(div().text_xs().whitespace_normal().child(explorer::rule_text(info, &finding.rule_id))) + .child(div().text_xs().font_family(mono.clone()).whitespace_normal().text_color(muted).child(finding.rule_id.clone())) + .children(conditions.collect::>()) + .when_some(finding.path.as_ref(), |this, path| { + this.child(div().pt_1().text_xs().font_semibold().child(tr("explore.path"))) + .children(path.hops.iter().map(|hop| div().text_xs().whitespace_normal().font_family(mono.clone()).child(hop.clone()))) + }) + .child(div().pt_1().text_xs().font_semibold().child(tr("analysis.assumptions"))) + .children(finding.assumptions.iter().map(|text| div().text_xs().whitespace_normal().text_color(muted).child(text.clone()))) + .child( + h_flex() + .gap_1() + .flex_wrap() + .when(drawable, |this| { + this.child( + Button::new(("analysis-path", button)) + .xsmall() + .outline() + .label(tr("analysis.show_path")) + .on_click(cx.listener(move |this, _, _, cx| this.show_finding(&finding_key, &finding_id, id, cx))), + ) + }) + .child( + Button::new(("analysis-copy", button)) + .xsmall() + .outline() + .label(tr("analysis.copy")) + .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()))), + ), + ), + ); + } + + let checked = report.is_some(); + let running = explorer.checking.contains_key(&key); + let complete = report.is_none_or(|report| report.complete); + let none = findings.as_ref().is_some_and(Vec::is_empty); + let claim = explorer::analysis_text(info, "claim", &info.claim); + let label = if running { + tr("analysis.stop") + } else if checked { + tr("analysis.recheck") + } else { + tr("analysis.check") + }; + let run = offered.clone(); + v_flex() + .gap_1() + .child(div().text_sm().font_semibold().child(explorer::analysis_text(info, "title", &info.title))) + .child( + Button::new(("analysis-check", section)) + .xsmall() + .outline() + .label(label) + .on_click(cx.listener(move |this, _, window, cx| this.check_analysis(run.clone(), window, cx))), + ) + .when_some(explorer.analysis_errors.get(&key).cloned(), |this, error| { + this.child(div().text_xs().whitespace_normal().text_color(warning).child(trf("analysis.failed", &[&error]))) + }) + .when(checked && !claim.is_empty(), |this| this.child(div().text_xs().whitespace_normal().text_color(muted).child(claim))) + .when(!complete, |this| this.child(div().text_xs().whitespace_normal().text_color(warning).child(tr("analysis.incomplete")))) + .when(none, |this| this.child(div().text_xs().whitespace_normal().text_color(muted).child(tr("analysis.none")))) + .children(list) + .into_any_element() + } + + fn selected_evidence(&self, selected: Selected) -> Option { + match selected { + Selected::Node(id) => self.nodes.get(&id).map(|node| evidence::for_node(node, &self.sources)), + Selected::Edge(id) => { + let edge = self.edges.get(&id)?; + let ends: Vec = [edge.source, edge.target].iter().filter_map(|id| self.nodes.get(id).cloned()).collect(); + Some(evidence::for_edge(edge, &ends, &self.sources)) + } + } + } + + fn render_explorer(&self, node: i64, cx: &mut Context) -> AnyElement { + let muted = cx.theme().muted_foreground; + let warning = cx.theme().warning; + let border = cx.theme().border; + let mono = cx.theme().mono_font_family.clone(); + let explorer = &self.explorer; + let running = explorer.running.is_some(); + let target = cx.entity().downgrade(); + let current = explorer.direction; + let way = |id: &'static str, label: &'static str, direction: Direction, cx: &mut Context| { + let button = Button::new(id).xsmall().label(tr(label)); + let button = if current == direction { button.primary() } else { button.outline() }; + button.on_click(cx.listener(move |this, _, _, cx| { + this.explorer.direction = direction; + cx.notify(); + })) + }; + let types_label = match explorer.types.len() { + 0 => tr("explore.types_all").to_string(), + count => trf("explore.types_some", &[&count.to_string()]), + }; + let (available, chosen) = (explorer.available.clone(), explorer.types.clone()); + let is_start = explorer.starts.contains(&node); + let path = explorer.walk.as_ref().map(|walk| explorer::explained_path(walk, node)).unwrap_or_default(); + let path_evidence = explorer + .walk + .as_ref() + .and_then(|walk| evidence::for_path(walk, node, &self.sources)) + .map(|record| serde_json::to_string_pretty(&record).expect("evidence serializes")); + + v_flex() + .pt_2() + .mt_1() + .gap_1p5() + .border_t_1() + .border_color(border) + .child(div().text_sm().font_semibold().child(tr("explore.title"))) + .child( + h_flex() + .gap_1() + .child(way("explore-out", "explore.out", Direction::Out, cx)) + .child(way("explore-in", "explore.in", Direction::In, cx)) + .child(way("explore-both", "explore.both", Direction::Both, cx)), + ) + .child( + h_flex() + .gap_1() + .items_center() + .child( + Button::new("explore-fewer") + .xsmall() + .outline() + .icon(IconName::Minus) + .tooltip(tr("explore.fewer_hops")) + .on_click(cx.listener(|this, _, _, cx| { + this.explorer.change_hops(-1); + cx.notify(); + })), + ) + .child(div().text_xs().child(trf("explore.hops", &[&explorer.max_hops.to_string()]))) + .child( + Button::new("explore-more") + .xsmall() + .outline() + .icon(IconName::Plus) + .tooltip(tr("explore.more_hops")) + .on_click(cx.listener(|this, _, _, cx| { + this.explorer.change_hops(1); + cx.notify(); + })), + ), + ) + .child(Button::new("explore-types").xsmall().outline().label(types_label).dropdown_menu(move |mut menu, _, _| { + for name in &available { + let (target, picked) = (target.clone(), name.clone()); + menu = menu.item(PopupMenuItem::new(name.clone()).checked(chosen.contains(name)).on_click(move |_, _, cx| { + target + .update(cx, |canvas, cx| { + canvas.explorer.toggle_type(&picked); + cx.notify(); + }) + .ok(); + })); + } + menu + })) + .child( + h_flex() + .gap_1() + .items_center() + .child( + Button::new("explore-start") + .xsmall() + .outline() + .label(tr(if is_start { "explore.remove_start" } else { "explore.add_start" })) + .on_click(cx.listener(move |this, _, _, cx| { + this.explorer.toggle_start(node); + cx.notify(); + })), + ) + .when(!explorer.starts.is_empty(), |this| { + this.child(div().text_xs().text_color(muted).child(trf("explore.starts", &[&explorer.starts.len().to_string()]))) + }), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("explore-run") + .xsmall() + .primary() + .label(tr("explore.run")) + .loading(running) + .on_click(cx.listener(|this, _, window, cx| this.explore(window, cx))), + ) + .child( + Button::new("investigation-save") + .xsmall() + .outline() + .label(tr("investigation.save")) + .on_click(cx.listener(move |this, _, window, cx| this.save_investigation(node, window, cx))), + ) + .when_some(explorer.running.clone(), |this, cancel| { + this.child( + Button::new("explore-cancel") + .xsmall() + .outline() + .label(tr("explore.cancel")) + .on_click(move |_, _, _| cancel.store(true, Ordering::Relaxed)), + ) + }), + ) + .children(explorer.status_lines().into_iter().map(|(line, warn)| { + div().text_xs().whitespace_normal().text_color(if warn { warning } else { muted }).child(line) + })) + .when(!path.is_empty(), |this| { + this.child(div().pt_1().text_xs().font_semibold().child(tr("explore.path"))) + .children(path.into_iter().map(|hop| { + div().text_xs().whitespace_normal().font_family(mono.clone()).child(hop) + })) + .child(div().text_xs().whitespace_normal().text_color(muted).child(tr("explore.candidate"))) + .when_some(path_evidence, |this, copy| { + this.child( + Button::new("evidence-copy-path") + .xsmall() + .outline() + .label(tr("evidence.copy_path")) + .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()))), + ) + }) + .when(!self.path_view, |this| { + this.child( + Button::new("explore-show-path") + .xsmall() + .outline() + .label(tr("explore.show_path")) + .on_click(cx.listener(move |this, _, _, cx| this.show_path(node, cx))), + ) + }) + }) + .into_any_element() + } + fn render_inspector(&self, cx: &mut Context) -> Option { let selected = self.selected?; let (title, color, subtitle, properties) = match selected { @@ -434,6 +1087,7 @@ impl GraphCanvas { rows.sort_by_key(|(k, _)| (k.starts_with("khonsu_"), k.to_string())); let mono = cx.theme().mono_font_family.clone(); let muted = cx.theme().muted_foreground; + let analyses = self.selected_node().and_then(|id| self.render_analyses(id, cx)); Some( v_flex() .id("inspector") @@ -454,6 +1108,17 @@ impl GraphCanvas { .child(div().font_semibold().child(title)), ) .children(subtitle.map(|s| div().text_xs().text_color(muted).font_family(mono.clone()).child(s))) + // Above the properties: a node can have enough of them to push + // a walk's result out of sight. + .when_some(self.selected_node(), |this, id| { + this.child(self.render_explorer(id, cx)).child(div().border_b_1().border_color(cx.theme().border)) + }) + .when_some(analyses, |this, analyses| { + this.child(analyses).child(div().border_b_1().border_color(cx.theme().border)) + }) + .when_some(self.selected_evidence(selected), |this, record| { + this.child(self.render_evidence(record, cx)).child(div().border_b_1().border_color(cx.theme().border)) + }) .children(rows.into_iter().map(|(key, value)| { let provenance = key.starts_with("khonsu_"); v_flex() @@ -494,6 +1159,7 @@ impl Render for GraphCanvas { let theme = cx.theme(); let halo = theme.background; let muted = theme.muted_foreground; + let warning = theme.warning; let border = theme.border; let mut legend: BTreeMap = BTreeMap::new(); @@ -505,7 +1171,10 @@ impl Render for GraphCanvas { let group_key = self.group_key.clone(); let this = cx.entity().downgrade(); - let note = if self.hidden > 0 { + let walk_warning = self.explorer.warning(); + let note = if let Some(warning) = walk_warning.clone() { + warning + } else if self.hidden > 0 { trf("canvas.truncated", &[&self.nodes.len().to_string(), &(self.nodes.len() + self.hidden).to_string()]) } else if self.compact { tr("canvas.compact_hint").to_string() @@ -548,6 +1217,15 @@ impl Render for GraphCanvas { this.graph.update(cx, |graph, cx| graph.relayout(cx)); })), ) + .when(self.explorer.can_go_back() && self.explorer.running.is_none(), |this_bar| { + this_bar.child( + Button::new("explore-back") + .xsmall() + .outline() + .label(tr("explore.back")) + .on_click(cx.listener(|this, _, _, cx| this.go_back(cx))), + ) + }) .when(!group_keys.is_empty() || group_key.is_some(), |this_bar| { let label = match &group_key { Some(key) => trf("canvas.group_by", &[key]), @@ -658,7 +1336,7 @@ impl Render for GraphCanvas { .border_color(border) .bg(halo) .text_xs() - .text_color(muted) + .text_color(if walk_warning.is_some() { warning } else { muted }) .whitespace_normal() .child(note), ), diff --git a/app/src/ui/explorer.rs b/app/src/ui/explorer.rs new file mode 100644 index 0000000..e8f8a86 --- /dev/null +++ b/app/src/ui/explorer.rs @@ -0,0 +1,733 @@ +//! Path Explorer's state, apart from the canvas that draws it: what to walk, +//! what the last walk found, and the views to go back to. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use khonsu_core::evidence::{Assertion, Evidence, Field, SourceCoverage}; +use khonsu_core::settings::RunStatus; +use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Hop, Limit, DEFAULT_MAX_HOPS, MAX_HOPS}; +use khonsu_core::investigation::{GraphState, Investigation, RecordRef, Reopened}; +use khonsu_core::plugin_analysis::{Finding, Offered, Report}; +use khonsu_core::protocol::{AnalysisInfo, ConditionStatus, HopRef}; +use khonsu_core::util::format_unix_time; +use khonsu_core::{GraphData, GraphEdge, GraphNode}; + +use crate::i18n::{tr, trf}; + +/// A canvas to return to. Positions are not kept: the layout starts over. +#[derive(Clone, Debug, PartialEq)] +pub struct View { + pub nodes: Vec, + pub edges: Vec, + pub walk: Option, + pub selected: Option, +} + +/// What a reopened investigation said, kept beside the walk it gave until the +/// canvas gets another result. +#[derive(Clone, Debug, PartialEq)] +pub struct Restored { + pub name: String, + pub saved_at: u64, + pub graph_state: GraphState, + pub differences: Vec, + pub missing_seeds: Vec, + pub target_missing: bool, + pub unsure: Vec, + pub path_same: Option, + pub incomplete_sources: Vec, +} + +impl Restored { + pub fn new(saved: &Investigation, reopened: &Reopened) -> Self { + Self { + name: saved.name.clone(), + saved_at: saved.saved_at, + graph_state: reopened.graph_state, + differences: reopened.differences.clone(), + missing_seeds: reopened.missing_seeds.clone(), + target_missing: reopened.target_missing, + unsure: reopened.unsure.clone(), + path_same: reopened.path_same, + incomplete_sources: reopened.incomplete_sources.clone(), + } + } + + /// Everything that differs from when it was saved, as warnings, so that a + /// new answer does not pass for the saved one. + pub fn status_lines(&self) -> Vec<(String, bool)> { + let name = |record: &RecordRef| record.stable_id.clone().unwrap_or_else(|| format!("#{}", record.internal_id)); + let names = |records: &[RecordRef]| records.iter().map(name).collect::>().join(", "); + let mut lines = Vec::new(); + match self.graph_state { + GraphState::Unchanged => {} + GraphState::Changed => lines.push((tr("investigation.changed").to_string(), true)), + GraphState::NotCompared => lines.push((tr("investigation.not_compared").to_string(), true)), + } + lines.extend(self.differences.iter().map(|text| (text.clone(), true))); + lines.extend(self.incomplete_sources.iter().map(|text| (text.clone(), true))); + if !self.missing_seeds.is_empty() { + lines.push((trf("investigation.missing_seeds", &[&names(&self.missing_seeds)]), true)); + } + if self.target_missing { + lines.push((tr("investigation.target_missing").to_string(), true)); + } else if self.path_same == Some(false) { + lines.push((tr("investigation.path_differs").to_string(), true)); + } + if !self.unsure.is_empty() { + lines.push((trf("investigation.unsure", &[&names(&self.unsure)]), true)); + } + lines.push((trf("investigation.reopened", &[&self.name, &format_unix_time(self.saved_at)]), false)); + lines + } +} + +/// At most this many views are kept; the oldest goes first. +const HISTORY: usize = 20; + +pub struct Explorer { + pub direction: Direction, + /// Empty follows every type. + pub types: BTreeSet, + pub max_hops: u32, + /// Added starts; with none, a walk starts from the selected node. + pub starts: BTreeSet, + /// Relationship types in the graph, to offer as filters. + pub available: Vec, + /// Set while a walk runs; setting the flag stops it at its next query. + pub running: Option>, + pub walk: Option, + /// A walk that failed; the canvas keeps what it had. + pub error: Option, + /// Analyses that plugins offer, and which apply is up to their labels. + pub analyses: Vec, + /// The last run of each analysis by [`analysis_key`], kept until the canvas + /// gets a new result. + pub reports: BTreeMap, + /// Those running; setting the flag ends the plugin. + pub checking: BTreeMap>, + pub analysis_errors: BTreeMap, + /// The reopened investigation this walk came from. + pub restored: Option, + /// The finding path on the canvas, as hops and as what to save for it. + pub finding_path: Option<(Vec, Vec)>, + history: Vec, +} + +impl Default for Explorer { + fn default() -> Self { + Self { + direction: Direction::Out, + types: BTreeSet::new(), + max_hops: DEFAULT_MAX_HOPS, + starts: BTreeSet::new(), + available: Vec::new(), + running: None, + walk: None, + error: None, + analyses: Vec::new(), + reports: BTreeMap::new(), + checking: BTreeMap::new(), + analysis_errors: BTreeMap::new(), + restored: None, + finding_path: None, + history: Vec::new(), + } + } +} + +impl Explorer { + /// What to walk from `selected`, unless no node was chosen. + pub fn request(&self, selected: Option) -> Option { + let starts: Vec = if self.starts.is_empty() { + vec![selected?] + } else { + self.starts.iter().copied().collect() + }; + Some(ExploreRequest { + direction: self.direction, + rel_types: self.types.iter().cloned().collect(), + max_hops: self.max_hops, + ..ExploreRequest::new(starts) + }) + } + + pub fn change_hops(&mut self, by: i32) { + self.max_hops = self.max_hops.saturating_add_signed(by).clamp(1, MAX_HOPS); + } + + pub fn toggle_type(&mut self, name: &str) { + if !self.types.remove(name) { + self.types.insert(name.to_string()); + } + } + + pub fn toggle_start(&mut self, id: i64) { + if !self.starts.remove(&id) { + self.starts.insert(id); + } + } + + pub fn push_view(&mut self, view: View) { + if self.history.len() == HISTORY { + self.history.remove(0); + } + self.history.push(view); + } + + pub fn pop_view(&mut self) -> Option { + self.history.pop() + } + + pub fn can_go_back(&self) -> bool { + !self.history.is_empty() + } + + /// A new result on the canvas: nothing here describes it any more. + /// Filters stay, since they are what the person is looking for. + pub fn forget_walks(&mut self) { + self.walk = None; + self.error = None; + self.reports.clear(); + self.analysis_errors.clear(); + // What they would report no longer describes the canvas. + for (_, cancel) in std::mem::take(&mut self.checking) { + cancel.store(true, Ordering::Relaxed); + } + self.restored = None; + self.finding_path = None; + self.starts.clear(); + self.history.clear(); + } + + /// The warnings about the last walk, for the canvas to show when no node is + /// selected and the panel is out of sight. + pub fn warning(&self) -> Option { + let warnings: Vec = self.status_lines().into_iter().filter(|(_, warn)| *warn).map(|(line, _)| line).collect(); + (!warnings.is_empty()).then(|| warnings.join(" · ")) + } + + /// Says what the last walk found and everything that limited it. These + /// sentences are what stops an empty answer reading as "nothing is there". + pub fn status_lines(&self) -> Vec<(String, bool)> { + let mut lines = Vec::new(); + if let Some(restored) = &self.restored { + lines.extend(restored.status_lines()); + } + if let Some(error) = &self.error { + lines.push((trf("explore.failed", &[error]), true)); + } + let Some(walk) = &self.walk else { + return lines; + }; + if walk.hops.is_empty() && walk.is_complete() { + lines.push((tr("explore.none").to_string(), false)); + } else { + lines.push(( + trf( + "explore.found", + &[&walk.graph.nodes.len().to_string(), &walk.graph.edges.len().to_string(), &walk.depth_reached.to_string()], + ), + false, + )); + } + for limit in &walk.limits { + let key = match limit { + Limit::Hops => "explore.limit.hops", + Limit::Nodes => "explore.limit.nodes", + Limit::Edges => "explore.limit.edges", + }; + lines.push((tr(key).to_string(), true)); + } + if walk.cancelled { + lines.push((tr("explore.cancelled").to_string(), true)); + } + if !walk.missing_starts.is_empty() { + lines.push((tr("explore.missing_start").to_string(), true)); + } + lines + } +} + +/// The hops to `target` in words, when the walk reached it from a start. +pub fn explained_path(walk: &Exploration, target: i64) -> Vec { + walk.path_to(target).unwrap_or_default().into_iter().map(|hop| walk.explain(hop)).collect() +} + +/// A source's last run in words, for the inspector. +pub fn coverage_text(coverage: &SourceCoverage) -> String { + let status = match coverage.status { + RunStatus::Complete => tr("evidence.coverage.complete"), + RunStatus::Partial => tr("evidence.coverage.partial"), + RunStatus::Failed => tr("evidence.coverage.failed"), + RunStatus::Cancelled => tr("evidence.coverage.cancelled"), + }; + format!("{status} ({})", coverage.run_id) +} + +/// The evidence for a record, as the rows the inspector shows. +pub fn evidence_rows(record: &Evidence) -> Vec<(&'static str, String)> { + let unknown = || tr("evidence.unknown").to_string(); + let text = |field: &Field| field.known().cloned().unwrap_or_else(unknown); + let time = |field: &Field| field.known().map(|at| format_unix_time(*at)).unwrap_or_else(unknown); + let assertion = match record.assertion { + Assertion::Explicit => tr("evidence.explicit"), + Assertion::Derived => tr("evidence.derived"), + Assertion::Unknown => tr("evidence.unknown"), + }; + vec![ + ("evidence.id", record.evidence_id.clone()), + ("evidence.source", text(&record.source)), + ("evidence.plugin", text(&record.plugin)), + ("evidence.written", time(&record.record_written_at)), + ("evidence.last_sync", time(&record.source_last_successful_sync)), + ("evidence.coverage", record.source_coverage.known().map_or_else(unknown, |coverage| coverage_text(coverage))), + ("evidence.snapshot", text(&record.source_snapshot)), + ("evidence.assertion", assertion.to_string()), + ] +} + +/// The findings about `node`, in the report's order. +pub fn findings_for<'a>(report: &'a Report, node: &GraphNode) -> Vec<&'a Finding> { + let resource = khonsu_core::evidence::for_node(node, &[]).evidence_id; + report.findings.iter().filter(|finding| finding.resource == resource).collect() +} + +impl Explorer { + /// What to save as the exact path when saving at `node`: the finding path, if + /// the walk is still that path and `node` is where it ends. + pub fn exact_path_to(&self, node: i64) -> Vec { + let current = self.walk.as_ref().map(|walk| walk.hops.as_slice()); + match &self.finding_path { + Some((hops, refs)) if current == Some(hops.as_slice()) && hops.last().is_some_and(|hop| hop.to == node) => refs.clone(), + _ => Vec::new(), + } + } +} + +/// Which run of an analysis this is about: plugins each name their own. +pub fn analysis_key(offered: &Offered) -> String { + format!("{}/{}", offered.plugin.id(), offered.info.id) +} + +/// A plugin's text in the window's language, falling back on what it wrote. +pub fn analysis_text(info: &AnalysisInfo, key: &str, fallback: &str) -> String { + info.translated(crate::i18n::current().code(), key).unwrap_or(fallback).to_string() +} + +/// A verdict's words, from the analysis that gave it. +pub fn verdict_text(info: &AnalysisInfo, id: &str) -> String { + let title = info.verdicts.iter().find(|verdict| verdict.id == id).map_or(id, |verdict| verdict.title.as_str()); + analysis_text(info, &format!("verdict.{id}"), title) +} + +/// A rule's title, from the analysis that has it. +pub fn rule_text(info: &AnalysisInfo, id: &str) -> String { + let title = info.rules.iter().find(|rule| rule.rule_id == id).map_or(id, |rule| rule.title.as_str()); + analysis_text(info, &format!("rule.{id}"), title) +} + +pub fn status_key(status: ConditionStatus) -> &'static str { + match status { + ConditionStatus::Met => "analysis.status.met", + ConditionStatus::NotMet => "analysis.status.not_met", + ConditionStatus::Unknown => "analysis.status.unknown", + } +} + +/// Only the nodes and relationships on the path to `target`. +pub fn path_graph(walk: &Exploration, target: i64) -> Option { + let path = walk.path_to(target).filter(|path| !path.is_empty())?; + let mut ids: BTreeSet = path.iter().flat_map(|hop| [hop.from, hop.to]).collect(); + ids.insert(target); + let edges: BTreeSet = path.iter().map(|hop| hop.edge).collect(); + Some(GraphData { + nodes: walk.graph.nodes.iter().filter(|node| ids.contains(&node.id)).cloned().collect(), + edges: walk.graph.edges.iter().filter(|edge| edges.contains(&edge.id)).cloned().collect(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use khonsu_core::explore::Hop; + use serde_json::{json, Map}; + + fn node(id: i64, name: &str) -> GraphNode { + GraphNode { + id, + labels: vec!["N".into()], + properties: Map::from_iter([("name".to_string(), json!(name))]), + } + } + + fn edge(id: i64, source: i64, target: i64) -> GraphEdge { + GraphEdge { + id, + source, + target, + rel_type: "NEXT".into(), + properties: Map::new(), + } + } + + fn hop(depth: u32, from: i64, to: i64, edge: i64) -> Hop { + Hop { + depth, + from, + to, + edge, + rel_type: "NEXT".into(), + along: true, + } + } + + /// `a → b → c`, and `a → d` off to the side. + fn walk() -> Exploration { + Exploration { + starts: vec![1], + graph: GraphData { + nodes: vec![node(1, "a"), node(2, "b"), node(3, "c"), node(4, "d")], + edges: vec![edge(10, 1, 2), edge(11, 2, 3), edge(12, 1, 4)], + }, + hops: vec![hop(1, 1, 2, 10), hop(1, 1, 4, 12), hop(2, 2, 3, 11)], + depth_reached: 2, + ..Exploration::default() + } + } + + fn offered() -> Offered { + Offered { + plugin: khonsu_core::plugin::PluginInfo { + manifest: serde_json::from_value(json!({"id": "demo", "name": "Demo", "version": "1", "protocol": 1, "entry": ["demo"]})).unwrap(), + dir: Default::default(), + builtin: true, + }, + info: serde_json::from_value(json!({ + "id": "links", + "title": "Links", + "subject_labels": ["Thing"], + "verdicts": [{"id": "flagged", "title": "Flagged", "tone": "attention"}], + "rules": [{"rule_id": "demo.rule", "title": "A rule", "statement": "It holds."}], + "translations": {"zh": {"verdict.flagged": "\u{5df2}\u{6807}\u{8bb0}"}}, + })) + .unwrap(), + } + } + + fn report() -> Report { + let finding: Finding = serde_json::from_value(json!({ + "finding_id": "f", "rule_id": "demo.rule", "verdict": "flagged", + "resource": "node:db", "resource_name": "db", + "conditions": [], "unknowns": [], "assumptions": [], + "path": null, "walk": null, "contributing": [], "evidence": [], + })) + .unwrap(); + Report { + plugin: "demo".into(), + analysis: "links".into(), + title: "Links".into(), + claim: String::new(), + order: String::new(), + verdicts: vec![], + rules: vec![], + subjects: 1, + summary: BTreeMap::new(), + complete: true, + sources: BTreeMap::new(), + findings: vec![finding], + } + } + + #[test] + fn a_plugins_words_are_used_with_its_own_as_the_fallback() { + let info = offered().info; + assert_eq!(analysis_text(&info, "no.such.key", "fallback"), "fallback"); + assert_eq!(rule_text(&info, "demo.rule"), "A rule"); + assert_eq!(rule_text(&info, "unheard.of"), "unheard.of", "an ID is better than nothing"); + assert_eq!(verdict_text(&info, "unheard_of"), "unheard_of"); + assert_eq!(analysis_key(&offered()), "demo/links"); + for status in [ConditionStatus::Met, ConditionStatus::NotMet, ConditionStatus::Unknown] { + assert!(!tr(status_key(status)).is_empty()); + } + } + + /// The window goes by what a plugin declares, so a label it never heard of + /// is offered the same way as any other. + #[test] + fn an_analysis_is_offered_on_the_labels_it_declares_and_nothing_is_hard_coded() { + let on = offered(); + assert!(on.applies_to(&["Thing".to_string()])); + assert!(!on.applies_to(&["Instance".to_string()])); + for (name, source) in [("explorer", include_str!("explorer.rs")), ("canvas", include_str!("canvas.rs"))] { + let code = source.split("#[cfg(test)]").next().unwrap(); + for provider in ["aws.exposure", "\"Instance\"", "SecurityGroup", "potential_exposure"] { + assert!(!code.contains(provider), "{name} names {provider}"); + } + } + } + + #[test] + fn findings_are_matched_to_the_selected_node_and_a_new_result_drops_them() { + let report = report(); + let db = GraphNode { + id: 8, + labels: vec!["Thing".into()], + properties: Map::from_iter([("khonsu_id".to_string(), json!("db"))]), + }; + let found = findings_for(&report, &db); + assert_eq!(found.len(), 1); + assert!(findings_for(&report, &node(99, "elsewhere")).is_empty()); + + let running = Arc::new(AtomicBool::new(false)); + let mut explorer = Explorer { + reports: BTreeMap::from([("demo/links".to_string(), report)]), + analysis_errors: BTreeMap::from([("demo/links".to_string(), "x".to_string())]), + checking: BTreeMap::from([("demo/links".to_string(), running.clone())]), + analyses: vec![offered()], + ..Explorer::default() + }; + explorer.forget_walks(); + assert!(explorer.reports.is_empty() && explorer.analysis_errors.is_empty(), "the graph may have changed"); + assert!(explorer.checking.is_empty() && running.load(Ordering::Relaxed), "and what is running is stopped"); + assert_eq!(explorer.analyses.len(), 1, "what plugins offer has not changed"); + } + + fn restored() -> Restored { + Restored { + name: "web tier".into(), + saved_at: 1000, + graph_state: GraphState::Unchanged, + differences: vec![], + missing_seeds: vec![], + target_missing: false, + unsure: vec![], + path_same: Some(true), + incomplete_sources: vec![], + } + } + + #[test] + fn a_reopened_investigation_says_nothing_alarming_when_nothing_changed() { + let lines = restored().status_lines(); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert!(!lines[0].1 && lines[0].0.contains("web tier"), "{lines:?}"); + } + + #[test] + fn every_difference_from_when_it_was_saved_is_a_warning() { + let record = |id: &str| RecordRef { stable_id: Some(id.into()), internal_id: 1 }; + let changed = Restored { + graph_state: GraphState::Changed, + differences: vec!["Nodes: 1 when saved, 2 now.".into()], + missing_seeds: vec![record("shop/gone")], + unsure: vec![RecordRef { stable_id: None, internal_id: 7 }], + path_same: Some(false), + ..restored() + }; + let lines = changed.status_lines(); + let warned = |text: &str| lines.iter().any(|(line, warn)| *warn && line.contains(text)); + assert!(warned(tr("investigation.changed")), "{lines:?}"); + assert!(warned("Nodes: 1 when saved"), "{lines:?}"); + assert!(warned("shop/gone"), "{lines:?}"); + assert!(warned("#7"), "{lines:?}"); + assert!(warned(tr("investigation.path_differs")), "{lines:?}"); + + // The target having gone says more than the path differing. + let gone = Restored { target_missing: true, path_same: Some(false), ..restored() }.status_lines(); + assert!(gone.iter().any(|(line, warn)| *warn && line == tr("investigation.target_missing")), "{gone:?}"); + assert!(!gone.iter().any(|(line, _)| line == tr("investigation.path_differs")), "{gone:?}"); + + // Too large to compare is not the same as unchanged. + let unsure = Restored { graph_state: GraphState::NotCompared, ..restored() }.status_lines(); + assert_eq!(unsure[0], (tr("investigation.not_compared").to_string(), true)); + } + + #[test] + fn the_warnings_of_a_reopened_investigation_reach_the_canvas_and_a_new_result_clears_them() { + let mut explorer = Explorer { + restored: Some(Restored { graph_state: GraphState::Changed, ..restored() }), + ..Explorer::default() + }; + assert_eq!(explorer.warning().as_deref(), Some(tr("investigation.changed"))); + + // With no node selected this is all that shows, so a lost path must be in it. + explorer.restored = Some(Restored { graph_state: GraphState::Changed, path_same: Some(false), ..restored() }); + assert!(explorer.warning().unwrap().contains(tr("investigation.path_differs"))); + + explorer.forget_walks(); + assert!(explorer.restored.is_none() && explorer.warning().is_none()); + } + + /// Saving a finding's path keeps that path, not the fewest hops a walk + /// would find. Once the walk or the selection moves on it is a normal save. + #[test] + fn only_the_finding_path_still_on_the_canvas_is_saved_exactly() { + let hops = vec![hop(1, 1, 2, 10), hop(2, 2, 3, 11)]; + let refs = vec![HopRef { edge: "rel:ab".into(), along: true }, HopRef { edge: "rel:bc".into(), along: true }]; + let mut explorer = Explorer { + walk: Some(Exploration { hops: hops.clone(), ..Exploration::default() }), + finding_path: Some((hops, refs.clone())), + ..Explorer::default() + }; + assert_eq!(explorer.exact_path_to(3), refs); + assert!(explorer.exact_path_to(2).is_empty(), "another node on it means the path to that node"); + explorer.walk = Some(walk()); + assert!(explorer.exact_path_to(3).is_empty(), "another walk replaced it"); + explorer.forget_walks(); + assert!(explorer.finding_path.is_none()); + } + + #[test] + fn evidence_rows_say_not_supplied_for_what_is_missing() { + let record = khonsu_core::evidence::for_node(&node(1, "by hand"), &[]); + let rows = evidence_rows(&record); + let value = |key: &str| rows.iter().find(|(row, _)| *row == key).unwrap().1.clone(); + assert_eq!(value("evidence.id"), "node-internal:1"); + for key in ["evidence.source", "evidence.plugin", "evidence.written", "evidence.last_sync", "evidence.snapshot", "evidence.assertion"] { + assert_eq!(value(key), tr("evidence.unknown"), "{key}"); + } + } + + #[test] + fn a_walk_starts_from_the_selection_unless_starts_were_added() { + let mut explorer = Explorer::default(); + assert!(explorer.request(None).is_none()); + assert_eq!(explorer.request(Some(7)).unwrap().starts, [7]); + + explorer.toggle_start(2); + explorer.toggle_start(1); + assert_eq!(explorer.request(Some(7)).unwrap().starts, [1, 2]); + assert_eq!(explorer.request(None).unwrap().starts, [1, 2]); + + explorer.toggle_start(1); + assert_eq!(explorer.request(None).unwrap().starts, [2]); + } + + #[test] + fn the_request_carries_the_filters_and_hops_stay_in_range() { + let mut explorer = Explorer { + direction: Direction::In, + ..Explorer::default() + }; + explorer.toggle_type("CALLS"); + explorer.toggle_type("RUNS"); + explorer.toggle_type("RUNS"); + for _ in 0..40 { + explorer.change_hops(1); + } + assert_eq!(explorer.max_hops, MAX_HOPS); + for _ in 0..40 { + explorer.change_hops(-1); + } + assert_eq!(explorer.max_hops, 1); + + let request = explorer.request(Some(1)).unwrap(); + assert_eq!(request.direction, Direction::In); + assert_eq!(request.rel_types, ["CALLS"]); + assert_eq!(request.max_hops, 1); + } + + #[test] + fn going_back_returns_views_newest_first_and_keeps_a_bounded_number() { + let mut explorer = Explorer::default(); + assert!(!explorer.can_go_back()); + let view = |id: i64| View { + nodes: vec![node(id, "x")], + edges: vec![], + walk: None, + selected: None, + }; + for id in 0..(HISTORY as i64 + 5) { + explorer.push_view(view(id)); + } + assert_eq!(explorer.pop_view().unwrap().nodes[0].id, HISTORY as i64 + 4); + let mut remaining = 1; + let mut oldest = None; + while let Some(view) = explorer.pop_view() { + remaining += 1; + oldest = Some(view.nodes[0].id); + } + assert_eq!(remaining, HISTORY); + assert_eq!(oldest, Some(5)); + } + + #[test] + fn a_new_result_forgets_walks_but_keeps_the_filters() { + let mut explorer = Explorer { + walk: Some(walk()), + error: Some("x".into()), + max_hops: 5, + ..Explorer::default() + }; + explorer.toggle_start(1); + explorer.toggle_type("CALLS"); + explorer.push_view(View { nodes: vec![], edges: vec![], walk: None, selected: None }); + explorer.forget_walks(); + assert!(explorer.walk.is_none() && explorer.error.is_none()); + assert!(explorer.starts.is_empty() && !explorer.can_go_back()); + assert_eq!(explorer.max_hops, 5); + assert!(explorer.types.contains("CALLS")); + } + + #[test] + fn a_path_keeps_only_its_own_nodes_and_relationships() { + let path = path_graph(&walk(), 3).unwrap(); + let ids: Vec = path.nodes.iter().map(|node| node.id).collect(); + let edges: Vec = path.edges.iter().map(|edge| edge.id).collect(); + assert_eq!(ids, [1, 2, 3]); + assert_eq!(edges, [10, 11]); + assert_eq!(explained_path(&walk(), 3), ["a -[NEXT]-> b", "b -[NEXT]-> c"]); + + assert!(path_graph(&walk(), 1).is_none(), "a start has no path to show"); + assert!(path_graph(&walk(), 99).is_none(), "not reached"); + assert!(explained_path(&walk(), 99).is_empty()); + } + + #[test] + fn an_empty_or_cut_answer_says_which_it_is() { + let lines = |walk: Exploration, error: Option<&str>| { + let explorer = Explorer { + walk: Some(walk), + error: error.map(String::from), + ..Explorer::default() + }; + explorer.status_lines() + }; + let none = lines(Exploration::default(), None); + assert_eq!(none, [(tr("explore.none").to_string(), false)]); + + // Empty because it was stopped is a warning, not "no relationships". + let cancelled = lines(Exploration { cancelled: true, ..Exploration::default() }, None); + assert!(cancelled.iter().all(|(line, _)| line != tr("explore.none"))); + assert!(cancelled.contains(&(tr("explore.cancelled").to_string(), true))); + + let capped = lines( + Exploration { + limits: vec![Limit::Hops, Limit::Nodes, Limit::Edges], + ..walk() + }, + None, + ); + for key in ["explore.limit.hops", "explore.limit.nodes", "explore.limit.edges"] { + assert!(capped.contains(&(tr(key).to_string(), true)), "{key}"); + } + + let missing = lines(Exploration { missing_starts: vec![9], ..Exploration::default() }, None); + assert!(missing.contains(&(tr("explore.missing_start").to_string(), true))); + + let failed = lines(Exploration::default(), Some("boom")); + assert!(failed[0].1 && failed[0].0.contains("boom")); + } + + #[test] + fn only_an_incomplete_walk_warns_on_the_canvas() { + let with = |walk: Exploration| Explorer { walk: Some(walk), ..Explorer::default() }; + assert!(Explorer::default().warning().is_none()); + assert!(with(walk()).warning().is_none()); + let cut = with(Exploration { limits: vec![Limit::Hops], ..walk() }); + assert_eq!(cut.warning().as_deref(), Some(tr("explore.limit.hops"))); + } +} diff --git a/app/src/ui/mod.rs b/app/src/ui/mod.rs index 2188f77..5413351 100644 --- a/app/src/ui/mod.rs +++ b/app/src/ui/mod.rs @@ -3,6 +3,7 @@ pub mod agent_avatar; pub mod canvas; pub mod chart; +pub mod explorer; pub mod geo; pub mod grid; pub mod palette; @@ -23,6 +24,7 @@ use std::path::PathBuf; use gpui_kit::component::notification::Notification; use gpui_kit::component::WindowExt; use gpui_kit::{Action, App, Entity, KeyBinding, KeyBindingContextPredicate, PathPromptOptions, Window}; +use khonsu_core::investigation::Investigation; use khonsu_core::OpenError; use serde::Deserialize; @@ -36,6 +38,7 @@ gpui_kit::actions!( ExplainQuery, SaveSpec, OpenDashboard, + OpenInvestigation, ZoomIn, ZoomOut, ZoomReset, @@ -158,6 +161,7 @@ pub fn set_menus(cx: &mut App) { MenuItem::action(tr("title_bar.new_graph"), NewGraph), MenuItem::action(tr("sources.add"), AddSource), MenuItem::action(tr("workspace.open_dashboard"), OpenDashboard), + MenuItem::action(tr("investigation.open"), OpenInvestigation), MenuItem::Separator, MenuItem::action(tr("workspace.new_cypher"), NewQuery), MenuItem::action(tr("workspace.new_sql"), NewSqlQuery), @@ -224,6 +228,19 @@ pub fn open_graph_at( read_only: bool, window: &mut Window, cx: &mut App, +) { + open_graph_then(state, path, create, read_only, |_, _| {}, window, cx); +} + +/// Opens a graph, and calls `then` once it is open. +fn open_graph_then( + state: Entity, + path: PathBuf, + create: bool, + read_only: bool, + then: impl FnOnce(&mut Window, &mut App) + 'static, + window: &mut Window, + cx: &mut App, ) { if state.read(cx).is_opening() { return; @@ -245,6 +262,7 @@ pub fn open_graph_at( Ok(info) => { state.update(cx, |state, cx| state.set_graph(Some(info), cx)); reload_schema(state, window, cx); + then(window, cx); } // Written by an older HawDB: offer to make it again // rather than show the engine's integrity error. @@ -266,6 +284,77 @@ pub fn open_graph_at( .detach(); } +/// Ask for a saved investigation and reopen it. +pub fn pick_investigation(state: Entity, workspace: Entity, window: &mut Window, cx: &mut App) { + let rx = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some(tr("investigation.open").into()), + }); + window + .spawn(cx, async move |cx| { + let Ok(Ok(Some(paths))) = rx.await else { + return; + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + cx.update(move |window, cx| reopen_investigation(state, workspace, &path, window, cx)).ok(); + }) + .detach(); +} + +/// Reads a saved investigation, opens its graph unless that is already open, +/// and shows it in a new tab. A file or graph it cannot use is reported. +pub fn reopen_investigation(state: Entity, workspace: Entity, path: &std::path::Path, window: &mut Window, cx: &mut App) { + // At startup the last graph is still opening, and only one can open at a + // time. Wait for it, or the request would be lost without a word. + if state.read(cx).is_opening() { + let path = path.to_path_buf(); + window + .spawn(cx, async move |cx| { + cx.background_executor().timer(std::time::Duration::from_millis(100)).await; + cx.update(move |window, cx| reopen_investigation(state, workspace, &path, window, cx)).ok(); + }) + .detach(); + return; + } + let saved = match Investigation::load(path) { + Ok(saved) => saved, + Err(error) => { + window.push_notification(Notification::error(trf("investigation.open_failed", &[&error.message])), cx); + return; + } + }; + let open = state.read(cx).graph.as_ref().map(|graph| khonsu_core::normalize_path(&graph.path)); + let graph = khonsu_core::normalize_path(&saved.graph); + if open.as_ref() == Some(&graph) { + show_reopened(workspace, saved, window, cx); + } else if !graph.is_dir() { + window.push_notification(Notification::error(trf("investigation.graph_missing", &[&graph.display().to_string()])), cx); + } else { + open_graph_then(state, graph, false, false, move |window, cx| show_reopened(workspace, saved, window, cx), window, cx); + } +} + +fn show_reopened(workspace: Entity, saved: Investigation, window: &mut Window, cx: &mut App) { + window + .spawn(cx, async move |cx| { + let (saved, result) = smol::unblock(move || { + let result = crate::db::reopen_investigation(&saved); + (saved, result) + }) + .await; + cx.update(move |window, cx| match result { + Ok(reopened) => workspace.update(cx, |workspace, cx| workspace.show_investigation(&saved, reopened, window, cx)), + Err(error) => window.push_notification(Notification::error(trf("investigation.open_failed", &[&error.message])), cx), + }) + .ok(); + }) + .detach(); +} + /// Ask for a folder and open it as a graph, or create one there. pub fn pick_graph(state: Entity, create: bool, window: &mut Window, cx: &mut App) { if state.read(cx).is_opening() { diff --git a/app/src/ui/results.rs b/app/src/ui/results.rs index 542e9ff..86a46ef 100644 --- a/app/src/ui/results.rs +++ b/app/src/ui/results.rs @@ -12,6 +12,7 @@ use gpui_kit::component::table::{Column, DataTable, TableDelegate, TableState}; use gpui_kit::component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, IconName, Sizable, StyledExt}; use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; +use khonsu_core::investigation::{Investigation, Reopened, SavedQuery}; use khonsu_core::{QueryError, QueryResult}; use serde_json::Value as Json; @@ -281,6 +282,33 @@ impl ResultsPanel { cx.notify(); } + /// The query this result comes from, to keep if an investigation is saved. + pub fn set_query(&mut self, query: Option, cx: &mut Context) { + self.canvas.update(cx, |canvas, _| canvas.set_query(query)); + } + + /// Show a reopened investigation on the canvas. Its saved query is in the + /// editor and has not run, so the table holds nothing. + pub fn show_investigation(&mut self, saved: &Investigation, reopened: Reopened, window: &mut Window, cx: &mut Context) { + let graph = reopened.walk.as_ref().map(|walk| walk.graph.clone()); + let result = Rc::new(QueryResult { + columns: Vec::new(), + rows: Vec::new(), + row_count: 0, + truncated: false, + elapsed_ms: 0, + graph, + }); + self.table.update(cx, |table, cx| { + table.delegate_mut().set_result(&result); + table.refresh(cx); + }); + self.content = Content::Rows(result); + self.view = View::Graph; + self.canvas.update(cx, |canvas, cx| canvas.restore_investigation(saved, reopened, window, cx)); + cx.notify(); + } + pub fn set_plan(&mut self, plan: String, cx: &mut Context) { self.content = Content::Plan(plan.into()); self.view = View::Plan; diff --git a/app/src/ui/sidebar.rs b/app/src/ui/sidebar.rs index 3a209b8..631c37a 100644 --- a/app/src/ui/sidebar.rs +++ b/app/src/ui/sidebar.rs @@ -253,7 +253,9 @@ impl Sidebar { .gap_2() .when(sources.is_empty(), |this| this.child(div().px_1().text_sm().text_color(muted).child(tr("sources.empty")))) .children(sources.into_iter().enumerate().map(|(ix, source)| { + let partial = source.last_run().is_some_and(|run| run.status == khonsu_core::settings::RunStatus::Partial); let last = match &source.last_sync { + Some(last) if partial => trf("sources.last_partial", &[&time_ago(last.at), &last.message]), Some(last) if last.ok => trf("sources.last_ok", &[&time_ago(last.at), &last.nodes.to_string(), &last.edges.to_string()]), Some(last) => trf("sources.last_failed", &[&time_ago(last.at), &last.message]), None => tr("sources.never").to_string(), diff --git a/app/src/ui/sources.rs b/app/src/ui/sources.rs index e886a21..da3efeb 100644 --- a/app/src/ui/sources.rs +++ b/app/src/ui/sources.rs @@ -599,6 +599,7 @@ impl SourceForm { cursor: self.existing.as_ref().and_then(|s| s.cursor.clone()), patterns: self.existing.as_ref().map(|s| s.patterns.clone()).unwrap_or_default(), last_sync: self.existing.as_ref().and_then(|s| s.last_sync.clone()), + runs: self.existing.as_ref().map(|s| s.runs.clone()).unwrap_or_default(), }; if let Err(e) = settings::upsert_source(source.clone()) { self.message = Some((false, e.to_string())); diff --git a/app/src/ui/workspace.rs b/app/src/ui/workspace.rs index 85170e6..75a6ef6 100644 --- a/app/src/ui/workspace.rs +++ b/app/src/ui/workspace.rs @@ -10,6 +10,7 @@ use gpui_kit::component::tab::{Tab, TabBar}; use gpui_kit::component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable}; use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; +use khonsu_core::investigation::{Investigation, Reopened, SavedQuery}; use khonsu_core::settings::{self, HistoryEntry}; use khonsu_core::{util, Language}; use serde::{Deserialize, Serialize}; @@ -249,6 +250,20 @@ impl Workspace { cx.notify(); } + /// A new tab with the saved query in its editor, not run, and the reopened + /// walk on its canvas. + pub fn show_investigation(&mut self, saved: &Investigation, reopened: Reopened, window: &mut Window, cx: &mut Context) { + let (language, text) = match &saved.query { + Some(query) if query.language == "sql" => (Language::Sql, query.text.as_str()), + Some(query) => (Language::Cypher, query.text.as_str()), + None => (Language::Cypher, ""), + }; + self.add_tab(language, Some(text), window, cx); + if let Some(WorkspaceTab::Query(tab)) = self.tabs.last() { + tab.results.clone().update(cx, |results, cx| results.show_investigation(saved, reopened, window, cx)); + } + } + fn push_dashboard(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { let id = self.next_tab_id; self.next_tab_id += 1; @@ -459,7 +474,14 @@ impl Workspace { let language = tab.language; let results = tab.results.clone(); self.running = true; - results.update(cx, |r, cx| r.set_running(cx)); + let saved_query = SavedQuery { + language: if language == Language::Sql { "sql" } else { "cypher" }.into(), + text: text.trim().to_string(), + }; + results.update(cx, |r, cx| { + r.set_query(Some(saved_query), cx); + r.set_running(cx); + }); cx.notify(); let state = self.state.clone(); cx.spawn_in(window, async move |this, cx| { diff --git a/app/tests/fixtures/snapshot-v1-after.json b/app/tests/fixtures/snapshot-v1-after.json new file mode 100644 index 0000000..98198e7 --- /dev/null +++ b/app/tests/fixtures/snapshot-v1-after.json @@ -0,0 +1,436 @@ +{ + "format": "khonsu-snapshot", + "version": 1, + "snapshot_id": "snapshot:14ca56cc22b6e995", + "captured_at": 1793647248, + "fingerprint": { + "nodes": 16, + "relationships": 23, + "hash": "14ca56cc22b6e995", + "sources": [ + "shop" + ] + }, + "sources": { + "shop": { + "coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "plugin": { + "status": "known", + "value": "aws@0.2.0" + } + } + }, + "nodes": [ + { + "id": "shop/123456789012:us-east-1:i-db1", + "labels": [ + "Instance" + ], + "source": "shop", + "properties": { + "image_id": "ami-db", + "instance_id": "i-db1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "db-1", + "private_ip": "10.0.2.20", + "state": "running", + "type": "r6g.large" + } + }, + { + "id": "shop/123456789012:us-east-1:i-web1", + "labels": [ + "Instance" + ], + "source": "shop", + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "id": "shop/123456789012:us-east-1:igw-shop", + "labels": [ + "InternetGateway" + ], + "source": "shop", + "properties": { + "name": "igw-shop" + } + }, + { + "id": "shop/123456789012:us-east-1:sg-db", + "labels": [ + "SecurityGroup" + ], + "source": "shop", + "properties": { + "description": "database", + "group_id": "sg-db", + "name": "db" + } + }, + { + "id": "shop/123456789012:us-east-1:sg-web", + "labels": [ + "SecurityGroup" + ], + "source": "shop", + "properties": { + "description": "web tier", + "group_id": "sg-web", + "name": "web" + } + }, + { + "id": "shop/123456789012:us-east-1:vpc-shop", + "labels": [ + "Vpc" + ], + "source": "shop", + "properties": { + "cidr": "10.0.0.0/16", + "is_default": false, + "name": "shop", + "vpc_id": "vpc-shop" + } + }, + { + "id": "shop/account:123456789012", + "labels": [ + "Account" + ], + "source": "shop", + "properties": { + "account_id": "123456789012" + } + }, + { + "id": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "labels": [ + "Subnet" + ], + "source": "shop", + "properties": { + "availability_zone": "us-east-1a", + "cidr": "10.0.2.0/24", + "name": "private-a", + "public_ip_on_launch": false, + "subnet_id": "subnet-private" + } + }, + { + "id": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "labels": [ + "Subnet" + ], + "source": "shop", + "properties": { + "availability_zone": "us-east-1a", + "cidr": "10.0.1.0/24", + "name": "public-a", + "public_ip_on_launch": true, + "subnet_id": "subnet-public" + } + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "labels": [ + "LoadBalancer" + ], + "source": "shop", + "properties": { + "dns_name": "shop-123.us-east-1.elb.amazonaws.com", + "name": "shop", + "scheme": "internet-facing", + "type": "application" + } + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "labels": [ + "TargetGroup" + ], + "source": "shop", + "properties": { + "name": "web", + "port": 8080, + "protocol": "HTTP" + } + }, + { + "id": "shop/arn:aws:iam::123456789012:role/db-backup", + "labels": [ + "Role" + ], + "source": "shop", + "properties": { + "arn": "arn:aws:iam::123456789012:role/db-backup", + "name": "db-backup", + "path": "/", + "trust_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + }, + { + "id": "shop/arn:aws:iam::123456789012:role/web-app", + "labels": [ + "Role" + ], + "source": "shop", + "properties": { + "arn": "arn:aws:iam::123456789012:role/web-app", + "name": "web-app", + "path": "/", + "trust_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + }, + { + "id": "shop/cidr:203.0.113.0/24", + "labels": [ + "Cidr" + ], + "source": "shop", + "properties": { + "cidr": "203.0.113.0/24", + "name": "203.0.113.0/24" + } + }, + { + "id": "shop/cidr:internet", + "labels": [ + "Cidr" + ], + "source": "shop", + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "id": "shop/region:123456789012:us-east-1", + "labels": [ + "Region" + ], + "source": "shop", + "properties": { + "name": "us-east-1" + } + } + ], + "relationships": [ + { + "id": "shop/123456789012:us-east-1:i-db1|ASSUMES|arn:aws:iam::123456789012:role/db-backup", + "rel_type": "ASSUMES", + "from": "shop/123456789012:us-east-1:i-db1", + "to": "shop/arn:aws:iam::123456789012:role/db-backup", + "source": "shop", + "properties": { + "instance_profile": "arn:aws:iam::123456789012:instance-profile/db-backup" + } + }, + { + "id": "shop/123456789012:us-east-1:i-db1|IN_SUBNET|arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "rel_type": "IN_SUBNET", + "from": "shop/123456789012:us-east-1:i-db1", + "to": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "rel_type": "PROTECTED_BY", + "from": "shop/123456789012:us-east-1:i-db1", + "to": "shop/123456789012:us-east-1:sg-db", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:i-web1|ASSUMES|arn:aws:iam::123456789012:role/web-app", + "rel_type": "ASSUMES", + "from": "shop/123456789012:us-east-1:i-web1", + "to": "shop/arn:aws:iam::123456789012:role/web-app", + "source": "shop", + "properties": { + "instance_profile": "arn:aws:iam::123456789012:instance-profile/web-app" + } + }, + { + "id": "shop/123456789012:us-east-1:i-web1|IN_SUBNET|arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "rel_type": "IN_SUBNET", + "from": "shop/123456789012:us-east-1:i-web1", + "to": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "rel_type": "PROTECTED_BY", + "from": "shop/123456789012:us-east-1:i-web1", + "to": "shop/123456789012:us-east-1:sg-web", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:igw-shop|ATTACHED_TO|123456789012:us-east-1:vpc-shop", + "rel_type": "ATTACHED_TO", + "from": "shop/123456789012:us-east-1:igw-shop", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:sg-db|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/123456789012:us-east-1:sg-db", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "rel_type": "ALLOWS", + "from": "shop/123456789012:us-east-1:sg-web", + "to": "shop/123456789012:us-east-1:sg-db", + "source": "shop", + "properties": { + "ports": "5432", + "protocol": "tcp", + "rule": "\"tcp\":\"5432\"" + } + }, + { + "id": "shop/123456789012:us-east-1:sg-web|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/123456789012:us-east-1:sg-web", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:vpc-shop|IN_REGION|region:123456789012:us-east-1", + "rel_type": "IN_REGION", + "from": "shop/123456789012:us-east-1:vpc-shop", + "to": "shop/region:123456789012:us-east-1", + "source": "shop", + "properties": {} + }, + { + "id": "shop/account:123456789012|OWNS|arn:aws:iam::123456789012:role/db-backup", + "rel_type": "OWNS", + "from": "shop/account:123456789012", + "to": "shop/arn:aws:iam::123456789012:role/db-backup", + "source": "shop", + "properties": {} + }, + { + "id": "shop/account:123456789012|OWNS|arn:aws:iam::123456789012:role/web-app", + "rel_type": "OWNS", + "from": "shop/account:123456789012", + "to": "shop/arn:aws:iam::123456789012:role/web-app", + "source": "shop", + "properties": {} + }, + { + "id": "shop/account:123456789012|OWNS|region:123456789012:us-east-1", + "rel_type": "OWNS", + "from": "shop/account:123456789012", + "to": "shop/region:123456789012:us-east-1", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "rel_type": "FORWARDS_TO", + "from": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "to": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|PROTECTED_BY|123456789012:us-east-1:sg-web", + "rel_type": "PROTECTED_BY", + "from": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "to": "shop/123456789012:us-east-1:sg-web", + "source": "shop", + "properties": {} + }, + { + "id": "shop/cidr:203.0.113.0/24|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"22\\\"\"", + "rel_type": "ALLOWS", + "from": "shop/cidr:203.0.113.0/24", + "to": "shop/123456789012:us-east-1:sg-web", + "source": "shop", + "properties": { + "ports": "22", + "protocol": "tcp", + "rule": "\"tcp\":\"22\"" + } + }, + { + "id": "shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "rel_type": "ALLOWS", + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:sg-web", + "source": "shop", + "properties": { + "ports": "443", + "protocol": "tcp", + "rule": "\"tcp\":\"443\"" + } + }, + { + "id": "shop/cidr:internet|ROUTES_TO|123456789012:us-east-1:igw-shop", + "rel_type": "ROUTES_TO", + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:igw-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "rel_type": "ROUTES_TO", + "from": "shop/cidr:internet", + "to": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "source": "shop", + "properties": {} + } + ], + "unstable_nodes": 0, + "unstable_relationships": 0 +} diff --git a/app/tests/fixtures/snapshot-v1-before.json b/app/tests/fixtures/snapshot-v1-before.json new file mode 100644 index 0000000..fbe0889 --- /dev/null +++ b/app/tests/fixtures/snapshot-v1-before.json @@ -0,0 +1,446 @@ +{ + "format": "khonsu-snapshot", + "version": 1, + "snapshot_id": "snapshot:85aa948bd39feb46", + "captured_at": 1793647247, + "fingerprint": { + "nodes": 16, + "relationships": 24, + "hash": "85aa948bd39feb46", + "sources": [ + "shop" + ] + }, + "sources": { + "shop": { + "coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "plugin": { + "status": "known", + "value": "aws@0.2.0" + } + } + }, + "nodes": [ + { + "id": "shop/123456789012:us-east-1:i-db1", + "labels": [ + "Instance" + ], + "source": "shop", + "properties": { + "image_id": "ami-db", + "instance_id": "i-db1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "db-1", + "private_ip": "10.0.2.20", + "state": "running", + "type": "r6g.large" + } + }, + { + "id": "shop/123456789012:us-east-1:i-web1", + "labels": [ + "Instance" + ], + "source": "shop", + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "id": "shop/123456789012:us-east-1:igw-shop", + "labels": [ + "InternetGateway" + ], + "source": "shop", + "properties": { + "name": "igw-shop" + } + }, + { + "id": "shop/123456789012:us-east-1:sg-db", + "labels": [ + "SecurityGroup" + ], + "source": "shop", + "properties": { + "description": "database", + "group_id": "sg-db", + "name": "db" + } + }, + { + "id": "shop/123456789012:us-east-1:sg-web", + "labels": [ + "SecurityGroup" + ], + "source": "shop", + "properties": { + "description": "web tier", + "group_id": "sg-web", + "name": "web" + } + }, + { + "id": "shop/123456789012:us-east-1:vpc-shop", + "labels": [ + "Vpc" + ], + "source": "shop", + "properties": { + "cidr": "10.0.0.0/16", + "is_default": false, + "name": "shop", + "vpc_id": "vpc-shop" + } + }, + { + "id": "shop/account:123456789012", + "labels": [ + "Account" + ], + "source": "shop", + "properties": { + "account_id": "123456789012" + } + }, + { + "id": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "labels": [ + "Subnet" + ], + "source": "shop", + "properties": { + "availability_zone": "us-east-1a", + "cidr": "10.0.2.0/24", + "name": "private-a", + "public_ip_on_launch": false, + "subnet_id": "subnet-private" + } + }, + { + "id": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "labels": [ + "Subnet" + ], + "source": "shop", + "properties": { + "availability_zone": "us-east-1a", + "cidr": "10.0.1.0/24", + "name": "public-a", + "public_ip_on_launch": true, + "subnet_id": "subnet-public" + } + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "labels": [ + "LoadBalancer" + ], + "source": "shop", + "properties": { + "dns_name": "shop-123.us-east-1.elb.amazonaws.com", + "name": "shop", + "scheme": "internet-facing", + "type": "application" + } + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "labels": [ + "TargetGroup" + ], + "source": "shop", + "properties": { + "name": "web", + "port": 8080, + "protocol": "HTTP" + } + }, + { + "id": "shop/arn:aws:iam::123456789012:role/db-backup", + "labels": [ + "Role" + ], + "source": "shop", + "properties": { + "arn": "arn:aws:iam::123456789012:role/db-backup", + "name": "db-backup", + "path": "/", + "trust_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + }, + { + "id": "shop/arn:aws:iam::123456789012:role/web-app", + "labels": [ + "Role" + ], + "source": "shop", + "properties": { + "arn": "arn:aws:iam::123456789012:role/web-app", + "name": "web-app", + "path": "/", + "trust_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" + } + }, + { + "id": "shop/cidr:203.0.113.0/24", + "labels": [ + "Cidr" + ], + "source": "shop", + "properties": { + "cidr": "203.0.113.0/24", + "name": "203.0.113.0/24" + } + }, + { + "id": "shop/cidr:internet", + "labels": [ + "Cidr" + ], + "source": "shop", + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "id": "shop/region:123456789012:us-east-1", + "labels": [ + "Region" + ], + "source": "shop", + "properties": { + "name": "us-east-1" + } + } + ], + "relationships": [ + { + "id": "shop/123456789012:us-east-1:i-db1|ASSUMES|arn:aws:iam::123456789012:role/db-backup", + "rel_type": "ASSUMES", + "from": "shop/123456789012:us-east-1:i-db1", + "to": "shop/arn:aws:iam::123456789012:role/db-backup", + "source": "shop", + "properties": { + "instance_profile": "arn:aws:iam::123456789012:instance-profile/db-backup" + } + }, + { + "id": "shop/123456789012:us-east-1:i-db1|IN_SUBNET|arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "rel_type": "IN_SUBNET", + "from": "shop/123456789012:us-east-1:i-db1", + "to": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "rel_type": "PROTECTED_BY", + "from": "shop/123456789012:us-east-1:i-db1", + "to": "shop/123456789012:us-east-1:sg-db", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:i-web1|ASSUMES|arn:aws:iam::123456789012:role/web-app", + "rel_type": "ASSUMES", + "from": "shop/123456789012:us-east-1:i-web1", + "to": "shop/arn:aws:iam::123456789012:role/web-app", + "source": "shop", + "properties": { + "instance_profile": "arn:aws:iam::123456789012:instance-profile/web-app" + } + }, + { + "id": "shop/123456789012:us-east-1:i-web1|IN_SUBNET|arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "rel_type": "IN_SUBNET", + "from": "shop/123456789012:us-east-1:i-web1", + "to": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "rel_type": "PROTECTED_BY", + "from": "shop/123456789012:us-east-1:i-web1", + "to": "shop/123456789012:us-east-1:sg-web", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:igw-shop|ATTACHED_TO|123456789012:us-east-1:vpc-shop", + "rel_type": "ATTACHED_TO", + "from": "shop/123456789012:us-east-1:igw-shop", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:sg-db|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/123456789012:us-east-1:sg-db", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "rel_type": "ALLOWS", + "from": "shop/123456789012:us-east-1:sg-web", + "to": "shop/123456789012:us-east-1:sg-db", + "source": "shop", + "properties": { + "ports": "5432", + "protocol": "tcp", + "rule": "\"tcp\":\"5432\"" + } + }, + { + "id": "shop/123456789012:us-east-1:sg-web|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/123456789012:us-east-1:sg-web", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/123456789012:us-east-1:vpc-shop|IN_REGION|region:123456789012:us-east-1", + "rel_type": "IN_REGION", + "from": "shop/123456789012:us-east-1:vpc-shop", + "to": "shop/region:123456789012:us-east-1", + "source": "shop", + "properties": {} + }, + { + "id": "shop/account:123456789012|OWNS|arn:aws:iam::123456789012:role/db-backup", + "rel_type": "OWNS", + "from": "shop/account:123456789012", + "to": "shop/arn:aws:iam::123456789012:role/db-backup", + "source": "shop", + "properties": {} + }, + { + "id": "shop/account:123456789012|OWNS|arn:aws:iam::123456789012:role/web-app", + "rel_type": "OWNS", + "from": "shop/account:123456789012", + "to": "shop/arn:aws:iam::123456789012:role/web-app", + "source": "shop", + "properties": {} + }, + { + "id": "shop/account:123456789012|OWNS|region:123456789012:us-east-1", + "rel_type": "OWNS", + "from": "shop/account:123456789012", + "to": "shop/region:123456789012:us-east-1", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "rel_type": "FORWARDS_TO", + "from": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "to": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|IN_VPC|123456789012:us-east-1:vpc-shop", + "rel_type": "IN_VPC", + "from": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "to": "shop/123456789012:us-east-1:vpc-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|PROTECTED_BY|123456789012:us-east-1:sg-web", + "rel_type": "PROTECTED_BY", + "from": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "to": "shop/123456789012:us-east-1:sg-web", + "source": "shop", + "properties": {} + }, + { + "id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "rel_type": "ROUTES_TO", + "from": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "to": "shop/123456789012:us-east-1:i-web1", + "source": "shop", + "properties": { + "health": "healthy" + } + }, + { + "id": "shop/cidr:203.0.113.0/24|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"22\\\"\"", + "rel_type": "ALLOWS", + "from": "shop/cidr:203.0.113.0/24", + "to": "shop/123456789012:us-east-1:sg-web", + "source": "shop", + "properties": { + "ports": "22", + "protocol": "tcp", + "rule": "\"tcp\":\"22\"" + } + }, + { + "id": "shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "rel_type": "ALLOWS", + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:sg-web", + "source": "shop", + "properties": { + "ports": "443", + "protocol": "tcp", + "rule": "\"tcp\":\"443\"" + } + }, + { + "id": "shop/cidr:internet|ROUTES_TO|123456789012:us-east-1:igw-shop", + "rel_type": "ROUTES_TO", + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:igw-shop", + "source": "shop", + "properties": {} + }, + { + "id": "shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "rel_type": "ROUTES_TO", + "from": "shop/cidr:internet", + "to": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "source": "shop", + "properties": {} + } + ], + "unstable_nodes": 0, + "unstable_relationships": 0 +} diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index f553602..31007c1 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -24,6 +24,7 @@ use std::sync::Once; const INTERNET: &str = "shop/cidr:internet"; const WEB_1: &str = "shop/123456789012:us-east-1:i-web1"; const DB_1: &str = "shop/123456789012:us-east-1:i-db1"; +const ALLOWS_443: &str = r#"shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|"\"tcp\":\"443\"""#; /// Questions an investigator asks of the fixture. The name says what each /// result means; several are meant to find nothing. @@ -169,6 +170,12 @@ fn fixture() -> PathBuf { repo().join("plugins/aws/tests/fixture") } +/// The saved responses with a positive, a negative and an undecided case, and +/// route tables. See `docs/exposure-rules.md`. +fn exposure_fixture() -> PathBuf { + repo().join("plugins/aws/tests/fixture-exposure") +} + fn golden_path() -> PathBuf { repo().join("examples/aws/golden.json") } @@ -191,17 +198,22 @@ fn build_plugins() { .status() .unwrap(); assert!(status.success()); + let status = Command::new(env!("CARGO")) + .args(["build", "-q", "--locked", "-p", "khonsu-plugin-protocol", "--example", "scripted"]) + .status() + .unwrap(); + assert!(status.success()); }); } -/// Drops what legitimately differs between runs and machines: timings, sync -/// times and paths. Floats are rounded so the last bits of a platform's +/// Drops what legitimately differs between runs and releases: timings, sync +/// times, paths and plugin versions. Floats are rounded so the last bits of a platform's /// arithmetic do not count as a change of meaning. fn normalize(value: &Value) -> Value { match value { Value::Object(map) => Value::Object( map.iter() - .filter(|(key, _)| !matches!(key.as_str(), "elapsed_ms" | "load_ms" | "khonsu_synced_at" | "file" | "line")) + .filter(|(key, _)| !matches!(key.as_str(), "started_at" | "ended_at" | "elapsed_ms" | "load_ms" | "khonsu_synced_at" | "khonsu_plugin" | "plugin" | "file" | "line" | "record_written_at" | "source_last_successful_sync")) .map(|(key, value)| (key.clone(), normalize(value))) .collect(), ), @@ -252,6 +264,16 @@ fn investigation(env: &Env) -> Value { }; let path = env.ok(&["analyze", "path", "-d", text(&db), "--from", &id(INTERNET), "--to", &id(WEB_1)]); let dash = repo().join("examples/aws/exposure.dash"); + let saved_file = env.path("shop.investigation"); + env.ok(&investigation_save(&db, &saved_file)); + let reopened = env.ok(&["investigation", "reopen", text(&saved_file)]); + let exposure_report = { + let other = Env::new(); + let graph = other.path("graph"); + other.add_source(&graph, &exposure_fixture()); + other.ok(&["plugin", "sync", "--source", "shop"]); + other.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&graph)]) + }; normalize(&json!({ "sync": first, @@ -265,9 +287,47 @@ fn investigation(env: &Env) -> Value { "path": path, }, "dashboard": env.ok(&["check", text(&dash), "-d", text(&db)]), + "evidence": { + "web_1": env.ok(&["evidence", "-d", text(&db), "--node", WEB_1]), + "internet_to_web_group": env.ok(&["evidence", "-d", text(&db), "--rel", ALLOWS_443]), + }, + "explore": { + "exposure_of_web_1": env.ok(&[ + "explore", "-d", text(&db), "--from", INTERNET, "--to", WEB_1, + "--type", "ROUTES_TO,FORWARDS_TO", "--max-hops", "4", + ]), + "what_points_at_web_1": env.ok(&["explore", "-d", text(&db), "--from", WEB_1, "--direction", "in", "--max-hops", "1"]), + }, + "investigation": { + "graph_state": reopened["graph_state"], + "fingerprint": reopened["fingerprint"], + "walk": reopened["walk"], + "path": reopened["path"], + "path_same": reopened["path_same"], + }, + "exposure": { + // Saved responses with no route tables, as a graph from before they were read. + "shop_fixture": exposure_brief(&env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)])), + "exposure_fixture": exposure_report, + }, })) } +/// Saves the walk from the internet to web-1 through the load balancer. +fn investigation_save<'a>(db: &'a Path, file: &'a Path) -> Vec<&'a str> { + vec![ + "investigation", "save", text(file), "-d", text(db), + "--from", INTERNET, "--to", WEB_1, "--type", "ROUTES_TO,FORWARDS_TO", "--max-hops", "4", + "--cypher", "MATCH (n:Instance) RETURN n", + ] +} + +/// (rule, resource, verdict) for every finding, which is what changes meaning. +fn exposure_brief(report: &Value) -> Value { + let findings = report["findings"].as_array().unwrap().iter(); + Value::Array(findings.map(|finding| json!([finding["rule_id"], finding["resource"], finding["verdict"]])).collect()) +} + /// Writes a graph as a zip without the lock files of the process that made it. fn write_snapshot(db: &Path, target: &Path) { fn add(zip: &mut zip::ZipWriter, root: &Path, dir: &Path) { @@ -300,7 +360,11 @@ fn the_offline_investigation_gives_the_golden_answers() { let text = serde_json::to_string_pretty(&actual).unwrap() + "\n"; std::fs::write(golden_path(), text).unwrap(); let name = format!("graph-hawdb-{}.zip", khonsu_core::HAWDB_VERSION); - write_snapshot(&env.path("graph"), &snapshots_dir().join(name)); + // A snapshot stays what its HawDB version wrote, so only add a missing one. + let snapshot = snapshots_dir().join(name); + if !snapshot.exists() { + write_snapshot(&env.path("graph"), &snapshot); + } return; } let expected = golden(); @@ -345,6 +409,661 @@ fn candidate_paths_follow_the_arrows_their_hop_cap_and_their_types() { assert_eq!(rules[0][3], json!(DB_1)); } +/// The AWS acceptance case of #32: a typed walk traces the resource, explains +/// each hop, and reports when a cap cut it short. +#[test] +fn explore_traces_a_resource_through_typed_hops() { + let env = Env::new(); + let db = env.synced_graph("graph"); + let trace = ["explore", "-d", text(&db), "--from", INTERNET, "--to", WEB_1, "--type", "ROUTES_TO,FORWARDS_TO"]; + + let out = env.ok(&trace); + assert_eq!(out["path"]["found"], true, "{out}"); + let words: Vec<&str> = out["path"]["hops"].as_array().unwrap().iter().map(|hop| hop["text"].as_str().unwrap()).collect(); + assert_eq!(words, ["Internet -[ROUTES_TO]-> shop", "shop -[FORWARDS_TO]-> web", "web -[ROUTES_TO]-> web-1"]); + assert_eq!(out["complete"], true, "{out}"); + + // Short of the hop it needs, it says the answer is cut, not that no path exists. + let mut short = trace.to_vec(); + short.extend(["--max-hops", "2"]); + let out = env.ok(&short); + assert_eq!(out["path"]["found"], false, "{out}"); + assert_eq!(out["complete"], false, "{out}"); + assert_eq!(out["limits"], json!(["hops"]), "{out}"); + + let (code, out) = env.khonsu(&["explore", "-d", text(&db), "--from", "shop/no-such-resource"]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("not_found")), "{out}"); + let (code, out) = env.khonsu(&["explore", "-d", text(&db), "--from", INTERNET, "--max-hops", "99"]); + assert_ne!(code, 0, "{out}"); +} + +/// #33 on the fixture: where a relationship came from, and the path that +/// rests on it, by IDs a script can keep. +#[test] +fn evidence_is_machine_readable_and_states_what_is_unknown() { + let env = Env::new(); + let db = env.synced_graph("graph"); + + let out = env.ok(&["evidence", "-d", text(&db), "--rel", ALLOWS_443]); + let record = &out["evidence"][0]; + assert_eq!(record["evidence_id"], format!("rel:{ALLOWS_443}"), "{out}"); + assert_eq!(record["kind"], "relationship"); + assert_eq!(record["types"], json!(["ALLOWS"])); + assert_eq!(record["from"]["value"], format!("node:{INTERNET}")); + assert_eq!(record["source"], json!({"status": "known", "value": "shop"})); + assert_eq!(record["assertion"], "explicit"); + assert_eq!(record["properties"]["ports"], "443"); + assert_eq!(record["source_snapshot"], json!({"status": "known", "value": "shop#1"})); + assert_eq!(record["record_written_at"]["status"], "known"); + assert_eq!(record["source_last_successful_sync"]["status"], "known"); + + // By its internal ID, the same record. + let internal = record["internal_id"].as_i64().unwrap().to_string(); + let again = env.ok(&["evidence", "-d", text(&db), "--rel", &internal]); + assert_eq!(again["evidence"][0]["evidence_id"], record["evidence_id"]); + + // Source configuration stays out of what gets copied around. + let everything = serde_json::to_string(&env.ok(&["evidence", "-d", text(&db), "--node", WEB_1, "--rel", ALLOWS_443])).unwrap(); + assert!(!everything.contains("fixture_dir") && !everything.contains(text(&fixture())), "{everything}"); + + let walk = env.ok(&["explore", "-d", text(&db), "--from", INTERNET, "--to", WEB_1, "--type", "ROUTES_TO,FORWARDS_TO"]); + let path = &walk["path"]["evidence"]; + assert_eq!(path["assertion"], "derived", "{walk}"); + assert_eq!(path["rule_id"], json!({"status": "unknown"})); + assert_eq!(path["supporting"].as_array().unwrap().len(), 7, "four nodes and three relationships: {path}"); + assert!(path["evidence_id"].as_str().unwrap().starts_with("path:")); + + let (code, out) = env.khonsu(&["evidence", "-d", text(&db), "--rel", "shop/no-such-relationship"]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("not_found")), "{out}"); + let (code, out) = env.khonsu(&["evidence", "-d", text(&db)]); + assert_eq!(code, 2, "naming nothing is a usage mistake: {out}"); +} + +/// Written by hand, a record has no provenance to report, and a secret-shaped +/// property does not get out. +#[test] +fn evidence_for_a_record_written_by_hand_masks_secrets() { + let env = Env::new(); + let db = env.path("graph"); + env.ok(&["init", text(&db)]); + env.ok(&["query", "-d", text(&db), "--write", "--cypher", "CREATE (:Host {name: 'web', api_token: 'abc123'})"]); + let id = env.query(&db, "MATCH (n:Host) RETURN id(n) AS id")["rows"][0][0].as_i64().unwrap().to_string(); + + let out = env.ok(&["evidence", "-d", text(&db), "--node", &id]); + let record = &out["evidence"][0]; + assert_eq!(record["stable"], false, "{out}"); + assert_eq!(record["assertion"], "unknown"); + for field in ["source", "plugin", "record_written_at", "source_last_successful_sync"] { + assert_eq!(record[field], json!({"status": "unknown"}), "{field}"); + } + assert_eq!(record["redacted"], json!(["api_token"])); + assert!(!out.to_string().contains("abc123"), "{out}"); +} + +/// The finding about `resource` under `rule`. +fn finding<'a>(report: &'a Value, rule: &str, resource: &str) -> &'a Value { + let wanted = format!("node:{resource}"); + report["findings"] + .as_array() + .unwrap() + .iter() + .find(|finding| finding["rule_id"] == rule && finding["resource"] == wanted) + .unwrap_or_else(|| panic!("no {rule} finding on {resource}: {report}")) +} + +fn condition_status<'a>(finding: &'a Value, id: &str) -> &'a str { + let conditions = finding["conditions"].as_array().unwrap(); + conditions.iter().find(|condition| condition["id"] == id).unwrap_or_else(|| panic!("no {id}: {finding}"))["status"].as_str().unwrap() +} + +const DIRECT: &str = "aws.exposure.direct-instance"; +const BALANCER: &str = "aws.exposure.public-load-balancer"; +const LATERAL: &str = "aws.exposure.security-group-lateral"; + +fn instance(id: &str) -> String { + format!("shop/123456789012:us-east-1:{id}") +} + +/// #35's three cases, spelled out so that blessing a wrong golden file would +/// still have to get past this test. +#[test] +fn the_exposure_fixture_has_a_positive_a_negative_and_an_undecided_case() { + let env = Env::new(); + let db = env.path("graph"); + env.add_source(&db, &exposure_fixture()); + env.ok(&["plugin", "sync", "--source", "shop"]); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + assert_eq!(report["complete"], true); + assert_eq!(report["truncated"], 0); + assert_eq!(report["subjects"], 5); + assert!(report["claim"].as_str().unwrap().contains("does not show that anything is reachable")); + + // Positive: every fact the rule asks for is in the configuration. + let web = finding(&report, DIRECT, &instance("i-web1")); + assert_eq!(web["verdict"], "potential_exposure", "{web}"); + assert_eq!(web["path"]["hops"], json!(["Internet -[ALLOWS]-> web", "web <-[PROTECTED_BY]- web-1"])); + assert_eq!(web["path"]["rule_id"], json!({"status": "known", "value": DIRECT})); + assert_eq!(web["path"]["assertion"], "derived"); + assert!(web["assumptions"].as_array().unwrap().iter().any(|text| text.as_str().unwrap().contains("Network ACLs"))); + // Exact fields, with the name AWS gives them. + let address = web["conditions"].as_array().unwrap().iter().find(|condition| condition["id"] == "public-address").unwrap(); + assert_eq!(address["fields"][0]["field"], "public_ip"); + assert_eq!(address["fields"][0]["source_field"], "ec2 describe-instances: PublicIpAddress"); + assert_eq!(address["fields"][0]["value"], json!({"status": "known", "value": "198.51.100.10"})); + + let app = finding(&report, BALANCER, &instance("i-app1")); + assert_eq!(app["verdict"], "potential_exposure", "{app}"); + assert_eq!(app["path"]["hops"].as_array().unwrap().len(), 3); + let db_1 = finding(&report, LATERAL, &instance("i-db1")); + assert_eq!(db_1["verdict"], "potential_exposure", "{db_1}"); + assert_eq!(db_1["depends_on"], web["finding_id"]); + + // Negative: an open rule, and on its own that is not exposure. + let batch = finding(&report, DIRECT, &instance("i-batch1")); + assert_eq!(condition_status(batch, "open-inbound-rule"), "met"); + assert_eq!(condition_status(batch, "public-address"), "not_met"); + assert_eq!(condition_status(batch, "internet-route"), "not_met", "its subnet uses the main table, whose default goes to a NAT gateway"); + assert_eq!(batch["verdict"], "not_indicated", "{batch}"); + + // Undecided: its VPC's route tables are not in the saved responses. + let legacy = finding(&report, DIRECT, &instance("i-legacy1")); + assert_eq!(condition_status(legacy, "open-inbound-rule"), "met"); + assert_eq!(condition_status(legacy, "public-address"), "met"); + assert_eq!(condition_status(legacy, "internet-route"), "unknown"); + assert_eq!(legacy["verdict"], "insufficient_data", "{legacy}"); + assert_eq!(legacy["unknowns"].as_array().unwrap().len(), 1); + + // The three verdicts are the only words a finding can use. + let verdicts: std::collections::BTreeSet<&str> = report["findings"].as_array().unwrap().iter().map(|finding| finding["verdict"].as_str().unwrap()).collect(); + assert_eq!(verdicts, ["insufficient_data", "not_indicated", "potential_exposure"].into_iter().collect()); + assert_eq!(report["summary"], json!({"potential_exposure": 3, "insufficient_data": 1, "not_indicated": 3})); + + // Filters narrow what is printed, not what is counted. + let only = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db), "--verdict", "insufficient-data"]); + assert_eq!(exposure_brief(&only), json!([[DIRECT, format!("node:{}", instance("i-legacy1")), "insufficient_data"]])); + let one = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db), "--resource", &instance("i-web1")]); + assert_eq!(one["findings"].as_array().unwrap().len(), 1); + let (code, out) = env.khonsu(&["plugin", "analyze", "aws", "exposure", "-d", text(&db), "--resource", "shop/no-such-instance"]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("not_found")), "a mistyped ID is not an empty answer: {out}"); + let cut = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db), "--max-findings", "2"]); + assert_eq!((cut["findings"].as_array().unwrap().len(), cut["truncated"].as_i64()), (2, Some(5))); + assert_eq!(cut["summary"], report["summary"]); +} + +/// A finding leads into the other two tools: its walk finds the resource in +/// Path Explorer, and every record it cites can be looked up as evidence. +#[test] +fn a_finding_opens_in_path_explorer_and_its_records_resolve_as_evidence() { + let env = Env::new(); + let db = env.path("graph"); + env.add_source(&db, &exposure_fixture()); + env.ok(&["plugin", "sync", "--source", "shop"]); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + + let mut walked = 0; + for finding in report["findings"].as_array().unwrap() { + let name = &finding["finding_id"]; + let Value::Object(walk) = &finding["walk"] else { + assert!(finding["path"].is_null(), "{finding}"); + continue; + }; + walked += 1; + let types = walk["types"].as_array().unwrap().iter().map(|name| name.as_str().unwrap()).collect::>().join(","); + let hops = walk["max_hops"].to_string(); + let out = env.ok(&[ + "explore", "-d", text(&db), + "--from", walk["from"].as_str().unwrap(), + "--to", walk["to"].as_str().unwrap(), + "--direction", walk["direction"].as_str().unwrap(), + "--type", &types, + "--max-hops", &hops, + ]); + assert_eq!(out["path"]["found"], true, "{name}: {out}"); + + let cited = finding["contributing"].as_array().unwrap(); + let records: Vec<&str> = finding["evidence"].as_array().unwrap().iter().map(|record| record["evidence_id"].as_str().unwrap()).collect(); + assert_eq!(records.len(), cited.len(), "{name}: every record it cites is included"); + for id in cited { + let id = id.as_str().unwrap(); + let (flag, stable) = match id.split_once(':').unwrap() { + ("node", stable) => ("--node", stable), + ("rel", stable) => ("--rel", stable), + other => panic!("{other:?}"), + }; + let found = env.ok(&["evidence", "-d", text(&db), flag, stable]); + assert_eq!(found["evidence"][0]["evidence_id"], id, "{name}"); + } + } + assert!(walked >= 3, "{report}"); +} + +/// #38: a response missing from the start leaves a condition unknown, and one +/// that goes missing later removes nothing and clears nothing. +#[test] +fn a_missing_response_makes_a_finding_undecided_rather_than_clearing_it() { + // Never there: nothing to read the route from. + let env = Env::new(); + let saved = env.path("saved"); + copy_dir(&exposure_fixture(), &saved); + std::fs::remove_file(saved.join("us-east-1/ec2-describe-route-tables.json")).unwrap(); + let db = env.path("graph"); + env.add_source(&db, &saved); + let out = env.ok(&["plugin", "sync", "--source", "shop"]); + assert_eq!(out["run"]["status"], "partial", "{out}"); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + let web = finding(&report, DIRECT, &instance("i-web1")); + assert_eq!(web["verdict"], "insufficient_data", "{web}"); + assert_eq!(condition_status(web, "internet-route"), "unknown"); + assert_eq!(report["complete"], false, "{report}"); + + // Goes missing later: the graph keeps what it knew, and says how far to trust it. + let env = Env::new(); + let saved = env.path("saved"); + copy_dir(&exposure_fixture(), &saved); + let db = env.path("graph"); + env.add_source(&db, &saved); + env.ok(&["plugin", "sync", "--source", "shop"]); + let before = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + assert_eq!(before["complete"], true, "{before}"); + assert_eq!(finding(&before, DIRECT, &instance("i-batch1"))["verdict"], "not_indicated"); + + std::fs::remove_file(saved.join("us-east-1/ec2-describe-security-groups.json")).unwrap(); + env.ok(&["plugin", "sync", "--source", "shop"]); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + assert_eq!(report["complete"], false, "{report}"); + assert_eq!(report["sources"]["shop"]["value"]["status"], "partial", "{report}"); + let web = finding(&report, DIRECT, &instance("i-web1")); + assert_eq!(web["verdict"], "potential_exposure", "what it did report still stands: {web}"); + assert_eq!(web["incomplete_sources"], json!(["shop"])); + // No false all-clear: on complete data this was not indicated. + let batch = finding(&report, DIRECT, &instance("i-batch1")); + assert_eq!(batch["verdict"], "insufficient_data", "{batch}"); + assert!(batch["unknowns"].to_string().contains("partial"), "{batch}"); +} + +/// The shop fixture has a load balancer path that the graph can walk, and whose +/// target group's port the target's security group does not allow. The path +/// exists; the rule says why that does not make it exposure. +#[test] +fn a_candidate_path_is_not_a_finding_until_the_rules_conditions_hold() { + let env = Env::new(); + let db = env.synced_graph("graph"); + let walk = env.ok(&["explore", "-d", text(&db), "--from", INTERNET, "--to", WEB_1, "--type", "ROUTES_TO,FORWARDS_TO"]); + assert_eq!(walk["path"]["found"], true, "{walk}"); + + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + let balancer = finding(&report, BALANCER, WEB_1); + assert_eq!(condition_status(balancer, "target-allows-balancer"), "not_met", "{balancer}"); + assert_eq!(balancer["verdict"], "not_indicated"); + assert_eq!(balancer["path"]["hops"].as_array().unwrap().len(), 3, "the path is still shown"); + assert_eq!(finding(&report, DIRECT, WEB_1)["verdict"], "insufficient_data", "no route tables in these responses"); +} + +#[test] +fn exposure_is_the_same_across_syncs() { + let run = || { + let env = Env::new(); + let db = env.path("graph"); + env.add_source(&db, &exposure_fixture()); + env.ok(&["plugin", "sync", "--source", "shop"]); + normalize(&env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)])) + }; + assert_eq!(run(), run()); +} + +/// #34: on unchanged data, reopening gives back the same filters and path, from +/// a file holding no source configuration. +#[test] +fn a_saved_investigation_reopens_with_its_path_and_filters() { + let env = Env::new(); + let db = env.synced_graph("graph"); + let file = env.path("web.investigation"); + let before = env.answers(&db); + + let out = env.ok(&investigation_save(&db, &file)); + assert_eq!(out["path_found"], true, "{out}"); + assert_eq!(env.answers(&db), before, "saving writes nothing to the graph"); + + let raw = std::fs::read_to_string(&file).unwrap(); + assert!(!raw.contains("fixture_dir") && !raw.contains(text(&fixture())) && !raw.contains("secret"), "{raw}"); + let saved: Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(saved["format"], "khonsu-investigation"); + assert_eq!(saved["version"], 1); + assert_eq!(saved["walk"]["seeds"][0]["stable_id"], INTERNET); + + // Another process, and a re-sync that changes nothing. + env.ok(&["plugin", "sync", "--source", "shop"]); + let out = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!(out["graph_state"], "unchanged", "{out}"); + assert_eq!(out["differences"], json!([])); + assert_eq!(out["path_same"], true); + assert_eq!(out["walk"]["complete"], true); + assert_eq!(out["saved_walk"]["direction"], "out"); + assert_eq!(out["saved_walk"]["types"], json!(["ROUTES_TO", "FORWARDS_TO"])); + assert_eq!(out["saved_walk"]["max_hops"], 4); + assert_eq!(out["query"]["text"], "MATCH (n:Instance) RETURN n"); + let hops: Vec<&str> = out["path"]["hops"].as_array().unwrap().iter().map(|hop| hop.as_str().unwrap()).collect(); + assert_eq!(hops, ["Internet -[ROUTES_TO]-> shop", "shop -[FORWARDS_TO]-> web", "web -[ROUTES_TO]-> web-1"]); +} + +/// The same data in another graph is the same data, whatever its internal IDs. +#[test] +fn a_saved_investigation_reopens_on_a_rebuilt_graph_and_refuses_to_guess_a_missing_one() { + let env = Env::new(); + let db = env.synced_graph("graph"); + let file = env.path("web.investigation"); + env.ok(&investigation_save(&db, &file)); + + let other = Env::new(); + let rebuilt = other.synced_graph("rebuilt"); + let out = other.ok(&["investigation", "reopen", text(&file), "-d", text(&rebuilt)]); + assert_eq!(out["graph_state"], "unchanged", "{out}"); + assert_eq!(out["path_same"], true, "{out}"); + + std::fs::remove_dir_all(&db).unwrap(); + let (code, out) = env.khonsu(&["investigation", "reopen", text(&file)]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("graph_missing")), "{out}"); + assert!(out["error"]["message"].as_str().unwrap().contains("--db"), "{out}"); +} + +#[test] +fn a_change_in_the_source_is_flagged_and_the_path_is_compared() { + let env = Env::new(); + let saved = env.path("saved"); + copy_dir(&fixture(), &saved); + let db = env.path("graph"); + env.add_source(&db, &saved); + env.ok(&["plugin", "sync", "--source", "shop"]); + let file = env.path("web.investigation"); + env.ok(&investigation_save(&db, &file)); + let replace = |name: &str, from: &str, to: &str| { + let path = saved.join("us-east-1").join(name); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains(from), "{text}"); + std::fs::write(&path, text.replace(from, to)).unwrap(); + }; + + // The same records and a different value: the path holds, and the graph has moved. + replace("ec2-describe-security-groups.json", "\"FromPort\": 443, \"ToPort\": 443", "\"FromPort\": 8443, \"ToPort\": 8443"); + env.ok(&["plugin", "sync", "--source", "shop"]); + let out = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!(out["graph_state"], "changed", "{out}"); + assert!(!out["differences"].as_array().unwrap().is_empty(), "{out}"); + assert_eq!(out["path_same"], true, "{out}"); + + // The balancer no longer sends traffic to web-1: the path has gone, and it says so. + replace("elbv2-describe-target-health-web.json", "i-web1", "i-db1"); + env.ok(&["plugin", "sync", "--source", "shop"]); + let out = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!(out["graph_state"], "changed", "{out}"); + assert_eq!(out["target_missing"], false, "{out}"); + assert!(out["path"].is_null(), "{out}"); + assert_eq!(out["path_same"], false, "{out}"); + + // web-1 itself has gone. + let instances = saved.join("us-east-1/ec2-describe-instances.json"); + let value: Value = serde_json::from_str(&std::fs::read_to_string(&instances).unwrap()).unwrap(); + let kept: Vec<&Value> = value["Reservations"][0]["Instances"].as_array().unwrap().iter().filter(|i| i["InstanceId"] != "i-web1").collect(); + std::fs::write(&instances, json!({"Reservations": [{"Instances": kept}]}).to_string()).unwrap(); + env.ok(&["plugin", "sync", "--source", "shop"]); + let out = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!(out["target_missing"], true, "{out}"); + assert!(out["walk"].is_object(), "the rest still runs: {out}"); +} + +#[test] +fn a_seed_that_has_gone_is_named_and_not_replaced() { + let env = Env::new(); + let saved = env.path("saved"); + copy_dir(&fixture(), &saved); + let db = env.path("graph"); + env.add_source(&db, &saved); + env.ok(&["plugin", "sync", "--source", "shop"]); + let file = env.path("web.investigation"); + env.ok(&["investigation", "save", text(&file), "-d", text(&db), "--from", WEB_1, "--direction", "in"]); + + std::fs::write(saved.join("us-east-1/ec2-describe-instances.json"), r#"{"Reservations": []}"#).unwrap(); + env.ok(&["plugin", "sync", "--source", "shop"]); + let out = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!(out["missing_seeds"][0]["stable_id"], WEB_1, "{out}"); + assert!(out["walk"].is_null(), "nothing to walk from: {out}"); + assert_eq!(out["graph_state"], "changed"); +} + +/// Written by hand, a record has only an internal ID, which a changed graph can +/// give to something else. +#[test] +fn an_internal_id_on_a_changed_graph_is_reported_as_unsure() { + let env = Env::new(); + let db = env.path("graph"); + env.ok(&["init", text(&db)]); + env.ok(&["query", "-d", text(&db), "--write", "--cypher", "CREATE (:Host {name: 'by hand'})"]); + let id = env.query(&db, "MATCH (n:Host) RETURN id(n) AS id")["rows"][0][0].as_i64().unwrap().to_string(); + let file = env.path("hand.investigation"); + env.ok(&["investigation", "save", text(&file), "-d", text(&db), "--from", &id]); + + let out = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!((out["graph_state"].as_str(), out["unsure"].as_array().map(Vec::len)), (Some("unchanged"), Some(0)), "{out}"); + + env.ok(&["query", "-d", text(&db), "--write", "--cypher", "CREATE (:Host {name: 'another'})"]); + let out = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!(out["graph_state"], "changed", "{out}"); + assert_eq!(out["unsure"].as_array().unwrap().len(), 1, "{out}"); +} + +#[test] +fn a_file_it_cannot_use_and_a_bad_request_fail_with_a_reason() { + let env = Env::new(); + let db = env.synced_graph("graph"); + let file = env.path("bad.investigation"); + let kind = |contents: &str| { + std::fs::write(&file, contents).unwrap(); + let (code, out) = env.khonsu(&["investigation", "reopen", text(&file)]); + assert_eq!(code, 1, "{out}"); + out["error"]["kind"].as_str().unwrap().to_string() + }; + assert_eq!(kind("{not json"), "invalid"); + assert_eq!(kind(r#"{"format": "something-else", "version": 1}"#), "invalid"); + assert_eq!(kind(r#"{"format": "khonsu-investigation", "version": 1}"#), "invalid"); + assert_eq!(kind(r#"{"format": "khonsu-investigation", "version": 999}"#), "too_new"); + + let (code, out) = env.khonsu(&["investigation", "reopen", text(&env.path("none.investigation"))]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("not_found")), "{out}"); + + // An existing file is kept, and a path needs somewhere to start from. + std::fs::write(&file, "keep me").unwrap(); + let (code, out) = env.khonsu(&["investigation", "save", text(&file), "-d", text(&db), "--from", WEB_1]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("exists")), "{out}"); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me"); + env.ok(&["investigation", "save", text(&file), "-d", text(&db), "--from", WEB_1, "--force"]); + + let (code, out) = env.khonsu(&["investigation", "save", text(&env.path("x")), "-d", text(&db), "--to", WEB_1]); + assert_eq!(code, 2, "{out}"); + let (code, out) = env.khonsu(&["investigation", "save", text(&env.path("x")), "-d", text(&db), "--from", "shop/no-such-node"]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("not_found")), "{out}"); + assert!(!env.path("x").exists()); +} + +/// Saves the graph's records to a snapshot named `name` and returns the path. +fn take_snapshot(env: &Env, db: &Path, name: &str) -> PathBuf { + let file = env.path(name); + env.ok(&["snapshot", "save", text(&file), "-d", text(db)]); + file +} + +fn ids_of(list: &Value) -> Vec { + list.as_array().unwrap().iter().map(|change| change["id"].as_str().unwrap().to_string()).collect() +} + +/// #37 on the AWS example: what changed between two syncs, by stable IDs. +#[test] +fn a_diff_of_two_aws_snapshots_reports_what_changed() { + let env = Env::new(); + let saved = env.path("saved"); + copy_dir(&fixture(), &saved); + let db = env.path("graph"); + env.add_source(&db, &saved); + env.ok(&["plugin", "sync", "--source", "shop"]); + let monday = take_snapshot(&env, &db, "monday.json"); + + // A re-sync that changes nothing gives an empty diff and the same ID. + env.ok(&["plugin", "sync", "--source", "shop"]); + let same = take_snapshot(&env, &db, "same.json"); + let out = env.ok(&["snapshot", "diff", text(&monday), text(&same)]); + assert_eq!(out["node_counts"], json!({"added": 0, "removed": 0, "not_seen": 0, "changed": 0}), "{out}"); + assert_eq!(out["relationship_counts"], json!({"added": 0, "removed": 0, "not_seen": 0, "changed": 0}), "{out}"); + assert_eq!(out["before"]["snapshot_id"], out["after"]["snapshot_id"], "{out}"); + let check = env.ok(&["snapshot", "check", text(&monday), "-d", text(&db)]); + assert_eq!(check["graph_state"], "unchanged", "{check}"); + + // db-1 goes, and a rule's port changes. + let instances = saved.join("us-east-1/ec2-describe-instances.json"); + let value: Value = serde_json::from_str(&std::fs::read_to_string(&instances).unwrap()).unwrap(); + let kept: Vec<&Value> = value["Reservations"][0]["Instances"].as_array().unwrap().iter().filter(|i| i["InstanceId"] != "i-db1").collect(); + std::fs::write(&instances, json!({"Reservations": [{"Instances": kept}]}).to_string()).unwrap(); + let groups = saved.join("us-east-1/ec2-describe-security-groups.json"); + let text_before = std::fs::read_to_string(&groups).unwrap(); + std::fs::write(&groups, text_before.replace("\"FromPort\": 443, \"ToPort\": 443", "\"FromPort\": 8443, \"ToPort\": 8443")).unwrap(); + env.ok(&["plugin", "sync", "--source", "shop"]); + let tuesday = take_snapshot(&env, &db, "tuesday.json"); + + let out = env.ok(&["snapshot", "diff", text(&monday), text(&tuesday)]); + assert_eq!(ids_of(&out["nodes"]["removed"]), [DB_1], "{out}"); + assert_eq!(out["nodes"]["not_seen"], json!([]), "{out}"); + let removed = ids_of(&out["relationships"]["removed"]); + assert!(removed.contains(&ALLOWS_443.to_string()), "the old port's rule has gone: {removed:?}"); + assert!(removed.iter().any(|id| id.contains("i-db1")), "and db-1's relationships: {removed:?}"); + let added = ids_of(&out["relationships"]["added"]); + assert!(added.iter().any(|id| id.contains("8443")), "the new port's rule: {added:?}"); + assert_eq!(out["warnings"], json!([]), "{out}"); + assert_eq!(out, env.ok(&["snapshot", "diff", text(&monday), text(&tuesday)]), "the same files give the same diff"); + + let check = env.ok(&["snapshot", "check", text(&monday), "-d", text(&db)]); + assert_eq!(check["graph_state"], "changed", "{check}"); + + let capped = env.ok(&["snapshot", "diff", text(&monday), text(&tuesday), "--max-changes", "1"]); + assert!(capped["truncated"].as_u64().unwrap() > 0, "{capped}"); + assert_eq!(capped["relationship_counts"], out["relationship_counts"], "counts are not cut"); +} + +/// A partial sync keeps old records, and a diff must not call anything gone. +#[test] +fn incomplete_coverage_is_never_reported_as_a_removal() { + let env = Env::new(); + let saved = env.path("saved"); + copy_dir(&fixture(), &saved); + let db = env.path("graph"); + env.add_source(&db, &saved); + env.ok(&["plugin", "sync", "--source", "shop"]); + let before = take_snapshot(&env, &db, "before.json"); + + std::fs::remove_file(saved.join("us-east-1/ec2-describe-instances.json")).unwrap(); + env.ok(&["plugin", "sync", "--source", "shop"]); + let partial = take_snapshot(&env, &db, "partial.json"); + let out = env.ok(&["snapshot", "diff", text(&before), text(&partial)]); + assert_eq!(out["after"]["sources"]["shop"]["coverage"]["value"]["status"], "partial", "{out}"); + assert_eq!(out["node_counts"]["removed"], 0, "{out}"); + assert_eq!(out["relationship_counts"]["removed"], 0, "{out}"); + assert!(out["warnings"].to_string().contains("not complete"), "{out}"); + + // A record really missing from a snapshot on an incomplete source: not seen, not removed. + let mut altered: Value = serde_json::from_str(&std::fs::read_to_string(&partial).unwrap()).unwrap(); + altered["nodes"].as_array_mut().unwrap().retain(|node| node["id"] != WEB_1); + let file = env.path("altered.json"); + std::fs::write(&file, altered.to_string()).unwrap(); + let out = env.ok(&["snapshot", "diff", text(&before), text(&file)]); + assert_eq!(ids_of(&out["nodes"]["not_seen"]), [WEB_1], "{out}"); + assert_eq!(out["node_counts"]["removed"], 0, "{out}"); + + // Source configuration stays out of the file. + let raw = std::fs::read_to_string(&before).unwrap(); + assert!(!raw.contains("fixture_dir") && !raw.contains(text(&saved)), "{raw}"); +} + +/// The other example: a blast radius on the CSV demo, where a link goes away. +#[test] +fn a_diff_works_on_the_csv_example_too() { + let env = Env::new(); + let db = env.path("graph"); + let csv = env.path("csv"); + copy_dir(&repo().join("examples/csv"), &csv); + let file = |name: &str| csv.join(name).to_str().unwrap().replace('\\', "/"); + env.ok(&["init", text(&db)]); + env.ok(&[ + "source", "add", "-d", text(&db), "--plugin", "csv", "--name", "infra", + "-c", &format!(r#"nodes=[{{"path":"{}","label_column":"kind","id_column":"id"}}]"#, file("assets.csv")), + "-c", &format!(r#"edges=[{{"path":"{}","type_column":"type","source_column":"source","target_column":"target","id_column":"id"}}]"#, file("links.csv")), + ]); + env.ok(&["plugin", "sync", "--source", "infra"]); + let before = take_snapshot(&env, &db, "before.json"); + + let links = csv.join("links.csv"); + let all = std::fs::read_to_string(&links).unwrap(); + let mut lines: Vec<&str> = all.lines().collect(); + let gone = lines.pop().unwrap().to_string(); + std::fs::write(&links, lines.join("\n") + "\n").unwrap(); + env.ok(&["plugin", "sync", "--source", "infra"]); + let after = take_snapshot(&env, &db, "after.json"); + + let out = env.ok(&["snapshot", "diff", text(&before), text(&after)]); + assert_eq!(out["relationship_counts"], json!({"added": 0, "removed": 1, "not_seen": 0, "changed": 0}), "{gone}: {out}"); + assert_eq!(out["node_counts"], json!({"added": 0, "removed": 0, "not_seen": 0, "changed": 0}), "{out}"); +} + +/// Two snapshots kept in the repository, so that the file format and the diff +/// of fixed input stay as they are. Written once, by the first run that blesses. +#[test] +fn two_fixed_snapshots_keep_their_diff() { + let dir = snapshots_dir(); + let (before, after) = (dir.join("snapshot-v1-before.json"), dir.join("snapshot-v1-after.json")); + if blessing() && !(before.exists() && after.exists()) { + let env = Env::new(); + let saved = env.path("saved"); + copy_dir(&fixture(), &saved); + let db = env.path("graph"); + env.add_source(&db, &saved); + env.ok(&["plugin", "sync", "--source", "shop"]); + env.ok(&["snapshot", "save", text(&before), "-d", text(&db), "--force"]); + std::fs::write(saved.join("us-east-1/elbv2-describe-target-health-web.json"), r#"{"TargetHealthDescriptions": []}"#).unwrap(); + env.ok(&["plugin", "sync", "--source", "shop"]); + env.ok(&["snapshot", "save", text(&after), "-d", text(&db), "--force"]); + } + let env = Env::new(); + let out = env.ok(&["snapshot", "diff", text(&before), text(&after)]); + assert_eq!(out["relationship_counts"], json!({"added": 0, "removed": 1, "not_seen": 0, "changed": 0}), "{out}"); + let removed = ids_of(&out["relationships"]["removed"]); + assert!(removed[0].contains("targetgroup/web") && removed[0].contains("i-web1"), "{removed:?}"); + assert_eq!(out["node_counts"], json!({"added": 0, "removed": 0, "not_seen": 0, "changed": 0}), "{out}"); +} + +/// A pinned investigation names the runs when the data has moved, and a no-op +/// re-sync is not a change. +#[test] +fn a_reopened_investigation_names_the_runs_it_was_pinned_to() { + let env = Env::new(); + let saved = env.path("saved"); + copy_dir(&fixture(), &saved); + let db = env.path("graph"); + env.add_source(&db, &saved); + env.ok(&["plugin", "sync", "--source", "shop"]); + let file = env.path("web.investigation"); + let out = env.ok(&investigation_save(&db, &file)); + assert_eq!(out["investigation"]["pinned_runs"]["shop"]["value"]["run_id"], "shop#1", "{out}"); + + env.ok(&["plugin", "sync", "--source", "shop"]); + let out = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!((out["graph_state"].as_str(), out["differences"].as_array().map(Vec::len)), (Some("unchanged"), Some(0)), "{out}"); + + let path = saved.join("us-east-1/ec2-describe-security-groups.json"); + let groups = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, groups.replace("\"FromPort\": 443, \"ToPort\": 443", "\"FromPort\": 8443, \"ToPort\": 8443")).unwrap(); + env.ok(&["plugin", "sync", "--source", "shop"]); + let out = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!(out["graph_state"], "changed", "{out}"); + let said = out["differences"].to_string(); + assert!(said.contains("shop#1") && said.contains("shop#3"), "{said}"); +} + #[test] fn two_syncs_of_the_same_input_agree_and_a_resync_changes_nothing() { let env = Env::new(); @@ -467,12 +1186,10 @@ fn corrupt_input_fails_without_changing_the_graph() { assert_eq!(out["error"]["kind"], "open", "{out}"); } -/// KNOWN GAP, recorded so that #38 has to change it on purpose: a saved -/// response that is missing reads as an empty one, so a partial export syncs -/// without complaint, and a full re-sync removes what the missing file used -/// to describe. An absent edge here is not proof of absence. +/// #38: a partial export says so and removes nothing, since an absent record +/// here is not proof of absence. The next complete sync catches up. #[test] -fn a_missing_response_is_read_as_empty_and_nothing_says_so() { +fn a_missing_response_removes_nothing_and_says_what_was_not_seen() { let env = Env::new(); let saved = env.path("saved"); copy_dir(&fixture(), &saved); @@ -482,10 +1199,209 @@ fn a_missing_response_is_read_as_empty_and_nothing_says_so() { let allows = "MATCH ()-[r:ALLOWS]->() RETURN count(r) AS c"; assert_eq!(env.count(&db, allows), 3); - std::fs::remove_file(saved.join("us-east-1/ec2-describe-security-groups.json")).unwrap(); + let file = env.path("web.investigation"); + env.ok(&investigation_save(&db, &file)); + let groups = saved.join("us-east-1/ec2-describe-security-groups.json"); + let kept = std::fs::read_to_string(&groups).unwrap(); + std::fs::remove_file(&groups).unwrap(); + let out = env.ok(&["plugin", "sync", "--source", "shop"]); + assert_eq!(out["run"]["status"], "partial", "{out}"); + let reopened = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!(reopened["graph_state"], "unchanged", "nothing was removed: {reopened}"); + assert!(reopened["incomplete_sources"][0].as_str().unwrap().contains("partial"), "and it says how far to trust that: {reopened}"); + assert_eq!(out["run"]["removals_applied"], false, "{out}"); + assert_eq!(out["run"]["gaps"], json!(["no saved response for ec2 describe-security-groups in us-east-1"]), "{out}"); + assert_eq!(out["merge"]["stale_nodes_removed"], 0, "{out}"); + assert_eq!(out["merge"]["stale_edges_removed"], 0, "{out}"); + assert_eq!(env.count(&db, allows), 3, "still known, not confirmed gone"); + + // Complete again, and something really has gone: now it can be removed. + let mut without_ssh: Value = serde_json::from_str(&kept).unwrap(); + without_ssh["SecurityGroups"][0]["IpPermissions"].as_array_mut().unwrap().remove(1); + std::fs::write(&groups, without_ssh.to_string()).unwrap(); let out = env.ok(&["plugin", "sync", "--source", "shop"]); - assert_eq!(out["merge"]["warnings"], json!([]), "{out}"); - assert_eq!(env.count(&db, allows), 0); + assert_eq!(out["run"]["status"], "complete", "{out}"); + assert_eq!(out["run"]["removals_applied"], true, "{out}"); + assert_eq!(env.count(&db, allows), 2); +} + +/// Installs the scripted test plugin and adds a source that sends `lines` and +/// exits with `exit`, on a new graph named `graph`. +fn scripted_source(env: &Env, lines: &[Value], exit: i32) -> PathBuf { + let plugin = env.path("scripted-plugin"); + std::fs::create_dir_all(&plugin).unwrap(); + let exe = format!("scripted{}", std::env::consts::EXE_SUFFIX); + let built = Path::new(env!("CARGO_BIN_EXE_khonsugraph")).parent().unwrap().join("examples").join(&exe); + std::fs::copy(&built, plugin.join(&exe)).unwrap(); + let manifest = json!({"id": "scripted", "name": "Scripted", "version": "0.1.0", "protocol": 1, "entry": [exe]}); + std::fs::write(plugin.join("khonsu-plugin.json"), manifest.to_string()).unwrap(); + env.ok(&["plugin", "install", text(&plugin), "--yes"]); + + let db = env.path("graph"); + env.ok(&["init", text(&db)]); + env.ok(&[ + "source", "add", "-d", text(&db), "--plugin", "scripted", "--name", "script", + "-c", &format!("lines={}", Value::Array(lines.to_vec())), + "-c", &format!("exit={exit}"), + ]); + db +} + +/// Rewrites what the scripted source sends next. +fn script(env: &Env, lines: &[Value], exit: i32) { + let path = env.config().join("sources.json"); + let mut sources: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let list = if sources.is_array() { sources.as_array_mut().unwrap() } else { sources["sources"].as_array_mut().unwrap() }; + let source = list.iter_mut().find(|source| source["id"] == "script").unwrap(); + source["config"]["lines"] = Value::Array(lines.to_vec()); + source["config"]["exit"] = json!(exit); + std::fs::write(&path, sources.to_string()).unwrap(); +} + +fn node_line(id: &str) -> Value { + json!({"type": "node", "id": id, "label": "Host", "properties": {"name": id}}) +} + +fn edge_line(id: &str, source: &str, target: &str) -> Value { + json!({"type": "edge", "id": id, "source": source, "target": target, "rel_type": "LINKS"}) +} + +fn done_line() -> Value { + json!({"type": "done"}) +} + +fn last_run(env: &Env) -> Value { + let (_, sources) = env.khonsu(&["source", "list"]); + sources["sources"][0]["runs"].as_array().unwrap().last().unwrap().clone() +} + +fn hosts(env: &Env, db: &Path) -> i64 { + env.count(db, "MATCH (n:Host) RETURN count(n) AS c") +} + +#[test] +fn a_plugin_that_fails_midway_removes_nothing_and_is_recorded_as_failed() { + let env = Env::new(); + let whole = [node_line("a"), node_line("b"), node_line("c"), done_line()]; + let db = scripted_source(&env, &whole, 0); + let out = env.ok(&["plugin", "sync", "--source", "script"]); + assert_eq!(out["run"]["run_id"], "script#1", "{out}"); + assert_eq!(hosts(&env, &db), 3); + + // An error message partway, and then stopping without one. + let cases: [(&[Value], i32); 2] = [(&[node_line("a"), json!({"type": "error", "message": "boom"})], 0), (&[node_line("a")], 3)]; + for (number, (lines, exit)) in (2..).zip(cases) { + script(&env, lines, exit); + let (code, out) = env.khonsu(&["plugin", "sync", "--source", "script"]); + assert_ne!(code, 0, "{out}"); + let run = last_run(&env); + assert_eq!(run["run_id"], format!("script#{number}"), "{run}"); + assert_eq!(run["status"], "failed", "{run}"); + assert_eq!(run["removals_applied"], false, "{run}"); + assert_eq!(hosts(&env, &db), 3, "b and c are not confirmed gone"); + } + + let evidence = env.ok(&["evidence", "-d", text(&db), "--node", "script/a"]); + let record = &evidence["evidence"][0]; + assert_eq!(record["source_coverage"]["value"]["status"], "failed", "{record}"); + assert_eq!(record["source_coverage"]["value"]["run_id"], "script#3", "{record}"); + assert_eq!(record["source_last_successful_sync"]["status"], "known", "the complete run still counts: {record}"); + + // Retrying after the fault: complete, and removals apply again. + script(&env, &[node_line("a"), node_line("b"), done_line()], 0); + let out = env.ok(&["plugin", "sync", "--source", "script"]); + assert_eq!(out["run"]["status"], "complete", "{out}"); + assert_eq!(hosts(&env, &db), 2); +} + +#[test] +fn only_a_complete_sync_moves_the_cursor() { + let cursor = |at: &str| json!({"type": "cursor", "value": {"since": at}}); + let env = Env::new(); + let db = scripted_source(&env, &[node_line("a"), node_line("b"), cursor("one"), done_line()], 0); + let saved_cursor = |env: &Env| { + let (_, sources) = env.khonsu(&["source", "list"]); + sources["sources"][0]["cursor"].clone() + }; + let out = env.ok(&["plugin", "sync", "--source", "script"]); + assert_eq!(out["run"]["cursor_advanced"], true, "{out}"); + assert_eq!(saved_cursor(&env), json!({"since": "one"})); + + // Finished but partial, and failed: the next sync starts from the same place. + let partial = json!({"type": "done", "complete": false, "gaps": ["page 2 timed out"]}); + script(&env, &[node_line("a"), cursor("two"), partial], 0); + let out = env.ok(&["plugin", "sync", "--source", "script"]); + assert_eq!(out["run"]["status"], "partial", "{out}"); + assert_eq!(out["run"]["cursor_advanced"], false, "{out}"); + assert_eq!(out["run"]["gaps"], json!(["page 2 timed out"]), "{out}"); + assert_eq!(saved_cursor(&env), json!({"since": "one"})); + assert_eq!(hosts(&env, &db), 2, "b is not confirmed gone"); + + script(&env, &[cursor("three")], 1); + let (code, _) = env.khonsu(&["plugin", "sync", "--source", "script"]); + assert_ne!(code, 0); + assert_eq!(saved_cursor(&env), json!({"since": "one"})); + + script(&env, &[node_line("a"), cursor("four"), done_line()], 0); + env.ok(&["plugin", "sync", "--source", "script"]); + assert_eq!(saved_cursor(&env), json!({"since": "four"})); + assert_eq!(hosts(&env, &db), 1); +} + +#[test] +fn duplicate_ids_and_missing_endpoints_are_counted_and_repeat_the_same_way() { + let lines = [ + node_line("a"), + node_line("a"), + node_line("b"), + edge_line("ab", "a", "b"), + edge_line("ab", "a", "b"), + edge_line("a-gone", "a", "not-sent"), + done_line(), + ]; + let env = Env::new(); + let db = scripted_source(&env, &lines, 0); + let first = env.ok(&["plugin", "sync", "--source", "script"]); + assert_eq!(first["run"]["status"], "complete", "{first}"); + assert_eq!(first["merge"]["duplicate_nodes"], 1, "{first}"); + assert_eq!(first["merge"]["duplicate_edges"], 1, "{first}"); + assert_eq!(first["merge"]["skipped_edges"], 1, "{first}"); + assert_eq!(hosts(&env, &db), 2); + assert_eq!(env.count(&db, "MATCH ()-[r:LINKS]->() RETURN count(r) AS c"), 1); + + let again = env.ok(&["plugin", "sync", "--source", "script"]); + assert_eq!(again["run"]["run_id"], "script#2", "{again}"); + assert_eq!(again["merge"]["edges_written"], 0, "{again}"); + assert_eq!(again["merge"]["unchanged_edges"], 1, "{again}"); + assert_eq!(hosts(&env, &db), 2); +} + +/// A source no longer configured has nothing to say about its records, and they +/// say so rather than passing as complete. +#[test] +fn records_of_a_source_that_has_gone_state_that_their_coverage_is_unknown() { + let env = Env::new(); + let db = env.synced_graph("graph"); + env.ok(&["source", "remove", "shop"]); + let out = env.ok(&["evidence", "-d", text(&db), "--node", WEB_1]); + assert_eq!(out["evidence"][0]["source_coverage"], json!({"status": "unknown"}), "{out}"); + assert_eq!(out["evidence"][0]["source"]["value"], "shop"); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + assert_eq!(report["sources"]["shop"], json!({"status": "unknown"}), "{report}"); +} + +#[test] +fn only_the_latest_runs_are_kept_and_numbering_goes_on() { + let env = Env::new(); + scripted_source(&env, &[done_line()], 0); + for _ in 0..22 { + env.ok(&["plugin", "sync", "--source", "script"]); + } + let (_, sources) = env.khonsu(&["source", "list"]); + let runs = sources["sources"][0]["runs"].as_array().unwrap(); + assert_eq!(runs.len(), 20); + assert_eq!(runs[0]["run_id"], "script#3"); + assert_eq!(runs[19]["run_id"], "script#22"); } #[test] @@ -507,6 +1423,9 @@ fn a_cancelled_sync_reports_it_and_leaves_the_graph_alone() { assert_eq!(env.answers(&db), before); let (_, sources) = env.khonsu(&["source", "list"]); assert_eq!(sources["sources"][0]["last_sync"]["ok"], false, "{sources}"); + let run = sources["sources"][0]["runs"].as_array().unwrap().last().unwrap().clone(); + assert_eq!(run["status"], "cancelled", "{run}"); + assert_eq!(run["removals_applied"], false, "{run}"); } /// Every graph kept in tests/fixtures still reads the same and takes a write, @@ -528,8 +1447,245 @@ fn graphs_written_by_earlier_hawdb_versions_keep_their_meaning() { let name = zip_path.file_name().unwrap().to_string_lossy().into_owned(); assert_same(&format!("{name}: answers"), &normalize(&env.answers(&db)), &expected["answers"]); assert_same(&format!("{name}: schema"), &normalize(&env.ok(&["schema", "-d", text(&db)])), &expected["schema"]); + assert_same(&format!("{name}: exposure"), &normalize(&exposure_brief(&env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]))), &expected["exposure"]["shop_fixture"]); + // An upgrade must not make every saved investigation read as changed. + let saved = env.ok(&investigation_save(&db, &env.path("saved.investigation"))); + assert_same(&format!("{name}: fingerprint"), &normalize(&saved["investigation"]["fingerprint"]), &expected["investigation"]["fingerprint"]); env.ok(&["query", "-d", text(&db), "--write", "--cypher", "CREATE (:Note {name: 'after upgrade'})"]); assert_eq!(env.count(&db, "MATCH (n) RETURN count(n) AS c"), 17, "{name}"); } } + +/// Builds the example analyzer, and installs it as a plugin. Approved unless asked otherwise. +fn install_analyzer(env: &Env, approve: bool) { + static BUILD: Once = Once::new(); + BUILD.call_once(|| { + let status = Command::new(env!("CARGO")) + .args(["build", "-q", "--locked", "-p", "khonsu-plugin-protocol", "--example", "analyzer"]) + .status() + .unwrap(); + assert!(status.success()); + }); + let exe_name = format!("analyzer{}", std::env::consts::EXE_SUFFIX); + let built = Path::new(env!("CARGO_BIN_EXE_khonsugraph")).parent().unwrap().join("examples").join(&exe_name); + let dir = env.path("analyzer-plugin"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::copy(built, dir.join(&exe_name)).unwrap(); + let manifest = json!({"id": "analyzer", "name": "Analyzer", "version": "1.0.0", "protocol": 1, "entry": ["analyzer"]}); + std::fs::write(dir.join("khonsu-plugin.json"), manifest.to_string()).unwrap(); + let mut args = vec!["plugin", "install", text(&dir)]; + if approve { + args.push("--yes"); + } + env.ok(&args); +} + +/// `a -> b -> c`, with nothing to do with any provider. +fn graph_of_things(env: &Env) -> PathBuf { + let db = env.path("things"); + env.ok(&["init", text(&db)]); + for name in ["a", "b", "c"] { + env.ok(&["query", "-d", text(&db), "--write", "--cypher", &format!("CREATE (:Thing {{khonsu_id: '{name}', name: '{name}'}})")]); + } + for (from, to) in [("a", "b"), ("b", "c")] { + let link = format!("MATCH (x:Thing {{khonsu_id: '{from}'}}), (y:Thing {{khonsu_id: '{to}'}}) CREATE (x)-[:LINKS {{khonsu_id: '{from}{to}'}}]->(y)"); + env.ok(&["query", "-d", text(&db), "--write", "--cypher", &link]); + } + db +} + +/// The shared interface works with no AWS anywhere in it. +#[test] +fn an_analysis_from_a_plugin_that_is_nothing_to_do_with_aws_works_the_same_way() { + let env = Env::new(); + install_analyzer(&env, true); + let db = graph_of_things(&env); + + let described = env.ok(&["plugin", "describe", "analyzer"]); + assert_eq!(described["analyses"][0]["id"], "dead-ends"); + + let report = env.ok(&["plugin", "analyze", "analyzer", "dead-ends", "-d", text(&db)]); + assert_eq!(report["plugin"], "analyzer"); + assert_eq!(report["subjects"], 3); + assert_eq!(report["complete"], true); + assert_eq!(report["summary"], json!({"dead_end": 1, "continues": 2})); + assert_eq!(report["truncated"], 0); + + let dead_end = &report["findings"][0]; + assert_eq!((dead_end["resource"].as_str(), dead_end["verdict"].as_str()), (Some("node:c"), Some("dead_end")), "{report}"); + assert_eq!(dead_end["resource_name"], "c"); + assert_eq!(dead_end["conditions"][0]["fields"][0]["value"], json!({"status": "known", "value": "c"}), "read from the graph"); + + // Its path is rebuilt from references, and it leads into the other tools. + assert_eq!(dead_end["path"]["hops"], json!(["b -[LINKS]-> c"])); + assert_eq!(dead_end["path_refs"], json!([{"edge": "rel:bc", "along": true}])); + let walk = &dead_end["walk"]; + let out = env.ok(&[ + "explore", "-d", text(&db), + "--from", walk["from"].as_str().unwrap(), + "--to", walk["to"].as_str().unwrap(), + "--direction", walk["direction"].as_str().unwrap(), + "--type", "LINKS", + "--max-hops", "1", + ]); + assert_eq!(out["path"]["found"], true, "{out}"); + for id in dead_end["contributing"].as_array().unwrap() { + let id = id.as_str().unwrap(); + let (flag, stable) = if let Some(stable) = id.strip_prefix("node:") { ("--node", stable) } else { ("--rel", id.strip_prefix("rel:").unwrap()) }; + assert_eq!(env.ok(&["evidence", "-d", text(&db), flag, stable])["evidence"][0]["evidence_id"], id); + } + + // Filters are in the analysis's own words. + let narrow = env.ok(&["plugin", "analyze", "analyzer", "dead-ends", "-d", text(&db), "--verdict", "dead-end"]); + assert_eq!(narrow["findings"].as_array().unwrap().len(), 1); + assert_eq!(narrow["summary"], report["summary"]); + let (code, out) = env.khonsu(&["plugin", "analyze", "analyzer", "dead-ends", "-d", text(&db), "--verdict", "reachable"]); + assert_eq!((code, out["error"]["kind"].as_str()), (2, Some("usage")), "{out}"); +} + +#[test] +fn a_result_that_misbehaves_is_refused_and_says_why() { + let env = Env::new(); + install_analyzer(&env, true); + let db = graph_of_things(&env); + for (analysis, expected) in [("forged", "node:forged"), ("unfinished", "without finishing"), ("fails", "could not run")] { + let (code, out) = env.khonsu(&["plugin", "analyze", "analyzer", analysis, "-d", text(&db)]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("plugin")), "{analysis}: {out}"); + let message = out["error"]["message"].as_str().unwrap(); + assert!(message.contains(expected), "{analysis}: {message}"); + } + let (code, out) = env.khonsu(&["plugin", "analyze", "analyzer", "no-such", "-d", text(&db)]); + assert_eq!(code, 1, "{out}"); + assert!(out["error"]["message"].as_str().unwrap().contains("offers dead-ends"), "{out}"); +} + +#[test] +fn an_analysis_needs_the_same_approval_as_a_sync() { + let env = Env::new(); + install_analyzer(&env, false); + let db = graph_of_things(&env); + let (code, out) = env.khonsu(&["plugin", "analyze", "analyzer", "dead-ends", "-d", text(&db)]); + assert_eq!(code, 1, "{out}"); + assert!(out["error"]["message"].as_str().unwrap().contains("approval"), "{out}"); +} + +/// A data-source plugin with no analyses goes on working, and says plainly that +/// it has none. +#[test] +fn a_plugin_with_no_analyses_still_syncs_and_says_it_has_none() { + let env = Env::new(); + let db = env.path("graph"); + let csv = env.path("hosts.csv"); + std::fs::write(&csv, "id,name +1,web +2,db +").unwrap(); + env.ok(&["init", text(&db)]); + env.ok(&["source", "add", "-d", text(&db), "--plugin", "csv", "--name", "hosts", "-c", &format!("node_file={}", text(&csv)), "-c", "node_label=Host"]); + env.ok(&["plugin", "sync", "--source", "hosts"]); + assert_eq!(env.query(&db, "MATCH (h:Host) RETURN count(h) AS hosts")["rows"][0][0], 2); + + assert!(env.ok(&["plugin", "describe", "csv"]).get("analyses").is_none()); + let (code, out) = env.khonsu(&["plugin", "analyze", "csv", "anything", "-d", text(&db)]); + assert_eq!(code, 1, "{out}"); + assert!(out["error"]["message"].as_str().unwrap().contains("offers no analyses"), "{out}"); +} + +/// The window and the command go through the same two steps, so they must agree. +#[test] +fn the_command_and_the_library_the_window_uses_give_the_same_findings() { + let env = Env::new(); + let db = env.path("graph"); + env.add_source(&db, &exposure_fixture()); + env.ok(&["plugin", "sync", "--source", "shop"]); + let mut from_command = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + from_command.as_object_mut().unwrap().remove("truncated"); + + let sources: Vec = serde_json::from_value(env.ok(&["source", "list"])["sources"].clone()).unwrap(); + let offered = khonsu_core::plugin_analysis::find("aws", "exposure").unwrap(); + let mut graph = Graph::open(&db, AccessMode::ReadOnly, "test").unwrap(); + let input = graph.read_analysis_input(&offered.info).unwrap(); + drop(graph); + let cancel = AtomicBool::new(false); + let report = khonsu_core::plugin_analysis::run(&offered, &input, &sources, &cancel, |_| {}).unwrap(); + assert_eq!(normalize(&serde_json::to_value(&report).unwrap()), normalize(&from_command)); + + // The window draws a finding's path from what crossed the boundary. + let web = report.findings.iter().find(|finding| finding.rule_id == DIRECT && finding.resource == format!("node:{}", instance("i-web1"))).unwrap(); + let walk = web.exploration().unwrap(); + assert_eq!(walk.hops.len(), 2); + assert_eq!(walk.graph.nodes.len(), 3); + assert_eq!(walk.explain(&walk.hops[0]), "Internet -[ALLOWS]-> web"); +} + +/// A finding's path can be saved, and reopening gives that same path. A walk would +/// not: the path of a lateral finding crosses back through an instance, which is +/// more hops than the fewest. +#[test] +fn a_findings_path_can_be_saved_and_reopened_as_it_was() { + let env = Env::new(); + let db = env.path("graph"); + env.add_source(&db, &exposure_fixture()); + env.ok(&["plugin", "sync", "--source", "shop"]); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + + let mut saved_some = 0; + for finding in report["findings"].as_array().unwrap() { + let Some(refs) = finding["path_refs"].as_array() else { continue }; + saved_some += 1; + let file = env.path(&format!("finding-{saved_some}.investigation")); + let refs = serde_json::to_string(refs).unwrap(); + let out = env.ok(&["investigation", "save", text(&file), "-d", text(&db), "--path-refs", &refs]); + assert_eq!(out["path_found"], true, "{out}"); + + let reopened = env.ok(&["investigation", "reopen", text(&file)]); + let name = &finding["finding_id"]; + assert_eq!(reopened["graph_state"], "unchanged", "{name}"); + assert_eq!(reopened["path_same"], true, "{name}: {reopened}"); + assert_eq!(reopened["path"]["supporting"], finding["path"]["supporting"], "{name}"); + assert_eq!(reopened["path"]["hops"], finding["path"]["hops"], "{name}"); + assert_eq!(reopened["walk"]["hops"].as_array().unwrap().len(), finding["path_refs"].as_array().unwrap().len(), "{name}"); + } + assert!(saved_some >= 3, "{report}"); + + // The lateral one is the case a walk gets wrong. + let lateral = finding(&report, LATERAL, &instance("i-db1")); + assert_eq!(lateral["path_refs"].as_array().unwrap().len(), 5); + + // Remove a record it rests on: reopening says so rather than showing another path. + let env = Env::new(); + let saved = env.path("saved"); + copy_dir(&exposure_fixture(), &saved); + let moved = env.path("moved"); + env.add_source(&moved, &saved); + env.ok(&["plugin", "sync", "--source", "shop"]); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&moved)]); + let lateral = finding(&report, LATERAL, &instance("i-db1")); + let file = env.path("lateral.investigation"); + env.ok(&["investigation", "save", text(&file), "-d", text(&moved), "--path-refs", &serde_json::to_string(&lateral["path_refs"]).unwrap()]); + assert_eq!(env.ok(&["investigation", "reopen", text(&file)])["path_same"], true); + + let groups_file = saved.join("us-east-1/ec2-describe-security-groups.json"); + let mut groups: Value = serde_json::from_str(&std::fs::read_to_string(&groups_file).unwrap()).unwrap(); + let database_group = groups["SecurityGroups"].as_array_mut().unwrap().iter_mut().find(|group| group["GroupId"] == "sg-db").unwrap(); + database_group["IpPermissions"] = json!([]); + std::fs::write(&groups_file, groups.to_string()).unwrap(); + env.ok(&["plugin", "sync", "--source", "shop"]); + let reopened = env.ok(&["investigation", "reopen", text(&file)]); + assert_eq!(reopened["graph_state"], "changed", "{reopened}"); + assert_eq!(reopened["path_same"], false, "{reopened}"); +} + +#[test] +fn an_exact_path_that_is_not_in_the_graph_is_not_saved() { + let env = Env::new(); + let db = env.synced_graph("graph"); + let file = env.path("x.investigation"); + let (code, out) = env.khonsu(&["investigation", "save", text(&file), "-d", text(&db), "--path-refs", r#"[{"edge": "rel:no-such", "along": true}]"#]); + assert_eq!(code, 1, "{out}"); + assert!(out["error"]["message"].as_str().unwrap().contains("not in the graph"), "{out}"); + assert!(!file.exists()); + let (code, out) = env.khonsu(&["investigation", "save", text(&file), "-d", text(&db), "--path-refs", "nonsense"]); + assert_eq!((code, out["error"]["kind"].as_str()), (2, Some("usage")), "{out}"); +} diff --git a/crates/khonsu-cli/src/dash.rs b/crates/khonsu-cli/src/dash.rs index 6f04342..05f5f22 100644 --- a/crates/khonsu-cli/src/dash.rs +++ b/crates/khonsu-cli/src/dash.rs @@ -285,7 +285,7 @@ pub fn open(args: OpenArgs) -> Result { _ => (None, None), }; if args.paths.is_empty() && text.is_none() { - return Err(CliError::usage("Name a graph or .dash file to open, or a query with --cypher or --sql")); + return Err(CliError::usage("Name a graph, .dash or .investigation file to open, or a query with --cypher or --sql")); } for path in &args.paths { if !path.exists() { diff --git a/crates/khonsu-cli/src/evidence.rs b/crates/khonsu-cli/src/evidence.rs new file mode 100644 index 0000000..2cb3b07 --- /dev/null +++ b/crates/khonsu-cli/src/evidence.rs @@ -0,0 +1,67 @@ +//! `khonsu evidence`: what supports a node or relationship. See +//! `khonsu_core::evidence`. + +use clap::Args; +use khonsu_core::evidence::{self, Evidence}; +use khonsu_core::settings::{self, Source}; +use khonsu_core::{AccessMode, Graph, GraphData, Language, QueryOptions}; +use serde_json::{json, Map, Value}; + +use crate::{resolve_db, CliError, DbArg, PROGRAM}; + +#[derive(Args)] +pub struct EvidenceArgs { + #[command(flatten)] + db: DbArg, + /// A node: its `khonsu_id`, or its ID (`id(n)`, `_id` in results). Repeat for several. + #[arg(long)] + node: Vec, + /// A relationship: its `khonsu_id`, or its ID (`id(r)`, `_id` in results). Repeat for several. + #[arg(long)] + rel: Vec, +} + +pub fn evidence(args: EvidenceArgs) -> Result { + if args.node.is_empty() && args.rel.is_empty() { + return Err(CliError::usage("name a record with --node or --rel")); + } + let path = resolve_db(&args.db)?; + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let sources = settings::load_sources(); + let mut records = Vec::new(); + for node in &args.node { + records.push(node_evidence(&mut graph, node, &sources)?); + } + for rel in &args.rel { + records.push(rel_evidence(&mut graph, rel, &sources)?); + } + Ok(json!({ "evidence": records })) +} + +fn node_evidence(graph: &mut Graph, node: &str, sources: &[Source]) -> Result { + let found = lookup(graph, node, "MATCH (n) WHERE id(n) = $id RETURN n LIMIT 1", "MATCH (n {khonsu_id: $id}) RETURN n LIMIT 1")?; + let record = found.nodes.first().ok_or_else(|| CliError::new("not_found", format!("no node {node:?}")))?; + Ok(evidence::for_node(record, sources)) +} + +fn rel_evidence(graph: &mut Graph, rel: &str, sources: &[Source]) -> Result { + let found = lookup( + graph, + rel, + "MATCH (a)-[r]->(b) WHERE id(r) = $id RETURN a, r, b LIMIT 1", + "MATCH (a)-[r]->(b) WHERE r.khonsu_id = $id RETURN a, r, b LIMIT 1", + )?; + let record = found.edges.first().ok_or_else(|| CliError::new("not_found", format!("no relationship {rel:?}")))?; + Ok(evidence::for_edge(record, &found.nodes, sources)) +} + +/// Runs `by_number` when `id` is a number, `by_stable_id` otherwise. +fn lookup(graph: &mut Graph, id: &str, by_number: &str, by_stable_id: &str) -> Result { + let (text, param) = match id.parse::() { + Ok(number) => (by_number, json!(number)), + Err(_) => (by_stable_id, json!(id)), + }; + let params = Map::from_iter([("id".to_string(), param)]); + let result = graph.run(Language::Cypher, text, ¶ms, QueryOptions::default())?; + Ok(result.graph.unwrap_or_default()) +} diff --git a/crates/khonsu-cli/src/explore.rs b/crates/khonsu-cli/src/explore.rs new file mode 100644 index 0000000..314aa4c --- /dev/null +++ b/crates/khonsu-cli/src/explore.rs @@ -0,0 +1,103 @@ +//! `khonsu explore`: a bounded walk from chosen nodes. See +//! `khonsu_core::explore`. + +use std::sync::atomic::AtomicBool; + +use clap::{Args, ValueEnum}; +use khonsu_core::explore::{self, Direction, ExploreRequest}; +use khonsu_core::{evidence, settings}; +use khonsu_core::{AccessMode, Graph, Language, QueryOptions}; +use serde_json::{json, Map, Value}; + +use crate::{resolve_db, CliError, DbArg, PROGRAM}; + +#[derive(Clone, Copy, ValueEnum)] +enum DirectionArg { + /// Along the arrows. + Out, + /// Against the arrows. + In, + Both, +} + +#[derive(Args)] +pub struct ExploreArgs { + #[command(flatten)] + db: DbArg, + /// A node to start from: its stable `khonsu_id`, or its ID (`id(n)`, `_id` in results). Repeat for several. + #[arg(long, required = true)] + from: Vec, + #[arg(long, value_enum, default_value = "out")] + direction: DirectionArg, + /// Only follow relationships of this type. Repeat or separate with commas. + #[arg(long = "type", value_delimiter = ',')] + rel_type: Vec, + /// Steps to walk from a start, at most 16. + #[arg(long, default_value_t = explore::DEFAULT_MAX_HOPS)] + max_hops: u32, + /// Nodes to return, starts included, at most 2,000. + #[arg(long, default_value_t = explore::DEFAULT_MAX_NODES)] + max_nodes: usize, + /// Relationships to return, at most 5,000. + #[arg(long, default_value_t = explore::DEFAULT_MAX_EDGES)] + max_edges: usize, + /// Also report a fewest-hops path to this node among what the walk found. + #[arg(long)] + to: Option, +} + +pub fn explore(args: ExploreArgs) -> Result { + let path = resolve_db(&args.db)?; + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let starts = args.from.iter().map(|node| node_id(&mut graph, node)).collect::, _>>()?; + let target = args.to.as_deref().map(|node| node_id(&mut graph, node)).transpose()?; + let request = ExploreRequest { + starts, + direction: match args.direction { + DirectionArg::Out => Direction::Out, + DirectionArg::In => Direction::In, + DirectionArg::Both => Direction::Both, + }, + rel_types: args.rel_type, + max_hops: args.max_hops, + max_nodes: args.max_nodes, + max_edges: args.max_edges, + }; + // Nothing cancels a command from outside but ending it. + let result = graph.explore(&request, &AtomicBool::new(false))?; + + let explained = |hop: &explore::Hop| { + let mut value = serde_json::to_value(hop).expect("a hop serializes"); + value["text"] = json!(result.explain(hop)); + value + }; + let mut out = json!({ + "complete": result.is_complete(), + "limits": result.limits, + "depth_reached": result.depth_reached, + "missing_starts": result.missing_starts, + "starts": result.starts, + "hops": result.hops.iter().map(explained).collect::>(), + "graph": result.graph, + }); + if let Some(target) = target { + let found = result.path_to(target); + out["path"] = json!({ + "to": target, + "found": found.is_some(), + "hops": found.unwrap_or_default().into_iter().map(explained).collect::>(), + "evidence": evidence::for_path(&result, target, &settings::load_sources()), + }); + } + Ok(out) +} + +/// `node` as an internal ID: a number is one already, anything else a stable ID. +fn node_id(graph: &mut Graph, node: &str) -> Result { + if let Ok(id) = node.parse() { + return Ok(id); + } + let params = Map::from_iter([("id".to_string(), json!(node))]); + let found = graph.run(Language::Cypher, "MATCH (n {khonsu_id: $id}) RETURN id(n) AS id LIMIT 1", ¶ms, QueryOptions::default())?; + found.rows.first().and_then(|row| row[0].as_i64()).ok_or_else(|| CliError::new("not_found", format!("no node has the khonsu_id {node:?}"))) +} diff --git a/crates/khonsu-cli/src/investigation.rs b/crates/khonsu-cli/src/investigation.rs new file mode 100644 index 0000000..d9668a7 --- /dev/null +++ b/crates/khonsu-cli/src/investigation.rs @@ -0,0 +1,199 @@ +//! `khonsu investigation`: save what is being looked at, and come back to it. +//! See `khonsu_core::investigation`. + +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; + +use clap::{Args, Subcommand, ValueEnum}; +use khonsu_core::explore::{self, Direction, ExploreRequest}; +use khonsu_core::investigation::{Capture, FileError, Investigation, SavedQuery}; +use khonsu_core::{normalize_path, settings, AccessMode, Graph, Language, QueryOptions}; +use serde_json::{json, Map, Value}; + +use crate::{resolve_db, CliError, DbArg, PROGRAM}; + +#[derive(Clone, Copy, ValueEnum)] +enum DirectionArg { + Out, + In, + Both, +} + +#[derive(Subcommand)] +pub enum InvestigationCommand { + /// Save a walk, a path, a query and a dashboard to a file of its own. The graph is not changed. + Save(SaveArgs), + /// Run a saved walk again, and say what differs from when it was saved. + Reopen(ReopenArgs), +} + +#[derive(Args)] +pub struct SaveArgs { + /// The file to write, e.g. `web-tier.investigation`. + file: PathBuf, + #[command(flatten)] + db: DbArg, + /// What to call it. Defaults to the file's name. + #[arg(long)] + name: Option, + /// A node to start from: its `khonsu_id`, or its ID. Repeat for several. + #[arg(long)] + from: Vec, + #[arg(long, value_enum, default_value = "out")] + direction: DirectionArg, + /// Only follow relationships of this type. Repeat or separate with commas. + #[arg(long = "type", value_delimiter = ',')] + rel_type: Vec, + #[arg(long, default_value_t = explore::DEFAULT_MAX_HOPS)] + max_hops: u32, + #[arg(long, default_value_t = explore::DEFAULT_MAX_NODES)] + max_nodes: usize, + #[arg(long, default_value_t = explore::DEFAULT_MAX_EDGES)] + max_edges: usize, + /// The node whose path to keep. Needs --from. + #[arg(long)] + to: Option, + /// An exact path to keep, as JSON: a finding's `path_refs`. Reopening looks up these + /// records rather than walking, so it keeps a path that isn't the fewest hops. + #[arg(long, conflicts_with_all = ["from", "to"])] + path_refs: Option, + /// A Cypher query to keep with it. Saved as typed, so leave out anything secret. + #[arg(long, conflicts_with = "sql")] + cypher: Option, + /// A SQL query to keep with it. Saved as typed, so leave out anything secret. + #[arg(long)] + sql: Option, + /// A .dash dashboard to keep with it. + #[arg(long)] + dashboard: Option, + /// Replace the file if it exists. + #[arg(long)] + force: bool, +} + +#[derive(Args)] +pub struct ReopenArgs { + /// The saved file. + file: PathBuf, + /// Use this graph instead of the one the file names. + #[arg(long, short = 'd')] + db: Option, +} + +pub fn investigation(command: InvestigationCommand) -> Result { + match command { + InvestigationCommand::Save(args) => save(args), + InvestigationCommand::Reopen(args) => reopen(args), + } +} + +impl From for CliError { + fn from(error: FileError) -> Self { + CliError::new(error.kind, error.message) + } +} + +fn save(args: SaveArgs) -> Result { + if args.to.is_some() && args.from.is_empty() { + return Err(CliError::usage("--to needs --from: a path is found by walking from somewhere")); + } + let path = normalize_path(&resolve_db(&args.db)?); + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let starts = args.from.iter().map(|node| node_id(&mut graph, node)).collect::, _>>()?; + let target = args.to.as_deref().map(|node| node_id(&mut graph, node)).transpose()?; + let walk = (!starts.is_empty()).then(|| ExploreRequest { + starts, + direction: match args.direction { + DirectionArg::Out => Direction::Out, + DirectionArg::In => Direction::In, + DirectionArg::Both => Direction::Both, + }, + rel_types: args.rel_type, + max_hops: args.max_hops, + max_nodes: args.max_nodes, + max_edges: args.max_edges, + }); + let query = match (args.cypher, args.sql) { + (Some(text), _) => Some(SavedQuery { language: "cypher".into(), text }), + (_, Some(text)) => Some(SavedQuery { language: "sql".into(), text }), + _ => None, + }; + let name = args.name.unwrap_or_else(|| args.file.file_stem().map(|stem| stem.to_string_lossy().into_owned()).unwrap_or_default()); + let path_refs = match &args.path_refs { + Some(text) => serde_json::from_str(text).map_err(|error| CliError::usage(format!("--path-refs is not a list of hops: {error}")))?, + None => Vec::new(), + }; + let capture = Capture { + name, + query, + walk, + target, + path_refs, + dashboard: args.dashboard.as_deref().map(normalize_path), + }; + let saved = graph.capture_investigation(&path, capture, &settings::load_sources(), &AtomicBool::new(false))?; + saved.save(&args.file, args.force)?; + Ok(json!({ + "saved": normalize_path(&args.file), + "path_found": saved.path.as_ref().map(|path| !path.supporting.is_empty()), + "investigation": saved, + })) +} + +fn reopen(args: ReopenArgs) -> Result { + let saved = Investigation::load(&args.file)?; + let path = args.db.clone().unwrap_or_else(|| saved.graph.clone()); + if !path.is_dir() { + return Err(CliError::new( + "graph_missing", + format!("the graph {} is not there; pass --db to use another", path.display()), + )); + } + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let result = graph.reopen_investigation(&saved, &settings::load_sources(), &AtomicBool::new(false))?; + + let walk = result.walk.as_ref().map(|walk| { + let explained = |hop: &explore::Hop| { + let mut value = serde_json::to_value(hop).expect("a hop serializes"); + value["text"] = json!(walk.explain(hop)); + value + }; + json!({ + "complete": walk.is_complete(), + "limits": walk.limits, + "depth_reached": walk.depth_reached, + "starts": walk.starts, + "hops": walk.hops.iter().map(explained).collect::>(), + "graph": walk.graph, + }) + }); + Ok(json!({ + "name": saved.name, + "saved_at": saved.saved_at, + "graph": path, + "graph_state": result.graph_state, + "differences": result.differences, + "saved_fingerprint": saved.fingerprint, + "fingerprint": result.now, + "missing_seeds": result.missing_seeds, + "target_missing": result.target_missing, + "unsure": result.unsure, + "incomplete_sources": result.incomplete_sources, + "saved_walk": saved.walk, + "walk": walk, + "path": result.path, + "path_same": result.path_same, + "query": saved.query, + "dashboard": saved.dashboard, + })) +} + +/// `node` as an internal ID: a number is one already, anything else a stable ID. +fn node_id(graph: &mut Graph, node: &str) -> Result { + if let Ok(id) = node.parse() { + return Ok(id); + } + let params = Map::from_iter([("id".to_string(), json!(node))]); + let found = graph.run(Language::Cypher, "MATCH (n {khonsu_id: $id}) RETURN id(n) AS id LIMIT 1", ¶ms, QueryOptions::default())?; + found.rows.first().and_then(|row| row[0].as_i64()).ok_or_else(|| CliError::new("not_found", format!("no node has the khonsu_id {node:?}"))) +} diff --git a/crates/khonsu-cli/src/lib.rs b/crates/khonsu-cli/src/lib.rs index 7c30025..ded9561 100644 --- a/crates/khonsu-cli/src/lib.rs +++ b/crates/khonsu-cli/src/lib.rs @@ -10,6 +10,11 @@ mod analyze; mod dash; +mod evidence; +mod explore; +mod investigation; +mod plugin_analysis; +mod snapshot; use clap::{Args, CommandFactory, Parser, Subcommand}; use khonsu_core::plugin::{self, SyncEvent}; @@ -118,6 +123,20 @@ enum Command { Open(dash::OpenArgs), /// Graph algorithms after networkx: PageRank, betweenness, components, communities, paths and more. Analyze(analyze::AnalyzeArgs), + /// Follow relationships from chosen nodes, within caps, and say when a cap or a filter limited the answer. + Explore(explore::ExploreArgs), + /// What supports a node or relationship: its source, plugin and times, with unknowns stated and secrets masked. + Evidence(evidence::EvidenceArgs), + /// Save a walk, path and query to a file, and reopen them later, with any change in the graph flagged. + Investigation { + #[command(subcommand)] + command: investigation::InvestigationCommand, + }, + /// Keep a graph's records in a file, and say what changed between two. A record is only called removed when its source was read completely. + Snapshot { + #[command(subcommand)] + command: snapshot::SnapshotCommand, + }, } #[derive(Subcommand)] @@ -135,6 +154,8 @@ enum PluginCommand { }, /// Remove an installed plugin. Uninstall { id: String }, + /// Run an analysis a plugin offers over a graph: each finding with the rule, records and fields behind it, what is assumed and what is unknown. + Analyze(plugin_analysis::AnalyzeArgs), /// Sync a configured source into its graph (or preview with --dry-run). Sync { /// Source ID (see `khonsu source list`). @@ -368,6 +389,10 @@ fn execute(command: Command) -> Result { Command::Presence(args) => dash::presence(args), Command::Open(args) => dash::open(args), Command::Analyze(args) => analyze::analyze(args), + Command::Explore(args) => explore::explore(args), + Command::Evidence(args) => evidence::evidence(args), + Command::Investigation { command } => investigation::investigation(command), + Command::Snapshot { command } => snapshot::snapshot(command), } } @@ -410,6 +435,7 @@ fn run_plugin(command: PluginCommand) -> Result { "note": if yes { "" } else { "re-run with --yes to approve these permissions before syncing" }, })) } + PluginCommand::Analyze(args) => plugin_analysis::analyze(args), PluginCommand::Uninstall { id } => { plugin::uninstall(&id)?; Ok(json!({ "uninstalled": id })) @@ -503,6 +529,7 @@ fn run_source(command: SourceCommand) -> Result { cursor: None, patterns: Vec::new(), last_sync: None, + runs: Vec::new(), }; let mut tested = Value::Null; if test { diff --git a/crates/khonsu-cli/src/plugin_analysis.rs b/crates/khonsu-cli/src/plugin_analysis.rs new file mode 100644 index 0000000..518e791 --- /dev/null +++ b/crates/khonsu-cli/src/plugin_analysis.rs @@ -0,0 +1,59 @@ +//! `khonsu plugin analyze`: findings from an analysis a plugin offers. See +//! `khonsu_core::plugin_analysis` and `docs/plugins.md`. + +use clap::Args; +use khonsu_core::plugin_analysis::{self, DEFAULT_MAX_FINDINGS}; +use khonsu_core::{settings, AccessMode, Graph}; +use serde_json::{json, Value}; +use std::sync::atomic::AtomicBool; + +use crate::{resolve_db, CliError, DbArg, PROGRAM}; + +#[derive(Args)] +pub struct AnalyzeArgs { + /// The plugin, e.g. `aws` (see `khonsu plugin list`). + plugin: String, + /// The analysis, e.g. `exposure` (see `khonsu plugin describe ID`). + analysis: String, + #[command(flatten)] + db: DbArg, + /// Only findings about this resource: its `khonsu_id`. + #[arg(long)] + resource: Option, + /// Only findings with this verdict, as the analysis names it. + #[arg(long)] + verdict: Option, + /// Findings to print; `truncated` counts the rest. + #[arg(long, default_value_t = DEFAULT_MAX_FINDINGS)] + max_findings: usize, +} + +pub fn analyze(args: AnalyzeArgs) -> Result { + let offered = plugin_analysis::find(&args.plugin, &args.analysis)?; + // `potential-exposure` reads as well as `potential_exposure` on a command line. + let verdict = args.verdict.map(|verdict| verdict.replace('-', "_")); + if let Some(wanted) = &verdict { + if !offered.info.verdicts.iter().any(|verdict| verdict.id == *wanted) { + let names: Vec<&str> = offered.info.verdicts.iter().map(|verdict| verdict.id.as_str()).collect(); + return Err(CliError::usage(format!("no verdict {wanted:?}: this analysis has {}", names.join(", ")))); + } + } + let path = resolve_db(&args.db)?; + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let input = graph.read_analysis_input(&offered.info)?; + let report = plugin_analysis::run(&offered, &input, &settings::load_sources(), &AtomicBool::new(false), |_| {})?; + let resource = args.resource.map(|id| format!("node:{id}")); + // A mistyped ID must not read as "no findings". + if let Some(id) = &resource { + if !report.findings.iter().any(|finding| finding.resource == *id) { + return Err(CliError::new("not_found", format!("no finding about {:?}", id.trim_start_matches("node:")))); + } + } + let (report, truncated) = report.narrowed( + |finding| resource.as_ref().is_none_or(|id| finding.resource == *id) && verdict.as_ref().is_none_or(|wanted| finding.verdict == *wanted), + args.max_findings, + ); + let mut out = json!(report); + out["truncated"] = json!(truncated); + Ok(out) +} diff --git a/crates/khonsu-cli/src/snapshot.rs b/crates/khonsu-cli/src/snapshot.rs new file mode 100644 index 0000000..644a045 --- /dev/null +++ b/crates/khonsu-cli/src/snapshot.rs @@ -0,0 +1,89 @@ +//! `khonsu snapshot`: keep a graph's records in a file, and compare two. See +//! `khonsu_core::snapshot`. + +use std::path::PathBuf; + +use clap::{Args, Subcommand}; +use khonsu_core::snapshot::{self, Snapshot}; +use khonsu_core::{normalize_path, settings, AccessMode, Graph}; +use serde_json::{json, Value}; + +use crate::{resolve_db, CliError, DbArg, PROGRAM}; + +#[derive(Subcommand)] +pub enum SnapshotCommand { + /// Write the graph's records to a file. The graph is not changed. + Save(SaveArgs), + /// What differs between two snapshots, by stable IDs. + Diff(DiffArgs), + /// Whether the graph still holds what a snapshot does. + Check(CheckArgs), +} + +#[derive(Args)] +pub struct SaveArgs { + /// The file to write, e.g. `monday.snapshot.json`. + file: PathBuf, + #[command(flatten)] + db: DbArg, + /// Replace the file if it exists. + #[arg(long)] + force: bool, +} + +#[derive(Args)] +pub struct DiffArgs { + /// The earlier snapshot. + before: PathBuf, + /// The later snapshot. + after: PathBuf, + /// Changes to list; `truncated` counts the rest. + #[arg(long, default_value_t = snapshot::DEFAULT_MAX_CHANGES)] + max_changes: usize, +} + +#[derive(Args)] +pub struct CheckArgs { + /// The snapshot. + file: PathBuf, + #[command(flatten)] + db: DbArg, +} + +pub fn snapshot(command: SnapshotCommand) -> Result { + match command { + SnapshotCommand::Save(args) => { + let path = resolve_db(&args.db)?; + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let taken = graph.snapshot(&settings::load_sources())?; + taken.save(&args.file, args.force)?; + Ok(json!({ + "saved": normalize_path(&args.file), + "snapshot_id": taken.snapshot_id, + "captured_at": taken.captured_at, + "nodes": taken.nodes.len(), + "relationships": taken.relationships.len(), + "unstable_nodes": taken.unstable_nodes, + "unstable_relationships": taken.unstable_relationships, + "sources": taken.sources, + })) + } + SnapshotCommand::Diff(args) => { + let (before, after) = (Snapshot::load(&args.before)?, Snapshot::load(&args.after)?); + Ok(json!(snapshot::diff(&before, &after, args.max_changes))) + } + SnapshotCommand::Check(args) => { + let saved = Snapshot::load(&args.file)?; + let path = resolve_db(&args.db)?; + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let (state, differences) = saved.check(&mut graph)?; + Ok(json!({ + "snapshot_id": saved.snapshot_id, + "captured_at": saved.captured_at, + "graph_state": state, + "differences": differences, + "sources": saved.sources, + })) + } + } +} diff --git a/crates/khonsu-core/src/evidence.rs b/crates/khonsu-core/src/evidence.rs new file mode 100644 index 0000000..9e78cab --- /dev/null +++ b/crates/khonsu-core/src/evidence.rs @@ -0,0 +1,568 @@ +//! What supports a node, a relationship or a path: where it came from, when, +//! and whether the source said it or KhonsuGraph worked it out. +//! +//! A value nobody supplied is [`Field::Unknown`], never a guess. Records are +//! meant to be copied out of the app, so they hold no source configuration, and +//! anything that looks like a secret is left out and named in `redacted`. + +use crate::explore::{explain_hop, Exploration, Hop}; +use crate::merge::{ID_PROPERTY, PLUGIN_PROPERTY, SOURCE_PROPERTY, SYNCED_AT_PROPERTY}; +use crate::result::{GraphData, GraphEdge, GraphNode}; +use crate::settings::{RunStatus, Source}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value as Json}; + +const REDACTED: &str = "[redacted]"; +/// Property names that hold secrets, compared in lower case without `_` or `-`. +const SECRET_NAMES: &[&str] = &[ + "password", "passwd", "secret", "token", "credential", "apikey", "accesskey", "privatekey", "authorization", "cookie", +]; + +/// How much of a source its last sync covered, and so how far its records can +/// be taken as the whole picture. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceCoverage { + pub run_id: String, + pub status: RunStatus, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gaps: Vec, +} + +/// The coverage of a source. Unknown when it is not configured here or was last +/// synced before runs were kept. +pub fn coverage_of(source: &str, sources: &[Source]) -> Field { + let run = sources.iter().find(|configured| configured.id == source).and_then(Source::last_run); + run.map(|run| SourceCoverage { + run_id: run.run_id.clone(), + status: run.status, + gaps: run.gaps.clone(), + }) + .into() +} + +/// A value that was supplied, or a plain statement that it was not. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", content = "value", rename_all = "snake_case")] +pub enum Field { + Known(T), + Unknown, +} + +impl From> for Field { + fn from(value: Option) -> Self { + value.map_or(Field::Unknown, Field::Known) + } +} + +impl Field { + pub fn known(&self) -> Option<&T> { + match self { + Field::Known(value) => Some(value), + Field::Unknown => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Assertion { + /// A data source reported this record. + Explicit, + /// KhonsuGraph worked this out from other records. + Derived, + /// Not written by a data source, for example by hand. + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + Node, + Relationship, + Path, +} + +/// What supports one node or relationship. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Evidence { + /// `node:`, `rel:`. Without a `khonsu_id` it falls + /// back on the internal ID, and `stable` is false: it may not survive a + /// rebuild of the graph. + pub evidence_id: String, + pub stable: bool, + pub kind: Kind, + pub internal_id: i64, + /// A node's labels, or a relationship's type. + pub types: Vec, + /// A relationship's ends, by their evidence IDs. + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option>, + /// The data source instance that wrote it. + pub source: Field, + /// `@`. + pub plugin: Field, + /// When this record last changed in the graph, in Unix seconds. An + /// unchanged relationship is not rewritten by a sync, so this can be older + /// than the last sync that saw it. + pub record_written_at: Field, + /// When the source last synced completely, in Unix seconds. + pub source_last_successful_sync: Field, + /// How much of the source its last sync covered. + pub source_coverage: Field, + /// The source's last sync, when it was complete: a version of the source + /// this record can be compared against. Unknown otherwise. + pub source_snapshot: Field, + pub assertion: Assertion, + /// The rule that produced it, for a derived record. + pub rule_id: Field, + /// The record's own properties, without bookkeeping or secrets. + pub properties: Map, + /// Property names left out or masked because they look like secrets. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub redacted: Vec, +} + +/// What supports a path a walk found. The path is derived, and the records it +/// rests on are explicit. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PathEvidence { + /// `path:`: the same hops give the same ID. + pub evidence_id: String, + pub stable: bool, + pub kind: Kind, + pub assertion: Assertion, + /// How it was worked out. + pub derivation: String, + pub rule_id: Field, + /// What the path does and does not show. + pub claim: String, + /// Each hop in words, in the order walked. + pub hops: Vec, + /// The evidence IDs of the records below, start to end. + pub supporting: Vec, + pub evidence: Vec, +} + +pub const PATH_CLAIM: &str = "These records connect in the data. This is not proof that anything can reach anything."; + +pub(crate) fn node_evidence_id(node: &GraphNode) -> String { + text(&node.properties, ID_PROPERTY).map_or_else(|| format!("node-internal:{}", node.id), |id| format!("node:{id}")) +} + +pub(crate) fn edge_evidence_id(edge: &GraphEdge) -> String { + text(&edge.properties, ID_PROPERTY).map_or_else(|| format!("rel-internal:{}", edge.id), |id| format!("rel:{id}")) +} + +pub fn for_node(node: &GraphNode, sources: &[Source]) -> Evidence { + let (properties, redacted) = own_properties(&node.properties); + let source = text(&node.properties, SOURCE_PROPERTY); + Evidence { + stable: text(&node.properties, ID_PROPERTY).is_some(), + evidence_id: node_evidence_id(node), + kind: Kind::Node, + internal_id: node.id, + types: node.labels.clone(), + from: None, + to: None, + ..provenance(&node.properties, source, sources, properties, redacted) + } +} + +/// `nodes` are looked in for the relationship's ends; an end not among them +/// is unknown. +pub fn for_edge(edge: &GraphEdge, nodes: &[GraphNode], sources: &[Source]) -> Evidence { + let (properties, redacted) = own_properties(&edge.properties); + let source = text(&edge.properties, SOURCE_PROPERTY); + let end = |id: i64| Field::from(nodes.iter().find(|node| node.id == id).map(node_evidence_id)); + Evidence { + stable: text(&edge.properties, ID_PROPERTY).is_some(), + evidence_id: edge_evidence_id(edge), + kind: Kind::Relationship, + internal_id: edge.id, + types: vec![edge.rel_type.clone()], + from: Some(end(edge.source)), + to: Some(end(edge.target)), + ..provenance(&edge.properties, source, sources, properties, redacted) + } +} + +/// The evidence for the path a walk took to `target`, unless it reached it by +/// no hops at all. +pub fn for_path(walk: &Exploration, target: i64, sources: &[Source]) -> Option { + let path = walk.path_to(target).filter(|path| !path.is_empty())?; + let hops: Vec = path.into_iter().cloned().collect(); + Some(for_hops(&hops, &walk.graph, sources, "fewest hops in a bounded walk", Field::Unknown)) +} + +/// The evidence for `hops`, which must not be empty, read from `graph`. A hop +/// that crosses a record twice lists that record once, where it first appears. +pub(crate) fn for_hops(hops: &[Hop], graph: &GraphData, sources: &[Source], derivation: &str, rule_id: Field) -> PathEvidence { + let mut evidence: Vec = Vec::new(); + let mut add = |record: Evidence| { + if !evidence.iter().any(|seen| seen.evidence_id == record.evidence_id) { + evidence.push(record); + } + }; + if let Some(start) = graph.nodes.iter().find(|node| node.id == hops[0].from) { + add(for_node(start, sources)); + } + for hop in hops { + if let Some(edge) = graph.edges.iter().find(|edge| edge.id == hop.edge) { + add(for_edge(edge, &graph.nodes, sources)); + } + if let Some(node) = graph.nodes.iter().find(|node| node.id == hop.to) { + add(for_node(node, sources)); + } + } + let supporting: Vec = evidence.iter().map(|record| record.evidence_id.clone()).collect(); + PathEvidence { + evidence_id: format!("path:{:016x}", fnv1a(&supporting.join("\n"))), + stable: evidence.iter().all(|record| record.stable), + kind: Kind::Path, + assertion: Assertion::Derived, + derivation: derivation.into(), + rule_id, + claim: PATH_CLAIM.into(), + hops: hops.iter().map(|hop| explain_hop(&graph.nodes, hop)).collect(), + supporting, + evidence, + } +} + +/// The fields a node and a relationship share. +fn provenance(properties: &Map, source: Option, sources: &[Source], own: Map, redacted: Vec) -> Evidence { + let configured = source.as_deref().and_then(|id| sources.iter().find(|configured| configured.id == id)); + let last_ok = configured.and_then(|configured| match configured.last_complete_run() { + Some(run) => Some(run.started_at), + // A source last synced before runs were kept. + None if configured.runs.is_empty() => configured.last_sync.as_ref().filter(|sync| sync.ok).map(|sync| sync.at), + None => None, + }); + let coverage = source.as_deref().map_or(Field::Unknown, |id| coverage_of(id, sources)); + Evidence { + evidence_id: String::new(), + stable: false, + kind: Kind::Node, + internal_id: 0, + types: Vec::new(), + from: None, + to: None, + assertion: if source.is_some() { Assertion::Explicit } else { Assertion::Unknown }, + source: source.into(), + plugin: text(properties, PLUGIN_PROPERTY).into(), + record_written_at: properties.get(SYNCED_AT_PROPERTY).and_then(Json::as_u64).into(), + source_last_successful_sync: last_ok.into(), + source_snapshot: coverage.known().filter(|coverage| coverage.status == RunStatus::Complete).map(|coverage| coverage.run_id.clone()).into(), + source_coverage: coverage, + rule_id: Field::Unknown, + properties: own, + redacted, + } +} + +fn text(properties: &Map, key: &str) -> Option { + properties.get(key).and_then(Json::as_str).filter(|value| !value.is_empty()).map(String::from) +} + +/// `properties` without KhonsuGraph's bookkeeping, and with secrets masked, +/// along with the names of those masked. +pub(crate) fn own_properties(properties: &Map) -> (Map, Vec) { + let mut redacted = Vec::new(); + let own = properties + .iter() + .filter(|(key, _)| !key.starts_with("khonsu_")) + .map(|(key, value)| { + if is_secret_name(key) { + redacted.push(key.clone()); + return (key.clone(), Json::String(REDACTED.into())); + } + let (clean, masked) = mask_values(value); + if masked { + redacted.push(key.clone()); + } + (key.clone(), clean) + }) + .collect(); + (own, redacted) +} + +fn is_secret_name(name: &str) -> bool { + let plain = name.to_lowercase().replace(['_', '-'], ""); + SECRET_NAMES.iter().any(|secret| plain.contains(secret)) +} + +fn mask_values(value: &Json) -> (Json, bool) { + match value { + Json::String(text) if looks_like_a_secret(text) => (Json::String(REDACTED.into()), true), + Json::Array(items) => { + let cleaned: Vec<(Json, bool)> = items.iter().map(mask_values).collect(); + let masked = cleaned.iter().any(|(_, masked)| *masked); + (Json::Array(cleaned.into_iter().map(|(item, _)| item).collect()), masked) + } + Json::Object(map) => { + let mut masked = false; + let cleaned = map + .iter() + .map(|(key, item)| { + if is_secret_name(key) { + masked = true; + return (key.clone(), Json::String(REDACTED.into())); + } + let (item, inner) = mask_values(item); + masked |= inner; + (key.clone(), item) + }) + .collect(); + (Json::Object(cleaned), masked) + } + other => (other.clone(), false), + } +} + +/// Key material by its shape: AWS access key IDs and PEM blocks. +fn looks_like_a_secret(text: &str) -> bool { + let aws_key = (text.starts_with("AKIA") || text.starts_with("ASIA")) + && text.len() == 20 + && text.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()); + aws_key || text.contains("-----BEGIN") +} + +pub(crate) use khonsu_plugin_protocol::fnv1a; + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::LastSync; + use serde_json::json; + + fn map(value: Json) -> Map { + value.as_object().unwrap().clone() + } + + fn synced(extra: Json) -> Map { + let mut properties = map(json!({ + "khonsu_source": "shop", + "khonsu_plugin": "aws@0.2.0", + "khonsu_synced_at": 1000, + "khonsu_hash": "abc", + })); + properties.extend(map(extra)); + properties + } + + fn source(ok: bool) -> Source { + serde_json::from_value(json!({ + "id": "shop", "name": "shop", "plugin": "aws", "graph": "g", + "config": {"fixture_dir": "/home/someone/private", "profile": "prod"}, + "secret_keys": ["secret_access_key"], + "last_sync": serde_json::to_value(LastSync { at: 2000, ok, nodes: 1, edges: 1, message: String::new() }).unwrap(), + })) + .unwrap() + } + + fn node(id: i64, extra: Json) -> GraphNode { + GraphNode { + id, + labels: vec!["Host".into()], + properties: synced(extra), + } + } + + #[test] + fn a_synced_node_says_where_it_came_from() { + let record = for_node(&node(1, json!({"khonsu_id": "shop/h1", "name": "web"})), &[source(true)]); + assert_eq!(record.evidence_id, "node:shop/h1"); + assert!(record.stable); + assert_eq!(record.source, Field::Known("shop".to_string())); + assert_eq!(record.plugin, Field::Known("aws@0.2.0".to_string())); + assert_eq!(record.record_written_at, Field::Known(1000)); + assert_eq!(record.source_last_successful_sync, Field::Known(2000)); + assert_eq!(record.assertion, Assertion::Explicit); + assert_eq!(record.properties, map(json!({"name": "web"})), "bookkeeping stays out of the properties"); + } + + #[test] + fn what_was_not_supplied_is_unknown_not_made_up() { + let bare = GraphNode { + id: 7, + labels: vec![], + properties: map(json!({"name": "by hand"})), + }; + let record = for_node(&bare, &[source(true)]); + assert_eq!(record.evidence_id, "node-internal:7"); + assert!(!record.stable); + assert_eq!(record.source, Field::Unknown); + assert_eq!(record.plugin, Field::Unknown); + assert_eq!(record.record_written_at, Field::Unknown); + assert_eq!(record.source_last_successful_sync, Field::Unknown); + assert_eq!(record.source_snapshot, Field::Unknown); + assert_eq!(record.rule_id, Field::Unknown); + assert_eq!(record.assertion, Assertion::Unknown); + } + + #[test] + fn a_failed_or_removed_source_gives_no_last_successful_sync() { + let host = node(1, json!({"khonsu_id": "shop/h1"})); + assert_eq!(for_node(&host, &[source(false)]).source_last_successful_sync, Field::Unknown); + assert_eq!(for_node(&host, &[]).source_last_successful_sync, Field::Unknown); + assert_eq!(for_node(&host, &[]).source, Field::Known("shop".to_string()), "the record still names it"); + } + + fn run(number: u64, status: RunStatus) -> crate::settings::SyncRun { + crate::settings::SyncRun { + run_id: format!("shop#{number}"), + number, + mode: crate::protocol::SyncMode::Full, + started_at: number * 100, + ended_at: number * 100 + 1, + status, + plugin: "aws@0.2.0".into(), + received_nodes: 0, + received_edges: 0, + removals_applied: false, + cursor_advanced: false, + gaps: vec!["no route tables".into()], + message: String::new(), + } + } + + #[test] + fn a_record_says_how_much_of_its_source_the_last_sync_covered() { + let host = node(1, json!({"khonsu_id": "shop/h1"})); + let mut configured = source(false); + configured.runs = vec![run(1, RunStatus::Complete), run(2, RunStatus::Partial)]; + let record = for_node(&host, std::slice::from_ref(&configured)); + let coverage = record.source_coverage.known().unwrap(); + assert_eq!((coverage.run_id.as_str(), coverage.status), ("shop#2", RunStatus::Partial)); + assert_eq!(coverage.gaps, ["no route tables"]); + assert_eq!(record.source_last_successful_sync, Field::Known(100), "the last complete one, not the last one"); + assert_eq!(record.source_snapshot, Field::Unknown, "a partial sync is no version to compare against"); + + configured.runs = vec![run(1, RunStatus::Complete)]; + let record = for_node(&host, std::slice::from_ref(&configured)); + assert_eq!(record.source_snapshot, Field::Known("shop#1".to_string())); + + configured.runs = vec![run(1, RunStatus::Failed)]; + let record = for_node(&host, std::slice::from_ref(&configured)); + assert_eq!(record.source_last_successful_sync, Field::Unknown, "no complete run, whatever last_sync says"); + + // Without the source configured, or synced before runs were kept. + assert_eq!(for_node(&host, &[]).source_coverage, Field::Unknown); + assert_eq!(for_node(&host, &[source(true)]).source_coverage, Field::Unknown); + } + + #[test] + fn a_field_serializes_with_its_status() { + assert_eq!(serde_json::to_value(Field::Known(5)).unwrap(), json!({"status": "known", "value": 5})); + assert_eq!(serde_json::to_value(Field::::Unknown).unwrap(), json!({"status": "unknown"})); + } + + #[test] + fn secrets_are_masked_and_named_and_source_config_stays_out() { + let host = node( + 1, + json!({ + "khonsu_id": "shop/h1", + "name": "web", + "api_token": "abc123", + "DB-Password": "hunter2", + "key_id": "AKIAIOSFODNN7EXAMPLE", + "pem": "-----BEGIN PRIVATE KEY-----", + "nested": {"client_secret": "x", "fine": "y"}, + "list": ["ok", "AKIAIOSFODNN7EXAMPLE"], + }), + ); + let record = for_node(&host, &[source(true)]); + let mut named = record.redacted.clone(); + named.sort(); + assert_eq!(named, ["DB-Password", "api_token", "key_id", "list", "nested", "pem"]); + assert_eq!(record.properties["name"], json!("web")); + assert_eq!(record.properties["nested"]["fine"], json!("y")); + + let text = serde_json::to_string(&record).unwrap(); + for secret in ["abc123", "hunter2", "AKIAIOSFODNN7EXAMPLE", "BEGIN PRIVATE", "\"x\""] { + assert!(!text.contains(secret), "{secret} in {text}"); + } + for config in ["/home/someone/private", "prod", "secret_access_key", "fixture_dir"] { + assert!(!text.contains(config), "{config} in {text}"); + } + } + + fn walk() -> Exploration { + let edge = |id: i64, source: i64, target: i64, stable: &str| GraphEdge { + id, + source, + target, + rel_type: "NEXT".into(), + properties: synced(json!({"khonsu_id": stable})), + }; + let hop = |depth: u32, from: i64, to: i64, edge: i64| Hop { + depth, + from, + to, + edge, + rel_type: "NEXT".into(), + along: true, + }; + Exploration { + starts: vec![1], + graph: GraphData { + nodes: vec![ + node(1, json!({"khonsu_id": "s/a", "name": "a"})), + node(2, json!({"khonsu_id": "s/b", "name": "b"})), + node(3, json!({"khonsu_id": "s/c", "name": "c"})), + ], + edges: vec![edge(10, 1, 2, "s/ab"), edge(11, 2, 3, "s/bc")], + }, + hops: vec![hop(1, 1, 2, 10), hop(2, 2, 3, 11)], + depth_reached: 2, + ..Exploration::default() + } + } + + #[test] + fn a_path_is_derived_and_links_to_the_records_it_rests_on() { + let path = for_path(&walk(), 3, &[]).unwrap(); + assert_eq!(path.assertion, Assertion::Derived); + assert_eq!(path.kind, Kind::Path); + assert_eq!(path.rule_id, Field::Unknown); + assert_eq!(path.supporting, ["node:s/a", "rel:s/ab", "node:s/b", "rel:s/bc", "node:s/c"]); + assert_eq!(path.hops, ["a -[NEXT]-> b", "b -[NEXT]-> c"]); + assert_eq!(path.claim, PATH_CLAIM); + assert!(path.stable); + assert!(path.evidence.iter().all(|record| record.assertion == Assertion::Explicit)); + assert_eq!(path.evidence[1].from, Some(Field::Known("node:s/a".to_string()))); + assert_eq!(path.evidence[1].to, Some(Field::Known("node:s/b".to_string()))); + } + + #[test] + fn the_same_hops_give_the_same_path_id_and_other_hops_another() { + let to_c = for_path(&walk(), 3, &[]).unwrap().evidence_id; + assert_eq!(to_c, for_path(&walk(), 3, &[]).unwrap().evidence_id); + assert_ne!(to_c, for_path(&walk(), 2, &[]).unwrap().evidence_id); + // A hash of known text, so that changing the function shows up here. + assert_eq!(fnv1a("a"), 0xaf63_dc4c_8601_ec8c); + } + + #[test] + fn a_start_or_an_unreached_node_has_no_path() { + assert!(for_path(&walk(), 1, &[]).is_none()); + assert!(for_path(&walk(), 99, &[]).is_none()); + } + + #[test] + fn an_end_outside_the_given_nodes_is_unknown() { + let edge = GraphEdge { + id: 10, + source: 1, + target: 2, + rel_type: "NEXT".into(), + properties: synced(json!({"khonsu_id": "s/ab"})), + }; + let record = for_edge(&edge, &[node(1, json!({"khonsu_id": "s/a"}))], &[]); + assert_eq!(record.from, Some(Field::Known("node:s/a".to_string()))); + assert_eq!(record.to, Some(Field::Unknown)); + } +} diff --git a/crates/khonsu-core/src/explore.rs b/crates/khonsu-core/src/explore.rs new file mode 100644 index 0000000..a59194a --- /dev/null +++ b/crates/khonsu-core/src/explore.rs @@ -0,0 +1,325 @@ +//! Following a suspected chain from chosen nodes without loading the graph. +//! +//! A breadth-first walk with one parameterized query per step, so every cap is +//! enforced here and cancellation is checked between queries. HawDB takes one +//! relationship type on a variable-length pattern and no `type(r)` in a +//! `WHERE`, so types go in the pattern, one query each. +//! +//! What a walk finds is a path in the data, not proof that anything can reach +//! anything: see `docs/investigation-gates.md`. + +use crate::engine::{Graph, QueryError}; +use crate::result::{value_to_json, GraphCollector, GraphData, GraphEdge, GraphNode}; +use hawdb::Value; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub const DEFAULT_MAX_HOPS: u32 = 3; +pub const DEFAULT_MAX_NODES: usize = 200; +pub const DEFAULT_MAX_EDGES: usize = 500; +/// The most a request may ask for, whatever the person types. +pub const MAX_HOPS: u32 = 16; +pub const MAX_NODES: usize = 2000; +pub const MAX_EDGES: usize = 5000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Direction { + /// Along the arrows. + Out, + /// Against the arrows. + In, + Both, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExploreRequest { + pub starts: Vec, + pub direction: Direction, + /// Relationship types to follow; empty follows every type. + pub rel_types: Vec, + pub max_hops: u32, + pub max_nodes: usize, + pub max_edges: usize, +} + +impl ExploreRequest { + pub fn new(starts: Vec) -> Self { + Self { + starts, + direction: Direction::Out, + rel_types: Vec::new(), + max_hops: DEFAULT_MAX_HOPS, + max_nodes: DEFAULT_MAX_NODES, + max_edges: DEFAULT_MAX_EDGES, + } + } + + fn validate(&self) -> Result<(), QueryError> { + let refuse = |message: String| Err(QueryError::new("semantic", message)); + if self.starts.is_empty() { + return refuse("choose at least one node to start from".into()); + } + if self.max_hops == 0 || self.max_hops > MAX_HOPS { + return refuse(format!("hops must be between 1 and {MAX_HOPS}, not {}", self.max_hops)); + } + if self.max_nodes < self.starts.len() || self.max_nodes > MAX_NODES { + return refuse(format!("the node cap must be between {} and {MAX_NODES}, not {}", self.starts.len(), self.max_nodes)); + } + if self.max_edges == 0 || self.max_edges > MAX_EDGES { + return refuse(format!("the relationship cap must be between 1 and {MAX_EDGES}, not {}", self.max_edges)); + } + // A type goes into the query text, since HawDB cannot bind one. + if let Some(bad) = self.rel_types.iter().find(|name| !is_identifier(name)) { + return refuse(format!("{bad:?} is not a relationship type name")); + } + Ok(()) + } +} + +pub(crate) fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// One relationship the walk followed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Hop { + /// Steps from the nearest start: 1 is a start's own relationship. + pub depth: u32, + pub from: i64, + pub to: i64, + pub edge: i64, + pub rel_type: String, + /// True when the walk went along the arrow, false when against it. + pub along: bool, +} + +/// A cap that stopped the walk from showing everything it could reach. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Limit { + Hops, + Nodes, + Edges, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Exploration { + /// The requested starts that are in the graph. + pub starts: Vec, + pub graph: GraphData, + /// In the order they were found, nearest first. + pub hops: Vec, + /// How far the walk found anything. + pub depth_reached: u32, + /// Empty means the walk saw everything its filters allow. + pub limits: Vec, + /// Stopped early on request; `graph` holds what was found until then. + pub cancelled: bool, + /// Requested starts that are not in the graph. + pub missing_starts: Vec, +} + +impl Exploration { + /// Whether the result is everything the filters allow. An incomplete walk + /// that found nothing has not shown that nothing is there. + pub fn is_complete(&self) -> bool { + self.limits.is_empty() && !self.cancelled + } + + /// The hops that first reached `target`, from a start: a fewest-hops path + /// among what the walk found. `None` when it did not reach it, and empty + /// when `target` is a start. + pub fn path_to(&self, target: i64) -> Option> { + if !self.graph.nodes.iter().any(|node| node.id == target) { + return None; + } + // The hop that first reached each node. A walk can arrive back at a + // start around a cycle, which does not count as reaching it. + let mut first: BTreeMap = BTreeMap::new(); + for hop in self.hops.iter().filter(|hop| !self.starts.contains(&hop.to)) { + first.entry(hop.to).or_insert(hop); + } + // Each hop's `from` was reached a step earlier, so this ends at a start. + let mut path = Vec::new(); + let mut at = target; + while let Some(hop) = first.get(&at) { + path.push(*hop); + at = hop.from; + } + path.reverse(); + Some(path) + } + + /// One hop in words, in the direction it was walked: + /// `Internet -[ROUTES_TO]-> shop`, `web-1 <-[ROUTES_TO]- web`. + pub fn explain(&self, hop: &Hop) -> String { + explain_hop(&self.graph.nodes, hop) + } +} + +/// One hop in words, with names from `nodes`. +pub(crate) fn explain_hop(nodes: &[GraphNode], hop: &Hop) -> String { + let name = |id: i64| nodes.iter().find(|node| node.id == id).map(node_name).unwrap_or_else(|| format!("#{id}")); + let (from, to, rel) = (name(hop.from), name(hop.to), &hop.rel_type); + if hop.along { + format!("{from} -[{rel}]-> {to}") + } else { + format!("{from} <-[{rel}]- {to}") + } +} + +pub(crate) fn node_name(node: &GraphNode) -> String { + for key in ["name", "title", "khonsu_id", "id"] { + match node.properties.get(key) { + Some(Json::String(text)) if !text.is_empty() => return text.clone(), + Some(value @ Json::Number(_)) => return value.to_string(), + _ => {} + } + } + format!("{} #{}", node.labels.first().map(String::as_str).unwrap_or("node"), node.id) +} + +impl Graph { + /// Walks from `request.starts`. `cancel` is checked before each query; a + /// query already running finishes first. + pub fn explore(&mut self, request: &ExploreRequest, cancel: &AtomicBool) -> Result { + request.validate()?; + let found = self.nodes_by_id(&request.starts)?; + let mut result = Exploration::default(); + let known: BTreeSet = found.nodes.iter().map(|node| node.id).collect(); + result.missing_starts = request.starts.iter().copied().filter(|id| !known.contains(id)).collect(); + result.missing_starts.sort_unstable(); + result.missing_starts.dedup(); + + let mut nodes: BTreeMap = found.nodes.into_iter().map(|node| (node.id, node)).collect(); + let mut edges: BTreeMap = BTreeMap::new(); + let mut frontier: Vec = known.into_iter().collect(); + result.starts = frontier.clone(); + let mut limits = BTreeSet::new(); + + for depth in 1..=request.max_hops { + if frontier.is_empty() { + break; + } + let Some(walks) = self.step(request, &frontier, cancel)? else { + result.cancelled = true; + break; + }; + let mut next = Vec::new(); + for walk in walks { + if edges.contains_key(&walk.edge.id) { + continue; + } + if edges.len() == request.max_edges { + limits.insert(Limit::Edges); + break; + } + let fresh = !nodes.contains_key(&walk.to.id); + if fresh && nodes.len() == request.max_nodes { + limits.insert(Limit::Nodes); + continue; + } + result.hops.push(Hop { + depth, + from: walk.from, + to: walk.to.id, + edge: walk.edge.id, + rel_type: walk.edge.rel_type.clone(), + along: walk.along, + }); + result.depth_reached = depth; + if fresh { + next.push(walk.to.id); + nodes.insert(walk.to.id, walk.to); + } + edges.insert(walk.edge.id, walk.edge); + } + next.sort_unstable(); + frontier = next; + if !limits.is_empty() { + frontier.clear(); + } + } + + // One step past the last hop, only to say whether the cap cut anything. + if !result.cancelled && !frontier.is_empty() { + match self.step(request, &frontier, cancel)? { + Some(walks) if walks.iter().any(|walk| !edges.contains_key(&walk.edge.id)) => { + limits.insert(Limit::Hops); + } + Some(_) => {} + None => result.cancelled = true, + } + } + + result.limits = limits.into_iter().collect(); + result.graph = GraphData { + nodes: nodes.into_values().collect(), + edges: edges.into_values().collect(), + }; + Ok(result) + } + + /// Every relationship the filters allow out of `frontier`, by edge ID so + /// that a repeat of the same walk gives the same result. `None` when + /// cancelled. Each query can return one row more than the edge cap, which + /// is enough to show the cap was exceeded even when all the others have + /// been seen already. + fn step(&mut self, request: &ExploreRequest, frontier: &[i64], cancel: &AtomicBool) -> Result>, QueryError> { + let types: Vec> = if request.rel_types.is_empty() { + vec![None] + } else { + request.rel_types.iter().map(|name| Some(name.as_str())).collect() + }; + let ways: &[bool] = match request.direction { + Direction::Out => &[true], + Direction::In => &[false], + Direction::Both => &[true, false], + }; + let ids = Value::List(frontier.iter().map(|id| Value::Int(*id)).collect()); + let params = BTreeMap::from([("ids".to_string(), ids)]); + let rows = request.max_edges + 1; + + let mut walks = Vec::new(); + for along in ways.iter().copied() { + for rel_type in &types { + if cancel.load(Ordering::Relaxed) { + return Ok(None); + } + let rel = rel_type.map(|name| format!("r:{name}")).unwrap_or_else(|| "r".into()); + let pattern = if along { format!("(n)-[{rel}]->(m)") } else { format!("(n)<-[{rel}]-(m)") }; + let text = format!("MATCH {pattern} WHERE id(n) IN $ids RETURN n, r, m ORDER BY id(r) LIMIT {rows}"); + let mut collector = GraphCollector::default(); + for row in self.raw_cypher(&text, ¶ms)? { + row.iter().for_each(|value| collector.visit(&value_to_json(value))); + } + let data = collector.finish().unwrap_or_default(); + let by_id: BTreeMap = data.nodes.into_iter().map(|node| (node.id, node)).collect(); + for edge in data.edges { + let (from, to) = if along { (edge.source, edge.target) } else { (edge.target, edge.source) }; + if let Some(node) = by_id.get(&to) { + walks.push(Walk { + from, + to: node.clone(), + edge, + along, + }); + } + } + } + } + walks.sort_by_key(|walk| (walk.edge.id, !walk.along)); + Ok(Some(walks)) + } +} + +struct Walk { + from: i64, + to: GraphNode, + edge: GraphEdge, + along: bool, +} diff --git a/crates/khonsu-core/src/investigation.rs b/crates/khonsu-core/src/investigation.rs new file mode 100644 index 0000000..6d145dc --- /dev/null +++ b/crates/khonsu-core/src/investigation.rs @@ -0,0 +1,729 @@ +//! A saved investigation: what someone was looking at, kept in a file of its +//! own, so that nothing but real records goes in the graph. +//! +//! The file names records by stable ID, since internal IDs can change when a +//! graph is rebuilt, and holds a fingerprint of the graph, so reopening can say +//! when the data has moved on instead of showing a new answer under an old +//! name. It holds no source configuration or credentials. See +//! `docs/investigations.md`. + +use crate::engine::{Graph, QueryError}; +use crate::evidence::{self, Field, PathEvidence, SourceCoverage}; +use crate::explore::{Direction, Exploration, ExploreRequest, Hop, DEFAULT_MAX_EDGES, DEFAULT_MAX_NODES}; +use crate::merge::{ID_PROPERTY, PLUGIN_PROPERTY, SOURCE_PROPERTY, SYNCED_AT_PROPERTY}; +use crate::protocol::HopRef; +use crate::result::{value_to_json, GraphCollector, GraphData, GraphNode}; +use crate::settings::{RunStatus, Source}; +use crate::util::unix_now; +use hawdb::Value; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value as Json}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; + +pub const FORMAT: &str = "khonsu-investigation"; +pub const VERSION: u32 = 1; +/// The usual file extension. A file is judged by its contents, not this. +pub const EXTENSION: &str = "investigation"; +/// Above this many nodes or relationships a graph is counted, not hashed. +const MAX_HASHED_RECORDS: usize = 200_000; + +/// A record, by its stable ID when it has one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordRef { + pub stable_id: Option, + /// Only trusted when there is no stable ID and the graph has not changed. + pub internal_id: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedQuery { + /// `cypher` or `sql`. + pub language: String, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedWalk { + pub seeds: Vec, + pub direction: Direction, + pub types: Vec, + pub max_hops: u32, + pub max_nodes: usize, + pub max_edges: usize, +} + +/// The path someone had chosen, and the records it rested on then. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedPath { + pub target: RecordRef, + /// Evidence IDs, start to end. Empty when no path was found at saving. + pub supporting: Vec, + /// The exact path, when it was not simply the fewest hops a walk finds, as + /// with a finding's path. Reopening looks these up instead of walking. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hops: Vec, +} + +/// What a graph held, without timestamps: a re-sync that changes nothing gives +/// the same fingerprint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Fingerprint { + pub nodes: u64, + pub relationships: u64, + /// `None` when the graph was too large to hash, so only counts compare. + pub hash: Option, + /// Data sources with records in the graph. + pub sources: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Investigation { + pub format: String, + pub version: u32, + pub name: String, + pub graph: PathBuf, + pub saved_at: u64, + pub fingerprint: Fingerprint, + /// The source runs this was evaluated against. + #[serde(default)] + pub pinned_runs: BTreeMap>, + #[serde(default)] + pub query: Option, + #[serde(default)] + pub walk: Option, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub dashboard: Option, +} + +/// Why a file could not be used. `kind` is `not_found`, `invalid`, `too_new`, +/// `exists` or `io`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileError { + pub kind: &'static str, + pub message: String, +} + +impl Investigation { + /// Reads a file, saying what is wrong with one it cannot use. Fields it + /// does not know are ignored, so a later release can add some. + pub fn load(path: &Path) -> Result { + let shown = path.display(); + let text = std::fs::read_to_string(path).map_err(|error| FileError { + kind: if error.kind() == std::io::ErrorKind::NotFound { "not_found" } else { "io" }, + message: format!("cannot read {shown}: {error}"), + })?; + Self::parse(&text).map_err(|error| FileError { + kind: error.kind, + message: format!("{shown}: {}", error.message), + }) + } + + pub fn parse(text: &str) -> Result { + let value = check_header(text, FORMAT, VERSION)?; + serde_json::from_value(value).map_err(|error| FileError { + kind: "invalid", + message: format!("its contents are not usable: {error}"), + }) + } + + /// Writes the file whole or not at all. An existing file is kept unless + /// `overwrite` says otherwise. + pub fn save(&self, path: &Path, overwrite: bool) -> Result<(), FileError> { + let text = serde_json::to_string_pretty(self).expect("an investigation serializes") + "\n"; + write_whole(path, &text, overwrite) + } +} + +/// Parses a file's JSON and checks that it is `format`, at a version this build +/// reads. Fields it does not know are left for the caller to ignore. +pub(crate) fn check_header(text: &str, format: &str, supported: u32) -> Result { + let invalid = |message: String| FileError { kind: "invalid", message }; + let value: Json = serde_json::from_str(text).map_err(|error| invalid(format!("not valid JSON: {error}")))?; + if value.get("format").and_then(Json::as_str) != Some(format) { + return Err(invalid(format!("its format is not {format:?}"))); + } + let version = value.get("version").and_then(Json::as_u64).ok_or_else(|| invalid("it has no version number".into()))?; + if version > u64::from(supported) { + return Err(FileError { + kind: "too_new", + message: format!("it is version {version}, and this build reads up to version {supported}; update KhonsuGraph"), + }); + } + Ok(value) +} + +/// Writes a file whole or not at all, keeping an existing one unless `overwrite`. +pub(crate) fn write_whole(path: &Path, text: &str, overwrite: bool) -> Result<(), FileError> { + let io = |error: std::io::Error| FileError { + kind: "io", + message: format!("cannot write {}: {error}", path.display()), + }; + if !overwrite && path.exists() { + return Err(FileError { + kind: "exists", + message: format!("{} already exists", path.display()), + }); + } + let mut scratch = path.as_os_str().to_owned(); + scratch.push(".tmp"); + let scratch = PathBuf::from(scratch); + std::fs::write(&scratch, text).map_err(io)?; + std::fs::rename(&scratch, path).map_err(|error| { + let _ = std::fs::remove_file(&scratch); + io(error) + }) +} + +/// What to save, by internal IDs as the caller has them. +#[derive(Debug, Clone, Default)] +pub struct Capture { + pub name: String, + pub query: Option, + pub walk: Option, + /// Needs `walk`: the node whose path was chosen. + pub target: Option, + /// An exact path to keep, such as a finding's. It gives its own target, and + /// its own walk when `walk` is empty. + pub path_refs: Vec, + pub dashboard: Option, +} + +/// Whether the graph still holds what it did at saving. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GraphState { + Unchanged, + Changed, + /// The counts agree, and the graph is too large to hash. + NotCompared, +} + +/// What came back on reopening, with everything that differs from the file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Reopened { + pub graph_state: GraphState, + /// What changed, in words. Not exhaustive: a hash says only that something did. + pub differences: Vec, + pub now: Fingerprint, + /// Saved starting points that are not in the graph now. + pub missing_seeds: Vec, + /// The saved path's end is not in the graph now. + pub target_missing: bool, + /// Records found by an internal ID on a changed graph, which may now be + /// something else. + pub unsure: Vec, + pub walk: Option, + pub target: Option, + pub path: Option, + /// Whether the path rests on the saved records. `None` when none was saved. + pub path_same: Option, + /// Sources in the graph whose last sync was not complete, in words. + pub incomplete_sources: Vec, + /// The saved exact path was found whole, so `walk` is that path alone. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exact_path: bool, +} + +impl Graph { + /// The fingerprint of everything in the graph. + pub fn fingerprint(&mut self) -> Result { + let params = BTreeMap::new(); + let count = |rows: Vec>| rows.first().and_then(|row| row.first()).map(value_to_json).and_then(|value| value.as_u64()).unwrap_or(0); + let nodes = count(self.raw_cypher("MATCH (n) RETURN count(n) AS c", ¶ms)?); + let relationships = count(self.raw_cypher("MATCH ()-[r]->() RETURN count(r) AS c", ¶ms)?); + if nodes as usize > MAX_HASHED_RECORDS || relationships as usize > MAX_HASHED_RECORDS { + return Ok(Fingerprint { + nodes, + relationships, + hash: None, + sources: Vec::new(), + }); + } + let data = self.read_all_records()?; + let (hash, sources) = content_hash(&data); + Ok(Fingerprint { + nodes, + relationships, + hash: Some(hash), + sources, + }) + } + + /// Every node and relationship. The caller has checked how many there are. + pub(crate) fn read_all_records(&mut self) -> Result { + let params = BTreeMap::new(); + let mut collector = GraphCollector::default(); + for text in ["MATCH (n) RETURN n", "MATCH ()-[r]->() RETURN r"] { + for row in self.raw_cypher(text, ¶ms)? { + row.iter().for_each(|value| collector.visit(&value_to_json(value))); + } + } + Ok(collector.finish().unwrap_or_default()) + } + + /// Records what `capture` describes, as it stands now. + pub fn capture_investigation(&mut self, graph: &Path, capture: Capture, sources: &[Source], cancel: &AtomicBool) -> Result { + let refuse = |message: String| QueryError::new("semantic", message); + if capture.target.is_some() && capture.walk.is_none() { + return Err(refuse("a path to a node needs a walk to find it: name where to start".into())); + } + let mut walk = None; + let mut path = None; + let mut capture = capture; + let exact = if capture.path_refs.is_empty() { + None + } else { + let found = self.walk_of_refs(&capture.path_refs)?.ok_or_else(|| refuse("that path is not in the graph".into()))?; + capture.target = found.hops.last().map(|hop| hop.to); + capture.walk.get_or_insert_with(|| plan_for(&found.hops)); + Some(found) + }; + if let Some(request) = &capture.walk { + let found = self.explore(request, cancel)?; + if let Some(start) = found.missing_starts.first() { + return Err(refuse(format!("no node has the ID {start}"))); + } + let seeds = self.references(&request.starts)?; + if let Some(target) = capture.target { + let refs = self.references(&[target])?; + let target_ref = refs.into_iter().next().ok_or_else(|| refuse(format!("no node has the ID {target}")))?; + let supporting = match &exact { + Some(exact) => evidence::for_hops(&exact.hops, &exact.graph, sources, EXACT_PATH, Field::Unknown).supporting, + None => evidence::for_path(&found, target, sources).map(|record| record.supporting).unwrap_or_default(), + }; + path = Some(SavedPath { target: target_ref, supporting, hops: capture.path_refs.clone() }); + } + walk = Some(SavedWalk { + seeds, + direction: request.direction, + types: request.rel_types.clone(), + max_hops: request.max_hops, + max_nodes: request.max_nodes, + max_edges: request.max_edges, + }); + } + let fingerprint = self.fingerprint()?; + let pinned_runs = fingerprint.sources.iter().map(|source| (source.clone(), evidence::coverage_of(source, sources))).collect(); + Ok(Investigation { + format: FORMAT.into(), + version: VERSION, + name: capture.name, + graph: graph.to_path_buf(), + saved_at: unix_now(), + fingerprint, + pinned_runs, + query: capture.query, + walk, + path, + dashboard: capture.dashboard, + }) + } + + /// Restores what can be restored, and says what could not. A record that + /// has gone, or a graph that has changed, is reported and not an error. + pub fn reopen_investigation(&mut self, saved: &Investigation, sources: &[Source], cancel: &AtomicBool) -> Result { + let now = self.fingerprint()?; + let (graph_state, mut differences) = compare(&saved.fingerprint, &now); + // Runs alone say nothing: a re-sync that changes nothing is a new run. + if graph_state == GraphState::Changed { + for (source, pinned) in &saved.pinned_runs { + let current = evidence::coverage_of(source, sources); + let name = |coverage: &Field| coverage.known().map_or("an unknown run".to_string(), |coverage| coverage.run_id.clone()); + if current != *pinned { + differences.push(format!("Source {source} was at {} when saved, and is at {} now.", name(pinned), name(¤t))); + } + } + } + + let mut missing_seeds = Vec::new(); + let mut unsure = Vec::new(); + let mut starts = Vec::new(); + for seed in saved.walk.iter().flat_map(|walk| &walk.seeds) { + match self.resolve(seed)? { + Some(id) => { + starts.push(id); + if seed.stable_id.is_none() && graph_state != GraphState::Unchanged { + unsure.push(seed.clone()); + } + } + None => missing_seeds.push(seed.clone()), + } + } + let target = match &saved.path { + Some(path) => { + let found = self.resolve(&path.target)?; + if found.is_some() && path.target.stable_id.is_none() && graph_state != GraphState::Unchanged { + unsure.push(path.target.clone()); + } + found + } + None => None, + }; + let target_missing = saved.path.is_some() && target.is_none(); + + // The exact path, if every record of it is still there and it still connects. + let exact = match saved.path.as_ref().filter(|path| !path.hops.is_empty()) { + Some(path) => self.walk_of_refs(&path.hops)?, + None => None, + }; + let mut walk = exact.clone(); + if let Some(plan) = saved.walk.as_ref().filter(|_| !starts.is_empty() && exact.is_none()) { + let request = ExploreRequest { + starts, + direction: plan.direction, + rel_types: plan.types.clone(), + max_hops: plan.max_hops, + max_nodes: plan.max_nodes, + max_edges: plan.max_edges, + }; + walk = Some(self.explore(&request, cancel)?); + } + let path = match &exact { + Some(exact) => Some(evidence::for_hops(&exact.hops, &exact.graph, sources, EXACT_PATH, Field::Unknown)), + None => walk.as_ref().zip(target).and_then(|(walk, target)| evidence::for_path(walk, target, sources)), + }; + let path_same = saved.path.as_ref().map(|saved_path| { + let now: &[String] = path.as_ref().map_or(&[], |path| path.supporting.as_slice()); + saved_path.supporting == now + }); + let incomplete_sources = now + .sources + .iter() + .filter_map(|source| { + let coverage = evidence::coverage_of(source, sources); + let coverage = coverage.known()?; + (coverage.status != RunStatus::Complete).then(|| format!("Source {source}'s last sync ({}) was {}.", coverage.run_id, coverage.status.word())) + }) + .collect(); + Ok(Reopened { + incomplete_sources, + graph_state, + differences, + now, + missing_seeds, + target_missing, + unsure, + walk, + target, + path, + path_same, + exact_path: exact.is_some(), + }) + } + + /// A path of stable references as a walk over this graph's records. `None` + /// when one of them has gone or the hops no longer connect. + fn walk_of_refs(&mut self, refs: &[HopRef]) -> Result, QueryError> { + let mut collector = GraphCollector::default(); + let mut hops: Vec = Vec::with_capacity(refs.len()); + for (at, hop) in refs.iter().enumerate() { + let (text, id) = if let Some(number) = hop.edge.strip_prefix("rel-internal:") { + let Ok(number) = number.parse::() else { return Ok(None) }; + ("MATCH (a)-[r]->(b) WHERE id(r) = $id RETURN a, r, b LIMIT 1", Value::Int(number)) + } else if let Some(stable) = hop.edge.strip_prefix("rel:") { + ("MATCH (a)-[r]->(b) WHERE r.khonsu_id = $id RETURN a, r, b LIMIT 1", Value::String(stable.into())) + } else { + return Ok(None); + }; + let params = BTreeMap::from([("id".to_string(), id)]); + let rows = self.raw_cypher(text, ¶ms)?; + let Some(row) = rows.first() else { return Ok(None) }; + let mut one = GraphCollector::default(); + for value in row { + let value = value_to_json(value); + one.visit(&value); + collector.visit(&value); + } + let Some(edge) = one.finish().and_then(|data| data.edges.into_iter().next()) else { return Ok(None) }; + let (from, to) = if hop.along { (edge.source, edge.target) } else { (edge.target, edge.source) }; + if hops.last().is_some_and(|last| last.to != from) { + return Ok(None); + } + hops.push(Hop { depth: at as u32 + 1, from, to, edge: edge.id, rel_type: edge.rel_type, along: hop.along }); + } + let Some(start) = hops.first().map(|hop| hop.from) else { return Ok(None) }; + Ok(Some(Exploration { + starts: vec![start], + graph: collector.finish().unwrap_or_default(), + depth_reached: hops.len() as u32, + hops, + ..Exploration::default() + })) + } + + fn references(&mut self, ids: &[i64]) -> Result, QueryError> { + let found = self.nodes_by_id(ids)?; + Ok(ids + .iter() + .filter_map(|id| found.nodes.iter().find(|node| node.id == *id)) + .map(|node| RecordRef { + stable_id: node.properties.get(ID_PROPERTY).and_then(Json::as_str).map(String::from), + internal_id: node.id, + }) + .collect()) + } + + /// The node a reference names now, if any. + fn resolve(&mut self, record: &RecordRef) -> Result, QueryError> { + match &record.stable_id { + Some(stable) => { + let params = BTreeMap::from([("id".to_string(), Value::String(stable.clone()))]); + let rows = self.raw_cypher(&format!("MATCH (n {{{ID_PROPERTY}: $id}}) RETURN id(n) AS id LIMIT 1"), ¶ms)?; + Ok(rows.first().and_then(|row| row.first()).map(value_to_json).and_then(|value| value.as_i64())) + } + None => { + let found = self.nodes_by_id(&[record.internal_id])?; + Ok(found.nodes.first().map(|node| node.id)) + } + } + } +} + +/// How a saved exact path is described, as it was not found by walking. +const EXACT_PATH: &str = "the path saved with this investigation"; + +/// The walk that shows `hops`, for an exact path saved with no walk of its own. +fn plan_for(hops: &[Hop]) -> ExploreRequest { + let types: BTreeSet = hops.iter().map(|hop| hop.rel_type.clone()).collect(); + ExploreRequest { + starts: hops.first().map(|hop| hop.from).into_iter().collect(), + direction: if hops.iter().all(|hop| hop.along) { Direction::Out } else { Direction::Both }, + rel_types: types.into_iter().collect(), + max_hops: (hops.len() as u32).clamp(1, crate::explore::MAX_HOPS), + max_nodes: DEFAULT_MAX_NODES, + max_edges: DEFAULT_MAX_EDGES, + } +} + +pub(crate) fn compare(saved: &Fingerprint, now: &Fingerprint) -> (GraphState, Vec) { + let mut differences = Vec::new(); + if saved.nodes != now.nodes { + differences.push(format!("Nodes: {} when saved, {} now.", saved.nodes, now.nodes)); + } + if saved.relationships != now.relationships { + differences.push(format!("Relationships: {} when saved, {} now.", saved.relationships, now.relationships)); + } + if let (Some(saved_hash), Some(now_hash)) = (&saved.hash, &now.hash) { + let was: BTreeSet<&String> = saved.sources.iter().collect(); + let is: BTreeSet<&String> = now.sources.iter().collect(); + differences.extend(was.difference(&is).map(|source| format!("Source {source} has no records now."))); + differences.extend(is.difference(&was).map(|source| format!("Source {source} was not in the graph when saved."))); + if saved_hash != now_hash && differences.is_empty() { + differences.push("The same number of records, with different contents.".into()); + } + } + let state = if !differences.is_empty() { + GraphState::Changed + } else if saved.hash.is_some() && now.hash.is_some() { + GraphState::Unchanged + } else { + GraphState::NotCompared + }; + (state, differences) +} + +/// A hash of what the records say, whatever order they are read in. Internal +/// IDs, sync times and plugin versions are left out: they differ between +/// graphs holding the same data. +pub(crate) fn content_hash(data: &GraphData) -> (String, Vec) { + let mut sources = BTreeSet::new(); + let mut names: BTreeMap = BTreeMap::new(); + let mut lines = Vec::with_capacity(data.nodes.len() + data.edges.len()); + for node in &data.nodes { + if let Some(source) = node.properties.get(SOURCE_PROPERTY).and_then(Json::as_str) { + sources.insert(source.to_string()); + } + let line = node_line(node); + names.insert(node.id, line.clone()); + lines.push(line); + } + for edge in &data.edges { + if let Some(source) = edge.properties.get(SOURCE_PROPERTY).and_then(Json::as_str) { + sources.insert(source.to_string()); + } + let end = |id: i64| names.get(&id).map_or("?", String::as_str); + lines.push(format!("{}|{}|{}|{}", end(edge.source), edge.rel_type, end(edge.target), canonical(&edge.properties))); + } + lines.sort_unstable(); + (format!("{:016x}", evidence::fnv1a(&lines.join("\n"))), sources.into_iter().collect()) +} + +fn node_line(node: &GraphNode) -> String { + let mut labels = node.labels.clone(); + labels.sort_unstable(); + format!("{}|{}", labels.join(":"), canonical(&node.properties)) +} + +/// Properties as text with keys in order, without what changes on a re-sync. +fn canonical(properties: &Map) -> String { + fn sorted(value: &Json) -> Json { + match value { + Json::Object(map) => { + let ordered: BTreeMap<&String, Json> = map.iter().map(|(key, item)| (key, sorted(item))).collect(); + Json::Object(ordered.into_iter().map(|(key, item)| (key.clone(), item)).collect()) + } + Json::Array(items) => Json::Array(items.iter().map(sorted).collect()), + other => other.clone(), + } + } + let kept: Map = properties + .iter() + .filter(|(key, _)| key.as_str() != SYNCED_AT_PROPERTY && key.as_str() != PLUGIN_PROPERTY) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + sorted(&Json::Object(kept)).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::result::GraphEdge; + use serde_json::json; + + fn saved() -> Investigation { + Investigation { + format: FORMAT.into(), + version: VERSION, + name: "web tier".into(), + graph: PathBuf::from("graphs/shop"), + saved_at: 1000, + fingerprint: Fingerprint { nodes: 1, relationships: 0, hash: Some("abc".into()), sources: vec!["shop".into()] }, + pinned_runs: BTreeMap::new(), + query: None, + walk: None, + path: None, + dashboard: None, + } + } + + fn with(field: &str, value: Json) -> String { + let mut text = serde_json::to_value(saved()).unwrap(); + text[field] = value; + text.to_string() + } + + #[test] + fn a_file_round_trips_and_ignores_fields_it_does_not_know() { + let text = serde_json::to_string(&saved()).unwrap(); + assert_eq!(Investigation::parse(&text).unwrap(), saved()); + assert_eq!(Investigation::parse(&with("from_a_later_release", json!(1))).unwrap(), saved()); + } + + #[test] + fn a_file_it_cannot_use_says_why() { + let kind = |text: &str| Investigation::parse(text).unwrap_err().kind; + assert_eq!(kind("{not json"), "invalid"); + assert_eq!(kind("{}"), "invalid"); + assert_eq!(kind(&with("format", json!("something-else"))), "invalid"); + assert_eq!(kind(&with("version", json!("one"))), "invalid"); + assert_eq!(kind(&with("fingerprint", json!(7))), "invalid"); + let newer = Investigation::parse(&with("version", json!(VERSION + 1))).unwrap_err(); + assert_eq!(newer.kind, "too_new"); + assert!(newer.message.contains("update"), "{}", newer.message); + } + + fn data(extra: Json) -> GraphData { + let node = |id: i64, stable: &str, extra: Json| { + let mut properties = Map::from_iter([ + ("khonsu_id".to_string(), json!(stable)), + ("khonsu_source".to_string(), json!("shop")), + ("khonsu_synced_at".to_string(), json!(id * 10)), + ("khonsu_plugin".to_string(), json!("aws@0.1.0")), + ]); + properties.extend(extra.as_object().unwrap().clone()); + GraphNode { id, labels: vec!["Host".into()], properties } + }; + GraphData { + nodes: vec![node(1, "a", extra), node(2, "b", json!({}))], + edges: vec![GraphEdge { + id: 9, + source: 1, + target: 2, + rel_type: "NEXT".into(), + properties: Map::from_iter([("khonsu_id".to_string(), json!("a|b"))]), + }], + } + } + + #[test] + fn the_hash_follows_what_records_say_not_how_they_were_stored() { + let base = content_hash(&data(json!({"name": "web"}))).0; + + // Other internal IDs, times, plugin versions, property order and read order. + let mut moved = data(json!({"name": "web"})); + moved.nodes.reverse(); + for node in &mut moved.nodes { + node.id += 100; + node.properties.insert("khonsu_synced_at".into(), json!(99_999)); + node.properties.insert("khonsu_plugin".into(), json!("aws@9.9.9")); + } + moved.edges[0].source += 100; + moved.edges[0].target += 100; + assert_eq!(content_hash(&moved).0, base); + + // A changed value, a new property, a changed label and a reversed relationship. + assert_ne!(content_hash(&data(json!({"name": "db"}))).0, base); + assert_ne!(content_hash(&data(json!({"name": "web", "port": 80}))).0, base); + let mut relabelled = data(json!({"name": "web"})); + relabelled.nodes[0].labels = vec!["Other".into()]; + assert_ne!(content_hash(&relabelled).0, base); + let mut reversed = data(json!({"name": "web"})); + let edge = &mut reversed.edges[0]; + std::mem::swap(&mut edge.source, &mut edge.target); + assert_ne!(content_hash(&reversed).0, base); + + assert_eq!(content_hash(&data(json!({}))).1, ["shop"]); + } + + fn print(nodes: u64, hash: Option<&str>, sources: &[&str]) -> Fingerprint { + Fingerprint { + nodes, + relationships: 2, + hash: hash.map(String::from), + sources: sources.iter().map(|source| source.to_string()).collect(), + } + } + + #[test] + fn comparing_says_what_it_can_and_never_calls_an_uncompared_graph_unchanged() { + let a = print(5, Some("x"), &["shop"]); + assert_eq!(compare(&a, &a), (GraphState::Unchanged, vec![])); + + let (state, differences) = compare(&a, &print(5, Some("y"), &["shop"])); + assert_eq!(state, GraphState::Changed); + assert_eq!(differences, ["The same number of records, with different contents."]); + + let (state, differences) = compare(&a, &print(6, Some("y"), &["other"])); + assert_eq!(state, GraphState::Changed); + assert_eq!(differences.len(), 3, "{differences:?}"); + + // Too large to hash: equal counts prove nothing, different ones do. + assert_eq!(compare(&print(5, None, &[]), &print(5, None, &[])).0, GraphState::NotCompared); + assert_eq!(compare(&a, &print(5, None, &[])).0, GraphState::NotCompared); + assert_eq!(compare(&print(5, None, &[]), &print(6, None, &[])).0, GraphState::Changed); + } + + #[test] + fn saving_keeps_an_existing_file_unless_told_otherwise() { + let base = std::env::var_os("KHONSU_TEST_DIR").map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap()); + std::fs::create_dir_all(&base).unwrap(); + let dir = tempfile::tempdir_in(base).unwrap(); + let path = dir.path().join("web.investigation"); + saved().save(&path, false).unwrap(); + assert_eq!(Investigation::load(&path).unwrap(), saved()); + + let mut other = saved(); + other.name = "another".into(); + assert_eq!(other.save(&path, false).unwrap_err().kind, "exists"); + assert_eq!(Investigation::load(&path).unwrap().name, "web tier"); + other.save(&path, true).unwrap(); + assert_eq!(Investigation::load(&path).unwrap().name, "another"); + assert!(!dir.path().join("web.investigation.tmp").exists()); + + assert_eq!(Investigation::load(&dir.path().join("none")).unwrap_err().kind, "not_found"); + } +} diff --git a/crates/khonsu-core/src/lib.rs b/crates/khonsu-core/src/lib.rs index adf31d5..f113982 100644 --- a/crates/khonsu-core/src/lib.rs +++ b/crates/khonsu-core/src/lib.rs @@ -5,14 +5,19 @@ pub mod analysis; mod cypher_columns; pub mod engine; +pub mod evidence; +pub mod explore; +pub mod investigation; pub mod lock; pub mod merge; pub mod plugin; +pub mod plugin_analysis; pub mod rebuild; pub mod result; pub mod schema; pub mod secrets; pub mod settings; +pub mod snapshot; pub mod util; pub use engine::{normalize_path, AccessMode, Graph, Language, OpenError, QueryError, QueryOptions}; diff --git a/crates/khonsu-core/src/merge.rs b/crates/khonsu-core/src/merge.rs index cbaec09..442fd9f 100644 --- a/crates/khonsu-core/src/merge.rs +++ b/crates/khonsu-core/src/merge.rs @@ -484,6 +484,20 @@ impl<'g> Merger<'g> { } } +/// The `khonsu_synced_at` for a sync starting at `now`: later than any this +/// source has written. A full sync removes nodes stamped before its own, and +/// stamps are whole seconds, so a sync sharing a stamp with the last one would +/// keep nodes that have gone, while removing their relationships. +pub fn next_sync_stamp(graph: &mut Graph, source: &str, now: i64) -> Result { + let params = BTreeMap::from([("source".to_string(), Value::String(source.to_string()))]); + let query = format!("MATCH (n {{{SOURCE_PROPERTY}: $source}}) RETURN max(n.{SYNCED_AT_PROPERTY}) AS last"); + let rows = graph.raw_cypher(&query, ¶ms)?; + Ok(match rows.first().and_then(|row| row.first()) { + Some(Value::Int(last)) => now.max(last + 1), + _ => now, + }) +} + /// HawDB 0.8 accepts only plain identifiers as property names in `SET`. /// Other characters become `_`; provenance names are reserved. pub fn property_key(key: &str) -> Option { diff --git a/crates/khonsu-core/src/plugin.rs b/crates/khonsu-core/src/plugin.rs index 0ef2c3a..348666f 100644 --- a/crates/khonsu-core/src/plugin.rs +++ b/crates/khonsu-core/src/plugin.rs @@ -7,8 +7,8 @@ //! running executable (`plugins//` or a bare `khonsu-plugin-` binary). use crate::engine::{Graph, QueryError}; -use crate::merge::{EdgePattern, MergeStats, Merger}; -use crate::settings::{self, LastSync, Source}; +use crate::merge::{next_sync_stamp, EdgePattern, MergeStats, Merger}; +use crate::settings::{self, LastSync, RunStatus, Source, SyncRun}; use crate::{secrets, util}; use khonsu_plugin_protocol::{ parse_line, Describe, EdgeRecord, Manifest, Message, NodeRecord, Permissions, Request, SyncMode, MANIFEST_FILE, @@ -457,13 +457,22 @@ pub enum SyncEvent { /// Runs `sync` and hands each record to `on_record`. Terminates the plugin /// when `cancel` is set. Returns the reported cursor, if any. +/// How a plugin finished a sync it did finish. +#[derive(Debug, Clone, PartialEq)] +pub struct SyncEnd { + pub cursor: Option, + /// False when the plugin said part of the source was not enumerated. + pub complete: bool, + pub gaps: Vec, +} + pub fn run_sync( plugin: &PluginInfo, request: &Request, cancel: &AtomicBool, mut on_event: impl FnMut(SyncEvent), mut on_record: impl FnMut(Record) -> Result<(), PluginError>, -) -> Result, PluginError> { +) -> Result { let mut child = spawn(plugin, "sync", request)?; let stderr = drain_stderr(&mut child); let stdout = child.stdout.take().expect("stdout is piped"); @@ -480,6 +489,7 @@ pub fn run_sync( let mut cursor = None; let mut done = false; + let (mut complete, mut gaps) = (true, Vec::new()); let (mut nodes, mut edges) = (0u64, 0u64); let result = loop { if cancel.load(Ordering::Relaxed) { @@ -520,15 +530,17 @@ pub fn run_sync( cursor = Some(value); Ok(()) } - Message::Done { .. } => { + Message::Done { complete: whole, gaps: missing, .. } => { done = true; + complete = whole; + gaps = missing; Ok(()) } Message::Error { message } => { let _ = child.kill(); break Err(failure(plugin, message, &stderr)); } - Message::Describe(_) | Message::TestResult { .. } => Ok(()), + Message::Describe(_) | Message::TestResult { .. } | Message::Finding(_) | Message::AnalysisDone { .. } => Ok(()), }; if let Err(err) = step { let _ = child.kill(); @@ -548,7 +560,7 @@ pub fn run_sync( )); } on_event(SyncEvent::Counts { nodes, edges }); - Ok(cursor) + Ok(SyncEnd { cursor, complete, gaps }) } #[derive(Debug, Clone)] @@ -602,6 +614,8 @@ pub struct SyncReport { pub received_edges: u64, pub merge: MergeStats, pub elapsed_ms: u64, + /// This sync as kept on the source. `status` says whether it was complete. + pub run: SyncRun, } /// Builds the request a source sends to its plugin, secrets included. @@ -618,6 +632,99 @@ pub fn request_for(source: &Source, mode: SyncMode) -> Result Result<(), PluginError> { + if !plugin.builtin && !settings::is_approved(plugin.id(), &plugin.manifest.version, &plugin.manifest.permissions) { + return Err(PluginError::NotApproved { + plugin: plugin.id().to_string(), + permissions: plugin.manifest.permissions.clone(), + }); + } + Ok(()) +} + +/// How an `analyze` ended. +pub(crate) struct AnalysisEnd { + pub findings: Vec, + pub subjects: u64, +} + +/// Runs `analyze`, sending `request` on a thread so that a plugin that writes +/// before it reads cannot wedge the host. The plugin is ended when `cancel` is set. +pub(crate) fn run_analyze( + plugin: &PluginInfo, + request: &khonsu_plugin_protocol::AnalyzeRequest, + cancel: &AtomicBool, + mut on_event: impl FnMut(SyncEvent), +) -> Result { + let mut line = serde_json::to_vec(request).map_err(std::io::Error::other)?; + line.push(b'\n'); + let mut child = plugin + .command() + .arg("analyze") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| PluginError::Failed { + plugin: plugin.id().to_string(), + message: format!("cannot start {}: {e}", plugin.executable().display()), + })?; + let mut stdin = child.stdin.take().expect("stdin is piped"); + thread::spawn(move || { + // A plugin that exits without reading is reported by what follows. + let _ = stdin.write_all(&line); + }); + let stderr = drain_stderr(&mut child); + let stdout = child.stdout.take().expect("stdout is piped"); + let (tx, rx) = std::sync::mpsc::sync_channel::(1024); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + + let mut findings = Vec::new(); + let mut subjects = None; + let result = loop { + if cancel.load(Ordering::Relaxed) { + let _ = child.kill(); + break Err(PluginError::Cancelled); + } + let line = match rx.recv_timeout(std::time::Duration::from_millis(100)) { + Ok(line) => line, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break Ok(()), + }; + match parse_line(&line) { + None => {} + Some(Err(err)) => { + let _ = child.kill(); + let shown: String = line.chars().take(200).collect(); + break Err(failure(plugin, format!("invalid output line: {err}: {shown}"), &stderr)); + } + Some(Ok(Message::Finding(finding))) => findings.push(*finding), + Some(Ok(Message::AnalysisDone { subjects: seen })) => subjects = Some(seen), + Some(Ok(Message::Progress { message, current, total })) => on_event(SyncEvent::Progress { message, current, total }), + Some(Ok(Message::Log { level, message })) => on_event(SyncEvent::Log { level, message }), + Some(Ok(Message::Error { message })) => { + let _ = child.kill(); + break Err(failure(plugin, message, &stderr)); + } + Some(Ok(_)) => {} + } + }; + let status = child.wait()?; + result?; + let Some(subjects) = subjects else { + return Err(failure(plugin, format!("plugin exited ({status}) without finishing the analysis"), &stderr)); + }; + Ok(AnalysisEnd { findings, subjects }) +} + /// Syncs a configured source into `graph`, then records the outcome in the /// source's settings. The plugin's declared permissions must be approved. pub fn sync_source( @@ -634,19 +741,23 @@ pub fn sync_source( } else { mode }; - if !plugin.builtin && !settings::is_approved(plugin.id(), &plugin.manifest.version, &plugin.manifest.permissions) { - return Err(PluginError::NotApproved { - plugin: plugin.id().to_string(), - permissions: plugin.manifest.permissions.clone(), - }); - } + require_approved(&plugin)?; let request = request_for(source, mode)?; let started = std::time::Instant::now(); - let synced_at = util::unix_now() as i64; + let synced_at = next_sync_stamp(graph, &source.id, util::unix_now() as i64)?; let plugin_tag = format!("{}@{}", plugin.id(), describe.version); let mut merger = Merger::new(graph, &source.id, &plugin_tag, synced_at); let (mut received_nodes, mut received_edges) = (0, 0); - let outcome = run_sync(&plugin, &request, cancel, on_event, |record| { + let secret_values = request.secrets.clone(); + let mut on_event = on_event; + let scrubbed_events = |event: SyncEvent| { + on_event(match event { + SyncEvent::Progress { message, current, total } => SyncEvent::Progress { message: scrub(&message, &secret_values), current, total }, + SyncEvent::Log { level, message } => SyncEvent::Log { level, message: scrub(&message, &secret_values) }, + other => other, + }); + }; + let outcome = run_sync(&plugin, &request, cancel, scrubbed_events, |record| { match record { Record::Node(node) => { received_nodes += 1; @@ -659,17 +770,39 @@ pub fn sync_source( } Ok(()) }) - .and_then(|cursor| { - let (merge, patterns) = merger.finish(mode == SyncMode::Full, &source.patterns)?; - Ok((cursor, merge, patterns)) + .and_then(|end| { + // Only a source enumerated whole can say that something has gone. + let (merge, patterns) = merger.finish(mode == SyncMode::Full && end.complete, &source.patterns)?; + Ok((end, merge, patterns)) }); + let number = source.last_run().map_or(1, |run| run.number + 1); + let run = |status: RunStatus, removals_applied: bool, cursor_advanced: bool, gaps: Vec, message: String| SyncRun { + run_id: format!("{}#{number}", source.id), + number, + mode, + started_at: synced_at as u64, + ended_at: util::unix_now(), + status, + plugin: plugin_tag.clone(), + received_nodes, + received_edges, + removals_applied, + cursor_advanced, + gaps, + message, + }; let mut updated = source.clone(); let report = match outcome { - Ok((cursor, merge, patterns)) => { - if cursor.is_some() { - updated.cursor = cursor; + Ok((end, merge, patterns)) => { + // A partial sync may have missed changes before its cursor, so the + // next one starts from the same place. + let advanced = end.complete && end.cursor.is_some(); + if advanced { + updated.cursor = end.cursor; } + let gaps: Vec = end.gaps.iter().map(|gap| scrub(gap, &request.secrets)).collect(); + let status = if end.complete { RunStatus::Complete } else { RunStatus::Partial }; let mut all: Vec = updated.patterns.clone(); all.extend(patterns); all.sort(); @@ -677,11 +810,13 @@ pub fn sync_source( updated.patterns = all; updated.last_sync = Some(LastSync { at: synced_at as u64, - ok: true, + ok: end.complete, nodes: merge.nodes_written, edges: merge.edges_written, - message: String::new(), + message: gaps.join("; "), }); + let this_run = run(status, mode == SyncMode::Full && end.complete, advanced, gaps, String::new()); + updated.push_run(this_run.clone()); Ok(SyncReport { source: source.id.clone(), mode, @@ -689,16 +824,20 @@ pub fn sync_source( received_edges, merge, elapsed_ms: started.elapsed().as_millis() as u64, + run: this_run, }) } Err(err) => { + let message = scrub(&err.to_string(), &request.secrets); updated.last_sync = Some(LastSync { at: synced_at as u64, ok: false, nodes: 0, edges: 0, - message: err.to_string(), + message: message.clone(), }); + let status = if matches!(err, PluginError::Cancelled) { RunStatus::Cancelled } else { RunStatus::Failed }; + updated.push_run(run(status, false, false, Vec::new(), message)); Err(err) } }; @@ -706,10 +845,27 @@ pub fn sync_source( report } +/// `text` with any secret value this source holds masked: what a plugin says +/// is kept in settings and shown to people. +fn scrub(text: &str, secrets: &serde_json::Map) -> String { + secrets + .values() + .filter_map(serde_json::Value::as_str) + .filter(|secret| !secret.is_empty()) + .fold(text.to_string(), |text, secret| text.replace(secret, "[redacted]")) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_secret_a_plugin_repeats_is_masked_before_it_is_kept() { + let secrets = serde_json::json!({"secret_access_key": "abc123", "empty": ""}).as_object().unwrap().clone(); + assert_eq!(scrub("denied for abc123 and abc123", &secrets), "denied for [redacted] and [redacted]"); + assert_eq!(scrub("nothing here", &secrets), "nothing here", "an empty secret must not match everywhere"); + } + fn plugin_with_entry(entry: &str) -> PluginInfo { PluginInfo { manifest: Manifest { diff --git a/crates/khonsu-core/src/plugin_analysis.rs b/crates/khonsu-core/src/plugin_analysis.rs new file mode 100644 index 0000000..2b8e9ee --- /dev/null +++ b/crates/khonsu-core/src/plugin_analysis.rs @@ -0,0 +1,784 @@ +//! Analyses that plugins offer over a graph. +//! +//! The boundary: a plugin owns what its findings mean, and the host owns the +//! graph. The host reads a bounded part of the graph, sends it with stable +//! references to each record, and checks what comes back. A field's value, a +//! path's records and a finding's evidence are all rebuilt here from the graph, +//! so a plugin can only point at records, not describe them. Nothing here knows +//! any provider's labels, rules or verdicts. See `docs/plugins.md`. + +use crate::engine::{Graph, QueryError}; +use crate::evidence::{self, Evidence, Field, PathEvidence, SourceCoverage}; +use crate::merge::SOURCE_PROPERTY; +use crate::explore::{is_identifier, node_name, Direction, Exploration, Hop}; +use crate::plugin::{self, PluginError, PluginInfo, SyncEvent}; +use crate::protocol::{ + AnalysisEdge, AnalysisInfo, AnalysisNode, AnalyzeRequest, ConditionStatus, FindingRecord, HopRef, RuleInfo, VerdictInfo, + PROTOCOL_VERSION, +}; +use crate::result::{value_to_json, GraphCollector, GraphData, GraphEdge, GraphNode}; +use crate::settings::{RunStatus, Source}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value as Json}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::AtomicBool; + +/// Findings kept in a report unless asked otherwise; the rest are counted. +pub const DEFAULT_MAX_FINDINGS: usize = 200; +/// Records read of each label or type. A larger graph gives an incomplete report. +const MAX_RECORDS: usize = 50_000; + +/// An exact field a condition read, with its value as the graph holds it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SourceField { + /// The evidence ID of the record that holds it. + pub record: String, + pub field: String, + /// What the source calls it. + pub source_field: String, + pub value: Field, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Condition { + pub id: String, + pub statement: String, + pub status: ConditionStatus, + /// What was found, in words. + pub detail: String, + pub fields: Vec, +} + +/// The `khonsu explore` walk that shows a finding's records in Path Explorer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WalkHint { + /// A `khonsu_id`, or an internal ID when the record has none. + pub from: String, + pub to: String, + pub direction: Direction, + pub types: Vec, + pub max_hops: u32, +} + +/// A finding checked against the graph, with its records looked up. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Finding { + pub finding_id: String, + pub rule_id: String, + pub verdict: String, + /// The evidence ID of the resource it is about, and what people call it. + pub resource: String, + pub resource_name: String, + pub conditions: Vec, + pub unknowns: Vec, + pub assumptions: Vec, + /// The candidate path, when the records for one exist. Its `rule_id` is known. + pub path: Option, + /// The same path as stable references, which is how it crosses between + /// processes and into a saved file. + #[serde(skip_serializing_if = "Option::is_none")] + pub path_refs: Option>, + /// That path as hops over this graph's internal IDs. + #[serde(skip)] + pub hops: Vec, + /// The records those hops cross, for drawing the path. + #[serde(skip)] + pub path_graph: GraphData, + pub walk: Option, + /// The finding this one rests on. + #[serde(skip_serializing_if = "Option::is_none")] + pub depends_on: Option, + /// Sources behind its records whose last sync was not complete. Worked out + /// here, so it shows whatever the plugin made of it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub incomplete_sources: Vec, + /// The evidence IDs of every record the conditions read or the path crosses. + pub contributing: Vec, + pub evidence: Vec, +} + +impl Finding { + /// The candidate path as a walk, for Path Explorer to show. + pub fn exploration(&self) -> Option { + let start = self.hops.first()?.from; + Some(Exploration { + starts: vec![start], + graph: self.path_graph.clone(), + hops: self.hops.clone(), + depth_reached: self.hops.len() as u32, + ..Exploration::default() + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Report { + /// The plugin and analysis that produced this. + pub plugin: String, + pub analysis: String, + pub title: String, + pub claim: String, + pub order: String, + pub verdicts: Vec, + pub rules: Vec, + /// Resources looked at. + pub subjects: u64, + /// Findings by verdict, counting any cut by a cap. + pub summary: BTreeMap, + /// False when the graph was too large to read whole, or a source's last + /// sync was not complete: a missing finding then means nothing. + pub complete: bool, + /// How much of each source its last sync covered. + pub sources: BTreeMap>, + pub findings: Vec, +} + +impl Report { + /// Keeps the findings `keep` accepts, up to `max`, and counts what it cut. + pub fn narrowed(mut self, keep: impl Fn(&Finding) -> bool, max: usize) -> (Self, usize) { + self.findings.retain(|finding| keep(finding)); + let cut = self.findings.len().saturating_sub(max); + self.findings.truncate(max); + (self, cut) + } +} + +/// An analysis a plugin offers, with the plugin that runs it. +#[derive(Debug, Clone, PartialEq)] +pub struct Offered { + pub plugin: PluginInfo, + pub info: AnalysisInfo, +} + +impl Offered { + /// Whether this analysis can say anything about a node with these labels. + pub fn applies_to(&self, labels: &[String]) -> bool { + labels.iter().any(|label| self.info.subject_labels.contains(label)) + } +} + +/// Every analysis on offer from plugins that may run. A plugin not approved as +/// it is now is not started just to ask, and one that fails to describe itself +/// offers nothing. +pub fn offered() -> Vec { + plugin::list() + .into_iter() + .filter(|plugin| plugin::require_approved(plugin).is_ok()) + .flat_map(|plugin| { + let analyses = plugin::describe(&plugin).map(|describe| describe.analyses).unwrap_or_default(); + analyses.into_iter().filter(|info| check_info(info).is_ok()).map(move |info| Offered { plugin: plugin.clone(), info }) + }) + .collect() +} + +/// Finds one analysis of a plugin, checking the plugin may run first. +pub fn find(plugin_id: &str, analysis: &str) -> Result { + let plugin = plugin::find(plugin_id)?; + plugin::require_approved(&plugin)?; + let analyses = plugin::describe(&plugin)?.analyses; + let names: Vec<&str> = analyses.iter().map(|info| info.id.as_str()).collect(); + let Some(info) = analyses.iter().find(|info| info.id == analysis) else { + let offers = if names.is_empty() { "offers no analyses".to_string() } else { format!("offers {}", names.join(", ")) }; + return Err(PluginError::Other(format!("plugin {plugin_id} has no analysis {analysis:?}: it {offers}"))); + }; + check_info(info).map_err(|message| PluginError::Failed { plugin: plugin_id.to_string(), message })?; + Ok(Offered { info: info.clone(), plugin }) +} + +/// A label or type goes into query text, so it must be a plain name. +fn check_info(info: &AnalysisInfo) -> Result<(), String> { + let names = info.node_labels.iter().chain(&info.relationship_types); + if let Some(bad) = names.into_iter().find(|name| !is_identifier(name)) { + return Err(format!("analysis {} asks to read {bad:?}, which is not a label or type name", info.id)); + } + if info.verdicts.is_empty() { + return Err(format!("analysis {} declares no verdicts", info.id)); + } + Ok(()) +} + +/// What an analysis was given to read. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AnalysisInput { + pub data: GraphData, + /// False when more was there than was read. + pub complete: bool, + /// The graph it came from. + pub graph: std::path::PathBuf, +} + +impl Graph { + /// Reads what `info` asks for, up to the cap. This holds the graph, and + /// running the plugin afterwards does not. + pub fn read_analysis_input(&mut self, info: &AnalysisInfo) -> Result { + let params = BTreeMap::new(); + let mut collector = GraphCollector::default(); + let mut complete = true; + let mut read = |collector: &mut GraphCollector, text: String| -> Result { + let rows = self.raw_cypher(&text, ¶ms)?; + let whole = rows.len() <= MAX_RECORDS; + for row in rows.iter().take(MAX_RECORDS) { + row.iter().for_each(|value| collector.visit(&value_to_json(value))); + } + Ok(whole) + }; + let limit = MAX_RECORDS + 1; + for label in &info.node_labels { + complete &= read(&mut collector, format!("MATCH (n:{label}) RETURN n ORDER BY id(n) LIMIT {limit}"))?; + } + for name in &info.relationship_types { + complete &= read(&mut collector, format!("MATCH (a)-[r:{name}]->(b) RETURN a, r, b ORDER BY id(r) LIMIT {limit}"))?; + } + Ok(AnalysisInput { data: collector.finish().unwrap_or_default(), complete, graph: self.path().to_path_buf() }) + } +} + +/// A last sync known to have missed something. Unknown is not counted: a source +/// synced before runs were kept says nothing either way. +fn is_incomplete(coverage: &Field) -> bool { + coverage.known().is_some_and(|coverage| coverage.status != RunStatus::Complete) +} + +/// How much of each source behind `input` its last sync covered. That includes a +/// source of this graph with nothing in it: its sync can have failed before +/// writing anything. +fn coverage_of_sources(input: &AnalysisInput, sources: &[Source]) -> BTreeMap> { + let properties = input.data.nodes.iter().map(|node| &node.properties).chain(input.data.edges.iter().map(|edge| &edge.properties)); + let named: BTreeSet<&str> = properties.filter_map(|properties| properties.get(SOURCE_PROPERTY).and_then(Json::as_str)).collect(); + let here = crate::engine::normalize_path(&input.graph); + let of_this_graph = sources.iter().filter(|source| crate::engine::normalize_path(&source.graph) == here).map(|source| source.id.as_str()); + named.into_iter().chain(of_this_graph).map(|source| (source.to_string(), evidence::coverage_of(source, sources))).collect() +} + +fn source_of(properties: &serde_json::Map) -> Option { + properties.get(SOURCE_PROPERTY).and_then(Json::as_str).map(String::from) +} + +/// The request for `input`. Record properties have bookkeeping left out and +/// secrets masked, as they are in evidence. +fn request_for(info: &AnalysisInfo, input: &AnalysisInput, sources: &[Source]) -> AnalyzeRequest { + let refs: BTreeMap = input.data.nodes.iter().map(|node| (node.id, evidence::node_evidence_id(node))).collect(); + let nodes = input.data.nodes.iter().map(|node| AnalysisNode { + reference: evidence::node_evidence_id(node), + labels: node.labels.clone(), + name: node_name(node), + source: source_of(&node.properties), + properties: evidence::own_properties(&node.properties).0, + }); + let edges = input.data.edges.iter().filter_map(|edge| { + Some(AnalysisEdge { + reference: evidence::edge_evidence_id(edge), + from: refs.get(&edge.source)?.clone(), + to: refs.get(&edge.target)?.clone(), + rel_type: edge.rel_type.clone(), + source: source_of(&edge.properties), + properties: evidence::own_properties(&edge.properties).0, + }) + }); + AnalyzeRequest { + protocol: PROTOCOL_VERSION, + analysis: info.id.clone(), + nodes: nodes.collect(), + edges: edges.collect(), + complete: input.complete, + coverage: coverage_of_sources(input, sources) + .into_iter() + .filter_map(|(source, coverage)| { + let coverage = coverage.known()?; + Some((source, crate::protocol::SourceCoverage { run_id: coverage.run_id.clone(), status: coverage.status.word().to_string(), gaps: coverage.gaps.clone() })) + }) + .collect(), + } +} + +/// Runs an analysis over what was read and checks the result. It holds no graph, +/// so a long analysis does not keep the window from using it. +pub fn run(offered: &Offered, input: &AnalysisInput, sources: &[Source], cancel: &AtomicBool, on_event: impl FnMut(SyncEvent)) -> Result { + let request = request_for(&offered.info, input, sources); + let end = plugin::run_analyze(&offered.plugin, &request, cancel, on_event)?; + report_from(offered, input, sources, end.findings, end.subjects).map_err(|message| PluginError::Failed { + plugin: offered.plugin.id().to_string(), + message: format!("its analysis was refused: {message}"), + }) +} + +/// Reads the graph and runs an analysis, for a caller that may hold it throughout. +pub fn analyze(graph: &mut Graph, offered: &Offered, sources: &[Source], cancel: &AtomicBool) -> Result { + let input = graph.read_analysis_input(&offered.info)?; + run(offered, &input, sources, cancel, |_| {}) +} + +/// Stable lookups over what the plugin was sent. +struct Records<'a> { + data: &'a GraphData, + nodes: BTreeMap, + edges: BTreeMap, + by_id: BTreeMap, +} + +impl<'a> Records<'a> { + fn new(data: &'a GraphData) -> Self { + Self { + data, + nodes: data.nodes.iter().map(|node| (evidence::node_evidence_id(node), node)).collect(), + edges: data.edges.iter().map(|edge| (evidence::edge_evidence_id(edge), edge)).collect(), + by_id: data.nodes.iter().map(|node| (node.id, node)).collect(), + } + } + + fn has(&self, reference: &str) -> bool { + self.nodes.contains_key(reference) || self.edges.contains_key(reference) + } + + /// A field as the graph holds it, whatever the plugin thought it was. + fn value(&self, reference: &str, field: &str) -> Field { + if let Some(edge) = self.edges.get(reference) { + let end = match field { + "@from" => Some(edge.source), + "@to" => Some(edge.target), + _ => None, + }; + if let Some(end) = end { + return self.by_id.get(&end).map(|node| json!(evidence::node_evidence_id(node))).into(); + } + return evidence::own_properties(&edge.properties).0.get(field).cloned().into(); + } + self.nodes.get(reference).and_then(|node| evidence::own_properties(&node.properties).0.get(field).cloned()).into() + } + + fn evidence(&self, reference: &str, sources: &[Source]) -> Option { + if let Some(node) = self.nodes.get(reference) { + return Some(evidence::for_node(node, sources)); + } + let edge = self.edges.get(reference)?; + Some(evidence::for_edge(edge, &self.data.nodes, sources)) + } + + /// References as hops over internal IDs. A hop must start where the last one + /// ended, so a plugin cannot claim a connection the graph does not have. + fn hops(&self, refs: &[HopRef]) -> Result, String> { + let mut hops: Vec = Vec::with_capacity(refs.len()); + for (at, hop) in refs.iter().enumerate() { + let edge = self.edges.get(&hop.edge).ok_or_else(|| format!("hop {} crosses {}, which it was not given", at + 1, hop.edge))?; + let (from, to) = if hop.along { (edge.source, edge.target) } else { (edge.target, edge.source) }; + if hops.last().is_some_and(|last| last.to != from) { + return Err(format!("hop {} crosses {}, which does not start where the hop before it ended", at + 1, hop.edge)); + } + hops.push(Hop { depth: at as u32 + 1, from, to, edge: edge.id, rel_type: edge.rel_type.clone(), along: hop.along }); + } + Ok(hops) + } + + /// The records `hops` cross, for drawing them and nothing else. + fn path_graph(&self, hops: &[Hop]) -> GraphData { + let nodes: BTreeSet = hops.iter().flat_map(|hop| [hop.from, hop.to]).collect(); + let edges: BTreeSet = hops.iter().map(|hop| hop.edge).collect(); + GraphData { + nodes: nodes.into_iter().filter_map(|id| self.by_id.get(&id).map(|node| (*node).clone())).collect(), + edges: self.data.edges.iter().filter(|edge| edges.contains(&edge.id)).cloned().collect(), + } + } + + /// How to show `hops` in Path Explorer. + fn walk_hint(&self, hops: &[Hop]) -> Option { + let name = |id: i64| { + let node = self.by_id.get(&id)?; + let reference = evidence::node_evidence_id(node); + Some(reference.strip_prefix("node:").map_or_else(|| node.id.to_string(), str::to_string)) + }; + let types: BTreeSet = hops.iter().map(|hop| hop.rel_type.clone()).collect(); + Some(WalkHint { + from: name(hops.first()?.from)?, + to: name(hops.last()?.to)?, + direction: if hops.iter().all(|hop| hop.along) { Direction::Out } else { Direction::Both }, + types: types.into_iter().collect(), + max_hops: hops.len() as u32, + }) + } +} + +/// Checks a plugin's findings against the records it was sent and looks up +/// everything they point at. One bad finding refuses the whole result: dropping +/// it would read as that resource having no finding. +fn report_from(offered: &Offered, input: &AnalysisInput, sources: &[Source], records: Vec, subjects: u64) -> Result { + let info = &offered.info; + let lookup = Records::new(&input.data); + let rank: BTreeMap<&str, usize> = info.verdicts.iter().enumerate().map(|(at, verdict)| (verdict.id.as_str(), at)).collect(); + let ids: BTreeSet<&str> = records.iter().map(|record| record.finding_id.as_str()).collect(); + if ids.len() != records.len() { + return Err("two findings have the same ID".into()); + } + let mut findings = Vec::with_capacity(records.len()); + for record in &records { + findings.push(check_finding(info, &lookup, sources, record, &rank, &ids).map_err(|message| format!("finding {}: {message}", record.finding_id))?); + } + findings.sort_by(|a, b| { + let key = |finding: &'_ Finding| rank.get(finding.verdict.as_str()).copied(); + (key(a), &a.rule_id, &a.resource, &a.finding_id).cmp(&(key(b), &b.rule_id, &b.resource, &b.finding_id)) + }); + let summary = info + .verdicts + .iter() + .map(|verdict| (verdict.id.clone(), findings.iter().filter(|finding| finding.verdict == verdict.id).count())) + .collect(); + let names: Vec<&str> = info.verdicts.iter().map(|verdict| verdict.id.as_str()).collect(); + let coverage = coverage_of_sources(input, sources); + let complete = input.complete && coverage.values().all(|coverage| !is_incomplete(coverage)); + Ok(Report { + plugin: offered.plugin.id().to_string(), + analysis: info.id.clone(), + title: info.title.clone(), + claim: info.claim.clone(), + order: format!("By verdict ({}), then rule ID, then resource ID.", names.join(", ")), + verdicts: info.verdicts.clone(), + rules: info.rules.clone(), + subjects, + summary, + complete, + sources: coverage, + findings, + }) +} + +fn check_finding(info: &AnalysisInfo, lookup: &Records, sources: &[Source], record: &FindingRecord, rank: &BTreeMap<&str, usize>, ids: &BTreeSet<&str>) -> Result { + if !info.rules.iter().any(|rule| rule.rule_id == record.rule_id) { + return Err(format!("rule {:?} is not one the analysis declares", record.rule_id)); + } + if !rank.contains_key(record.verdict.as_str()) { + return Err(format!("verdict {:?} is not one the analysis declares", record.verdict)); + } + let Some(resource) = lookup.nodes.get(&record.resource) else { + return Err(format!("resource {} is not a node it was given", record.resource)); + }; + if let Some(parent) = &record.depends_on { + if !ids.contains(parent.as_str()) { + return Err(format!("it depends on {parent}, which is not in the result")); + } + } + + let mut cited: BTreeSet = record.records.iter().cloned().collect(); + let mut conditions = Vec::with_capacity(record.conditions.len()); + for condition in &record.conditions { + let mut fields = Vec::with_capacity(condition.fields.len()); + for field in &condition.fields { + cited.insert(field.record.clone()); + fields.push(SourceField { + record: field.record.clone(), + field: field.field.clone(), + source_field: field.source_field.clone(), + value: lookup.value(&field.record, &field.field), + }); + } + conditions.push(Condition { + id: condition.id.clone(), + statement: condition.statement.clone(), + status: condition.status, + detail: condition.detail.clone(), + fields, + }); + } + cited.insert(record.resource.clone()); + if let Some(missing) = cited.iter().find(|reference| !lookup.has(reference)) { + return Err(format!("it cites {missing}, which it was not given")); + } + + let refs = record.path.as_ref().map(|path| path.hops.as_slice()).unwrap_or_default(); + let hops = lookup.hops(refs)?; + if let (Some(first), Some(last)) = (hops.first(), hops.last()) { + if first.from != resource.id && last.to != resource.id { + return Err(format!("its path neither starts nor ends at {}", record.resource)); + } + } + let path: Option = record + .path + .as_ref() + .filter(|_| !hops.is_empty()) + .map(|path| evidence::for_hops(&hops, lookup.data, sources, &path.derivation, Field::Known(record.rule_id.clone()))); + + // In the path's order, then the rest by ID. + let mut contributing: Vec = path.as_ref().map(|path| path.supporting.clone()).unwrap_or_default(); + let rest: Vec = cited.into_iter().filter(|id| !contributing.contains(id)).collect(); + contributing.extend(rest); + let records: Vec = contributing.iter().filter_map(|id| lookup.evidence(id, sources)).collect(); + let behind: BTreeSet<&str> = records.iter().filter_map(|record| record.source.known().map(String::as_str)).collect(); + let incomplete_sources = behind.into_iter().filter(|source| is_incomplete(&evidence::coverage_of(source, sources))).map(String::from).collect(); + + Ok(Finding { + finding_id: record.finding_id.clone(), + rule_id: record.rule_id.clone(), + verdict: record.verdict.clone(), + resource: record.resource.clone(), + resource_name: node_name(resource), + conditions, + unknowns: record.unknowns.clone(), + assumptions: record.assumptions.clone(), + path, + path_refs: (!refs.is_empty()).then(|| refs.to_vec()), + walk: lookup.walk_hint(&hops), + path_graph: lookup.path_graph(&hops), + hops, + depends_on: record.depends_on.clone(), + incomplete_sources, + contributing, + evidence: records, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{ConditionRecord, FieldRef, Manifest, PathRef, VerdictTone}; + use serde_json::{Map, Value}; + use std::path::PathBuf; + + fn props(id: &str, extra: Value) -> Map { + let mut map = Map::from_iter([("khonsu_id".to_string(), json!(id))]); + map.extend(extra.as_object().unwrap().clone()); + map + } + + /// `a -[e1]-> b -[e2]-> c`, in terms no provider uses. + fn input() -> AnalysisInput { + let node = |id: i64, name: &str| GraphNode { id, labels: vec!["Thing".into()], properties: props(name, json!({"name": name, "color": "red", "api_token": "abc123"})) }; + let edge = |id: i64, source: i64, target: i64, name: &str| GraphEdge { id, source, target, rel_type: "LINKS".into(), properties: props(name, json!({"weight": 3})) }; + AnalysisInput { + data: GraphData { nodes: vec![node(1, "a"), node(2, "b"), node(3, "c")], edges: vec![edge(10, 1, 2, "e1"), edge(11, 2, 3, "e2")] }, + complete: true, + graph: PathBuf::from("g"), + } + } + + fn offered() -> Offered { + Offered { + plugin: PluginInfo { + manifest: Manifest { id: "demo".into(), name: "Demo".into(), version: "1".into(), protocol: 1, entry: vec!["demo".into()], description: String::new(), permissions: Default::default() }, + dir: PathBuf::new(), + builtin: true, + }, + info: AnalysisInfo { + id: "links".into(), + title: "Links".into(), + claim: "Only what the records say.".into(), + subject_labels: vec!["Thing".into()], + verdicts: vec![ + VerdictInfo { id: "flagged".into(), title: "Flagged".into(), tone: VerdictTone::Attention }, + VerdictInfo { id: "clear".into(), title: "Clear".into(), tone: VerdictTone::Quiet }, + ], + rules: vec![RuleInfo { rule_id: "demo.rule".into(), title: "Rule".into(), statement: "It holds.".into(), assumptions: vec![] }], + ..AnalysisInfo::default() + }, + } + } + + fn finding(id: &str, verdict: &str, resource: &str) -> FindingRecord { + FindingRecord { + finding_id: id.into(), + rule_id: "demo.rule".into(), + verdict: verdict.into(), + resource: resource.into(), + conditions: vec![], + unknowns: vec![], + assumptions: vec![], + path: None, + depends_on: None, + records: vec![], + } + } + + fn along(edges: &[&str]) -> PathRef { + PathRef { derivation: "by rule".into(), hops: edges.iter().map(|edge| HopRef { edge: format!("rel:{edge}"), along: true }).collect() } + } + + fn checked(records: Vec) -> Result { + report_from(&offered(), &input(), &[], records, 3) + } + + #[test] + fn a_path_survives_the_wire_and_is_rebuilt_from_the_graph() { + let record = FindingRecord { path: Some(along(&["e1", "e2"])), ..finding("f", "flagged", "node:c") }; + // As it crosses the process boundary. + let line = serde_json::to_string(&record).unwrap(); + let report = checked(vec![serde_json::from_str(&line).unwrap()]).unwrap(); + let found = &report.findings[0]; + let walk = found.exploration().unwrap(); + assert_eq!(walk.starts, [1]); + assert_eq!(walk.hops.iter().map(|hop| (hop.depth, hop.from, hop.to, hop.edge)).collect::>(), [(1, 1, 2, 10), (2, 2, 3, 11)]); + assert_eq!(walk.graph.nodes.len(), 3); + assert_eq!(walk.graph.edges.len(), 2); + assert!(walk.is_complete()); + let path = found.path.as_ref().unwrap(); + assert_eq!(path.hops, ["a -[LINKS]-> b", "b -[LINKS]-> c"]); + assert_eq!(path.rule_id, Field::Known("demo.rule".to_string())); + assert_eq!(found.contributing, ["node:a", "rel:e1", "node:b", "rel:e2", "node:c"]); + let hint = found.walk.as_ref().unwrap(); + assert_eq!((hint.from.as_str(), hint.to.as_str(), hint.direction, hint.max_hops), ("a", "c", Direction::Out, 2)); + + // And its references survive the report's own JSON. + let text = serde_json::to_value(&report).unwrap(); + assert_eq!(text["findings"][0]["path_refs"][1]["edge"], "rel:e2"); + } + + #[test] + fn a_hop_against_the_arrow_reads_from_its_far_end() { + let path = PathRef { derivation: "by rule".into(), hops: vec![HopRef { edge: "rel:e1".into(), along: false }] }; + let report = checked(vec![FindingRecord { path: Some(path), ..finding("f", "flagged", "node:a") }]).unwrap(); + let hop = &report.findings[0].hops[0]; + assert_eq!((hop.from, hop.to, hop.along), (2, 1, false)); + assert_eq!(report.findings[0].walk.as_ref().unwrap().direction, Direction::Both); + } + + #[test] + fn a_value_is_read_from_the_graph_and_not_from_the_plugin() { + let field = |record: &str, field: &str| FieldRef { record: record.into(), field: field.into(), source_field: "Source.Field".into() }; + let condition = ConditionRecord { + id: "c".into(), + statement: "It holds.".into(), + status: ConditionStatus::Met, + detail: String::new(), + fields: vec![field("node:a", "color"), field("node:a", "absent"), field("node:a", "api_token"), field("rel:e1", "@to")], + }; + let report = checked(vec![FindingRecord { conditions: vec![condition], ..finding("f", "flagged", "node:a") }]).unwrap(); + let fields = &report.findings[0].conditions[0].fields; + assert_eq!(fields[0].value, Field::Known(json!("red"))); + assert_eq!(fields[0].source_field, "Source.Field"); + assert_eq!(fields[1].value, Field::Unknown, "a field that is not there is unknown"); + assert_eq!(fields[3].value, Field::Known(json!("node:b"))); + let text = serde_json::to_string(&report).unwrap(); + assert!(!text.contains("abc123"), "a secret stays masked: {text}"); + assert_eq!(report.findings[0].contributing, ["node:a", "rel:e1"], "every record a condition read is cited"); + } + + #[test] + fn a_result_that_does_not_match_the_graph_is_refused_whole() { + let refused = |record: FindingRecord, expected: &str| { + let good = finding("good", "clear", "node:a"); + let message = checked(vec![good, record]).unwrap_err(); + assert!(message.contains(expected), "{message}"); + }; + refused(finding("f", "flagged", "node:forged"), "not a node it was given"); + refused(finding("f", "flagged", "rel:e1"), "not a node it was given"); + refused(finding("f", "reachable", "node:a"), "not one the analysis declares"); + refused(FindingRecord { rule_id: "other".into(), ..finding("f", "flagged", "node:a") }, "not one the analysis declares"); + refused(FindingRecord { records: vec!["node:forged".into()], ..finding("f", "flagged", "node:a") }, "cites node:forged"); + refused(FindingRecord { path: Some(along(&["forged"])), ..finding("f", "flagged", "node:a") }, "not given"); + refused(FindingRecord { path: Some(along(&["e2", "e1"])), ..finding("f", "flagged", "node:c") }, "does not start where"); + refused(FindingRecord { path: Some(along(&["e1"])), ..finding("f", "flagged", "node:c") }, "neither starts nor ends"); + refused(FindingRecord { depends_on: Some("nothing".into()), ..finding("f", "flagged", "node:a") }, "depends on"); + refused(finding("good", "flagged", "node:b"), "same ID"); + } + + #[test] + fn findings_come_in_the_declared_order_whatever_the_plugin_sent() { + let report = checked(vec![finding("3", "clear", "node:a"), finding("2", "flagged", "node:c"), finding("1", "flagged", "node:b")]).unwrap(); + let order: Vec<&str> = report.findings.iter().map(|finding| finding.finding_id.as_str()).collect(); + assert_eq!(order, ["1", "2", "3"]); + assert_eq!(report.summary, BTreeMap::from([("flagged".to_string(), 2), ("clear".to_string(), 1)])); + assert_eq!(report.subjects, 3); + assert!(report.order.contains("flagged, clear"), "{}", report.order); + + let (narrow, cut) = report.narrowed(|_| true, 1); + assert_eq!((narrow.findings.len(), cut), (1, 2)); + assert_eq!(narrow.summary["flagged"], 2, "the summary still counts everything"); + } + + #[test] + fn a_graph_too_large_to_read_says_so() { + let mut large = input(); + large.complete = false; + let report = report_from(&offered(), &large, &[], vec![], 0).unwrap(); + assert!(!report.complete); + assert!(!request_for(&offered().info, &large, &[]).complete); + } + + #[test] + fn what_a_plugin_is_sent_has_no_bookkeeping_or_secrets() { + let mut given = input(); + given.data.nodes[0].properties.insert("khonsu_hash".into(), json!("private")); + let request = request_for(&offered().info, &given, &[]); + assert_eq!(request.nodes[0].reference, "node:a"); + assert_eq!(request.nodes[0].name, "a"); + assert_eq!(request.edges[0].from, "node:a"); + assert_eq!(request.edges[0].to, "node:b"); + let text = serde_json::to_string(&request).unwrap(); + assert!(!text.contains("abc123"), "{text}"); + assert!(!text.contains("private"), "{text}"); + } + + fn source_with(status: RunStatus) -> Source { + serde_json::from_value(json!({ + "id": "shop", "name": "shop", "plugin": "demo", "graph": "g", + "runs": [{ + "run_id": "shop#1", "number": 1, "mode": "full", "started_at": 1, "ended_at": 2, + "status": status, "plugin": "demo@0", "received_nodes": 0, "received_edges": 0, + "removals_applied": false, "cursor_advanced": false, "gaps": ["page 3"], + }], + })) + .unwrap() + } + + fn tagged() -> AnalysisInput { + let mut given = input(); + for node in &mut given.data.nodes { + node.properties.insert(SOURCE_PROPERTY.into(), json!("shop")); + } + for edge in &mut given.data.edges { + edge.properties.insert(SOURCE_PROPERTY.into(), json!("shop")); + } + given + } + + /// A plugin that ignores coverage altogether: the result still shows it. + #[test] + fn an_incomplete_source_stays_visible_whatever_the_plugin_says() { + for status in [RunStatus::Partial, RunStatus::Failed, RunStatus::Cancelled] { + let sources = [source_with(status)]; + let report = report_from(&offered(), &tagged(), &sources, vec![finding("f", "clear", "node:a")], 1).unwrap(); + assert!(!report.complete, "{status:?}"); + assert_eq!(report.findings[0].incomplete_sources, ["shop"], "{status:?}"); + assert_eq!(report.findings[0].verdict, "clear", "the verdict is the plugin's to set"); + let sent = request_for(&offered().info, &tagged(), &sources); + assert_eq!(sent.coverage["shop"].status, status.word()); + assert_eq!(sent.coverage["shop"].gaps, ["page 3"]); + assert_eq!(sent.nodes[0].source.as_deref(), Some("shop")); + assert!(!sent.coverage["shop"].is_complete()); + } + let whole = [source_with(RunStatus::Complete)]; + let report = report_from(&offered(), &tagged(), &whole, vec![finding("f", "clear", "node:a")], 1).unwrap(); + assert!(report.complete); + assert!(report.findings[0].incomplete_sources.is_empty()); + } + + #[test] + fn a_source_with_no_record_of_its_runs_is_not_called_incomplete() { + let report = report_from(&offered(), &tagged(), &[], vec![], 0).unwrap(); + assert!(report.complete); + assert_eq!(report.sources["shop"], Field::Unknown); + assert!(request_for(&offered().info, &tagged(), &[]).coverage.is_empty()); + } + + /// Its sync can have failed before writing anything, so it has no record to + /// be found by, and the absence of its records must not read as clear. + #[test] + fn a_source_of_this_graph_with_nothing_in_it_still_counts() { + let report = report_from(&offered(), &input(), &[source_with(RunStatus::Failed)], vec![], 0).unwrap(); + assert!(!report.complete); + assert!(report.sources.contains_key("shop")); + } + + #[test] + fn a_plugin_cannot_put_query_text_in_what_it_reads() { + let mut info = offered().info; + info.relationship_types = vec!["LINKS]->() DETACH DELETE a //".into()]; + assert!(check_info(&info).unwrap_err().contains("not a label or type name")); + info.relationship_types = vec![]; + info.node_labels = vec!["Thing) DETACH DELETE n //".into()]; + assert!(check_info(&info).is_err()); + } + + #[test] + fn an_analysis_is_offered_by_label_alone() { + let on = offered(); + assert!(on.applies_to(&["Thing".to_string()])); + assert!(!on.applies_to(&["Other".to_string()])); + } +} diff --git a/crates/khonsu-core/src/rebuild.rs b/crates/khonsu-core/src/rebuild.rs index d0fb9d2..2197a91 100644 --- a/crates/khonsu-core/src/rebuild.rs +++ b/crates/khonsu-core/src/rebuild.rs @@ -160,6 +160,7 @@ mod tests { cursor: None, patterns: Vec::new(), last_sync: None, + runs: Vec::new(), }; let sources = [source("a", "no-such-plugin"), source("b", "no-such-plugin")]; assert_eq!(missing_plugins(&sources), ["no-such-plugin"]); diff --git a/crates/khonsu-core/src/settings.rs b/crates/khonsu-core/src/settings.rs index f0898ce..d0226cf 100644 --- a/crates/khonsu-core/src/settings.rs +++ b/crates/khonsu-core/src/settings.rs @@ -5,7 +5,7 @@ use crate::engine::Language; use crate::merge::EdgePattern; -use khonsu_plugin_protocol::Permissions; +use khonsu_plugin_protocol::{Permissions, SyncMode}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value as Json}; @@ -129,6 +129,58 @@ pub struct LastSync { pub message: String, } +/// How one sync ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunStatus { + /// The plugin enumerated the whole source. + Complete, + /// It finished, and said part of the source could not be enumerated. + Partial, + /// It stopped early. Records written before that are kept. + Failed, + Cancelled, +} + +impl RunStatus { + pub fn word(self) -> &'static str { + match self { + RunStatus::Complete => "complete", + RunStatus::Partial => "partial", + RunStatus::Failed => "failed", + RunStatus::Cancelled => "cancelled", + } + } +} + +/// One sync of a source. The plugin's cursor is not copied here: it stays on +/// the source, and only whether it moved is recorded. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncRun { + /// `#`, counting this source's syncs. + pub run_id: String, + pub number: u64, + pub mode: SyncMode, + pub started_at: u64, + pub ended_at: u64, + pub status: RunStatus, + /// `@`: what mapped the source to records. + pub plugin: String, + pub received_nodes: u64, + pub received_edges: u64, + /// Whether records this sync did not see were removed. + pub removals_applied: bool, + pub cursor_advanced: bool, + /// What the plugin said it could not enumerate. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gaps: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub message: String, +} + +/// Runs kept per source; the oldest goes first. +pub const MAX_RUNS: usize = 20; + /// A configured data source: one plugin instance writing into one graph. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Source { @@ -149,6 +201,27 @@ pub struct Source { pub patterns: Vec, #[serde(default)] pub last_sync: Option, + /// The latest syncs, oldest first. Empty for a source last synced before + /// these were kept. + #[serde(default)] + pub runs: Vec, +} + +impl Source { + pub fn last_run(&self) -> Option<&SyncRun> { + self.runs.last() + } + + pub fn last_complete_run(&self) -> Option<&SyncRun> { + self.runs.iter().rev().find(|run| run.status == RunStatus::Complete) + } + + /// Adds a run, dropping the oldest beyond [`MAX_RUNS`]. + pub fn push_run(&mut self, run: SyncRun) { + self.runs.push(run); + let extra = self.runs.len().saturating_sub(MAX_RUNS); + self.runs.drain(..extra); + } } pub fn load_sources() -> Vec { diff --git a/crates/khonsu-core/src/snapshot.rs b/crates/khonsu-core/src/snapshot.rs new file mode 100644 index 0000000..e99b1c7 --- /dev/null +++ b/crates/khonsu-core/src/snapshot.rs @@ -0,0 +1,626 @@ +//! A snapshot: the records of a graph at one moment, kept in a file by stable +//! IDs, so that two can be compared long after the graph has moved on. HawDB has +//! no time travel, and this does not add any. +//! +//! A record gone from a later snapshot counts as removed only when its source +//! was read completely for that snapshot. Otherwise it may simply not have been +//! seen, and is listed apart. See `docs/snapshots.md`. + +use crate::engine::{Graph, QueryError}; +use crate::evidence::{self, Field, SourceCoverage}; +use crate::investigation::{check_header, compare, content_hash, write_whole, FileError, Fingerprint, GraphState}; +use crate::merge::{ID_PROPERTY, SOURCE_PROPERTY}; +use crate::result::GraphData; +use crate::settings::{RunStatus, Source}; +use crate::util::unix_now; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value as Json}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +pub const FORMAT: &str = "khonsu-snapshot"; +pub const VERSION: u32 = 1; +/// Above this many nodes or relationships a graph is refused, not cut short: a +/// snapshot missing records would read as removals later. +pub const MAX_RECORDS: usize = 200_000; +/// Changes listed unless asked otherwise; the rest are counted. +pub const DEFAULT_MAX_CHANGES: usize = 1000; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NodeRecord { + pub id: String, + pub labels: Vec, + pub source: Option, + /// Its own properties, with anything secret-looking masked. + pub properties: Map, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub redacted: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RelationshipRecord { + pub id: String, + pub rel_type: String, + pub from: String, + pub to: String, + pub source: Option, + pub properties: Map, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub redacted: Vec, +} + +/// What is known about a source at the moment of a snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceState { + pub coverage: Field, + /// `@`: what mapped the source to these records. + pub plugin: Field, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Snapshot { + pub format: String, + pub version: u32, + /// `snapshot:`: the same records give the same ID. + pub snapshot_id: String, + pub captured_at: u64, + pub fingerprint: Fingerprint, + pub sources: BTreeMap, + /// Sorted by ID. + pub nodes: Vec, + pub relationships: Vec, + /// Records with no stable ID, or with an end that has none. They cannot be + /// matched between snapshots, so they are counted and not kept. + pub unstable_nodes: u64, + pub unstable_relationships: u64, +} + +impl Snapshot { + pub fn load(path: &Path) -> Result { + let shown = path.display(); + let text = std::fs::read_to_string(path).map_err(|error| FileError { + kind: if error.kind() == std::io::ErrorKind::NotFound { "not_found" } else { "io" }, + message: format!("cannot read {shown}: {error}"), + })?; + Self::parse(&text).map_err(|error| FileError { + kind: error.kind, + message: format!("{shown}: {}", error.message), + }) + } + + pub fn parse(text: &str) -> Result { + let value = check_header(text, FORMAT, VERSION)?; + serde_json::from_value(value).map_err(|error| FileError { + kind: "invalid", + message: format!("its contents are not usable: {error}"), + }) + } + + pub fn save(&self, path: &Path, overwrite: bool) -> Result<(), FileError> { + let text = serde_json::to_string_pretty(self).expect("a snapshot serializes") + "\n"; + write_whole(path, &text, overwrite) + } + + /// Whether `graph` still holds what this snapshot does. + pub fn check(&self, graph: &mut Graph) -> Result<(GraphState, Vec), QueryError> { + Ok(compare(&self.fingerprint, &graph.fingerprint()?)) + } + + /// Sources whose last sync at this moment was known not to be complete. + fn incomplete_sources(&self) -> Vec { + self.sources + .iter() + .filter(|(_, state)| state.coverage.known().is_some_and(|coverage| coverage.status != RunStatus::Complete)) + .map(|(source, _)| source.clone()) + .collect() + } + + /// Whether a record of `source` missing from this snapshot has really gone. + fn confirms_removal(&self, source: Option<&str>) -> bool { + let state = source.and_then(|source| self.sources.get(source)); + state.and_then(|state| state.coverage.known()).is_some_and(|coverage| coverage.status == RunStatus::Complete) + } +} + +impl Graph { + /// The graph's records as they stand. A graph too large to hold is refused. + pub fn snapshot(&mut self, sources: &[Source]) -> Result { + self.snapshot_capped(sources, MAX_RECORDS) + } + + pub(crate) fn snapshot_capped(&mut self, sources: &[Source], cap: usize) -> Result { + let fingerprint = self.fingerprint()?; + let too_large = fingerprint.nodes as usize > cap || fingerprint.relationships as usize > cap; + if too_large { + return Err(QueryError::new( + "too_large", + format!("the graph has {} nodes and {} relationships, and a snapshot holds at most {cap} of each", fingerprint.nodes, fingerprint.relationships), + )); + } + let data = self.read_all_records()?; + Ok(from_records(&data, fingerprint, sources)) + } +} + +fn from_records(data: &GraphData, fingerprint: Fingerprint, sources: &[Source]) -> Snapshot { + let text = |properties: &Map, key: &str| properties.get(key).and_then(Json::as_str).filter(|value| !value.is_empty()).map(String::from); + let stable: BTreeMap = data.nodes.iter().filter_map(|node| Some((node.id, text(&node.properties, ID_PROPERTY)?))).collect(); + + let mut nodes = Vec::new(); + let mut unstable_nodes = 0; + for node in &data.nodes { + let Some(id) = stable.get(&node.id) else { + unstable_nodes += 1; + continue; + }; + let (properties, mut redacted) = evidence::own_properties(&node.properties); + redacted.sort_unstable(); + let mut labels = node.labels.clone(); + labels.sort_unstable(); + nodes.push(NodeRecord { + id: id.clone(), + labels, + source: text(&node.properties, SOURCE_PROPERTY), + properties: sorted(properties), + redacted, + }); + } + nodes.sort_by(|a, b| a.id.cmp(&b.id)); + + let mut relationships = Vec::new(); + let mut unstable_relationships = 0; + for edge in &data.edges { + let (Some(id), Some(from), Some(to)) = (text(&edge.properties, ID_PROPERTY), stable.get(&edge.source), stable.get(&edge.target)) else { + unstable_relationships += 1; + continue; + }; + let (properties, mut redacted) = evidence::own_properties(&edge.properties); + redacted.sort_unstable(); + relationships.push(RelationshipRecord { + id, + rel_type: edge.rel_type.clone(), + from: from.clone(), + to: to.clone(), + source: text(&edge.properties, SOURCE_PROPERTY), + properties: sorted(properties), + redacted, + }); + } + relationships.sort_by(|a, b| a.id.cmp(&b.id)); + + let states = fingerprint + .sources + .iter() + .map(|source| { + let run = sources.iter().find(|configured| configured.id == *source).and_then(Source::last_run); + let state = SourceState { + coverage: evidence::coverage_of(source, sources), + plugin: run.map(|run| run.plugin.clone()).into(), + }; + (source.clone(), state) + }) + .collect(); + let digest = fingerprint.hash.clone().unwrap_or_else(|| content_hash(data).0); + Snapshot { + format: FORMAT.into(), + version: VERSION, + snapshot_id: format!("snapshot:{digest}"), + captured_at: unix_now(), + fingerprint, + sources: states, + nodes, + relationships, + unstable_nodes, + unstable_relationships, + } +} + +fn sorted(properties: Map) -> Map { + let ordered: BTreeMap = properties.into_iter().collect(); + ordered.into_iter().collect() +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PropertyChange { + pub property: String, + pub before: Option, + pub after: Option, +} + +/// One node or relationship in a diff. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Change { + /// A `khonsu_id`. + pub id: String, + /// A node's labels, or a relationship's type. + pub types: Vec, + pub source: Option, + /// For a relationship: its ends, as they were last seen. + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + /// For a changed record: what changed. Masked values compare as masked. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub properties: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Changes { + pub added: Vec, + /// Gone, and the source was read completely, so they really are. + pub removed: Vec, + /// Not seen, and the source was not known to be read completely. They may + /// still exist. + pub not_seen: Vec, + pub changed: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Counts { + pub added: usize, + pub removed: usize, + pub not_seen: usize, + pub changed: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SnapshotRef { + pub snapshot_id: String, + pub captured_at: u64, + pub sources: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Diff { + pub before: SnapshotRef, + pub after: SnapshotRef, + /// Counts every change, including any cut from the lists below. + pub node_counts: Counts, + pub relationship_counts: Counts, + pub nodes: Changes, + pub relationships: Changes, + /// Changes left out of the lists by the cap. + pub truncated: usize, + /// What to bear in mind when reading this. + pub warnings: Vec, +} + +fn property_changes(before: &Map, after: &Map) -> Vec { + let keys: BTreeSet<&String> = before.keys().chain(after.keys()).collect(); + keys.into_iter() + .filter(|key| before.get(*key) != after.get(*key)) + .map(|key| PropertyChange { + property: key.clone(), + before: before.get(key).cloned(), + after: after.get(key).cloned(), + }) + .collect() +} + +fn text_change(property: &str, before: &str, after: &str) -> Option { + (before != after).then(|| PropertyChange { + property: property.into(), + before: Some(Json::String(before.into())), + after: Some(Json::String(after.into())), + }) +} + +/// What differs from `before` to `after`, by stable IDs, in ID order, listing +/// at most `max_changes` in all. The same two snapshots give the same result. +pub fn diff(before: &Snapshot, after: &Snapshot, max_changes: usize) -> Diff { + let mut nodes = Changes::default(); + let was: BTreeMap<&str, &NodeRecord> = before.nodes.iter().map(|node| (node.id.as_str(), node)).collect(); + let is: BTreeMap<&str, &NodeRecord> = after.nodes.iter().map(|node| (node.id.as_str(), node)).collect(); + let node_change = |node: &NodeRecord, properties: Vec| Change { + id: node.id.clone(), + types: node.labels.clone(), + source: node.source.clone(), + from: None, + to: None, + properties, + }; + for (id, old) in &was { + match is.get(id) { + None if after.confirms_removal(old.source.as_deref()) => nodes.removed.push(node_change(old, Vec::new())), + None => nodes.not_seen.push(node_change(old, Vec::new())), + Some(new) => { + let mut changes = property_changes(&old.properties, &new.properties); + changes.extend(text_change("labels", &old.labels.join(":"), &new.labels.join(":"))); + if !changes.is_empty() { + nodes.changed.push(node_change(new, changes)); + } + } + } + } + nodes.added = is.iter().filter(|(id, _)| !was.contains_key(*id)).map(|(_, node)| node_change(node, Vec::new())).collect(); + + let mut relationships = Changes::default(); + let was: BTreeMap<&str, &RelationshipRecord> = before.relationships.iter().map(|edge| (edge.id.as_str(), edge)).collect(); + let is: BTreeMap<&str, &RelationshipRecord> = after.relationships.iter().map(|edge| (edge.id.as_str(), edge)).collect(); + let edge_change = |edge: &RelationshipRecord, properties: Vec| Change { + id: edge.id.clone(), + types: vec![edge.rel_type.clone()], + source: edge.source.clone(), + from: Some(edge.from.clone()), + to: Some(edge.to.clone()), + properties, + }; + for (id, old) in &was { + match is.get(id) { + None if after.confirms_removal(old.source.as_deref()) => relationships.removed.push(edge_change(old, Vec::new())), + None => relationships.not_seen.push(edge_change(old, Vec::new())), + Some(new) => { + let mut changes = property_changes(&old.properties, &new.properties); + changes.extend(text_change("from", &old.from, &new.from)); + changes.extend(text_change("to", &old.to, &new.to)); + changes.extend(text_change("rel_type", &old.rel_type, &new.rel_type)); + if !changes.is_empty() { + relationships.changed.push(edge_change(new, changes)); + } + } + } + } + relationships.added = is.iter().filter(|(id, _)| !was.contains_key(*id)).map(|(_, edge)| edge_change(edge, Vec::new())).collect(); + + let count = |changes: &Changes| Counts { + added: changes.added.len(), + removed: changes.removed.len(), + not_seen: changes.not_seen.len(), + changed: changes.changed.len(), + }; + let (node_counts, relationship_counts) = (count(&nodes), count(&relationships)); + + let mut left = max_changes; + let mut truncated = 0; + for list in [ + &mut nodes.added, + &mut nodes.removed, + &mut nodes.not_seen, + &mut nodes.changed, + &mut relationships.added, + &mut relationships.removed, + &mut relationships.not_seen, + &mut relationships.changed, + ] { + let keep = list.len().min(left); + truncated += list.len() - keep; + list.truncate(keep); + left -= keep; + } + + let mut warnings = Vec::new(); + for source in after.incomplete_sources() { + warnings.push(format!("Source {source}'s last sync was not complete in the later snapshot, so what it did not report is listed as not seen, not removed.")); + } + for source in before.incomplete_sources() { + warnings.push(format!("Source {source}'s last sync was not complete in the earlier snapshot, so some additions may have existed already.")); + } + let unknown: Vec<&String> = after.sources.iter().filter(|(_, state)| state.coverage.known().is_none()).map(|(source, _)| source).collect(); + for source in unknown { + warnings.push(format!("Nothing is known of how complete source {source}'s last sync was in the later snapshot, so what it did not report is listed as not seen.")); + } + let gone = before.sources.keys().filter(|source| !after.sources.contains_key(*source)); + for source in gone { + warnings.push(format!("Source {source} has no records in the later snapshot, so its records are listed as not seen.")); + } + if before.captured_at > after.captured_at { + warnings.push("The earlier snapshot was captured after the later one, so additions and removals read backwards.".into()); + } + let unstable = before.unstable_nodes + before.unstable_relationships + after.unstable_nodes + after.unstable_relationships; + if unstable > 0 { + warnings.push(format!("{unstable} records have no stable ID and are not compared.")); + } + let reference = |snapshot: &Snapshot| SnapshotRef { + snapshot_id: snapshot.snapshot_id.clone(), + captured_at: snapshot.captured_at, + sources: snapshot.sources.clone(), + }; + Diff { + before: reference(before), + after: reference(after), + node_counts, + relationship_counts, + nodes, + relationships, + truncated, + warnings, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::result::{GraphEdge, GraphNode}; + use serde_json::json; + + fn node(id: i64, stable: &str, extra: Json) -> GraphNode { + let mut properties = Map::from_iter([("khonsu_id".to_string(), json!(stable)), ("khonsu_source".to_string(), json!("shop"))]); + properties.extend(extra.as_object().unwrap().clone()); + GraphNode { id, labels: vec!["Host".into()], properties } + } + + fn edge(id: i64, source: i64, target: i64, stable: &str) -> GraphEdge { + GraphEdge { + id, + source, + target, + rel_type: "LINKS".into(), + properties: Map::from_iter([("khonsu_id".to_string(), json!(stable)), ("khonsu_source".to_string(), json!("shop"))]), + } + } + + fn source_with(status: Option) -> Vec { + let runs = status.map_or(json!([]), |status| { + json!([{ + "run_id": "shop#1", "number": 1, "mode": "full", "started_at": 1, "ended_at": 2, + "status": status, "plugin": "aws@0", "received_nodes": 0, "received_edges": 0, + "removals_applied": false, "cursor_advanced": false, + }]) + }); + vec![serde_json::from_value(json!({"id": "shop", "name": "shop", "plugin": "aws", "graph": "g", "runs": runs})).unwrap()] + } + + fn snap(nodes: Vec, edges: Vec, status: Option, at: u64) -> Snapshot { + let data = GraphData { nodes, edges }; + let (hash, sources) = content_hash(&data); + let fingerprint = Fingerprint { + nodes: data.nodes.len() as u64, + relationships: data.edges.len() as u64, + hash: Some(hash), + sources, + }; + let mut snapshot = from_records(&data, fingerprint, &source_with(status)); + snapshot.captured_at = at; + snapshot + } + + fn before() -> Snapshot { + snap( + vec![node(1, "s/a", json!({"port": 80})), node(2, "s/b", json!({})), node(3, "s/c", json!({}))], + vec![edge(10, 1, 2, "s/ab"), edge(11, 2, 3, "s/bc")], + Some(RunStatus::Complete), + 100, + ) + } + + fn after(status: Option) -> Snapshot { + // Other internal IDs: a is changed, c has gone, d is new. + snap( + vec![node(7, "s/a", json!({"port": 443})), node(8, "s/b", json!({})), node(9, "s/d", json!({}))], + vec![edge(20, 7, 8, "s/ab"), edge(21, 8, 9, "s/bd")], + status, + 200, + ) + } + + fn ids(changes: &[Change]) -> Vec<&str> { + changes.iter().map(|change| change.id.as_str()).collect() + } + + #[test] + fn a_diff_goes_by_stable_ids_and_reports_each_kind_of_change() { + let result = diff(&before(), &after(Some(RunStatus::Complete)), DEFAULT_MAX_CHANGES); + assert_eq!(ids(&result.nodes.added), ["s/d"]); + assert_eq!(ids(&result.nodes.removed), ["s/c"]); + assert!(result.nodes.not_seen.is_empty()); + assert_eq!(ids(&result.nodes.changed), ["s/a"]); + assert_eq!( + result.nodes.changed[0].properties, + [PropertyChange { property: "port".into(), before: Some(json!(80)), after: Some(json!(443)) }] + ); + assert_eq!(ids(&result.relationships.added), ["s/bd"]); + assert_eq!(ids(&result.relationships.removed), ["s/bc"]); + assert!(result.relationships.changed.is_empty(), "s/ab has other internal IDs, and is the same relationship"); + assert_eq!(result.relationships.removed[0].from.as_deref(), Some("s/b")); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + assert_eq!(diff(&before(), &after(Some(RunStatus::Complete)), DEFAULT_MAX_CHANGES), result, "the same snapshots give the same diff"); + } + + #[test] + fn nothing_changed_gives_an_empty_diff_whatever_the_internal_ids() { + let result = diff(&before(), &before(), DEFAULT_MAX_CHANGES); + assert_eq!(result.node_counts, Counts::default()); + assert_eq!(result.relationship_counts, Counts::default()); + } + + #[test] + fn only_a_complete_later_sync_can_confirm_a_removal() { + for status in [Some(RunStatus::Partial), Some(RunStatus::Failed), Some(RunStatus::Cancelled), None] { + let result = diff(&before(), &after(status), DEFAULT_MAX_CHANGES); + assert!(result.nodes.removed.is_empty(), "{status:?}"); + assert!(result.relationships.removed.is_empty(), "{status:?}"); + assert_eq!(ids(&result.nodes.not_seen), ["s/c"], "{status:?}"); + assert_eq!(ids(&result.relationships.not_seen), ["s/bc"], "{status:?}"); + assert!(result.warnings.iter().any(|text| text.contains("shop")), "{status:?}: {:?}", result.warnings); + // Additions and changes are what the source did report. + assert_eq!(ids(&result.nodes.added), ["s/d"], "{status:?}"); + assert_eq!(ids(&result.nodes.changed), ["s/a"], "{status:?}"); + } + } + + #[test] + fn a_source_gone_from_the_later_snapshot_confirms_nothing() { + let empty = snap(vec![], vec![], None, 200); + let result = diff(&before(), &empty, DEFAULT_MAX_CHANGES); + assert!(result.nodes.removed.is_empty()); + assert_eq!(result.node_counts.not_seen, 3); + assert!(result.warnings.iter().any(|text| text.contains("no records")), "{:?}", result.warnings); + } + + #[test] + fn the_lists_are_capped_and_the_counts_are_not() { + let result = diff(&before(), &after(Some(RunStatus::Complete)), 2); + let listed = result.nodes.added.len() + result.nodes.removed.len() + result.nodes.changed.len() + result.relationships.added.len() + result.relationships.removed.len(); + assert_eq!(listed, 2); + assert_eq!(result.truncated, 3); + assert_eq!(result.node_counts.added, 1); + assert_eq!(result.relationship_counts.removed, 1); + } + + #[test] + fn records_without_stable_ids_are_counted_not_compared() { + let mut bare = node(1, "x", json!({})); + bare.properties.remove("khonsu_id"); + let snapshot = snap(vec![bare, node(2, "s/b", json!({}))], vec![edge(10, 1, 2, "s/ab")], Some(RunStatus::Complete), 1); + assert_eq!((snapshot.nodes.len(), snapshot.unstable_nodes), (1, 1)); + assert_eq!((snapshot.relationships.len(), snapshot.unstable_relationships), (0, 1), "its end has no stable ID"); + let result = diff(&snapshot, &snapshot, DEFAULT_MAX_CHANGES); + assert!(result.warnings.iter().any(|text| text.contains("no stable ID")), "{:?}", result.warnings); + } + + #[test] + fn secrets_stay_out_of_a_snapshot_and_a_bookkeeping_change_is_not_a_change() { + let snapshot = snap(vec![node(1, "s/a", json!({"api_token": "abc123", "khonsu_synced_at": 5}))], vec![], Some(RunStatus::Complete), 1); + let text = serde_json::to_string(&snapshot).unwrap(); + assert!(!text.contains("abc123"), "{text}"); + assert_eq!(snapshot.nodes[0].redacted, ["api_token"]); + let later = snap(vec![node(1, "s/a", json!({"api_token": "abc123", "khonsu_synced_at": 9}))], vec![], Some(RunStatus::Complete), 2); + assert_eq!(diff(&snapshot, &later, 10).node_counts, Counts::default()); + } + + #[test] + fn the_id_follows_the_records_not_the_moment() { + assert_eq!(before().snapshot_id, snap(before_nodes(), before_edges(), Some(RunStatus::Partial), 999).snapshot_id); + assert_ne!(before().snapshot_id, after(Some(RunStatus::Complete)).snapshot_id); + } + + fn before_nodes() -> Vec { + vec![node(5, "s/a", json!({"port": 80})), node(6, "s/b", json!({})), node(7, "s/c", json!({}))] + } + + fn before_edges() -> Vec { + vec![edge(30, 5, 6, "s/ab"), edge(31, 6, 7, "s/bc")] + } + + #[test] + fn a_graph_over_the_cap_is_refused_rather_than_cut_short() { + let base = std::env::var_os("KHONSU_TEST_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap()); + std::fs::create_dir_all(&base).unwrap(); + let dir = tempfile::tempdir_in(base).unwrap(); + let mut graph = Graph::create(dir.path().join("graph"), "test").unwrap(); + for name in ["a", "b", "c"] { + let text = format!("CREATE (:Host {{khonsu_id: '{name}'}})"); + graph.run(crate::Language::Cypher, &text, &Map::new(), crate::QueryOptions::default()).unwrap(); + } + let refused = graph.snapshot_capped(&[], 2).unwrap_err(); + assert_eq!(refused.kind, "too_large", "{refused:?}"); + assert!(refused.message.contains('3'), "{refused:?}"); + + let taken = graph.snapshot_capped(&[], 3).unwrap(); + assert_eq!(taken.nodes.iter().map(|node| node.id.as_str()).collect::>(), ["a", "b", "c"]); + // Nothing in it says the graph changed. + assert_eq!(taken.check(&mut graph).unwrap().0, GraphState::Unchanged); + } + + #[test] + fn a_file_it_cannot_use_says_why() { + let kind = |text: &str| Snapshot::parse(text).unwrap_err().kind; + assert_eq!(kind("{not json"), "invalid"); + assert_eq!(kind(r#"{"format": "khonsu-investigation", "version": 1}"#), "invalid"); + assert_eq!(kind(r#"{"format": "khonsu-snapshot", "version": 1}"#), "invalid"); + assert_eq!(kind(r#"{"format": "khonsu-snapshot", "version": 99}"#), "too_new"); + let text = serde_json::to_string(&before()).unwrap(); + assert_eq!(Snapshot::parse(&text).unwrap(), before()); + } +} diff --git a/crates/khonsu-core/tests/explore.rs b/crates/khonsu-core/tests/explore.rs new file mode 100644 index 0000000..c090c90 --- /dev/null +++ b/crates/khonsu-core/tests/explore.rs @@ -0,0 +1,237 @@ +mod common; + +use common::TestDir; +use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Limit}; +use khonsu_core::{Graph, Language, QueryOptions}; +use serde_json::{json, Map}; +use std::collections::BTreeMap; +use std::sync::atomic::AtomicBool; + +/// `a -NEXT-> b -NEXT-> c -NEXT-> a` (a cycle), `b -SIDE-> d`, and `e`, alone. +struct Fixture { + _dir: TestDir, + graph: Graph, + ids: BTreeMap, +} + +impl Fixture { + fn new() -> Self { + let dir = TestDir::new(); + let mut graph = Graph::create(dir.graph(), "test").unwrap(); + let mut run = |text: String| { + graph.run(Language::Cypher, &text, &Map::new(), QueryOptions::default()).unwrap(); + }; + for name in ["a", "b", "c", "d", "e"] { + run(format!("CREATE (:N {{name: '{name}'}})")); + } + for (from, rel, to) in [("a", "NEXT", "b"), ("b", "NEXT", "c"), ("c", "NEXT", "a"), ("b", "SIDE", "d")] { + run(format!("MATCH (x:N {{name: '{from}'}}), (y:N {{name: '{to}'}}) CREATE (x)-[:{rel}]->(y)")); + } + let out = graph + .run(Language::Cypher, "MATCH (n:N) RETURN n.name AS name, id(n) AS id", &Map::new(), QueryOptions::default()) + .unwrap(); + let ids = out.rows.iter().map(|row| (row[0].as_str().unwrap().to_string(), row[1].as_i64().unwrap())).collect(); + Self { _dir: dir, graph, ids } + } + + fn request(&self, starts: &[&str]) -> ExploreRequest { + ExploreRequest::new(starts.iter().map(|name| self.ids[*name]).collect()) + } + + fn walk(&mut self, request: &ExploreRequest) -> Exploration { + self.graph.explore(request, &AtomicBool::new(false)).unwrap() + } +} + +fn names(result: &Exploration) -> Vec { + let mut names: Vec = result.graph.nodes.iter().map(|node| node.properties["name"].as_str().unwrap().to_string()).collect(); + names.sort(); + names +} + +fn explained(result: &Exploration) -> Vec { + result.hops.iter().map(|hop| result.explain(hop)).collect() +} + +#[test] +fn out_follows_the_arrows_and_in_goes_against_them() { + let mut f = Fixture::new(); + let mut request = f.request(&["b"]); + request.max_hops = 1; + + request.direction = Direction::Out; + let out = f.walk(&request); + assert_eq!(names(&out), ["b", "c", "d"]); + assert_eq!(explained(&out), ["b -[NEXT]-> c", "b -[SIDE]-> d"]); + assert!(out.hops.iter().all(|hop| hop.along)); + + request.direction = Direction::In; + let inbound = f.walk(&request); + assert_eq!(names(&inbound), ["a", "b"]); + assert_eq!(explained(&inbound), ["b <-[NEXT]- a"]); + assert!(!inbound.hops[0].along); + + request.direction = Direction::Both; + let both = f.walk(&request); + assert_eq!(names(&both), ["a", "b", "c", "d"]); + assert_eq!(both.hops.len(), 3); +} + +#[test] +fn a_cycle_ends_and_uses_each_relationship_once() { + let mut f = Fixture::new(); + let mut request = f.request(&["a"]); + request.rel_types = vec!["NEXT".into()]; + request.max_hops = 16; + let result = f.walk(&request); + + assert_eq!(names(&result), ["a", "b", "c"]); + assert_eq!(result.hops.len(), 3, "the closing c → a is shown once"); + assert_eq!(result.depth_reached, 3); + assert!(result.is_complete(), "{:?}", result.limits); +} + +#[test] +fn types_filter_what_is_followed() { + let mut f = Fixture::new(); + let mut request = f.request(&["b"]); + request.rel_types = vec!["SIDE".into()]; + assert_eq!(names(&f.walk(&request)), ["b", "d"]); + + request.rel_types = vec!["SIDE".into(), "NEXT".into()]; + request.max_hops = 1; + assert_eq!(names(&f.walk(&request)), ["b", "c", "d"]); +} + +#[test] +fn zero_matches_is_a_complete_empty_answer() { + let mut f = Fixture::new(); + let alone = f.walk(&f.request(&["e"])); + assert_eq!(names(&alone), ["e"]); + assert!(alone.hops.is_empty()); + assert_eq!(alone.depth_reached, 0); + assert!(alone.is_complete()); + + let mut request = f.request(&["a"]); + request.rel_types = vec!["NO_SUCH_TYPE".into()]; + let unknown = f.walk(&request); + assert!(unknown.hops.is_empty()); + assert!(unknown.is_complete()); +} + +#[test] +fn a_start_missing_from_the_graph_is_reported() { + let mut f = Fixture::new(); + let a = f.ids["a"]; + let mut request = ExploreRequest::new(vec![a, 987_654]); + request.max_hops = 1; + let result = f.walk(&request); + assert_eq!(result.missing_starts, [987_654]); + assert_eq!(names(&result), ["a", "b"]); +} + +#[test] +fn several_starts_walk_together() { + let mut f = Fixture::new(); + let mut request = f.request(&["a", "c"]); + request.max_hops = 1; + request.direction = Direction::Out; + let result = f.walk(&request); + assert_eq!(names(&result), ["a", "b", "c"]); + assert_eq!(explained(&result), ["a -[NEXT]-> b", "c -[NEXT]-> a"]); +} + +#[test] +fn each_cap_stops_the_walk_and_says_so() { + let mut f = Fixture::new(); + + let mut hops = f.request(&["a"]); + hops.max_hops = 1; + let result = f.walk(&hops); + assert_eq!(names(&result), ["a", "b"]); + assert_eq!(result.limits, [Limit::Hops]); + assert!(!result.is_complete()); + + let mut nodes = f.request(&["b"]); + nodes.max_nodes = 2; + let result = f.walk(&nodes); + assert_eq!(result.graph.nodes.len(), 2); + assert_eq!(result.limits, [Limit::Nodes]); + + let mut edges = f.request(&["b"]); + edges.max_edges = 1; + let result = f.walk(&edges); + assert_eq!(result.graph.edges.len(), 1); + assert_eq!(result.limits, [Limit::Edges]); + + // Exactly at the caps is not over them. + let mut exact = f.request(&["b"]); + exact.rel_types = vec!["SIDE".into()]; + exact.max_nodes = 2; + exact.max_edges = 1; + exact.max_hops = 1; + let result = f.walk(&exact); + assert_eq!(names(&result), ["b", "d"]); + assert!(result.is_complete(), "{:?}", result.limits); +} + +#[test] +fn requests_beyond_the_ceilings_are_refused_not_clamped() { + let mut f = Fixture::new(); + let cancel = AtomicBool::new(false); + let base = f.request(&["a"]); + for bad in [ + ExploreRequest { max_hops: 0, ..base.clone() }, + ExploreRequest { max_hops: 17, ..base.clone() }, + ExploreRequest { max_nodes: 0, ..base.clone() }, + ExploreRequest { max_nodes: 2001, ..base.clone() }, + ExploreRequest { max_edges: 0, ..base.clone() }, + ExploreRequest { max_edges: 5001, ..base.clone() }, + ExploreRequest { starts: vec![], ..base.clone() }, + ] { + let error = f.graph.explore(&bad, &cancel).unwrap_err(); + assert_eq!(error.kind, "semantic", "{bad:?}"); + } +} + +#[test] +fn a_type_name_cannot_change_the_query() { + let mut f = Fixture::new(); + let mut request = f.request(&["a"]); + request.rel_types = vec!["NEXT]->(m) DETACH DELETE m //".into()]; + let error = f.graph.explore(&request, &AtomicBool::new(false)).unwrap_err(); + assert!(error.message.contains("not a relationship type"), "{}", error.message); + let count = f.graph.run(Language::Cypher, "MATCH (n:N) RETURN count(n) AS c", &Map::new(), QueryOptions::default()).unwrap(); + assert_eq!(count.rows[0][0], json!(5)); +} + +#[test] +fn a_cancelled_walk_says_so_and_is_not_complete() { + let mut f = Fixture::new(); + let result = f.graph.explore(&f.request(&["a"]), &AtomicBool::new(true)).unwrap(); + assert!(result.cancelled); + assert!(!result.is_complete()); + assert_eq!(names(&result), ["a"], "the start is known before the first query"); +} + +#[test] +fn the_same_walk_gives_the_same_result() { + let mut f = Fixture::new(); + let mut request = f.request(&["b"]); + request.direction = Direction::Both; + assert_eq!(f.walk(&request), f.walk(&request)); +} + +#[test] +fn a_path_goes_from_a_start_to_its_target_in_fewest_hops() { + let mut f = Fixture::new(); + let (a, d) = (f.ids["a"], f.ids["d"]); + let result = f.walk(&f.request(&["a"])); + + let path = result.path_to(d).unwrap(); + let words: Vec = path.iter().map(|hop| result.explain(hop)).collect(); + assert_eq!(words, ["a -[NEXT]-> b", "b -[SIDE]-> d"]); + + assert!(result.path_to(a).unwrap().is_empty(), "a start is already there, even on a cycle"); + assert!(result.path_to(f.ids["e"]).is_none(), "not reached"); +} diff --git a/crates/khonsu-core/tests/merge.rs b/crates/khonsu-core/tests/merge.rs index 8231dde..a800712 100644 --- a/crates/khonsu-core/tests/merge.rs +++ b/crates/khonsu-core/tests/merge.rs @@ -1,7 +1,7 @@ mod common; use common::TestDir; -use khonsu_core::merge::{EdgePattern, MergeStats, Merger}; +use khonsu_core::merge::{next_sync_stamp, EdgePattern, MergeStats, Merger}; use khonsu_core::protocol::{EdgeRecord, NodeRecord}; use khonsu_core::{Graph, Language, QueryOptions}; use serde_json::{json, Map}; @@ -90,3 +90,24 @@ fn resync_reads_back_in_pages_and_only_rewrites_changes() { assert_eq!(count(&mut graph, "MATCH ()-[r:ON_TEAM]->() WHERE r.jersey = 40 RETURN count(r) AS c"), 1); assert_eq!(count(&mut graph, "MATCH ()-[r:ON_TEAM]->() WHERE r.jersey = 99 RETURN count(r) AS c"), 1); } + +/// Two full syncs in one second must not share a stamp: stale nodes are the ones +/// stamped before the sync, so a shared stamp would keep nodes that have gone. +#[test] +fn a_sync_in_the_same_second_as_the_last_still_removes_stale_nodes() { + let dir = TestDir::new(); + let mut graph = Graph::create(dir.graph(), "test").unwrap(); + let now = 1000; + assert_eq!(next_sync_stamp(&mut graph, "nfl", now).unwrap(), now, "nothing synced yet"); + + let (_, patterns) = sync(&mut graph, "nfl", now, 3, |i| i as i64, &[]); + let stamp = next_sync_stamp(&mut graph, "nfl", now).unwrap(); + assert_eq!(stamp, now + 1); + let (second, _) = sync(&mut graph, "nfl", stamp, 2, |i| i as i64, &patterns); + assert_eq!(second.stale_nodes_removed, 1, "{second:?}"); + assert_eq!(count(&mut graph, "MATCH (n:Player) RETURN count(n) AS c"), 2); + + // Later time wins, and another source's stamps do not count. + assert_eq!(next_sync_stamp(&mut graph, "nfl", 5000).unwrap(), 5000); + assert_eq!(next_sync_stamp(&mut graph, "other", now).unwrap(), now); +} diff --git a/crates/khonsu-core/tests/sync.rs b/crates/khonsu-core/tests/sync.rs index 3ee7011..be6b0f8 100644 --- a/crates/khonsu-core/tests/sync.rs +++ b/crates/khonsu-core/tests/sync.rs @@ -79,6 +79,7 @@ fn csv_source_syncs_counts_match_rows_and_resync_is_idempotent() { cursor: None, patterns: Vec::new(), last_sync: None, + runs: Vec::new(), }; settings::upsert_source(source.clone()).unwrap(); @@ -114,13 +115,11 @@ fn csv_source_syncs_counts_match_rows_and_resync_is_idempotent() { let source = settings::load_sources().into_iter().find(|s| s.id == "lab").unwrap(); assert!(source.last_sync.as_ref().unwrap().ok); assert!(!source.patterns.is_empty()); - std::thread::sleep(std::time::Duration::from_millis(1100)); plugin::sync_source(&mut graph, &source, SyncMode::Full, &cancel, |_| {}).unwrap(); assert_eq!(count(&mut graph, "MATCH (n:Asset) RETURN count(n) AS c"), 62); assert_eq!(count(&mut graph, "MATCH ()-[r:RUNS]->() RETURN count(r) AS c"), 60); // Rows removed from the files disappear on the next full sync. - std::thread::sleep(std::time::Duration::from_millis(1100)); write_csvs(&data, 59, Some(10)); let source = settings::load_sources().into_iter().find(|s| s.id == "lab").unwrap(); let report = plugin::sync_source(&mut graph, &source, SyncMode::Full, &cancel, |_| {}).unwrap(); diff --git a/crates/khonsu-plugin-protocol/examples/analyzer.rs b/crates/khonsu-plugin-protocol/examples/analyzer.rs new file mode 100644 index 0000000..fcf2c9e --- /dev/null +++ b/crates/khonsu-plugin-protocol/examples/analyzer.rs @@ -0,0 +1,118 @@ +//! A small analyzer with nothing to do with any cloud provider: it shows the +//! whole of what a plugin needs to offer an analysis, and its other analyses +//! misbehave in the ways a host must survive. Tests install it as a plugin. +//! +//! `dead-ends` says which `Thing` nodes have no way onward along `LINKS`. + +use khonsu_plugin_protocol::{ + fnv1a, read_analyze_request, AnalysisInfo, AnalyzeRequest, ConditionRecord, ConditionStatus, Describe, Emitter, FieldRef, FindingRecord, HopRef, Message, PathRef, RuleInfo, VerdictInfo, VerdictTone, +}; +use std::collections::BTreeMap; +use std::io::{self, Write}; +use std::process::ExitCode; + +const RULE: &str = "demo.dead-end"; + +fn analysis(id: &str) -> AnalysisInfo { + AnalysisInfo { + id: id.into(), + title: "Dead ends".into(), + description: "Things with no link onward.".into(), + claim: "Only says what the links in the graph say.".into(), + subject_labels: vec!["Thing".into()], + node_labels: vec!["Thing".into()], + relationship_types: vec!["LINKS".into()], + verdicts: vec![ + VerdictInfo { id: "dead_end".into(), title: "Dead end".into(), tone: VerdictTone::Attention }, + VerdictInfo { id: "continues".into(), title: "Continues".into(), tone: VerdictTone::Quiet }, + ], + rules: vec![RuleInfo { rule_id: RULE.into(), title: "No link onward".into(), statement: "A thing with nothing linked after it.".into(), assumptions: vec!["Only LINKS is read.".into()] }], + translations: BTreeMap::from([("zh".to_string(), BTreeMap::from([("verdict.dead_end".to_string(), "死路".to_string())]))]), + } +} + +fn dead_ends(request: &AnalyzeRequest) -> Vec { + let mut findings = Vec::new(); + for node in request.nodes.iter().filter(|node| node.labels.iter().any(|label| label == "Thing")) { + let onward = request.edges.iter().any(|edge| edge.from == node.reference); + let arriving = request.edges.iter().find(|edge| edge.to == node.reference); + findings.push(FindingRecord { + finding_id: format!("finding:{:016x}", fnv1a(&node.reference)), + rule_id: RULE.into(), + verdict: if onward { "continues" } else { "dead_end" }.into(), + resource: node.reference.clone(), + conditions: vec![ConditionRecord { + id: "no-link-onward".into(), + statement: "Nothing is linked after it.".into(), + status: if onward { ConditionStatus::NotMet } else { ConditionStatus::Met }, + detail: format!("{} {} onward.", node.name, if onward { "links" } else { "does not link" }), + fields: vec![FieldRef { record: node.reference.clone(), field: "name".into(), source_field: "name".into() }], + }], + unknowns: vec![], + assumptions: vec!["Only LINKS is read.".into()], + path: arriving.map(|edge| PathRef { derivation: "the link that arrives here".into(), hops: vec![HopRef { edge: edge.reference.clone(), along: true }] }), + depends_on: None, + records: vec![], + }); + } + findings +} + +fn analyze(out: &mut Emitter>) -> Result<(), String> { + let request = read_analyze_request().map_err(|e| e.to_string())?; + let io_error = |e: io::Error| e.to_string(); + let findings = dead_ends(&request); + let subjects = findings.len() as u64; + match request.analysis.as_str() { + "dead-ends" => {} + // Findings about a record it was not sent. + "forged" => { + let mut finding = findings.into_iter().next().ok_or("no records")?; + finding.resource = "node:forged".into(); + out.emit(&Message::Finding(Box::new(finding))).map_err(io_error)?; + return out.emit(&Message::AnalysisDone { subjects }).map_err(io_error); + } + // Everything but saying that it finished. + "unfinished" => { + for finding in findings { + out.emit(&Message::Finding(Box::new(finding))).map_err(io_error)?; + } + return Ok(()); + } + "fails" => return Err("the analysis could not run".into()), + other => return Err(format!("no analysis {other:?}")), + } + for finding in findings { + out.emit(&Message::Finding(Box::new(finding))).map_err(io_error)?; + } + out.emit(&Message::AnalysisDone { subjects }).map_err(io_error) +} + +fn main() -> ExitCode { + let mut out = Emitter::stdout(); + let result = match std::env::args().nth(1).as_deref() { + Some("describe") => out + .emit(&Message::Describe(Describe { + name: "Analyzer".into(), + version: "1.0.0".into(), + description: "An example analyzer".into(), + config: vec![], + permissions: Default::default(), + labels: vec![], + relationship_types: vec![], + supports_incremental: false, + analyses: ["dead-ends", "forged", "unfinished", "fails"].map(analysis).to_vec(), + })) + .map_err(|e| e.to_string()), + Some("analyze") => analyze(&mut out), + other => Err(format!("unknown command {other:?}")), + }; + let _ = io::stdout().flush(); + match result { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + let _ = out.emit(&Message::Error { message }); + ExitCode::FAILURE + } + } +} diff --git a/crates/khonsu-plugin-protocol/examples/scripted.rs b/crates/khonsu-plugin-protocol/examples/scripted.rs new file mode 100644 index 0000000..1c2fd75 --- /dev/null +++ b/crates/khonsu-plugin-protocol/examples/scripted.rs @@ -0,0 +1,49 @@ +//! A plugin for tests: sends the JSON lines in its `lines` setting, then exits +//! with its `exit` setting, so a test can make a sync fail in any way it likes. +//! Not built into the app or shipped. + +use std::io::Write; +use std::process::ExitCode; + +use khonsu_plugin_protocol::{read_request, ConfigField, Describe, Emitter, FieldKind, Message, Permissions}; + +fn main() -> ExitCode { + let command = std::env::args().nth(1).unwrap_or_default(); + if command == "describe" { + let describe = Describe { + name: "Scripted".into(), + version: "0.1.0".into(), + description: "Sends what a test tells it to".into(), + config: ["lines", "exit"] + .map(|key| ConfigField { + key: key.into(), + label: key.into(), + kind: FieldKind::String, + required: false, + secret: false, + help: String::new(), + default: None, + }) + .to_vec(), + permissions: Permissions::default(), + labels: Vec::new(), + relationship_types: Vec::new(), + supports_incremental: true, + analyses: Vec::new(), + }; + Emitter::stdout().emit(&Message::Describe(describe)).unwrap(); + return ExitCode::SUCCESS; + } + let request = read_request().unwrap(); + if command == "test" { + Emitter::stdout().emit(&Message::TestResult { ok: true, message: String::new() }).unwrap(); + return ExitCode::SUCCESS; + } + let mut out = std::io::stdout().lock(); + for line in request.config.get("lines").and_then(|lines| lines.as_array()).into_iter().flatten() { + writeln!(out, "{line}").unwrap(); + } + out.flush().unwrap(); + let code = request.config.get("exit").and_then(|exit| exit.as_u64()).unwrap_or(0); + ExitCode::from(code as u8) +} diff --git a/crates/khonsu-plugin-protocol/src/lib.rs b/crates/khonsu-plugin-protocol/src/lib.rs index 980e90e..98c4b5d 100644 --- a/crates/khonsu-plugin-protocol/src/lib.rs +++ b/crates/khonsu-plugin-protocol/src/lib.rs @@ -6,15 +6,21 @@ //! describe # prints one `Describe` message //! test # reads a `Request` on stdin, prints `TestResult` //! sync # reads a `Request` on stdin, streams records +//! analyze # optional: reads an `AnalyzeRequest`, streams findings //! ``` //! //! Every message is one JSON object per line on stdout, tagged by `type`. //! Anything a plugin writes to stderr is treated as diagnostics. Plugins never //! open the graph directory: the host is the only writer and merges records by //! their stable IDs. Cancellation is the host terminating the process. +//! +//! `analyze` is only run for an analysis the plugin lists in `Describe::analyses`. +//! The host reads the graph and sends records, and the plugin answers in stable +//! references to them, so what it says can be checked against the graph. use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::collections::BTreeMap; use std::io::{self, BufRead, Write}; pub const PROTOCOL_VERSION: u32 = 1; @@ -74,6 +80,222 @@ pub struct Describe { pub relationship_types: Vec, #[serde(default)] pub supports_incremental: bool, + /// Analyses this plugin offers over a graph. Most plugins offer none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub analyses: Vec, +} + +/// An analysis a plugin offers: what it reads, and the words for what it says. +/// All meaning lives here and in the plugin, and the host only checks and shows it. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct AnalysisInfo { + /// Letters, digits, `-` or `_`, unique within the plugin. + pub id: String, + pub title: String, + #[serde(default)] + pub description: String, + /// What a result does and does not show. + #[serde(default)] + pub claim: String, + /// Labels a finding can be about. The window offers the analysis on a node + /// with one of them. + #[serde(default)] + pub subject_labels: Vec, + /// Labels whose nodes are read even without a relationship in scope. + #[serde(default)] + pub node_labels: Vec, + /// Relationship types to read, with the nodes at their ends. + #[serde(default)] + pub relationship_types: Vec, + /// Every verdict a finding can have, most in need of attention first. Findings + /// are ordered by this. + pub verdicts: Vec, + /// Every rule a finding can come from. + pub rules: Vec, + /// Text for other languages: locale, then `title`, `claim`, `verdict.`, + /// `rule.` or `condition.`. Anything missing falls back on the text above. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub translations: BTreeMap>, +} + +impl AnalysisInfo { + /// The text for `key` in `locale`, if the plugin supplies one. + pub fn translated(&self, locale: &str, key: &str) -> Option<&str> { + self.translations.get(locale)?.get(key).map(String::as_str) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerdictInfo { + pub id: String, + pub title: String, + pub tone: VerdictTone, +} + +/// How much a verdict asks of the reader, which is all the host needs to know +/// to show it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VerdictTone { + /// Worth acting on. + Attention, + /// Could not be decided, so worth getting more data. + Undecided, + /// Nothing to act on. This is not a claim that all is well. + Quiet, +} + +/// A rule in words, for a person reading a report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuleInfo { + pub rule_id: String, + pub title: String, + pub statement: String, + #[serde(default)] + pub assumptions: Vec, +} + +/// Sent on stdin for `analyze`, as one line. It holds no source configuration +/// or secrets, and record properties have secrets masked. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct AnalyzeRequest { + pub protocol: u32, + /// The `AnalysisInfo::id` to run. + pub analysis: String, + pub nodes: Vec, + pub edges: Vec, + /// False when the graph held more than the host reads, so absent records + /// say nothing. + #[serde(default = "default_complete")] + pub complete: bool, + /// How much of each source its last sync covered, where that is known. What + /// an incomplete source left out can be what would have met a condition. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub coverage: BTreeMap, +} + +/// A source's last sync, as far as an analysis needs it. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SourceCoverage { + pub run_id: String, + /// `complete`, `partial`, `failed` or `cancelled`. + pub status: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub gaps: Vec, +} + +impl SourceCoverage { + pub fn is_complete(&self) -> bool { + self.status == "complete" + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct AnalysisNode { + /// The stable reference to this record: use it to refer to it in findings. + #[serde(rename = "ref")] + pub reference: String, + pub labels: Vec, + /// What people call it. + #[serde(default)] + pub name: String, + /// The source that wrote it, when one did. See `AnalyzeRequest::coverage`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default)] + pub properties: Map, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct AnalysisEdge { + #[serde(rename = "ref")] + pub reference: String, + /// The node this relationship leaves and arrives at, by reference. + pub from: String, + pub to: String, + pub rel_type: String, + /// The source that wrote it, when one did. See `AnalyzeRequest::coverage`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default)] + pub properties: Map, +} + +/// Whether a condition holds. Unknown is not the same as false: what was not +/// read cannot rule anything out. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConditionStatus { + Met, + NotMet, + Unknown, +} + +/// A field a condition read. The host looks its value up in the record itself, +/// so a plugin cannot misquote it. A relationship's ends read as `@from` and `@to`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FieldRef { + pub record: String, + pub field: String, + /// Where the source calls it this, e.g. the API and field it came from. + #[serde(default)] + pub source_field: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConditionRecord { + pub id: String, + pub statement: String, + pub status: ConditionStatus, + /// What was found, in words. + #[serde(default)] + pub detail: String, + #[serde(default)] + pub fields: Vec, +} + +/// One relationship crossed: along its arrow, or against it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HopRef { + pub edge: String, + pub along: bool, +} + +/// A path as references, which is all that crosses the process boundary. The host +/// checks that it connects and rebuilds it from the graph. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathRef { + /// How it was worked out. + pub derivation: String, + pub hops: Vec, +} + +/// One finding, as a plugin reports it. Every reference must be to a record in +/// the request, and the host refuses the whole result if one is not. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FindingRecord { + /// The same rule and records should give the same ID. + pub finding_id: String, + pub rule_id: String, + /// A `VerdictInfo::id`. + pub verdict: String, + /// The node it is about, by reference. + pub resource: String, + #[serde(default)] + pub conditions: Vec, + /// What could not be evaluated. + #[serde(default)] + pub unknowns: Vec, + #[serde(default)] + pub assumptions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// The finding this one rests on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub depends_on: Option, + /// Records it rests on beyond those its conditions and path name. + #[serde(default)] + pub records: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -181,8 +403,32 @@ pub enum Message { nodes: u64, #[serde(default)] edges: u64, + /// False when part of the source could not be enumerated, so what is + /// absent from this sync may still exist. A full sync then removes + /// nothing. Absent means complete, as for plugins older than this. + #[serde(default = "default_complete", skip_serializing_if = "is_true")] + complete: bool, + /// What was not enumerated, in words, when `complete` is false. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + gaps: Vec, }, Error { message: String }, + /// One finding of an `analyze`. + Finding(Box), + /// The end of an `analyze`. Without it the result is refused. + AnalysisDone { + /// How many resources were looked at. + #[serde(default)] + subjects: u64, + }, +} + +fn default_complete() -> bool { + true +} + +fn is_true(value: &bool) -> bool { + *value } /// Helper for plugin authors: writes messages as JSON lines to stdout. @@ -241,6 +487,19 @@ pub fn read_request() -> io::Result { serde_json::from_str(&line).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) } +/// Helper for plugin authors: reads the `AnalyzeRequest` the host sends on stdin. +pub fn read_analyze_request() -> io::Result { + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + serde_json::from_str(&line).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) +} + +/// A hash that stays the same across platforms and Rust versions, for IDs that +/// must not change between runs. +pub fn fnv1a(text: &str) -> u64 { + text.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)) +} + /// Parses one line of plugin output. Blank lines yield `None`. pub fn parse_line(line: &str) -> Option> { let trimmed = line.trim(); @@ -267,6 +526,46 @@ mod tests { assert!(parse_line(" ").is_none()); } + #[test] + fn a_describe_from_before_analyses_parses_with_none() { + let line = r#"{"type":"describe","name":"Old","version":"1"}"#; + match parse_line(line).unwrap().unwrap() { + Message::Describe(describe) => assert!(describe.analyses.is_empty()), + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn a_finding_and_its_path_survive_the_line() { + let finding = Message::Finding(Box::new(FindingRecord { + finding_id: "finding:1".into(), + rule_id: "rule".into(), + verdict: "flagged".into(), + resource: "node:a".into(), + conditions: vec![ConditionRecord { + id: "c".into(), + statement: "It holds.".into(), + status: ConditionStatus::Unknown, + detail: String::new(), + fields: vec![FieldRef { record: "node:a".into(), field: "state".into(), source_field: "State".into() }], + }], + unknowns: vec![], + assumptions: vec![], + path: Some(PathRef { derivation: "by rule".into(), hops: vec![HopRef { edge: "rel:ab".into(), along: false }] }), + depends_on: Some("finding:0".into()), + records: vec![], + })); + let line = serde_json::to_string(&finding).unwrap(); + assert!(line.contains(r#""status":"unknown""#), "{line}"); + assert_eq!(parse_line(&line).unwrap().unwrap(), finding); + } + + #[test] + fn a_record_is_referred_to_as_ref_on_the_wire() { + let node = AnalysisNode { reference: "node:a".into(), ..AnalysisNode::default() }; + assert!(serde_json::to_string(&node).unwrap().starts_with(r#"{"ref":"node:a""#)); + } + #[test] fn edge_line_written_by_a_third_party_parses() { let line = r#"{"type":"edge","id":"e1","source":"a","target":"b","rel_type":"IN_VPC"}"#; diff --git a/docs/exposure-rules.md b/docs/exposure-rules.md new file mode 100644 index 0000000..0de3063 --- /dev/null +++ b/docs/exposure-rules.md @@ -0,0 +1,197 @@ +# AWS exposure rules + +`khonsu plugin analyze aws exposure` says which instances an account's +configuration leaves potentially open to the internet, and shows how it decided. +It reads the graph only. It sends no traffic and needs no AWS access once the +graph is synced. + +These rules belong to the AWS plugin, in `plugins/aws/src/exposure.rs`. KhonsuGraph +itself knows nothing about AWS: it sends the plugin records from the graph, checks +the findings that come back against them, and looks up their values and paths. See +[offering an analysis](plugins.md#offering-an-analysis). + +A finding is **potential exposure worked out from configuration**. It does not +show that anything is reachable, and no finding does not show that a resource is +safe. No verdict says "reachable". + +## Verdicts + +Each rule is a list of conditions, each `met`, `not_met` or `unknown`. There is +no score. + +| Verdict | When | How to read it | +|---|---|---| +| `potential_exposure` | Every condition is met | Check it: the configuration allows this path | +| `insufficient_data` | None is unmet and at least one is unknown, or a source behind it was not read completely | `unknowns` says what could not be read; get that data | +| `not_indicated` | At least one is unmet, and every source behind it was read completely as far as is known | This rule does not point here. Another route may exist | + +Findings are ordered by verdict, then rule ID, then resource ID, so the same +graph always gives the same order. + +## Rules + +### `aws.exposure.direct-instance` + +Every instance gets one finding. + +| Condition | Field | Unknown when | +|---|---|---| +| `instance-running` | `describe-instances`: `State.Name` | No state was supplied | +| `public-address` | `describe-instances`: `PublicIpAddress`, `Ipv6Address` | Never: AWS omits the field when there is no address | +| `open-inbound-rule` | `describe-security-groups`: `IpPermissions[]` from `0.0.0.0/0` or `::/0` | No security groups are recorded, since every instance has one | +| `internet-route` | `describe-route-tables`: `Routes[]`, `Associations[]` | No subnet or route table is recorded, or only a narrower route leads to a gateway | + +A subnet with no route table of its own uses its VPC's main table. A route counts +when it is active, goes to an internet gateway, and matches the family of the +instance's address. + +### `aws.exposure.public-load-balancer` + +Each target gets one finding per load balancer and target group that sends +traffic to it. + +| Condition | Field | Unknown when | +|---|---|---| +| `instance-running` | `describe-instances`: `State.Name` | No state was supplied | +| `internet-facing` | `describe-load-balancers`: `Scheme` | No scheme was supplied | +| `balancer-open-rule` | `describe-security-groups`, on the balancer's groups | The balancer has no groups recorded, as a network load balancer can | +| `target-healthy` | `describe-target-health`: `TargetHealth.State` | Not supplied, or anything but `healthy` | +| `target-allows-balancer` | `describe-target-groups`: `Port`, `Protocol`; the target's groups and their rules | No port, protocol or target groups are recorded, only a CIDR rule covers the port, or no rule lets it in and the balancer has no groups recorded | + +A `TCP_UDP` or `TCP_QUIC` target group carries both, so a rule for either protocol +counts. `UDP`, `QUIC` and `GENEVE` need a UDP rule, and anything else a TCP one. + +### `aws.exposure.security-group-lateral` + +One finding for each instance whose security group allows the +group of an instance with a `potential_exposure` or `insufficient_data` finding. +It follows one step. `depends_on` names the finding it rests on, and its verdict +can be no stronger than that one's. + +## What the rules do not read + +Each finding lists its assumptions, and they include these: + +- Network ACLs, host firewalls, and anything outside the account. +- Whether anything listens on an allowed port. +- Load balancer listeners, so an open group is not shown to lead to a given target group. +- A target's own registered port. The target group's port is used. +- Address family on rules: `0.0.0.0/0` and `::/0` are one Internet record. +- IAM. The rules say nothing about what an instance's role can do. + +Only `0.0.0.0/0` and `::/0` count as the internet. Rules that together cover the +address space, such as `0.0.0.0/1` and `128.0.0.0/1`, are not recognized. + +## A missing response is not a clear result + +A record that should exist and doesn't makes a condition `unknown`, not `not_met`. +A graph synced before route tables were read gives `insufficient_data` on the direct +rule until it is synced again. + +A source's last sync can also be incomplete, because a saved response was missing, +the plugin reported a gap, or the sync failed or was cancelled. Then: + +- Each finding that rests on it names it in `incomplete_sources` and in `unknowns`. +- A finding that would be `not_indicated` becomes `insufficient_data`, because what + the sync missed could be what would have met a condition. +- A `potential_exposure` stays, because what the source did report still stands. +- The report's `complete` is false, and `sources` gives each source's last run. + +A security group that is one of several on an instance and is missing from the +responses can't be detected this way, because the instance's other groups are +still there. + +`complete` is also false when the graph held more than 50,000 records of one type. A +missing finding then means nothing. + +## Walkthrough + +This uses the saved responses in `plugins/aws/tests/fixture-exposure`, with no AWS +access. It holds one case for each verdict. Run it from the repository root. In +Git Bash on Windows, use `$(pwd -W)` in place of `$PWD`. + +```sh +khonsu init graphs/exposure +khonsu source add --db graphs/exposure --plugin aws --name shop \ + -c "fixture_dir=\"$PWD/plugins/aws/tests/fixture-exposure\"" -c regions=us-east-1 +khonsu plugin sync --source shop +khonsu plugin analyze aws exposure --db graphs/exposure --pretty +``` + +Read `summary` first: three potential exposures, one undecided, three not indicated. + +**1. A potential exposure.** Look at one instance: + +```sh +khonsu plugin analyze aws exposure --db graphs/exposure --pretty --resource shop/123456789012:us-east-1:i-web1 +``` + +`web-1` meets all four conditions. Each condition's `fields` give the record, the +stored field and, in `source_field`, the AWS field it came from. `path.hops` shows the candidate path, +`path.rule_id` the rule, and `assumptions` what was not checked. + +**2. Open it in Path Explorer.** A finding's `walk` holds the arguments: + +```sh +khonsu explore --db graphs/exposure --from shop/cidr:internet --to shop/123456789012:us-east-1:i-web1 \ + --direction both --type ALLOWS,PROTECTED_BY --max-hops 2 +``` + +To see more around it, raise `--max-hops` or drop `--type`. + +**3. Check a record.** Every ID in `contributing` can be looked up. Remove the +`node:` or `rel:` prefix: + +```sh +khonsu evidence --db graphs/exposure --node shop/123456789012:us-east-1:i-web1 +``` + +**4. A security group rule that is not exposure.** `batch-1` has port 22 open to +`0.0.0.0/0`. It has no public address, and its subnet's default route goes to a NAT +gateway, so it gets `not_indicated`. The conditions show why, and the open rule +still deserves a look: + +```sh +khonsu plugin analyze aws exposure --db graphs/exposure --pretty --resource shop/123456789012:us-east-1:i-batch1 +``` + +**5. An undecided case.** `legacy-1` has a public address and an open rule, but the +saved responses hold no route tables for its VPC. `unknowns` says so: + +```sh +khonsu plugin analyze aws exposure --db graphs/exposure --pretty --verdict insufficient-data +``` + +**6. One step further in.** `db-1` has no public address, and its group allows +`web-1`'s group on 5432. Its `aws.exposure.security-group-lateral` finding follows +from `web-1`'s through `depends_on`: + +```sh +khonsu plugin analyze aws exposure --db graphs/exposure --pretty --resource shop/123456789012:us-east-1:i-db1 +``` + +The commands above need the graph free. They fail with kind `locked` while the +window has it open. + +## In the window + +Select an instance on a graph canvas, then choose **Run analysis** under Exposure in +the inspector. The window offers it because the AWS plugin says it can speak about +instances, and every word it shows comes from the plugin. It evaluates the whole +graph, not just what is on the canvas, and the result lasts until the canvas gets a +new result. After a sync, choose **Check again**. **Stop** ends a run that is taking +too long. + +The window holds the graph only while reading it, not while the plugin works, and +gives the same findings as `khonsu plugin analyze aws exposure`. + +Each finding shows its verdict, rule, conditions with their AWS fields, candidate +path and what was not checked. + +- **Show path on canvas** narrows the canvas to the path. **Back** returns to the + earlier view, and selecting a node on the path shows its evidence. +- **Copy finding as JSON** copies the same record as the command. + +In the Chinese interface, verdicts, rule titles and condition statements are +translated, by the plugin. Each condition's detail and the list of what was not +checked stay in English. diff --git a/docs/investigation-gates.md b/docs/investigation-gates.md index 23d939b..85d4799 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -16,12 +16,20 @@ cargo test --locked --workspace --test investigation | Candidate paths, direction, hop cap and type filters | Named queries in the golden file; the path answers are also written out in the test | | Determinism | Two separate syncs give the same answers and internal IDs; a re-sync writes no relationships | | Limits | `--max-rows` reports `truncated`; the 100,000-row ceiling is in `crates/khonsu-core/tests/engine.rs` | +| Path Explorer | `crates/khonsu-core/tests/explore.rs` covers direction, cycles, zero matches, each cap and cancellation; the golden file holds two `khonsu explore` walks | | Cancellation | A sync cancelled partway reports it, and the graph keeps its answers | | Corrupt input | A malformed response, a missing folder, a bad dashboard and a damaged graph each fail with a reason | | Cycles | A shortest path and a variable-length pattern both end on a cycle | -| HawDB upgrades | `app/tests/fixtures/graph-hawdb-*.zip` must still give the golden answers and accept a write | - -Timings, sync times, paths and line numbers are left out of the comparison. +| Evidence | `khonsu evidence` and `explore --to` give stable IDs, state what is unknown, and mask secrets; the golden file holds a node and a relationship | +| Exposure rules | `plugins/aws/tests/fixture-exposure` has a positive, a negative and an undecided case; the golden file holds its whole report, and rule IDs and verdicts for the shop fixture. See [the rules](exposure-rules.md) | +| Plugin analyses | An example analyzer with nothing to do with AWS gives findings on a graph of its own; a forged reference, a missing end and a failure are each refused; a plugin with no analyses goes on syncing | +| Saved investigations | Reopening restores a walk and path on unchanged data, and flags a changed source, a missing record, a missing graph and a bad file; the golden file holds a reopened walk and the fixture's fingerprint. See [saved investigations](investigations.md) | +| Sync coverage | A scripted plugin fails midway, stops without `done`, reports gaps, sends duplicates and missing endpoints, and moves its cursor; none of it removes records or moves the cursor unless the sync was complete | +| Snapshots and diff | Two kept snapshots give a fixed diff; the AWS and CSV examples report what changed, a no-op re-sync gives none, and incomplete coverage is never called a removal. See [snapshots](snapshots.md) | +| HawDB upgrades | `app/tests/fixtures/graph-hawdb-*.zip` must still give the golden answers and fingerprint, and accept a write | + +Timings, sync times, paths, line numbers and plugin versions are left out of the +comparison. Floats are rounded to nine decimal places. ## After a change @@ -47,19 +55,33 @@ These are written down so that nobody reads an empty result as proof: `db-1` exists as rules, while the directed path query finds nothing, because `PROTECTED_BY` points from the instance to its group. The two queries ask different questions. -- **A missing saved response reads as an empty one (#38).** A partial export - syncs without a warning, and a re-sync removes what the missing file used to - describe. A test records this behaviour, so fixing it has to change the test. +- **A partial sync removes nothing, so its records can be out of date.** A saved + response that is missing makes the sync `partial`. What the graph already held + stays until a complete sync. Evidence, exposure findings and reopened + investigations say which sources this applies to. +- **A real account's sync is complete or failed, never partial.** The AWS plugin + stops at the first call that fails. Records from before that are kept. +- **Runs aren't recorded for older syncs.** A source last synced before runs were + kept has unknown coverage, and isn't treated as incomplete. - **HawDB's variable-length patterns take one relationship type.** `[:A|B*..6]` does not parse. Use explicit hops for a typed chain, or an untyped `shortestPath`. - **`type(r)` fails inside a comprehension over `relationships(p)`.** Use `[r IN relationships(p) | r.type]`. -- **A running query cannot be cancelled.** `Graph::run` has no way to stop one; - only syncs can be cancelled, and only they are tested. +- **A running query cannot be cancelled.** `Graph::run` has no way to stop one. + Syncs can be cancelled, and a walk stops at its next query, so one slow step + still finishes first. + +- **`record_written_at` is when a record last changed.** A sync does not rewrite + an unchanged relationship, so this can be older than the sync that last saw it. + `source_last_successful_sync` is the source's last complete sync, and + `source_coverage` says how its latest one ended. +- **Evidence shows what is stored, not the raw response.** The AWS plugin stores + chosen fields, not the API call each came from. + +- **An exposure finding is not reachability.** It says what configuration allows. + [The rules](exposure-rules.md) list what they do not read. -Evidence provenance and rule names are not in the golden file yet, because they -arrive with #33 and #35. ## Baselines diff --git a/docs/investigations.md b/docs/investigations.md new file mode 100644 index 0000000..827595c --- /dev/null +++ b/docs/investigations.md @@ -0,0 +1,101 @@ +# Saved investigations + +A saved investigation keeps what you were looking at in a file of its own: a walk's +starting points and filters, the path you chose, a query and a dashboard. Saving +writes nothing to the graph. + +```sh +khonsu investigation save web.investigation --db graphs/aws \ + --from shop/cidr:internet --to shop/123456789012:us-east-1:i-web1 \ + --type ROUTES_TO,FORWARDS_TO --max-hops 4 +khonsu investigation reopen web.investigation +``` + +To keep a finding's path exactly, pass its `path_refs`. A walk finds the fewest hops, +which isn't always the path a finding describes, so these are looked up one by one: + +```sh +khonsu investigation save db.investigation --db graphs/aws \ + --path-refs '[{"edge": "rel:...", "along": true}]' +``` + +Reopening then checks that each of those records is still there and that they still +connect. If not, `path_same` is false, and the walk runs instead. Saving from the +window does this for you after **Show path on canvas**. + +## What reopening tells you + +Reopening runs the walk again on the graph as it is now and says how that differs +from when it was saved. Read `graph_state` first. + +| `graph_state` | Meaning | +|---|---| +| `unchanged` | The graph holds what it did when saved | +| `changed` | Something differs. `differences` gives what it can, and is not a full list | +| `not_compared` | The graph holds over 200,000 nodes or relationships, so it was not hashed, and the counts agree | + +A changed answer doesn't replace the saved one silently: + +- `path_same` says whether the path rests on the same records. It compares which + records the path crosses, so a changed value on one of them gives `graph_state` + `changed` with `path_same` true. +- `missing_seeds` and `target_missing` name saved records that have gone. Nothing + stands in for them, and the walk runs from the seeds that remain. +- `unsure` lists records found by an internal ID on a changed graph. A record + written by hand has no stable ID, so the same number may now mean something else. +- `incomplete_sources` names sources whose last sync wasn't complete, so the graph + may be missing records whatever `graph_state` says. + +A saved query is returned, not run. + +## What counts as a change + +The fingerprint hashes every node and relationship: labels, types, ends and +properties. Internal IDs, sync times and plugin versions are left out, so a re-sync +that changes nothing stays `unchanged`, and a graph rebuilt from the same data +matches. It covers the whole graph, not just the records you saved, so an unrelated +change also reads as `changed`. + +The hash is for noticing change, not for security: someone who wants to can +produce a matching one. + +A file pins the source runs it was evaluated against. On a changed graph, +`differences` names the run when saved and the run now. To see what changed, compare +[snapshots](snapshots.md), since a saved investigation holds no records of its own. + +## The file + +It is JSON with a `format` and a `version`, so it can be read and compared. + +- **Holds:** the graph's path, data source IDs, stable record IDs and filters. +- **Never holds:** source configuration, or credentials. +- **Saved as typed:** the name and any query. Leave anything secret out of them. The + graph's path can include a user name, so check it before sharing a file. + +Fields a release doesn't know are ignored, so a later one can add some. A file from +a later version fails with kind `too_new` rather than being read wrongly. A file +that can't be used fails with kind `invalid`, and a graph that has moved fails with +`graph_missing`; pass `-d` to use another one. + +## In the window + +Select a node on a graph canvas, then choose **Save investigation…** in Path +Explorer. It keeps the current filters, the last walk's starting points, and the +path to the selected node if that walk reached it, along with the tab's query. + +**File > Open investigation…** or `khonsu open web.investigation` reopens one in a +new tab, and opens its graph first if another one is open. The query goes in the +editor without running. The canvas shows the saved path, with **Back** leading to +the whole walk, and Path Explorer shows the filters. Anything that differs from +when it was saved appears as a warning on the canvas and in Path Explorer. + +A new query in that tab clears the warnings along with the walk. Reopening a file +again adds another tab. + +## Limits + +- `khonsu investigation` needs the graph free. It fails with kind `locked` while + the window has it open, so use the window for a graph that's open there. +- The window saves no dashboard or layout. A saved file's `dashboard` is not opened + on reopening, and node positions start over. +- A file isn't reopened by itself when the app restarts. diff --git a/docs/plugins.md b/docs/plugins.md index 6f45803..5b0e82f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -127,6 +127,122 @@ A sync that ends without `done` is reported as failed. Records already written are kept and the next sync reconciles them. To fail on purpose, send `{"type": "error", "message": "..."}`; the person sees the message. +A full sync removes records it did not see, so only finish with a plain `done` +when you enumerated the whole source. If part of it could not be read and you +still have something useful to send, say so: + +```json +{"type": "done", "nodes": 2, "edges": 1, "complete": false, "gaps": ["page 3 timed out"]} +``` + +The sync is then recorded as `partial`: nothing is removed, the cursor doesn't +move, and exposure findings can't read the absence of a record as a clear result. +Leaving out `complete` means complete. Don't put secrets in `gaps` or in messages; +values from the source's secret fields are masked before they are kept. + +## Offering an analysis + +A plugin can also say something about a graph, as well as fill one. This is +optional, and a plugin that offers nothing goes on working as before. The AWS +plugin's [exposure rules](exposure-rules.md) are one, and +`crates/khonsu-plugin-protocol/examples/analyzer.rs` is a small one with nothing to +do with AWS. + +**The boundary.** Your plugin owns what a finding means: its rules, verdicts, +assumptions and words. KhonsuGraph owns the graph. It reads records for you, so an +analysis also works while the window has the graph open, and it checks what you say +against them. KhonsuGraph knows nothing about your labels, rules or verdicts. + +List what you offer in `describe`: + +```json +{"analyses": [{ + "id": "dead-ends", + "title": "Dead ends", + "claim": "Only says what the links in the graph say.", + "subject_labels": ["Thing"], + "node_labels": ["Thing"], + "relationship_types": ["LINKS"], + "verdicts": [ + {"id": "dead_end", "title": "Dead end", "tone": "attention"}, + {"id": "continues", "title": "Continues", "tone": "quiet"} + ], + "rules": [{"rule_id": "demo.dead-end", "title": "No link onward", + "statement": "A thing with nothing linked after it.", "assumptions": []}], + "translations": {"zh": {"verdict.dead_end": "死路"}} +}]} +``` + +| Field | What it does | +|---|---| +| `subject_labels` | The window offers the analysis on a node with one of these | +| `node_labels`, `relationship_types` | What KhonsuGraph reads for you. Each must be a plain name | +| `verdicts` | Every verdict you can give, most in need of attention first, which sets the order of findings. `tone` is `attention`, `undecided` or `quiet` | +| `rules` | Every rule a finding can come from | +| `translations` | By locale: `title`, `claim`, `verdict.`, `rule.`, `condition.`. Anything missing falls back on the English | + +` analyze` then gets one line on stdin: + +```json +{"protocol": 1, "analysis": "dead-ends", "complete": true, + "nodes": [{"ref": "node:b", "labels": ["Thing"], "name": "b", "properties": {"name": "b"}}], + "edges": [{"ref": "rel:ab", "from": "node:a", "to": "node:b", "rel_type": "LINKS", "properties": {}}]} +``` + +It holds no source configuration or secrets, and properties that look like secrets +are masked. `complete` is false when the graph held more than 50,000 records of one +label or type, and then a record's absence says nothing. + +A record's `source` says which data source wrote it, and `coverage` says how much of +each source its last sync covered, where that is known: + +```json +{"coverage": {"shop": {"run_id": "shop#3", "status": "partial", "gaps": ["page 3 timed out"]}}} +``` + +What an incomplete source left out could be what your rule was looking for, so don't +let it clear anything. KhonsuGraph lists the incomplete sources behind each finding +in `incomplete_sources` and marks the report incomplete whatever you decide, but the +verdict is yours. + +Answer with one line per finding, then `analysis_done`: + +```json +{"type": "finding", "finding_id": "finding:1", "rule_id": "demo.dead-end", "verdict": "dead_end", + "resource": "node:c", + "conditions": [{"id": "no-link-onward", "statement": "Nothing is linked after it.", "status": "met", + "detail": "c does not link onward.", + "fields": [{"record": "node:c", "field": "name", "source_field": "name"}]}], + "unknowns": [], "assumptions": ["Only LINKS is read."], + "path": {"derivation": "the link that arrives here", "hops": [{"edge": "rel:bc", "along": true}]}, + "depends_on": null, "records": []} +{"type": "analysis_done", "subjects": 3} +``` + +- **You point at records, and KhonsuGraph reads them.** A field's value comes from + the graph, not from you. A relationship's ends read as the fields `@from` and `@to`. +- **A path is references.** Each hop names a relationship and whether it was crossed + along its arrow. KhonsuGraph checks that the hops connect and that the path starts + or ends at the resource, then rebuilds it from the graph, so it can be drawn and + saved. +- **A bad answer is refused whole.** That means an unknown reference, rule or verdict, + a path that doesn't connect, a repeated `finding_id`, or no `analysis_done`. Dropping + one finding would read as that resource having none. +- **Keep `finding_id` stable:** the same rule and records should give the same ID. + `fnv1a` in the protocol crate is there for this. +- **Say when you don't know.** A condition's `status` can be `unknown`. Work your + verdict out from what you were sent, and don't read a missing record as a fact. + +An analysis needs the same approval as a sync. + +```sh +khonsu plugin describe my-plugin # what it offers +khonsu plugin analyze my-plugin dead-ends --db graphs/lab --pretty +``` + +`--resource ID`, `--verdict ID` and `--max-findings` narrow what is printed, and +`summary` still counts everything. + ## Stable IDs and merging Every node and relationship carries an `id` that must stay the same from one diff --git a/docs/snapshots.md b/docs/snapshots.md new file mode 100644 index 0000000..0645909 --- /dev/null +++ b/docs/snapshots.md @@ -0,0 +1,70 @@ +# Snapshots and diff + +A snapshot keeps a graph's records in a file, by stable IDs, so you can later ask +what changed. HawDB has no time travel, and this doesn't add any: a snapshot exists +only if you took it. + +```sh +khonsu snapshot save monday.snapshot.json --db graphs/aws +khonsu plugin sync --source shop +khonsu snapshot save tuesday.snapshot.json --db graphs/aws +khonsu snapshot diff monday.snapshot.json tuesday.snapshot.json --pretty +khonsu snapshot check monday.snapshot.json --db graphs/aws +``` + +## What a diff says + +Nodes and relationships each list: + +| List | Meaning | +|---|---| +| `added` | In the later snapshot only | +| `removed` | Gone, and the source's last sync was `complete` in the later snapshot | +| `not_seen` | Missing from the later snapshot, and nothing shows the source was read completely. They may still exist | +| `changed` | The same stable ID with different properties, labels or ends | + +A record is only `removed` when the later snapshot can back that up. After a +partial, failed or cancelled sync, or with a source that has no record of its runs +or no records left, it stays in `not_seen`, and `warnings` says why. + +`node_counts` and `relationship_counts` count everything. The lists hold at most +`--max-changes` in all, 1,000 by default, and `truncated` counts the rest. + +The same two files always give the same diff. + +## What a snapshot holds + +- Each record's stable ID, labels or type, ends and own properties. +- Each source's last run and plugin version at that moment. +- A digest of the records, which also gives the `snapshot_id`. The same records give + the same ID. + +It doesn't hold the graph's path, source configuration or credentials. Anything that +looks secret is masked as evidence masks it, so a changed secret doesn't show as a +change. KhonsuGraph's own bookkeeping isn't kept, so a re-sync that changes nothing +gives an empty diff. + +The file does hold your data's other properties, such as names and addresses, so +treat it as you would the graph. + +## Limits + +- **A graph over 200,000 nodes or relationships is refused** with kind `too_large`. + A snapshot cut short would read as removals later. +- **Records with no stable ID aren't compared.** They're counted, and `warnings` says + how many. A record written by hand usually has none. +- **A diff lists records, not meanings.** It doesn't say which exposure findings or + paths changed. To see that, run the analysis on the graph, such as `khonsu plugin analyze aws exposure`, or compare the + relationships it lists. +- **Snapshots are taken by hand.** Nothing takes one when a sync runs. +- **The window has no snapshot or diff view yet.** It does warn when a reopened + investigation's graph has moved on. + +## Staleness + +`khonsu snapshot check` compares a snapshot with the graph and gives `graph_state`, as +reopening an investigation does. + +A saved investigation pins the source runs it was evaluated against, along with the +graph's digest. Reopening one on a changed graph names the run it was saved at and +the run now, in `differences`, and the window shows them as warnings. diff --git a/examples/README.md b/examples/README.md index 587d9ff..22c5e43 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,8 +23,8 @@ khonsu plugin sync --source infra khonsu check examples/infra/blast_radius.dash --db graphs/infra ``` -`aws/exposure.dash` shows what the internet can reach in an AWS account. Try -it on the saved responses that ship with the AWS plugin: +`aws/exposure.dash` shows what an AWS account's configuration opens to the +internet. Try it on the saved responses that ship with the AWS plugin: ```sh khonsu init graphs/aws @@ -40,6 +40,9 @@ profile; the plugin only calls `describe`, `list` and `get` operations. [the investigation gates](../docs/investigation-gates.md) for what is tested and what a result does not prove. +For findings that explain themselves, with the rule, fields and unknowns behind +each, follow the walkthrough in [the exposure rules](../docs/exposure-rules.md). + ## Build a story graph ```sh diff --git a/examples/aws/exposure.dash b/examples/aws/exposure.dash index b634a7e..8ce0994 100644 --- a/examples/aws/exposure.dash +++ b/examples/aws/exposure.dash @@ -1,4 +1,5 @@ -# What the internet can reach in an AWS account, from the official AWS plugin. +# What an AWS account's configuration opens to the internet, from the official +# AWS plugin. For explained findings, run `khonsu plugin analyze aws exposure`. # Build the graph from the saved responses in plugins/aws/tests/fixture, or # from a real account with a read-only profile (see examples/README.md). plot "intro" { diff --git a/examples/aws/golden.json b/examples/aws/golden.json index 353aa9f..b2a3a33 100644 --- a/examples/aws/golden.json +++ b/examples/aws/golden.json @@ -14,6 +14,16 @@ "stale_edges_removed": 0, "unchanged_edges": 0, "warnings": [] + }, + "run": { + "run_id": "shop#1", + "number": 1, + "mode": "full", + "status": "complete", + "received_nodes": 16, + "received_edges": 24, + "removals_applied": true, + "cursor_advanced": false } }, "resync": { @@ -31,6 +41,16 @@ "stale_edges_removed": 0, "unchanged_edges": 24, "warnings": [] + }, + "run": { + "run_id": "shop#2", + "number": 2, + "mode": "full", + "status": "complete", + "received_nodes": 16, + "received_edges": 24, + "removals_applied": true, + "cursor_advanced": false } }, "schema": { @@ -1043,5 +1063,5193 @@ "width": 6 } ] + }, + "evidence": { + "web_1": { + "evidence": [ + { + "evidence_id": "node:shop/123456789012:us-east-1:i-web1", + "stable": true, + "kind": "node", + "internal_id": 1, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + } + ] + }, + "internet_to_web_group": { + "evidence": [ + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 21, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "443", + "protocol": "tcp", + "rule": "\"tcp\":\"443\"" + } + } + ] + } + }, + "explore": { + "exposure_of_web_1": { + "complete": true, + "limits": [], + "depth_reached": 3, + "missing_starts": [], + "starts": [ + 14 + ], + "hops": [ + { + "depth": 1, + "from": 14, + "to": 2, + "edge": 22, + "rel_type": "ROUTES_TO", + "along": true, + "text": "Internet -[ROUTES_TO]-> igw-shop" + }, + { + "depth": 1, + "from": 14, + "to": 9, + "edge": 23, + "rel_type": "ROUTES_TO", + "along": true, + "text": "Internet -[ROUTES_TO]-> shop" + }, + { + "depth": 2, + "from": 9, + "to": 10, + "edge": 16, + "rel_type": "FORWARDS_TO", + "along": true, + "text": "shop -[FORWARDS_TO]-> web" + }, + { + "depth": 3, + "from": 10, + "to": 1, + "edge": 19, + "rel_type": "ROUTES_TO", + "along": true, + "text": "web -[ROUTES_TO]-> web-1" + } + ], + "graph": { + "nodes": [ + { + "id": 1, + "labels": [ + "Instance" + ], + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "khonsu_id": "shop/123456789012:us-east-1:i-web1", + "khonsu_source": "shop", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "id": 2, + "labels": [ + "InternetGateway" + ], + "properties": { + "khonsu_id": "shop/123456789012:us-east-1:igw-shop", + "khonsu_source": "shop", + "name": "igw-shop" + } + }, + { + "id": 9, + "labels": [ + "LoadBalancer" + ], + "properties": { + "dns_name": "shop-123.us-east-1.elb.amazonaws.com", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "khonsu_source": "shop", + "name": "shop", + "scheme": "internet-facing", + "type": "application" + } + }, + { + "id": 10, + "labels": [ + "TargetGroup" + ], + "properties": { + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "khonsu_source": "shop", + "name": "web", + "port": 8080, + "protocol": "HTTP" + } + }, + { + "id": 14, + "labels": [ + "Cidr" + ], + "properties": { + "cidr": "0.0.0.0/0", + "khonsu_id": "shop/cidr:internet", + "khonsu_source": "shop", + "name": "Internet" + } + } + ], + "edges": [ + { + "id": 16, + "source": 9, + "target": 10, + "rel_type": "FORWARDS_TO", + "properties": { + "khonsu_hash": "08f44b07b5901a25", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "khonsu_source": "shop" + } + }, + { + "id": 19, + "source": 10, + "target": 1, + "rel_type": "ROUTES_TO", + "properties": { + "health": "healthy", + "khonsu_hash": "a7a7247ee928e602", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "khonsu_source": "shop" + } + }, + { + "id": 22, + "source": 14, + "target": 2, + "rel_type": "ROUTES_TO", + "properties": { + "khonsu_hash": "08f44b07b5901a25", + "khonsu_id": "shop/cidr:internet|ROUTES_TO|123456789012:us-east-1:igw-shop", + "khonsu_source": "shop" + } + }, + { + "id": 23, + "source": 14, + "target": 9, + "rel_type": "ROUTES_TO", + "properties": { + "khonsu_hash": "08f44b07b5901a25", + "khonsu_id": "shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "khonsu_source": "shop" + } + } + ] + }, + "path": { + "to": 1, + "found": true, + "hops": [ + { + "depth": 1, + "from": 14, + "to": 9, + "edge": 23, + "rel_type": "ROUTES_TO", + "along": true, + "text": "Internet -[ROUTES_TO]-> shop" + }, + { + "depth": 2, + "from": 9, + "to": 10, + "edge": 16, + "rel_type": "FORWARDS_TO", + "along": true, + "text": "shop -[FORWARDS_TO]-> web" + }, + { + "depth": 3, + "from": 10, + "to": 1, + "edge": 19, + "rel_type": "ROUTES_TO", + "along": true, + "text": "web -[ROUTES_TO]-> web-1" + } + ], + "evidence": { + "evidence_id": "path:7e78c5aff0fd2860", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "fewest hops in a bounded walk", + "rule_id": { + "status": "unknown" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ROUTES_TO]-> shop", + "shop -[FORWARDS_TO]-> web", + "web -[ROUTES_TO]-> web-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "node:shop/123456789012:us-east-1:i-web1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 14, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "stable": true, + "kind": "relationship", + "internal_id": 23, + "types": [ + "ROUTES_TO" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "stable": true, + "kind": "node", + "internal_id": 9, + "types": [ + "LoadBalancer" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "dns_name": "shop-123.us-east-1.elb.amazonaws.com", + "name": "shop", + "scheme": "internet-facing", + "type": "application" + } + }, + { + "evidence_id": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "stable": true, + "kind": "relationship", + "internal_id": 16, + "types": [ + "FORWARDS_TO" + ], + "from": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc" + }, + "to": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "stable": true, + "kind": "node", + "internal_id": 10, + "types": [ + "TargetGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "name": "web", + "port": 8080, + "protocol": "HTTP" + } + }, + { + "evidence_id": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "stable": true, + "kind": "relationship", + "internal_id": 19, + "types": [ + "ROUTES_TO" + ], + "from": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-web1" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "health": "healthy" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-web1", + "stable": true, + "kind": "node", + "internal_id": 1, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + } + ] + } + } + }, + "what_points_at_web_1": { + "complete": false, + "limits": [ + "hops" + ], + "depth_reached": 1, + "missing_starts": [], + "starts": [ + 1 + ], + "hops": [ + { + "depth": 1, + "from": 1, + "to": 10, + "edge": 19, + "rel_type": "ROUTES_TO", + "along": false, + "text": "web-1 <-[ROUTES_TO]- web" + } + ], + "graph": { + "nodes": [ + { + "id": 1, + "labels": [ + "Instance" + ], + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "khonsu_id": "shop/123456789012:us-east-1:i-web1", + "khonsu_source": "shop", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "id": 10, + "labels": [ + "TargetGroup" + ], + "properties": { + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "khonsu_source": "shop", + "name": "web", + "port": 8080, + "protocol": "HTTP" + } + } + ], + "edges": [ + { + "id": 19, + "source": 10, + "target": 1, + "rel_type": "ROUTES_TO", + "properties": { + "health": "healthy", + "khonsu_hash": "a7a7247ee928e602", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "khonsu_source": "shop" + } + } + ] + } + } + }, + "investigation": { + "graph_state": "unchanged", + "fingerprint": { + "nodes": 16, + "relationships": 24, + "hash": "85aa948bd39feb46", + "sources": [ + "shop" + ] + }, + "walk": { + "complete": true, + "limits": [], + "depth_reached": 3, + "starts": [ + 14 + ], + "hops": [ + { + "depth": 1, + "from": 14, + "to": 2, + "edge": 22, + "rel_type": "ROUTES_TO", + "along": true, + "text": "Internet -[ROUTES_TO]-> igw-shop" + }, + { + "depth": 1, + "from": 14, + "to": 9, + "edge": 23, + "rel_type": "ROUTES_TO", + "along": true, + "text": "Internet -[ROUTES_TO]-> shop" + }, + { + "depth": 2, + "from": 9, + "to": 10, + "edge": 16, + "rel_type": "FORWARDS_TO", + "along": true, + "text": "shop -[FORWARDS_TO]-> web" + }, + { + "depth": 3, + "from": 10, + "to": 1, + "edge": 19, + "rel_type": "ROUTES_TO", + "along": true, + "text": "web -[ROUTES_TO]-> web-1" + } + ], + "graph": { + "nodes": [ + { + "id": 1, + "labels": [ + "Instance" + ], + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "khonsu_id": "shop/123456789012:us-east-1:i-web1", + "khonsu_source": "shop", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "id": 2, + "labels": [ + "InternetGateway" + ], + "properties": { + "khonsu_id": "shop/123456789012:us-east-1:igw-shop", + "khonsu_source": "shop", + "name": "igw-shop" + } + }, + { + "id": 9, + "labels": [ + "LoadBalancer" + ], + "properties": { + "dns_name": "shop-123.us-east-1.elb.amazonaws.com", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "khonsu_source": "shop", + "name": "shop", + "scheme": "internet-facing", + "type": "application" + } + }, + { + "id": 10, + "labels": [ + "TargetGroup" + ], + "properties": { + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "khonsu_source": "shop", + "name": "web", + "port": 8080, + "protocol": "HTTP" + } + }, + { + "id": 14, + "labels": [ + "Cidr" + ], + "properties": { + "cidr": "0.0.0.0/0", + "khonsu_id": "shop/cidr:internet", + "khonsu_source": "shop", + "name": "Internet" + } + } + ], + "edges": [ + { + "id": 16, + "source": 9, + "target": 10, + "rel_type": "FORWARDS_TO", + "properties": { + "khonsu_hash": "08f44b07b5901a25", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "khonsu_source": "shop" + } + }, + { + "id": 19, + "source": 10, + "target": 1, + "rel_type": "ROUTES_TO", + "properties": { + "health": "healthy", + "khonsu_hash": "a7a7247ee928e602", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "khonsu_source": "shop" + } + }, + { + "id": 22, + "source": 14, + "target": 2, + "rel_type": "ROUTES_TO", + "properties": { + "khonsu_hash": "08f44b07b5901a25", + "khonsu_id": "shop/cidr:internet|ROUTES_TO|123456789012:us-east-1:igw-shop", + "khonsu_source": "shop" + } + }, + { + "id": 23, + "source": 14, + "target": 9, + "rel_type": "ROUTES_TO", + "properties": { + "khonsu_hash": "08f44b07b5901a25", + "khonsu_id": "shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "khonsu_source": "shop" + } + } + ] + } + }, + "path": { + "evidence_id": "path:7e78c5aff0fd2860", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "fewest hops in a bounded walk", + "rule_id": { + "status": "unknown" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ROUTES_TO]-> shop", + "shop -[FORWARDS_TO]-> web", + "web -[ROUTES_TO]-> web-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "node:shop/123456789012:us-east-1:i-web1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 14, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "stable": true, + "kind": "relationship", + "internal_id": 23, + "types": [ + "ROUTES_TO" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "stable": true, + "kind": "node", + "internal_id": 9, + "types": [ + "LoadBalancer" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "dns_name": "shop-123.us-east-1.elb.amazonaws.com", + "name": "shop", + "scheme": "internet-facing", + "type": "application" + } + }, + { + "evidence_id": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "stable": true, + "kind": "relationship", + "internal_id": 16, + "types": [ + "FORWARDS_TO" + ], + "from": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc" + }, + "to": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "stable": true, + "kind": "node", + "internal_id": 10, + "types": [ + "TargetGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "name": "web", + "port": 8080, + "protocol": "HTTP" + } + }, + { + "evidence_id": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "stable": true, + "kind": "relationship", + "internal_id": 19, + "types": [ + "ROUTES_TO" + ], + "from": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-web1" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "health": "healthy" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-web1", + "stable": true, + "kind": "node", + "internal_id": 1, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#2" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + } + ] + }, + "path_same": true + }, + "exposure": { + "shop_fixture": [ + [ + "aws.exposure.direct-instance", + "node:shop/123456789012:us-east-1:i-web1", + "insufficient_data" + ], + [ + "aws.exposure.security-group-lateral", + "node:shop/123456789012:us-east-1:i-db1", + "insufficient_data" + ], + [ + "aws.exposure.direct-instance", + "node:shop/123456789012:us-east-1:i-db1", + "not_indicated" + ], + [ + "aws.exposure.public-load-balancer", + "node:shop/123456789012:us-east-1:i-web1", + "not_indicated" + ] + ], + "exposure_fixture": { + "analysis": "exposure", + "title": "Exposure", + "claim": "Potential exposure worked out from configuration. No traffic was sent, so this does not show that anything is reachable, and a resource with no finding is not shown to be safe.", + "order": "By verdict (potential_exposure, insufficient_data, not_indicated), then rule ID, then resource ID.", + "verdicts": [ + { + "id": "potential_exposure", + "title": "Potential exposure", + "tone": "attention" + }, + { + "id": "insufficient_data", + "title": "Insufficient data", + "tone": "undecided" + }, + { + "id": "not_indicated", + "title": "Not indicated by this rule", + "tone": "quiet" + } + ], + "rules": [ + { + "rule_id": "aws.exposure.direct-instance", + "title": "Instance open to the internet directly", + "statement": "A running instance with a public address, a security group rule open to the internet, and a route to an internet gateway from its subnet.", + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ] + }, + { + "rule_id": "aws.exposure.public-load-balancer", + "title": "Instance behind an internet-facing load balancer", + "statement": "A running, healthy target of an internet-facing load balancer whose security group is open to the internet, where the target's security group allows the target port from the balancer.", + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "Listeners and their rules are not read, so an open security group is not shown to lead to this target group.", + "The target group's port is used; a target registered on another port is not seen.", + "A CIDR rule is not compared with the balancer's addresses, which are not read." + ] + }, + { + "rule_id": "aws.exposure.security-group-lateral", + "title": "Instance one security group rule behind a potentially exposed one", + "statement": "A running instance whose security group allows traffic from the security group of an instance with a potential exposure finding.", + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "Only one step is followed; use khonsu explore for longer chains.", + "Whether the source instance could be made to send this traffic is not assessed." + ] + } + ], + "subjects": 5, + "summary": { + "insufficient_data": 1, + "not_indicated": 3, + "potential_exposure": 3 + }, + "complete": true, + "sources": { + "shop": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + } + }, + "findings": [ + { + "finding_id": "finding:f2dfac00f69ee196", + "rule_id": "aws.exposure.direct-instance", + "verdict": "potential_exposure", + "resource": "node:shop/123456789012:us-east-1:i-web1", + "resource_name": "web-1", + "conditions": [ + { + "id": "instance-running", + "statement": "The instance is running.", + "status": "met", + "detail": "The instance is running.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-web1", + "field": "state", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "met", + "detail": "Public address 198.51.100.10.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-web1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "known", + "value": "198.51.100.10" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-web1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "met", + "detail": "Open to the internet on tcp 443.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "443" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "met", + "detail": "Route table public sends 0.0.0.0/0 to an internet gateway.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:rtb-public", + "field": "ipv4_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "value": { + "status": "known", + "value": "igw-shop" + } + }, + { + "record": "node:shop/123456789012:us-east-1:rtb-public", + "field": "ipv6_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "value": { + "status": "unknown" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:rtb-public|ROUTES_VIA|123456789012:us-east-1:igw-shop|\"0.0.0.0/0\"", + "field": "destination", + "source_field": "ec2 describe-route-tables: Routes[].DestinationCidrBlock", + "value": { + "status": "known", + "value": "0.0.0.0/0" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:rtb-public|ROUTES_VIA|123456789012:us-east-1:igw-shop|\"0.0.0.0/0\"", + "field": "state", + "source_field": "ec2 describe-route-tables: Routes[].State", + "value": { + "status": "known", + "value": "active" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": { + "evidence_id": "path:e58dfab0a5655b39", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.direct-instance" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ALLOWS]-> web", + "web <-[PROTECTED_BY]- web-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "node:shop/123456789012:us-east-1:sg-web", + "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "node:shop/123456789012:us-east-1:i-web1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 41, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "443", + "protocol": "tcp", + "rule": "\"tcp\":\"443\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-web", + "stable": true, + "kind": "node", + "internal_id": 14, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "web tier", + "group_id": "sg-web", + "name": "web" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "stable": true, + "kind": "relationship", + "internal_id": 11, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-web1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-web1", + "stable": true, + "kind": "node", + "internal_id": 4, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "along": false + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-web1", + "direction": "both", + "types": [ + "ALLOWS", + "PROTECTED_BY" + ], + "max_hops": 2 + }, + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "node:shop/123456789012:us-east-1:sg-web", + "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "node:shop/123456789012:us-east-1:i-web1", + "node:shop/123456789012:us-east-1:rtb-public", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "rel:shop/123456789012:us-east-1:rtb-public|ROUTES_VIA|123456789012:us-east-1:igw-shop|\"0.0.0.0/0\"" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 41, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "443", + "protocol": "tcp", + "rule": "\"tcp\":\"443\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-web", + "stable": true, + "kind": "node", + "internal_id": 14, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "web tier", + "group_id": "sg-web", + "name": "web" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "stable": true, + "kind": "relationship", + "internal_id": 11, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-web1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-web1", + "stable": true, + "kind": "node", + "internal_id": 4, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:rtb-public", + "stable": true, + "kind": "node", + "internal_id": 8, + "types": [ + "RouteTable" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ipv4_default_target": "igw-shop", + "is_main": false, + "name": "public", + "route_table_id": "rtb-public" + } + }, + { + "evidence_id": "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "stable": true, + "kind": "node", + "internal_id": 20, + "types": [ + "Subnet" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "availability_zone": "us-east-1a", + "cidr": "10.0.1.0/24", + "name": "public-a", + "public_ip_on_launch": true, + "subnet_id": "subnet-public" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:rtb-public|ROUTES_VIA|123456789012:us-east-1:igw-shop|\"0.0.0.0/0\"", + "stable": true, + "kind": "relationship", + "internal_id": 16, + "types": [ + "ROUTES_VIA" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:rtb-public" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:igw-shop" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "destination": "0.0.0.0/0", + "rule": "0.0.0.0/0", + "state": "active" + } + } + ] + }, + { + "finding_id": "finding:5f8bbb1e573fb631", + "rule_id": "aws.exposure.public-load-balancer", + "verdict": "potential_exposure", + "resource": "node:shop/123456789012:us-east-1:i-app1", + "resource_name": "app-1", + "conditions": [ + { + "id": "instance-running", + "statement": "The instance is running.", + "status": "met", + "detail": "The instance is running.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-app1", + "field": "state", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "internet-facing", + "statement": "The load balancer is internet-facing.", + "status": "met", + "detail": "The load balancer is internet-facing.", + "fields": [ + { + "record": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "field": "scheme", + "source_field": "elbv2 describe-load-balancers: Scheme", + "value": { + "status": "known", + "value": "internet-facing" + } + } + ] + }, + { + "id": "balancer-open-rule", + "statement": "The load balancer has a security group rule open to the internet.", + "status": "met", + "detail": "Open to the internet on tcp 443.", + "fields": [ + { + "record": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|PROTECTED_BY|123456789012:us-east-1:sg-lb", + "field": "@to", + "source_field": "elbv2 describe-load-balancers: SecurityGroups[]", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-lb" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-lb|\"\\\"tcp\\\":\\\"443\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-lb|\"\\\"tcp\\\":\\\"443\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "443" + } + } + ] + }, + { + "id": "target-healthy", + "statement": "The target is healthy.", + "status": "met", + "detail": "The target was healthy.", + "fields": [ + { + "record": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "field": "health", + "source_field": "elbv2 describe-target-health: TargetHealth.State", + "value": { + "status": "known", + "value": "healthy" + } + } + ] + }, + { + "id": "target-allows-balancer", + "statement": "The target's security group allows the target group's port from the load balancer.", + "status": "met", + "detail": "A rule allows tcp 8080 from the load balancer's group or the internet.", + "fields": [ + { + "record": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "field": "port", + "source_field": "elbv2 describe-target-groups: Port", + "value": { + "status": "known", + "value": 8080 + } + }, + { + "record": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "field": "protocol", + "source_field": "elbv2 describe-target-groups: Protocol", + "value": { + "status": "known", + "value": "HTTP" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-app" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:sg-lb|ALLOWS|123456789012:us-east-1:sg-app|\"\\\"tcp\\\":\\\"8080\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:sg-lb|ALLOWS|123456789012:us-east-1:sg-app|\"\\\"tcp\\\":\\\"8080\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "8080" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "Listeners and their rules are not read, so an open security group is not shown to lead to this target group.", + "The target group's port is used; a target registered on another port is not seen.", + "A CIDR rule is not compared with the balancer's addresses, which are not read." + ], + "path": { + "evidence_id": "path:ee48d3346c316825", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.public-load-balancer" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ROUTES_TO]-> shop", + "shop -[FORWARDS_TO]-> app", + "app -[ROUTES_TO]-> app-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "node:shop/123456789012:us-east-1:i-app1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "stable": true, + "kind": "relationship", + "internal_id": 44, + "types": [ + "ROUTES_TO" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "stable": true, + "kind": "node", + "internal_id": 21, + "types": [ + "LoadBalancer" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "dns_name": "shop-123.us-east-1.elb.amazonaws.com", + "name": "shop", + "scheme": "internet-facing", + "type": "application" + } + }, + { + "evidence_id": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "stable": true, + "kind": "relationship", + "internal_id": 34, + "types": [ + "FORWARDS_TO" + ], + "from": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc" + }, + "to": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "stable": true, + "kind": "node", + "internal_id": 22, + "types": [ + "TargetGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "name": "app", + "port": 8080, + "protocol": "HTTP" + } + }, + { + "evidence_id": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "stable": true, + "kind": "relationship", + "internal_id": 37, + "types": [ + "ROUTES_TO" + ], + "from": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-app1" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "health": "healthy" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-app1", + "stable": true, + "kind": "node", + "internal_id": 0, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-app", + "instance_id": "i-app1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "app-1", + "private_ip": "10.0.2.30", + "state": "running", + "type": "t3.small" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "along": true + }, + { + "edge": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "along": true + }, + { + "edge": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "along": true + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-app1", + "direction": "out", + "types": [ + "FORWARDS_TO", + "ROUTES_TO" + ], + "max_hops": 3 + }, + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "node:shop/123456789012:us-east-1:i-app1", + "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app", + "rel:shop/123456789012:us-east-1:sg-lb|ALLOWS|123456789012:us-east-1:sg-app|\"\\\"tcp\\\":\\\"8080\\\"\"", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|PROTECTED_BY|123456789012:us-east-1:sg-lb", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-lb|\"\\\"tcp\\\":\\\"443\\\"\"" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "stable": true, + "kind": "relationship", + "internal_id": 44, + "types": [ + "ROUTES_TO" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "stable": true, + "kind": "node", + "internal_id": 21, + "types": [ + "LoadBalancer" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "dns_name": "shop-123.us-east-1.elb.amazonaws.com", + "name": "shop", + "scheme": "internet-facing", + "type": "application" + } + }, + { + "evidence_id": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "stable": true, + "kind": "relationship", + "internal_id": 34, + "types": [ + "FORWARDS_TO" + ], + "from": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc" + }, + "to": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "stable": true, + "kind": "node", + "internal_id": 22, + "types": [ + "TargetGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "name": "app", + "port": 8080, + "protocol": "HTTP" + } + }, + { + "evidence_id": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "stable": true, + "kind": "relationship", + "internal_id": 37, + "types": [ + "ROUTES_TO" + ], + "from": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-app1" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "health": "healthy" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-app1", + "stable": true, + "kind": "node", + "internal_id": 0, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-app", + "instance_id": "i-app1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "app-1", + "private_ip": "10.0.2.30", + "state": "running", + "type": "t3.small" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app", + "stable": true, + "kind": "relationship", + "internal_id": 1, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-app1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-app" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:sg-lb|ALLOWS|123456789012:us-east-1:sg-app|\"\\\"tcp\\\":\\\"8080\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 20, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-lb" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-app" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "8080", + "protocol": "tcp", + "rule": "\"tcp\":\"8080\"" + } + }, + { + "evidence_id": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|PROTECTED_BY|123456789012:us-east-1:sg-lb", + "stable": true, + "kind": "relationship", + "internal_id": 36, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-lb" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-lb|\"\\\"tcp\\\":\\\"443\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 39, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-lb" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "443", + "protocol": "tcp", + "rule": "\"tcp\":\"443\"" + } + } + ] + }, + { + "finding_id": "finding:55110fd21a8ba08b", + "rule_id": "aws.exposure.security-group-lateral", + "verdict": "potential_exposure", + "resource": "node:shop/123456789012:us-east-1:i-db1", + "resource_name": "db-1", + "conditions": [ + { + "id": "instance-running", + "statement": "The instance is running.", + "status": "met", + "detail": "The instance is running.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-db1", + "field": "state", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "source-potentially-exposed", + "statement": "The source instance has a potential exposure finding.", + "status": "met", + "detail": "web-1 has a potential exposure finding from aws.exposure.direct-instance.", + "fields": [] + }, + { + "id": "group-allows-source", + "statement": "A security group rule on this instance allows the source instance's group.", + "status": "met", + "detail": "Allowed on tcp 5432.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "5432" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "Only one step is followed; use khonsu explore for longer chains.", + "Whether the source instance could be made to send this traffic is not assessed." + ], + "path": { + "evidence_id": "path:b8e817cb02495d22", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.security-group-lateral" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ALLOWS]-> web", + "web <-[PROTECTED_BY]- web-1", + "web-1 -[PROTECTED_BY]-> web", + "web -[ALLOWS]-> db", + "db <-[PROTECTED_BY]- db-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "node:shop/123456789012:us-east-1:sg-web", + "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "node:shop/123456789012:us-east-1:i-web1", + "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "node:shop/123456789012:us-east-1:sg-db", + "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "node:shop/123456789012:us-east-1:i-db1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 41, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "443", + "protocol": "tcp", + "rule": "\"tcp\":\"443\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-web", + "stable": true, + "kind": "node", + "internal_id": 14, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "web tier", + "group_id": "sg-web", + "name": "web" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "stable": true, + "kind": "relationship", + "internal_id": 11, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-web1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-web1", + "stable": true, + "kind": "node", + "internal_id": 4, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 23, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-db" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "5432", + "protocol": "tcp", + "rule": "\"tcp\":\"5432\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-db", + "stable": true, + "kind": "node", + "internal_id": 11, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "database", + "group_id": "sg-db", + "name": "db" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "stable": true, + "kind": "relationship", + "internal_id": 6, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-db1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-db" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-db1", + "stable": true, + "kind": "node", + "internal_id": 2, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-db", + "instance_id": "i-db1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "db-1", + "private_ip": "10.0.2.20", + "state": "running", + "type": "r6g.large" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "along": false + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "along": false + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-db1", + "direction": "both", + "types": [ + "ALLOWS", + "PROTECTED_BY" + ], + "max_hops": 5 + }, + "depends_on": "finding:f2dfac00f69ee196", + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "node:shop/123456789012:us-east-1:sg-web", + "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "node:shop/123456789012:us-east-1:i-web1", + "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "node:shop/123456789012:us-east-1:sg-db", + "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "node:shop/123456789012:us-east-1:i-db1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 41, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "443", + "protocol": "tcp", + "rule": "\"tcp\":\"443\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-web", + "stable": true, + "kind": "node", + "internal_id": 14, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "web tier", + "group_id": "sg-web", + "name": "web" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "stable": true, + "kind": "relationship", + "internal_id": 11, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-web1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-web1", + "stable": true, + "kind": "node", + "internal_id": 4, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 23, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-db" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "5432", + "protocol": "tcp", + "rule": "\"tcp\":\"5432\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-db", + "stable": true, + "kind": "node", + "internal_id": 11, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "database", + "group_id": "sg-db", + "name": "db" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "stable": true, + "kind": "relationship", + "internal_id": 6, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-db1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-db" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-db1", + "stable": true, + "kind": "node", + "internal_id": 2, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-db", + "instance_id": "i-db1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "db-1", + "private_ip": "10.0.2.20", + "state": "running", + "type": "r6g.large" + } + } + ] + }, + { + "finding_id": "finding:a866b83abf26af97", + "rule_id": "aws.exposure.direct-instance", + "verdict": "insufficient_data", + "resource": "node:shop/123456789012:us-east-1:i-legacy1", + "resource_name": "legacy-1", + "conditions": [ + { + "id": "instance-running", + "statement": "The instance is running.", + "status": "met", + "detail": "The instance is running.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-legacy1", + "field": "state", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "met", + "detail": "Public address 198.51.100.77.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-legacy1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "known", + "value": "198.51.100.77" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-legacy1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "met", + "detail": "Open to the internet on tcp 80.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-legacy" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "80" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "unknown", + "detail": "No route table is recorded for the subnet or as its VPC's main table, so its routes could not be read.", + "fields": [] + } + ], + "unknowns": [ + "The instance's subnet has an active default route to an internet gateway." + ], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": { + "evidence_id": "path:2bdb0f342ca09503", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.direct-instance" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ALLOWS]-> legacy", + "legacy <-[PROTECTED_BY]- legacy-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "node:shop/123456789012:us-east-1:sg-legacy", + "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "node:shop/123456789012:us-east-1:i-legacy1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 40, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-legacy" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "80", + "protocol": "tcp", + "rule": "\"tcp\":\"80\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-legacy", + "stable": true, + "kind": "node", + "internal_id": 13, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "old account", + "group_id": "sg-legacy", + "name": "legacy" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "stable": true, + "kind": "relationship", + "internal_id": 8, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-legacy1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-legacy" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-legacy1", + "stable": true, + "kind": "node", + "internal_id": 3, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-old", + "instance_id": "i-legacy1", + "launched_at": "2023-03-01T08:00:00+00:00", + "name": "legacy-1", + "private_ip": "10.9.1.5", + "public_ip": "198.51.100.77", + "state": "running", + "type": "t2.micro" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "along": false + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-legacy1", + "direction": "both", + "types": [ + "ALLOWS", + "PROTECTED_BY" + ], + "max_hops": 2 + }, + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "node:shop/123456789012:us-east-1:sg-legacy", + "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "node:shop/123456789012:us-east-1:i-legacy1", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-legacy" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 40, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-legacy" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "80", + "protocol": "tcp", + "rule": "\"tcp\":\"80\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-legacy", + "stable": true, + "kind": "node", + "internal_id": 13, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "old account", + "group_id": "sg-legacy", + "name": "legacy" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "stable": true, + "kind": "relationship", + "internal_id": 8, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-legacy1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-legacy" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-legacy1", + "stable": true, + "kind": "node", + "internal_id": 3, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-old", + "instance_id": "i-legacy1", + "launched_at": "2023-03-01T08:00:00+00:00", + "name": "legacy-1", + "private_ip": "10.9.1.5", + "public_ip": "198.51.100.77", + "state": "running", + "type": "t2.micro" + } + }, + { + "evidence_id": "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-legacy", + "stable": true, + "kind": "node", + "internal_id": 18, + "types": [ + "Subnet" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "availability_zone": "us-east-1a", + "cidr": "10.9.1.0/24", + "name": "legacy-a", + "public_ip_on_launch": true, + "subnet_id": "subnet-legacy" + } + } + ] + }, + { + "finding_id": "finding:abb703787e9dce7d", + "rule_id": "aws.exposure.direct-instance", + "verdict": "not_indicated", + "resource": "node:shop/123456789012:us-east-1:i-app1", + "resource_name": "app-1", + "conditions": [ + { + "id": "instance-running", + "statement": "The instance is running.", + "status": "met", + "detail": "The instance is running.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-app1", + "field": "state", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "not_met", + "detail": "AWS reported no public IPv4 or IPv6 address.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-app1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "unknown" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-app1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "not_met", + "detail": "None of its security groups has a rule from 0.0.0.0/0 or ::/0.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-app" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "not_met", + "detail": "Route table main has no active default route to an internet gateway.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv4_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "value": { + "status": "known", + "value": "nat-shop" + } + }, + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv6_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "value": { + "status": "unknown" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": null, + "walk": null, + "contributing": [ + "node:shop/123456789012:us-east-1:i-app1", + "node:shop/123456789012:us-east-1:rtb-main", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app" + ], + "evidence": [ + { + "evidence_id": "node:shop/123456789012:us-east-1:i-app1", + "stable": true, + "kind": "node", + "internal_id": 0, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-app", + "instance_id": "i-app1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "app-1", + "private_ip": "10.0.2.30", + "state": "running", + "type": "t3.small" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:rtb-main", + "stable": true, + "kind": "node", + "internal_id": 7, + "types": [ + "RouteTable" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ipv4_default_target": "nat-shop", + "is_main": true, + "name": "main", + "route_table_id": "rtb-main" + } + }, + { + "evidence_id": "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "stable": true, + "kind": "node", + "internal_id": 19, + "types": [ + "Subnet" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "availability_zone": "us-east-1a", + "cidr": "10.0.2.0/24", + "name": "private-a", + "public_ip_on_launch": false, + "subnet_id": "subnet-private" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app", + "stable": true, + "kind": "relationship", + "internal_id": 1, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-app1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-app" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + } + ] + }, + { + "finding_id": "finding:df1e8c7264295bb4", + "rule_id": "aws.exposure.direct-instance", + "verdict": "not_indicated", + "resource": "node:shop/123456789012:us-east-1:i-batch1", + "resource_name": "batch-1", + "conditions": [ + { + "id": "instance-running", + "statement": "The instance is running.", + "status": "met", + "detail": "The instance is running.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-batch1", + "field": "state", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "not_met", + "detail": "AWS reported no public IPv4 or IPv6 address.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-batch1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "unknown" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-batch1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "met", + "detail": "Open to the internet on tcp 22.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-batch" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "22" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "not_met", + "detail": "Route table main has no active default route to an internet gateway.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv4_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "value": { + "status": "known", + "value": "nat-shop" + } + }, + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv6_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "value": { + "status": "unknown" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": { + "evidence_id": "path:cc501cb09671c83c", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.direct-instance" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ALLOWS]-> batch", + "batch <-[PROTECTED_BY]- batch-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "node:shop/123456789012:us-east-1:sg-batch", + "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "node:shop/123456789012:us-east-1:i-batch1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 38, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-batch" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "22", + "protocol": "tcp", + "rule": "\"tcp\":\"22\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-batch", + "stable": true, + "kind": "node", + "internal_id": 10, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "opened by mistake", + "group_id": "sg-batch", + "name": "batch" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "stable": true, + "kind": "relationship", + "internal_id": 3, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-batch1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-batch" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-batch1", + "stable": true, + "kind": "node", + "internal_id": 1, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-batch", + "instance_id": "i-batch1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "batch-1", + "private_ip": "10.0.2.40", + "state": "running", + "type": "t3.small" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "along": false + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-batch1", + "direction": "both", + "types": [ + "ALLOWS", + "PROTECTED_BY" + ], + "max_hops": 2 + }, + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "node:shop/123456789012:us-east-1:sg-batch", + "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "node:shop/123456789012:us-east-1:i-batch1", + "node:shop/123456789012:us-east-1:rtb-main", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "cidr": "0.0.0.0/0", + "name": "Internet" + } + }, + { + "evidence_id": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "stable": true, + "kind": "relationship", + "internal_id": 38, + "types": [ + "ALLOWS" + ], + "from": { + "status": "known", + "value": "node:shop/cidr:internet" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-batch" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "22", + "protocol": "tcp", + "rule": "\"tcp\":\"22\"" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:sg-batch", + "stable": true, + "kind": "node", + "internal_id": 10, + "types": [ + "SecurityGroup" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "description": "opened by mistake", + "group_id": "sg-batch", + "name": "batch" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "stable": true, + "kind": "relationship", + "internal_id": 3, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-batch1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-batch" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:i-batch1", + "stable": true, + "kind": "node", + "internal_id": 1, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-batch", + "instance_id": "i-batch1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "batch-1", + "private_ip": "10.0.2.40", + "state": "running", + "type": "t3.small" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:rtb-main", + "stable": true, + "kind": "node", + "internal_id": 7, + "types": [ + "RouteTable" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ipv4_default_target": "nat-shop", + "is_main": true, + "name": "main", + "route_table_id": "rtb-main" + } + }, + { + "evidence_id": "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "stable": true, + "kind": "node", + "internal_id": 19, + "types": [ + "Subnet" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "availability_zone": "us-east-1a", + "cidr": "10.0.2.0/24", + "name": "private-a", + "public_ip_on_launch": false, + "subnet_id": "subnet-private" + } + } + ] + }, + { + "finding_id": "finding:d0d126dda67e15e4", + "rule_id": "aws.exposure.direct-instance", + "verdict": "not_indicated", + "resource": "node:shop/123456789012:us-east-1:i-db1", + "resource_name": "db-1", + "conditions": [ + { + "id": "instance-running", + "statement": "The instance is running.", + "status": "met", + "detail": "The instance is running.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-db1", + "field": "state", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "not_met", + "detail": "AWS reported no public IPv4 or IPv6 address.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-db1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "unknown" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-db1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "not_met", + "detail": "None of its security groups has a rule from 0.0.0.0/0 or ::/0.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-db" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "not_met", + "detail": "Route table main has no active default route to an internet gateway.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv4_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "value": { + "status": "known", + "value": "nat-shop" + } + }, + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv6_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "value": { + "status": "unknown" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": null, + "walk": null, + "contributing": [ + "node:shop/123456789012:us-east-1:i-db1", + "node:shop/123456789012:us-east-1:rtb-main", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db" + ], + "evidence": [ + { + "evidence_id": "node:shop/123456789012:us-east-1:i-db1", + "stable": true, + "kind": "node", + "internal_id": 2, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "image_id": "ami-db", + "instance_id": "i-db1", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "db-1", + "private_ip": "10.0.2.20", + "state": "running", + "type": "r6g.large" + } + }, + { + "evidence_id": "node:shop/123456789012:us-east-1:rtb-main", + "stable": true, + "kind": "node", + "internal_id": 7, + "types": [ + "RouteTable" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ipv4_default_target": "nat-shop", + "is_main": true, + "name": "main", + "route_table_id": "rtb-main" + } + }, + { + "evidence_id": "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "stable": true, + "kind": "node", + "internal_id": 19, + "types": [ + "Subnet" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "availability_zone": "us-east-1a", + "cidr": "10.0.2.0/24", + "name": "private-a", + "public_ip_on_launch": false, + "subnet_id": "subnet-private" + } + }, + { + "evidence_id": "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "stable": true, + "kind": "relationship", + "internal_id": 6, + "types": [ + "PROTECTED_BY" + ], + "from": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:i-db1" + }, + "to": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-db" + }, + "source": { + "status": "known", + "value": "shop" + }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, + "source_snapshot": { + "status": "known", + "value": "shop#1" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + } + ] + } + ], + "truncated": 0 + } } } diff --git a/plugins/aws/src/exposure.rs b/plugins/aws/src/exposure.rs new file mode 100644 index 0000000..d5842eb --- /dev/null +++ b/plugins/aws/src/exposure.rs @@ -0,0 +1,1085 @@ +//! Explainable exposure rules: which instances the configuration in a graph +//! leaves potentially open to the internet, and exactly why. +//! +//! A rule is a list of conditions, each met, not met or unknown, and each +//! naming the fields it read. The verdict follows from those alone: anything +//! not met gives [`NOT_INDICATED`], otherwise anything unknown gives +//! [`INSUFFICIENT_DATA`], otherwise [`POTENTIAL_EXPOSURE`]. There is no score, +//! and no verdict says "reachable": the graph holds configuration, and nothing +//! here sends traffic. See `docs/exposure-rules.md`. +//! +//! A record missing from the graph is not read as a fact about the account: a +//! saved response that was absent reads as empty, so an instance with no +//! security groups or a subnet with no route table gives "unknown". +//! +//! This all lives in the plugin because it is all about AWS. The host sends the +//! records named in [`info`], and checks and shows what comes back. + +use std::collections::{BTreeMap, BTreeSet}; + +use khonsu_plugin_protocol::{ + fnv1a, AnalysisEdge, AnalysisInfo, AnalysisNode, AnalyzeRequest, ConditionRecord, ConditionStatus as Status, FieldRef, FindingRecord, HopRef, PathRef, RuleInfo, SourceCoverage, VerdictInfo, VerdictTone, +}; +use serde_json::Value; + +pub const ANALYSIS: &str = "exposure"; +const RULE_DIRECT: &str = "aws.exposure.direct-instance"; +const RULE_BALANCER: &str = "aws.exposure.public-load-balancer"; +const RULE_LATERAL: &str = "aws.exposure.security-group-lateral"; + +/// Every condition is met by configuration. There is deliberately no "reachable". +const POTENTIAL_EXPOSURE: &str = "potential_exposure"; +/// None ruled it out and at least one could not be evaluated, or a source behind +/// its records was not read completely. +const INSUFFICIENT_DATA: &str = "insufficient_data"; +/// A condition is not met, so this rule does not point at it. +const NOT_INDICATED: &str = "not_indicated"; + +const CLAIM: &str = "Potential exposure worked out from configuration. No traffic was sent, so this does not show that anything is reachable, and a resource with no finding is not shown to be safe."; +const NOT_READ_NETWORK: &str = "Network ACLs, host firewalls and anything outside this AWS account are not read."; +const FAMILY: &str = "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address."; +const DERIVATION: &str = "the rule's candidate path"; + +fn rules() -> Vec { + let rule = |id: &str, title: &str, statement: &str, assumptions: &[&str]| RuleInfo { + rule_id: id.into(), + title: title.into(), + statement: statement.into(), + assumptions: assumptions.iter().map(|text| (*text).into()).collect(), + }; + vec![ + rule( + RULE_DIRECT, + "Instance open to the internet directly", + "A running instance with a public address, a security group rule open to the internet, and a route to an internet gateway from its subnet.", + &[NOT_READ_NETWORK, FAMILY, "Whether anything listens on the allowed ports is not read."], + ), + rule( + RULE_BALANCER, + "Instance behind an internet-facing load balancer", + "A running, healthy target of an internet-facing load balancer whose security group is open to the internet, where the target's security group allows the target port from the balancer.", + &[ + NOT_READ_NETWORK, + "Listeners and their rules are not read, so an open security group is not shown to lead to this target group.", + "The target group's port is used; a target registered on another port is not seen.", + "A CIDR rule is not compared with the balancer's addresses, which are not read.", + ], + ), + rule( + RULE_LATERAL, + "Instance one security group rule behind a potentially exposed one", + "A running instance whose security group allows traffic from the security group of an instance with a potential exposure finding.", + &[ + NOT_READ_NETWORK, + "Only one step is followed; use khonsu explore for longer chains.", + "Whether the source instance could be made to send this traffic is not assessed.", + ], + ), + ] +} + +/// What the host needs to offer this analysis, read for it, and show its result. +pub fn info() -> AnalysisInfo { + let names = |names: &[&str]| names.iter().map(|name| (*name).to_string()).collect::>(); + let verdict = |id: &str, title: &str, tone: VerdictTone| VerdictInfo { id: id.into(), title: title.into(), tone }; + let zh: BTreeMap = [ + ("title", "暴露分析"), + ("claim", "根据配置推断的潜在暴露。未发送任何流量,因此不能说明资源可达;没有发现也不能说明资源安全。"), + ("verdict.potential_exposure", "潜在暴露"), + ("verdict.insufficient_data", "数据不足"), + ("verdict.not_indicated", "规则未指向"), + ("rule.aws.exposure.direct-instance", "实例直接向互联网开放"), + ("rule.aws.exposure.public-load-balancer", "位于面向互联网的负载均衡器之后"), + ("rule.aws.exposure.security-group-lateral", "位于潜在暴露实例之后一条安全组规则"), + ("condition.instance-running", "实例正在运行"), + ("condition.public-address", "实例有公网地址"), + ("condition.open-inbound-rule", "实例有向互联网开放的安全组规则"), + ("condition.internet-route", "子网有通往互联网网关的活动默认路由"), + ("condition.internet-facing", "负载均衡器面向互联网"), + ("condition.balancer-open-rule", "负载均衡器有向互联网开放的安全组规则"), + ("condition.target-healthy", "目标健康"), + ("condition.target-allows-balancer", "目标的安全组允许来自负载均衡器的目标端口"), + ("condition.source-potentially-exposed", "来源实例有潜在暴露发现"), + ("condition.group-allows-source", "安全组规则允许来源实例的安全组"), + ] + .into_iter() + .map(|(key, text)| (key.to_string(), text.to_string())) + .collect(); + AnalysisInfo { + id: ANALYSIS.into(), + title: "Exposure".into(), + description: "Which instances the configuration leaves potentially open to the internet, and why.".into(), + claim: CLAIM.into(), + subject_labels: names(&["Instance"]), + // An instance with no relationships still counts as looked at. + node_labels: names(&["Instance"]), + relationship_types: names(&["ALLOWS", "PROTECTED_BY", "ROUTES_TO", "FORWARDS_TO", "IN_SUBNET", "IN_VPC", "USES_ROUTE_TABLE", "ROUTES_VIA"]), + verdicts: vec![ + verdict(POTENTIAL_EXPOSURE, "Potential exposure", VerdictTone::Attention), + verdict(INSUFFICIENT_DATA, "Insufficient data", VerdictTone::Undecided), + verdict(NOT_INDICATED, "Not indicated by this rule", VerdictTone::Quiet), + ], + rules: rules(), + translations: BTreeMap::from([("zh".to_string(), zh)]), + } +} + +type ByNode<'a> = BTreeMap<&'a str, BTreeMap<&'a str, Vec<&'a AnalysisEdge>>>; + +/// Stable lookups over the records, sorted so that every choice is repeatable. +struct Index<'a> { + nodes: BTreeMap<&'a str, &'a AnalysisNode>, + /// Relationships by node then type, in the order of their references. + out: ByNode<'a>, + into: ByNode<'a>, +} + +impl<'a> Index<'a> { + fn new(request: &'a AnalyzeRequest) -> Self { + let mut out: ByNode = BTreeMap::new(); + let mut into: ByNode = BTreeMap::new(); + for edge in &request.edges { + out.entry(&edge.from).or_default().entry(&edge.rel_type).or_default().push(edge); + into.entry(&edge.to).or_default().entry(&edge.rel_type).or_default().push(edge); + } + out.values_mut().chain(into.values_mut()).flat_map(BTreeMap::values_mut).for_each(|edges| edges.sort_by(|a, b| a.reference.cmp(&b.reference))); + Self { + nodes: request.nodes.iter().map(|node| (node.reference.as_str(), node)).collect(), + out, + into, + } + } + + fn with_label(&self, label: &str) -> Vec<&'a AnalysisNode> { + self.nodes.values().copied().filter(|node| has_label(node, label)).collect() + } + + fn out_of(&self, node: &str, rel_type: &'static str) -> &[&'a AnalysisEdge] { + self.out.get(node).and_then(|types| types.get(rel_type)).map_or(&[], Vec::as_slice) + } + + fn arriving_at(&self, node: &str, rel_type: &'static str) -> &[&'a AnalysisEdge] { + self.into.get(node).and_then(|types| types.get(rel_type)).map_or(&[], Vec::as_slice) + } + + fn node(&self, reference: &str) -> Option<&'a AnalysisNode> { + self.nodes.get(reference).copied() + } + + /// `ALLOWS` rules into `groups` from the internet. + fn internet_rules(&self, groups: &[&AnalysisEdge]) -> Vec<&'a AnalysisEdge> { + groups + .iter() + .flat_map(|protected| self.arriving_at(&protected.to, "ALLOWS")) + .filter(|rule| self.node(&rule.from).is_some_and(is_internet)) + .copied() + .collect() + } + + /// Every (balancer, target group, group → instance, balancer → group) that + /// sends traffic to `instance`. + fn balancers_of(&self, instance: &AnalysisNode) -> Vec<(&'a AnalysisNode, &'a AnalysisNode, &'a AnalysisEdge, &'a AnalysisEdge)> { + let mut found = Vec::new(); + for to_target in self.arriving_at(&instance.reference, "ROUTES_TO") { + let Some(group) = self.node(&to_target.from).filter(|node| has_label(node, "TargetGroup")) else { continue }; + for forwards in self.arriving_at(&group.reference, "FORWARDS_TO") { + if let Some(balancer) = self.node(&forwards.from) { + found.push((balancer, group, *to_target, *forwards)); + } + } + } + found + } + + /// The table a subnet uses: its own association, else its VPC's main one. + fn route_table_of(&self, subnet: &AnalysisNode) -> Option<&'a AnalysisNode> { + if let Some(explicit) = self.out_of(&subnet.reference, "USES_ROUTE_TABLE").first() { + return self.node(&explicit.to); + } + let vpc = &self.out_of(&subnet.reference, "IN_VPC").first()?.to; + self.arriving_at(vpc, "IN_VPC") + .iter() + .filter_map(|edge| self.node(&edge.from)) + .find(|node| has_label(node, "RouteTable") && node.properties.get("is_main") == Some(&Value::Bool(true))) + } +} + +fn has_label(node: &AnalysisNode, label: &str) -> bool { + node.labels.iter().any(|name| name == label) +} + +fn is_internet(node: &AnalysisNode) -> bool { + has_label(node, "Cidr") && matches!(node.properties.get("cidr").and_then(Value::as_str), Some("0.0.0.0/0" | "::/0")) +} + +fn field(record: &str, name: &str, source_field: &str) -> FieldRef { + FieldRef { record: record.into(), field: name.into(), source_field: source_field.into() } +} + +fn text<'a>(edge: &'a AnalysisEdge, key: &str) -> &'a str { + edge.properties.get(key).and_then(Value::as_str).unwrap_or_default() +} + +/// A rule's ports in words, as the plugin wrote them. +fn ports_of(rule: &AnalysisEdge) -> String { + let word = |key: &str| rule.properties.get(key).and_then(Value::as_str).unwrap_or("unknown").to_string(); + format!("{} {}", word("protocol"), word("ports")) +} + +/// Whether a security group rule covers `port` on `protocol`. The plugin writes +/// ports as `all`, `443` or `8000-8100`. +fn rule_covers(rule: &AnalysisEdge, protocol: &str, port: i64) -> bool { + if text(rule, "protocol") != "all" && text(rule, "protocol") != protocol { + return false; + } + let ports = text(rule, "ports"); + if ports == "all" { + return true; + } + match ports.split_once('-') { + Some((low, high)) => matches!((low.parse::(), high.parse::()), (Ok(low), Ok(high)) if (low..=high).contains(&port)), + None => ports.parse() == Ok(port), + } +} + +/// The IP protocols a target group's traffic can use, which is more than one for +/// `TCP_UDP`. Its protocol is one of the names AWS gives: anything else that +/// reaches a security group is TCP (`HTTP`, `HTTPS`, `TCP`, `TLS`). +fn traffic_protocols(group_protocol: &str) -> &'static [&'static str] { + match group_protocol { + "UDP" | "QUIC" | "GENEVE" => &["udp"], + "TCP_UDP" | "TCP_QUIC" => &["tcp", "udp"], + _ => &["tcp"], + } +} + +fn condition(id: &str, statement: &str, status: Status, detail: impl Into, fields: Vec) -> ConditionRecord { + ConditionRecord { id: id.into(), statement: statement.into(), status, detail: detail.into(), fields } +} + +fn running(instance: &AnalysisNode) -> ConditionRecord { + let (status, detail) = match instance.properties.get("state").and_then(Value::as_str) { + Some("running") => (Status::Met, "The instance is running.".to_string()), + Some(state) => (Status::NotMet, format!("The instance is {state}.")), + None => (Status::Unknown, "The instance's state was not supplied.".to_string()), + }; + condition("instance-running", "The instance is running.", status, detail, vec![field(&instance.reference, "state", "ec2 describe-instances: State.Name")]) +} + +/// Which security group each of `protected` is. +fn membership(protected: &[&AnalysisEdge], source_field: &str) -> Vec { + protected.iter().map(|edge| field(&edge.reference, "@to", source_field)).collect() +} + +/// Whether the groups in `protected` have an open rule, with no groups at all +/// being unknown: every instance has one, so none means they were not read. +fn open_rule(index: &Index, id: &str, whose: &str, protected: &[&AnalysisEdge], aws_groups: &str) -> ConditionRecord { + let statement = format!("{whose} has a security group rule open to the internet."); + let rules = index.internet_rules(protected); + if protected.is_empty() { + let detail = "No security groups are recorded for it, so its rules could not be read."; + return condition(id, &statement, Status::Unknown, detail, vec![]); + } + let mut fields = membership(protected, aws_groups); + for rule in &rules { + fields.push(field(&rule.reference, "protocol", "ec2 describe-security-groups: IpPermissions[].IpProtocol")); + fields.push(field(&rule.reference, "ports", "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort")); + } + if rules.is_empty() { + return condition(id, &statement, Status::NotMet, "None of its security groups has a rule from 0.0.0.0/0 or ::/0.", fields); + } + let ports: BTreeSet = rules.iter().map(|rule| ports_of(rule)).collect(); + let detail = format!("Open to the internet on {}.", ports.into_iter().collect::>().join(", ")); + condition(id, &statement, Status::Met, detail, fields) +} + +fn verdict_of(conditions: &[ConditionRecord]) -> &'static str { + if conditions.iter().any(|condition| condition.status == Status::NotMet) { + NOT_INDICATED + } else if conditions.iter().any(|condition| condition.status == Status::Unknown) { + INSUFFICIENT_DATA + } else { + POTENTIAL_EXPOSURE + } +} + +/// A verdict's place in the order findings come in. +fn rank(verdict: &str) -> u8 { + match verdict { + POTENTIAL_EXPOSURE => 0, + INSUFFICIENT_DATA => 1, + _ => 2, + } +} + +fn public_address(instance: &AnalysisNode) -> ConditionRecord { + let fields = vec![ + field(&instance.reference, "public_ip", "ec2 describe-instances: PublicIpAddress"), + field(&instance.reference, "ipv6_address", "ec2 describe-instances: Ipv6Address"), + ]; + let addresses: Vec<&str> = ["public_ip", "ipv6_address"].iter().filter_map(|key| instance.properties.get(*key).and_then(Value::as_str)).collect(); + let (status, detail) = if addresses.is_empty() { + (Status::NotMet, "AWS reported no public IPv4 or IPv6 address.".to_string()) + } else { + (Status::Met, format!("Public address {}.", addresses.join(", "))) + }; + condition("public-address", "The instance has a public address.", status, detail, fields) +} + +fn internet_route(index: &Index, instance: &AnalysisNode) -> (ConditionRecord, Vec) { + let id = "internet-route"; + let statement = "The instance's subnet has an active default route to an internet gateway."; + let Some(subnet) = index.out_of(&instance.reference, "IN_SUBNET").first().and_then(|edge| index.node(&edge.to)) else { + return (condition(id, statement, Status::Unknown, "No subnet is recorded for the instance.", vec![]), vec![]); + }; + let Some(table) = index.route_table_of(subnet) else { + let detail = "No route table is recorded for the subnet or as its VPC's main table, so its routes could not be read."; + return (condition(id, statement, Status::Unknown, detail, vec![]), vec![subnet.reference.clone()]); + }; + let wanted: Vec<&str> = match (instance.properties.contains_key("public_ip"), instance.properties.contains_key("ipv6_address")) { + (true, false) => vec!["0.0.0.0/0"], + (false, true) => vec!["::/0"], + _ => vec!["0.0.0.0/0", "::/0"], + }; + let routes = index.out_of(&table.reference, "ROUTES_VIA"); + let is_v6 = |destination: &str| destination.contains(':'); + // Only routes of the address's own family can carry its traffic. + let active: Vec<&&AnalysisEdge> = routes + .iter() + .filter(|route| text(route, "state") == "active" && wanted.iter().any(|family| is_v6(family) == is_v6(text(route, "destination")))) + .collect(); + let mut fields = vec![ + field(&table.reference, "ipv4_default_target", "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]"), + field(&table.reference, "ipv6_default_target", "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]"), + ]; + for route in routes { + fields.push(field(&route.reference, "destination", "ec2 describe-route-tables: Routes[].DestinationCidrBlock")); + fields.push(field(&route.reference, "state", "ec2 describe-route-tables: Routes[].State")); + } + let name = &table.name; + let (status, detail) = if let Some(route) = active.iter().find(|route| wanted.contains(&text(route, "destination"))) { + (Status::Met, format!("Route table {name} sends {} to an internet gateway.", text(route, "destination"))) + } else if let Some(route) = active.first() { + (Status::Unknown, format!("Route table {name} sends only {} to an internet gateway, so whether it covers a given client is not known.", text(route, "destination"))) + } else { + (Status::NotMet, format!("Route table {name} has no active default route to an internet gateway.")) + }; + let mut records = vec![subnet.reference.clone(), table.reference.clone()]; + records.extend(routes.iter().map(|route| route.reference.clone())); + (condition(id, statement, status, detail, fields), records) +} + +fn along(edge: &AnalysisEdge) -> HopRef { + HopRef { edge: edge.reference.clone(), along: true } +} + +fn against(edge: &AnalysisEdge) -> HopRef { + HopRef { edge: edge.reference.clone(), along: false } +} + +fn direct(request: &AnalyzeRequest, index: &Index, instance: &AnalysisNode) -> Built { + let protected = index.out_of(&instance.reference, "PROTECTED_BY"); + let open = open_rule(index, "open-inbound-rule", "The instance", protected, "ec2 describe-instances: SecurityGroups[].GroupId"); + let (route, route_records) = internet_route(index, instance); + let conditions = vec![running(instance), public_address(instance), open, route]; + + // The first open rule, with the group it opens, is the candidate path. + let hops = protected.iter().find_map(|edge| { + let rule = index.arriving_at(&edge.to, "ALLOWS").iter().find(|rule| index.node(&rule.from).is_some_and(is_internet))?; + Some(vec![along(rule), against(edge)]) + }); + let mut records = route_records; + records.push(instance.reference.clone()); + records.extend(protected.iter().map(|edge| edge.reference.clone())); + records.extend(index.internet_rules(protected).iter().map(|rule| rule.reference.clone())); + build(request, instance, RULE_DIRECT, conditions, hops.unwrap_or_default(), records, None) +} + +fn behind_balancer(request: &AnalyzeRequest, index: &Index, instance: &AnalysisNode, balancer: &AnalysisNode, group: &AnalysisNode, to_target: &AnalysisEdge, forwards: &AnalysisEdge) -> Built { + let balancer_groups = index.out_of(&balancer.reference, "PROTECTED_BY"); + let instance_groups = index.out_of(&instance.reference, "PROTECTED_BY"); + + let (status, detail) = match balancer.properties.get("scheme").and_then(Value::as_str) { + Some("internet-facing") => (Status::Met, "The load balancer is internet-facing.".to_string()), + Some(scheme) => (Status::NotMet, format!("The load balancer's scheme is {scheme}.")), + None => (Status::Unknown, "The load balancer's scheme was not supplied.".to_string()), + }; + let facing = condition("internet-facing", "The load balancer is internet-facing.", status, detail, vec![field(&balancer.reference, "scheme", "elbv2 describe-load-balancers: Scheme")]); + let open = open_rule(index, "balancer-open-rule", "The load balancer", balancer_groups, "elbv2 describe-load-balancers: SecurityGroups[]"); + let health = { + let read = field(&to_target.reference, "health", "elbv2 describe-target-health: TargetHealth.State"); + let (status, detail) = match to_target.properties.get("health").and_then(Value::as_str) { + Some("healthy") => (Status::Met, "The target was healthy.".to_string()), + Some(state) => (Status::Unknown, format!("The target's health was {state}, which can change without the configuration changing.")), + None => (Status::Unknown, "The target's health was not supplied.".to_string()), + }; + condition("target-healthy", "The target is healthy.", status, detail, vec![read]) + }; + let allows = target_allows_balancer(index, group, balancer_groups, instance_groups); + let conditions = vec![running(instance), facing, open, health, allows]; + + let opening = index.arriving_at(&balancer.reference, "ROUTES_TO").iter().find(|edge| index.node(&edge.from).is_some_and(is_internet)); + let hops = opening.map_or_else(Vec::new, |edge| vec![along(edge), along(forwards), along(to_target)]); + let mut records = vec![instance.reference.clone(), balancer.reference.clone(), group.reference.clone(), to_target.reference.clone(), forwards.reference.clone()]; + records.extend(balancer_groups.iter().map(|edge| edge.reference.clone())); + records.extend(index.internet_rules(balancer_groups).iter().map(|rule| rule.reference.clone())); + build(request, instance, RULE_BALANCER, conditions, hops, records, Some(format!("{}\n{}", balancer.reference, group.reference))) +} + +/// Whether the target's groups let the balancer in on the target group's port. +fn target_allows_balancer(index: &Index, group: &AnalysisNode, balancer_groups: &[&AnalysisEdge], instance_groups: &[&AnalysisEdge]) -> ConditionRecord { + let id = "target-allows-balancer"; + let statement = "The target's security group allows the target group's port from the load balancer."; + let port_field = field(&group.reference, "port", "elbv2 describe-target-groups: Port"); + let protocol_field = field(&group.reference, "protocol", "elbv2 describe-target-groups: Protocol"); + let Some(port) = group.properties.get("port").and_then(Value::as_i64) else { + return condition(id, statement, Status::Unknown, "The target group's port was not supplied.", vec![port_field]); + }; + if instance_groups.is_empty() { + let detail = "No security groups are recorded for the target, so its rules could not be read."; + return condition(id, statement, Status::Unknown, detail, vec![port_field]); + } + let Some(group_protocol) = group.properties.get("protocol").and_then(Value::as_str) else { + return condition(id, statement, Status::Unknown, "The target group's protocol was not supplied.", vec![port_field, protocol_field]); + }; + let protocols = traffic_protocols(group_protocol); + let covering = |rule: &AnalysisEdge| protocols.iter().find(|protocol| rule_covers(rule, protocol, port)).copied(); + let from_balancer: BTreeSet<&str> = balancer_groups.iter().map(|edge| edge.to.as_str()).collect(); + let rules: Vec<&AnalysisEdge> = instance_groups + .iter() + .flat_map(|edge| index.arriving_at(&edge.to, "ALLOWS")) + .filter(|rule| covering(rule).is_some()) + .copied() + .collect(); + let matching = rules.iter().find(|rule| from_balancer.contains(rule.from.as_str()) || index.node(&rule.from).is_some_and(is_internet)); + let other_cidr = rules.iter().find(|rule| index.node(&rule.from).is_some_and(|node| has_label(node, "Cidr"))); + let mut fields = vec![port_field, protocol_field]; + // Which groups the target is in is part of the answer, whichever way it went. + fields.extend(membership(instance_groups, "ec2 describe-instances: SecurityGroups[].GroupId")); + if let Some(rule) = matching.or(other_cidr) { + fields.push(field(&rule.reference, "protocol", "ec2 describe-security-groups: IpPermissions[].IpProtocol")); + fields.push(field(&rule.reference, "ports", "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort")); + } + let wanted = protocols.join(" or "); + let (status, detail) = if let Some(rule) = matching { + (Status::Met, format!("A rule allows {} {port} from the load balancer's group or the internet.", covering(rule).unwrap_or(protocols[0]))) + } else if let Some(rule) = other_cidr { + (Status::Unknown, format!("A CIDR rule allows {} {port}, and the load balancer's addresses are not read to compare with it.", covering(rule).unwrap_or(protocols[0]))) + } else if balancer_groups.is_empty() { + // Nothing says the balancer's groups are not among those a rule names. + (Status::Unknown, "No security groups are recorded for the load balancer, so whether a rule lets it in could not be read.".to_string()) + } else { + (Status::NotMet, format!("No rule on the target's groups allows {wanted} {port} from the load balancer.")) + }; + condition(id, statement, status, detail, fields) +} + +/// One finding for each instance one group rule behind an instance that a rule +/// left potentially exposed or undecided. Findings about instances the rules +/// do not point at would only be noise. +fn lateral_findings(request: &AnalyzeRequest, index: &Index, findings: &[Built]) -> Vec { + // The strongest finding on each instance, first by verdict then by rule. + let mut best: BTreeMap<&str, &Built> = BTreeMap::new(); + for found in findings.iter().filter(|found| found.record.verdict != NOT_INDICATED) { + best.entry(found.record.resource.as_str()) + .and_modify(|held| { + if (rank(&found.record.verdict), &found.record.rule_id) < (rank(&held.record.verdict), &held.record.rule_id) { + *held = found; + } + }) + .or_insert(found); + } + let mut out = Vec::new(); + for source in index.with_label("Instance") { + let Some(found) = best.get(source.reference.as_str()) else { continue }; + let mut pairs: BTreeMap<&str, (&AnalysisEdge, &AnalysisEdge, Vec<&AnalysisEdge>)> = BTreeMap::new(); + for &own in index.out_of(&source.reference, "PROTECTED_BY") { + for &rule in index.out_of(&own.to, "ALLOWS") { + for &protected in index.arriving_at(&rule.to, "PROTECTED_BY") { + if protected.from != source.reference && index.node(&protected.from).is_some_and(|node| has_label(node, "Instance")) { + pairs.entry(protected.from.as_str()).or_insert((own, protected, Vec::new())).2.push(rule); + } + } + } + } + for (target, (own, protected, rules)) in pairs { + let Some(instance) = index.node(target) else { continue }; + out.push(lateral(request, instance, source, found, own, protected, &rules)); + } + } + out +} + +fn lateral(request: &AnalyzeRequest, instance: &AnalysisNode, source: &AnalysisNode, found: &Built, own: &AnalysisEdge, protected: &AnalysisEdge, rules: &[&AnalysisEdge]) -> Built { + let name = &source.name; + let rule_id = &found.record.rule_id; + let (status, detail) = match found.record.verdict.as_str() { + POTENTIAL_EXPOSURE => (Status::Met, format!("{name} has a potential exposure finding from {rule_id}.")), + _ => (Status::Unknown, format!("{name} has insufficient data under {rule_id}.")), + }; + let exposed = condition("source-potentially-exposed", "The source instance has a potential exposure finding.", status, detail, vec![]); + let ports: BTreeSet = rules.iter().map(|rule| ports_of(rule)).collect(); + let mut rule_fields = Vec::new(); + for rule in rules { + rule_fields.push(field(&rule.reference, "protocol", "ec2 describe-security-groups: IpPermissions[].IpProtocol")); + rule_fields.push(field(&rule.reference, "ports", "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort")); + } + let allows = condition( + "group-allows-source", + "A security group rule on this instance allows the source instance's group.", + Status::Met, + format!("Allowed on {}.", ports.into_iter().collect::>().join(", ")), + rule_fields, + ); + let conditions = vec![running(instance), exposed, allows]; + + // The source's own path, then across the rule to this instance. + let mut hops = found.hops.clone(); + if !hops.is_empty() { + hops.extend([along(own), along(rules[0]), against(protected)]); + } + let mut records = vec![instance.reference.clone(), source.reference.clone(), own.reference.clone(), protected.reference.clone()]; + records.extend(rules.iter().map(|rule| rule.reference.clone())); + let mut built = build(request, instance, RULE_LATERAL, conditions, hops, records, Some(source.reference.clone())); + built.record.depends_on = Some(found.record.finding_id.clone()); + built +} + +/// A finding with the hops a later rule continues from. +struct Built { + record: FindingRecord, + hops: Vec, +} + +/// The sources behind every record a finding rests on, whose last sync was not +/// complete, with what that sync was. +fn incomplete_sources<'a>(request: &'a AnalyzeRequest, conditions: &[ConditionRecord], hops: &[HopRef], records: &[String]) -> Vec<(&'a str, &'a SourceCoverage)> { + let mut cited: BTreeSet<&str> = records.iter().map(String::as_str).collect(); + cited.extend(conditions.iter().flat_map(|condition| condition.fields.iter().map(|field| field.record.as_str()))); + for hop in hops { + cited.insert(&hop.edge); + if let Some(edge) = request.edges.iter().find(|edge| edge.reference == hop.edge) { + cited.extend([edge.from.as_str(), edge.to.as_str()]); + } + } + let of_nodes = request.nodes.iter().filter(|node| cited.contains(node.reference.as_str())).filter_map(|node| node.source.as_deref()); + let of_edges = request.edges.iter().filter(|edge| cited.contains(edge.reference.as_str())).filter_map(|edge| edge.source.as_deref()); + let behind: BTreeSet<&str> = of_nodes.chain(of_edges).collect(); + behind.into_iter().filter_map(|source| Some((source, request.coverage.get(source).filter(|coverage| !coverage.is_complete())?))).collect() +} + +fn build(request: &AnalyzeRequest, instance: &AnalysisNode, rule_id: &str, conditions: Vec, hops: Vec, records: Vec, subject: Option) -> Built { + let mut unknowns: Vec = conditions.iter().filter(|condition| condition.status == Status::Unknown).map(|condition| condition.statement.clone()).collect(); + let mut verdict = verdict_of(&conditions); + // What a partial source left out can be what would have met a condition, + // so it cannot rule anything out. What it did report still stands. + let incomplete = incomplete_sources(request, &conditions, &hops, &records); + for (source, coverage) in &incomplete { + unknowns.push(format!("Source {source}'s last sync ({}) was {}, so records may be missing or out of date.", coverage.run_id, coverage.status)); + } + if !incomplete.is_empty() && verdict == NOT_INDICATED { + verdict = INSUFFICIENT_DATA; + } + let id_text = [Some(rule_id.to_string()), Some(instance.reference.clone()), subject].into_iter().flatten().collect::>().join("\n"); + let assumptions = rules().into_iter().find(|rule| rule.rule_id == rule_id).map(|rule| rule.assumptions).unwrap_or_default(); + Built { + record: FindingRecord { + finding_id: format!("finding:{:016x}", fnv1a(&id_text)), + rule_id: rule_id.into(), + verdict: verdict.into(), + resource: instance.reference.clone(), + conditions, + unknowns, + assumptions, + path: (!hops.is_empty()).then(|| PathRef { derivation: DERIVATION.into(), hops: hops.clone() }), + depends_on: None, + records, + }, + hops, + } +} + +/// Evaluates every rule. The result depends on nothing but the records, in +/// whatever order they came. +pub fn evaluate(request: &AnalyzeRequest) -> (Vec, u64) { + let index = Index::new(request); + let instances = index.with_label("Instance"); + let mut findings = Vec::new(); + for instance in &instances { + findings.push(direct(request, &index, instance)); + for (balancer, group, to_target, forwards) in index.balancers_of(instance) { + findings.push(behind_balancer(request, &index, instance, balancer, group, to_target, forwards)); + } + } + let lateral = lateral_findings(request, &index, &findings); + findings.extend(lateral); + let mut records: Vec = findings.into_iter().map(|built| built.record).collect(); + records.sort_by(|a, b| (rank(&a.verdict), &a.rule_id, &a.resource, &a.finding_id).cmp(&(rank(&b.verdict), &b.rule_id, &b.resource, &b.finding_id))); + (records, instances.len() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Map}; + + fn props(extra: Value) -> Map { + extra.as_object().unwrap().clone() + } + + /// Builds the records the host would send, by stable name. + #[derive(Default, Clone)] + struct Builder { + request: AnalyzeRequest, + } + + impl Builder { + fn node(mut self, id: &str, label: &str, extra: Value) -> Self { + let name = extra.get("name").and_then(Value::as_str).unwrap_or(id).to_string(); + self.request.nodes.push(AnalysisNode { reference: format!("node:{id}"), labels: vec![label.into()], name, source: None, properties: props(extra) }); + self + } + + fn edge(mut self, from: &str, rel_type: &str, to: &str, extra: Value) -> Self { + for end in [from, to] { + assert!(self.request.nodes.iter().any(|node| node.reference == format!("node:{end}")), "no node {end}"); + } + let reference = format!("rel:{from}|{rel_type}|{to}|{extra}"); + self.request.edges.push(AnalysisEdge { reference, from: format!("node:{from}"), to: format!("node:{to}"), rel_type: rel_type.into(), source: None, properties: props(extra) }); + self + } + + fn report(&self) -> Vec { + evaluate(&self.request).0 + } + } + + /// An internet-open group on a running public instance with a route: every + /// direct condition is met. Tests take this apart one fact at a time. + fn exposed() -> Builder { + Builder::default() + .node("internet", "Cidr", json!({"name": "Internet", "cidr": "0.0.0.0/0"})) + .node("sg", "SecurityGroup", json!({"name": "sg"})) + .node("vpc", "Vpc", json!({"name": "vpc"})) + .node("subnet", "Subnet", json!({"name": "subnet"})) + .node("rtb", "RouteTable", json!({"name": "rtb", "is_main": false, "ipv4_default_target": "igw"})) + .node("igw", "InternetGateway", json!({"name": "igw"})) + .node("web", "Instance", json!({"name": "web", "state": "running", "public_ip": "198.51.100.1"})) + .edge("internet", "ALLOWS", "sg", json!({"protocol": "tcp", "ports": "443", "rule": "a"})) + .edge("web", "PROTECTED_BY", "sg", json!({})) + .edge("web", "IN_SUBNET", "subnet", json!({})) + .edge("subnet", "IN_VPC", "vpc", json!({})) + .edge("subnet", "USES_ROUTE_TABLE", "rtb", json!({})) + .edge("rtb", "ROUTES_VIA", "igw", json!({"destination": "0.0.0.0/0", "state": "active", "rule": "r"})) + } + + fn without_edge(mut builder: Builder, rel_type: &str) -> Builder { + builder.request.edges.retain(|edge| edge.rel_type != rel_type); + builder + } + + fn set(mut builder: Builder, id: &str, key: &str, value: Option) -> Builder { + let node = builder.request.nodes.iter_mut().find(|node| node.reference == format!("node:{id}")).unwrap(); + match value { + Some(value) => node.properties.insert(key.into(), value), + None => node.properties.remove(key), + }; + builder + } + + fn only<'a>(report: &'a [FindingRecord], rule: &str, resource: &str) -> &'a FindingRecord { + let mut found = report.iter().filter(|finding| finding.rule_id == rule && finding.resource == format!("node:{resource}")); + let first = found.next().unwrap_or_else(|| panic!("no {rule} finding on {resource}: {report:#?}")); + assert!(found.next().is_none()); + first + } + + fn found_condition<'a>(finding: &'a FindingRecord, id: &str) -> &'a ConditionRecord { + finding.conditions.iter().find(|condition| condition.id == id).unwrap_or_else(|| panic!("no condition {id}: {finding:#?}")) + } + + fn status(finding: &FindingRecord, id: &str) -> Status { + found_condition(finding, id).status + } + + fn hops(finding: &FindingRecord) -> Vec<(&str, bool)> { + finding.path.as_ref().unwrap().hops.iter().map(|hop| (hop.edge.split('|').nth(1).unwrap(), hop.along)).collect() + } + + #[test] + fn every_direct_condition_met_is_a_potential_exposure_with_its_path() { + let report = exposed().report(); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(finding.verdict, POTENTIAL_EXPOSURE); + assert!(finding.unknowns.is_empty()); + assert_eq!(hops(finding), [("ALLOWS", true), ("PROTECTED_BY", false)]); + assert!(finding.records.contains(&"node:rtb".to_string()), "the route is part of the case: {:?}", finding.records); + assert!(!finding.assumptions.is_empty()); + } + + #[test] + fn a_security_group_rule_alone_is_not_potential_exposure() { + // The same open rule, with each other fact taken away in turn. + let no_address = set(exposed(), "web", "public_ip", None); + let no_route = without_edge(exposed(), "ROUTES_VIA"); + let stopped = set(exposed(), "web", "state", Some(json!("stopped"))); + for (case, builder) in [("no address", no_address), ("no route", no_route), ("stopped", stopped)] { + let report = builder.report(); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(status(finding, "open-inbound-rule"), Status::Met, "{case}"); + assert_eq!(finding.verdict, NOT_INDICATED, "{case}"); + } + } + + #[test] + fn no_verdict_ever_says_reachable() { + for verdict in info().verdicts { + assert!(!verdict.id.contains("reachable") && !verdict.title.to_lowercase().contains("reachable")); + } + assert!(info().claim.contains("does not show that anything is reachable")); + } + + #[test] + fn what_was_not_read_is_unknown_not_absent() { + // No group edges: every instance has a group, so they were not read. + let report = without_edge(exposed(), "PROTECTED_BY").report(); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(finding.verdict, INSUFFICIENT_DATA); + assert_eq!(status(finding, "open-inbound-rule"), Status::Unknown); + assert_eq!(finding.unknowns.len(), 1); + assert!(finding.path.is_none()); + + // No route tables recorded at all. + let report = without_edge(without_edge(exposed(), "USES_ROUTE_TABLE"), "ROUTES_VIA").report(); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(status(finding, "internet-route"), Status::Unknown); + assert_eq!(finding.verdict, INSUFFICIENT_DATA); + + // No subnet, and no state. + let report = without_edge(exposed(), "IN_SUBNET").report(); + assert_eq!(status(only(&report, RULE_DIRECT, "web"), "internet-route"), Status::Unknown); + let report = set(exposed(), "web", "state", None).report(); + assert_eq!(status(only(&report, RULE_DIRECT, "web"), "instance-running"), Status::Unknown); + } + + #[test] + fn a_fact_that_rules_it_out_outweighs_an_unknown() { + let report = set(without_edge(exposed(), "USES_ROUTE_TABLE"), "web", "public_ip", None).report(); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(finding.verdict, NOT_INDICATED); + assert_eq!(finding.unknowns.len(), 1, "the unknown is still said"); + } + + #[test] + fn a_subnet_without_its_own_table_uses_the_main_one() { + let main = without_edge(exposed(), "USES_ROUTE_TABLE").edge("rtb", "IN_VPC", "vpc", json!({})); + let report = set(main, "rtb", "is_main", Some(json!(true))).report(); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(status(finding, "internet-route"), Status::Met); + assert_eq!(finding.verdict, POTENTIAL_EXPOSURE); + + // A table in the VPC that is not the main one says nothing about it. + let not_main = without_edge(exposed(), "USES_ROUTE_TABLE").edge("rtb", "IN_VPC", "vpc", json!({})); + assert_eq!(status(only(¬_main.report(), RULE_DIRECT, "web"), "internet-route"), Status::Unknown); + } + + fn with_route(destination: &str, state: &str) -> Builder { + without_edge(exposed(), "ROUTES_VIA").edge("rtb", "ROUTES_VIA", "igw", json!({"destination": destination, "state": state, "rule": "r"})) + } + + #[test] + fn a_route_counts_only_when_active_default_and_of_the_address_family() { + let route = |builder: Builder| status(only(&builder.report(), RULE_DIRECT, "web"), "internet-route"); + assert_eq!(route(with_route("0.0.0.0/0", "blackhole")), Status::NotMet); + assert_eq!(route(with_route("203.0.113.0/24", "active")), Status::Unknown, "a narrower route is not a default one"); + assert_eq!(route(with_route("::/0", "active")), Status::NotMet, "an IPv4 address and a route for IPv6"); + + let ipv6 = set(set(with_route("::/0", "active"), "web", "public_ip", None), "web", "ipv6_address", Some(json!("2001:db8::1"))); + let report = ipv6.report(); + assert_eq!(only(&report, RULE_DIRECT, "web").verdict, POTENTIAL_EXPOSURE); + } + + #[test] + fn a_narrower_cidr_is_not_the_internet() { + let builder = set(exposed(), "internet", "cidr", Some(json!("203.0.113.0/24"))); + let report = builder.report(); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(status(finding, "open-inbound-rule"), Status::NotMet); + assert!(finding.path.is_none()); + } + + fn balanced(target_rule: Value, target_source: &str, health: &str) -> Builder { + Builder::default() + .node("internet", "Cidr", json!({"name": "Internet", "cidr": "0.0.0.0/0"})) + .node("office", "Cidr", json!({"name": "office", "cidr": "10.0.0.0/8"})) + .node("sg-lb", "SecurityGroup", json!({"name": "sg-lb"})) + .node("sg-app", "SecurityGroup", json!({"name": "sg-app"})) + .node("sg-other", "SecurityGroup", json!({"name": "sg-other"})) + .node("lb", "LoadBalancer", json!({"name": "lb", "scheme": "internet-facing"})) + .node("tg", "TargetGroup", json!({"name": "tg", "protocol": "HTTP", "port": 8080})) + .node("app", "Instance", json!({"name": "app", "state": "running"})) + .edge("internet", "ROUTES_TO", "lb", json!({})) + .edge("internet", "ALLOWS", "sg-lb", json!({"protocol": "tcp", "ports": "443", "rule": "a"})) + .edge("lb", "PROTECTED_BY", "sg-lb", json!({})) + .edge("lb", "FORWARDS_TO", "tg", json!({})) + .edge("tg", "ROUTES_TO", "app", json!({"health": health})) + .edge("app", "PROTECTED_BY", "sg-app", json!({})) + .edge(target_source, "ALLOWS", "sg-app", target_rule) + } + + fn app_rule(ports: &str) -> Value { + json!({"protocol": "tcp", "ports": ports, "rule": "b"}) + } + + const ALLOWS_TARGET: &str = "target-allows-balancer"; + + #[test] + fn a_target_behind_a_public_balancer_needs_its_own_group_to_allow_the_port() { + let report = balanced(app_rule("8080"), "sg-lb", "healthy").report(); + let finding = only(&report, RULE_BALANCER, "app"); + assert_eq!(finding.verdict, POTENTIAL_EXPOSURE, "{finding:#?}"); + assert_eq!(hops(finding), [("ROUTES_TO", true), ("FORWARDS_TO", true), ("ROUTES_TO", true)]); + assert_eq!(only(&report, RULE_DIRECT, "app").verdict, NOT_INDICATED, "it has no public address of its own"); + + // The port range matters, and so does whom the rule lets in. + let status_for = |builder: Builder| status(only(&builder.report(), RULE_BALANCER, "app"), ALLOWS_TARGET); + assert_eq!(status_for(balanced(app_rule("8000-8100"), "sg-lb", "healthy")), Status::Met); + assert_eq!(status_for(balanced(app_rule("all"), "sg-lb", "healthy")), Status::Met); + assert_eq!(status_for(balanced(app_rule("9000"), "sg-lb", "healthy")), Status::NotMet); + assert_eq!(status_for(balanced(app_rule("8080"), "office", "healthy")), Status::Unknown, "a CIDR may or may not hold the balancer's addresses"); + assert_eq!(status_for(balanced(app_rule("8080"), "internet", "healthy")), Status::Met); + } + + /// With no group recorded for the balancer, a rule from some other group + /// says nothing either way. This was once read as "does not allow it", which + /// cleared the finding on data that was simply missing. + #[test] + fn missing_balancer_groups_leave_the_target_rule_unknown_rather_than_unmet() { + let no_balancer_groups = |builder: Builder| { + let mut builder = builder; + builder.request.edges.retain(|edge| !(edge.rel_type == "PROTECTED_BY" && edge.from == "node:lb")); + builder + }; + // A rule from another group, and the balancer's own groups not known. + let report = no_balancer_groups(balanced(app_rule("8080"), "sg-other", "healthy")).report(); + let finding = only(&report, RULE_BALANCER, "app"); + assert_eq!(status(finding, ALLOWS_TARGET), Status::Unknown, "{finding:#?}"); + assert_eq!(finding.verdict, INSUFFICIENT_DATA); + assert!(finding.unknowns.iter().any(|text| text.contains("allows the target group's port"))); + + // No rule covers the port either: still not known to be the balancer's. + let report = no_balancer_groups(balanced(app_rule("9000"), "sg-other", "healthy")).report(); + assert_eq!(status(only(&report, RULE_BALANCER, "app"), ALLOWS_TARGET), Status::Unknown); + + // With the groups known, the same rule from another group does rule it out. + let report = balanced(app_rule("8080"), "sg-other", "healthy").report(); + let finding = only(&report, RULE_BALANCER, "app"); + assert_eq!(status(finding, ALLOWS_TARGET), Status::NotMet); + assert_eq!(finding.verdict, NOT_INDICATED); + + // An open rule needs no knowledge of the balancer's groups. + let report = no_balancer_groups(balanced(app_rule("8080"), "internet", "healthy")).report(); + assert_eq!(status(only(&report, RULE_BALANCER, "app"), ALLOWS_TARGET), Status::Met); + } + + /// A `TCP_UDP` target group carries both, so a UDP rule alone lets traffic in. + #[test] + fn udp_through_a_tcp_udp_target_group_counts() { + let with_protocol = |group: &str, rule: Value| set(balanced(rule, "sg-lb", "healthy"), "tg", "protocol", Some(json!(group))); + let udp_rule = || json!({"protocol": "udp", "ports": "8080", "rule": "b"}); + let tcp_rule = || json!({"protocol": "tcp", "ports": "8080", "rule": "b"}); + let status_for = |builder: Builder| status(only(&builder.report(), RULE_BALANCER, "app"), ALLOWS_TARGET); + + for group in ["TCP_UDP", "TCP_QUIC"] { + assert_eq!(status_for(with_protocol(group, udp_rule())), Status::Met, "{group} over udp"); + assert_eq!(status_for(with_protocol(group, tcp_rule())), Status::Met, "{group} over tcp"); + } + for group in ["UDP", "QUIC", "GENEVE"] { + assert_eq!(status_for(with_protocol(group, udp_rule())), Status::Met, "{group} over udp"); + assert_eq!(status_for(with_protocol(group, tcp_rule())), Status::NotMet, "{group} is not tcp"); + } + for group in ["HTTP", "HTTPS", "TCP", "TLS"] { + assert_eq!(status_for(with_protocol(group, tcp_rule())), Status::Met, "{group} over tcp"); + assert_eq!(status_for(with_protocol(group, udp_rule())), Status::NotMet, "{group} is not udp"); + } + + let report = with_protocol("TCP_UDP", udp_rule()).report(); + assert!(found_condition(only(&report, RULE_BALANCER, "app"), ALLOWS_TARGET).detail.contains("udp 8080"), "it says which"); + let report = with_protocol("TCP_UDP", json!({"protocol": "icmp", "ports": "all", "rule": "b"})).report(); + assert!(found_condition(only(&report, RULE_BALANCER, "app"), ALLOWS_TARGET).detail.contains("tcp or udp 8080")); + + // Not knowing the protocol is not the same as it being TCP. + let report = set(balanced(app_rule("8080"), "sg-lb", "healthy"), "tg", "protocol", None).report(); + assert_eq!(status(only(&report, RULE_BALANCER, "app"), ALLOWS_TARGET), Status::Unknown); + } + + /// The target's security groups are what the condition turns on, so they are + /// part of its evidence whichever way it went. + #[test] + fn the_targets_group_membership_is_evidence_for_the_rule() { + let membership_of = |finding: &FindingRecord| -> Vec { + found_condition(finding, ALLOWS_TARGET).fields.iter().filter(|field| field.field == "@to").map(|field| field.record.clone()).collect() + }; + for (case, builder) in [("allowed", balanced(app_rule("8080"), "sg-lb", "healthy")), ("refused", balanced(app_rule("9000"), "sg-lb", "healthy"))] { + let report = builder.report(); + let found = membership_of(only(&report, RULE_BALANCER, "app")); + assert_eq!(found.len(), 1, "{case}"); + assert!(found[0].contains("app|PROTECTED_BY|sg-app"), "{case}: {found:?}"); + } + } + + #[test] + fn an_internal_balancer_or_an_unhealthy_target_does_not_give_a_potential_exposure() { + let internal = set(balanced(app_rule("8080"), "sg-lb", "healthy"), "lb", "scheme", Some(json!("internal"))); + assert_eq!(only(&internal.report(), RULE_BALANCER, "app").verdict, NOT_INDICATED); + + let unhealthy = balanced(app_rule("8080"), "sg-lb", "unhealthy").report(); + let finding = only(&unhealthy, RULE_BALANCER, "app"); + assert_eq!(finding.verdict, INSUFFICIENT_DATA); + assert_eq!(status(finding, "target-healthy"), Status::Unknown); + + // A network load balancer can have no security group to read. + let no_groups = without_edge(balanced(app_rule("8080"), "internet", "healthy"), "PROTECTED_BY"); + let report = no_groups.report(); + let finding = only(&report, RULE_BALANCER, "app"); + assert_eq!(status(finding, "balancer-open-rule"), Status::Unknown); + } + + /// `web` is exposed; `db` allows `web`'s group. + fn with_database(builder: Builder) -> Builder { + builder + .node("sg-db", "SecurityGroup", json!({"name": "sg-db"})) + .node("db", "Instance", json!({"name": "db", "state": "running"})) + .edge("db", "PROTECTED_BY", "sg-db", json!({})) + .edge("sg", "ALLOWS", "sg-db", json!({"protocol": "tcp", "ports": "5432", "rule": "c"})) + } + + #[test] + fn one_step_behind_an_exposed_instance_links_to_the_finding_it_rests_on() { + let report = with_database(exposed()).report(); + let web = only(&report, RULE_DIRECT, "web"); + let db = only(&report, RULE_LATERAL, "db"); + assert_eq!(db.verdict, POTENTIAL_EXPOSURE); + assert_eq!(db.depends_on.as_ref(), Some(&web.finding_id)); + assert_eq!(hops(db), [("ALLOWS", true), ("PROTECTED_BY", false), ("PROTECTED_BY", true), ("ALLOWS", true), ("PROTECTED_BY", false)]); + assert_eq!(only(&report, RULE_DIRECT, "db").verdict, NOT_INDICATED); + } + + #[test] + fn a_step_behind_an_undecided_instance_is_undecided_and_behind_a_safe_one_is_silent() { + let undecided = with_database(without_edge(without_edge(exposed(), "USES_ROUTE_TABLE"), "ROUTES_VIA")).report(); + let db = only(&undecided, RULE_LATERAL, "db"); + assert_eq!(db.verdict, INSUFFICIENT_DATA); + assert_eq!(status(db, "source-potentially-exposed"), Status::Unknown); + + let not_exposed = with_database(set(exposed(), "web", "public_ip", None)).report(); + assert!(not_exposed.iter().all(|finding| finding.rule_id != RULE_LATERAL), "{not_exposed:#?}"); + } + + #[test] + fn a_stopped_target_of_a_lateral_rule_is_not_indicated() { + let builder = set(with_database(exposed()), "db", "state", Some(json!("stopped"))); + assert_eq!(only(&builder.report(), RULE_LATERAL, "db").verdict, NOT_INDICATED); + } + + #[test] + fn findings_come_in_a_stated_order_and_input_order_does_not_change_them() { + let builder = with_database(exposed()).node("idle", "Instance", json!({"name": "idle", "state": "running", "public_ip": "198.51.100.9"})); + let (report, subjects) = evaluate(&builder.request); + let order: Vec<(&str, &str, &str)> = report.iter().map(|finding| (finding.verdict.as_str(), finding.rule_id.as_str(), finding.resource.as_str())).collect(); + assert_eq!( + order, + [ + (POTENTIAL_EXPOSURE, RULE_DIRECT, "node:web"), + (POTENTIAL_EXPOSURE, RULE_LATERAL, "node:db"), + (INSUFFICIENT_DATA, RULE_DIRECT, "node:idle"), + (NOT_INDICATED, RULE_DIRECT, "node:db"), + ], + "{report:#?}" + ); + assert_eq!(subjects, 3); + + let mut shuffled = builder.request.clone(); + shuffled.nodes.reverse(); + shuffled.edges.reverse(); + assert_eq!(evaluate(&shuffled).0, report); + } + + #[test] + fn the_same_records_keep_their_finding_id() { + let report = exposed().report(); + assert_eq!(report[0].finding_id, exposed().report()[0].finding_id); + assert_ne!(report[0].finding_id, only(&with_database(exposed()).report(), RULE_LATERAL, "db").finding_id); + // The ID format, so that changing it shows up here. + let id_text = format!("{RULE_DIRECT}\nnode:web"); + assert_eq!(only(&report, RULE_DIRECT, "web").finding_id, format!("finding:{:016x}", fnv1a(&id_text))); + } + + fn tagged(mut builder: Builder, status: &str) -> Builder { + for node in &mut builder.request.nodes { + node.source = Some("shop".into()); + } + for edge in &mut builder.request.edges { + edge.source = Some("shop".into()); + } + builder.request.coverage.insert("shop".into(), SourceCoverage { run_id: "shop#1".into(), status: status.into(), gaps: vec![] }); + builder + } + + #[test] + fn a_partial_source_cannot_clear_anything() { + // No public address, so the rule would not point here on complete data. + let bare = || set(exposed(), "web", "public_ip", None); + let whole = tagged(bare(), "complete").report(); + assert_eq!(only(&whole, RULE_DIRECT, "web").verdict, NOT_INDICATED); + + for status in ["partial", "failed", "cancelled"] { + let report = tagged(bare(), status).report(); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(finding.verdict, INSUFFICIENT_DATA, "{status}"); + assert!(finding.unknowns.iter().any(|text| text.contains("shop#1") && text.contains(status)), "{:?}", finding.unknowns); + } + } + + #[test] + fn what_a_partial_source_did_report_still_counts() { + let report = tagged(exposed(), "partial").report(); + assert_eq!(only(&report, RULE_DIRECT, "web").verdict, POTENTIAL_EXPOSURE); + } + + #[test] + fn a_source_with_no_record_of_its_runs_is_not_called_incomplete() { + let mut builder = tagged(set(exposed(), "web", "public_ip", None), "complete"); + builder.request.coverage.clear(); + assert_eq!(only(&builder.report(), RULE_DIRECT, "web").verdict, NOT_INDICATED); + } + + #[test] + fn a_partial_source_behind_a_path_counts_even_when_no_condition_read_it() { + // Only the node a hop arrives at comes from the partial source. + let mut builder = set(exposed(), "web", "public_ip", None); + let internet = builder.request.nodes.iter_mut().find(|node| node.reference == "node:internet").unwrap(); + internet.source = Some("other".into()); + builder.request.coverage.insert("other".into(), SourceCoverage { run_id: "other#2".into(), status: "partial".into(), gaps: vec![] }); + let report = builder.report(); + assert_eq!(only(&report, RULE_DIRECT, "web").verdict, INSUFFICIENT_DATA); + } + + #[test] + fn every_rule_and_verdict_a_finding_uses_is_declared_and_translated() { + let declared = info(); + let builders = [with_database(exposed()), balanced(app_rule("8080"), "sg-lb", "healthy")]; + let mut rules_seen = BTreeSet::new(); + for finding in builders.iter().flat_map(Builder::report) { + rules_seen.insert(finding.rule_id.clone()); + assert!(declared.rules.iter().any(|rule| rule.rule_id == finding.rule_id)); + assert!(declared.verdicts.iter().any(|verdict| verdict.id == finding.verdict)); + assert!(declared.translated("zh", &format!("rule.{}", finding.rule_id)).is_some(), "{}", finding.rule_id); + assert!(declared.translated("zh", &format!("verdict.{}", finding.verdict)).is_some(), "{}", finding.verdict); + for condition in &finding.conditions { + assert!(declared.translated("zh", &format!("condition.{}", condition.id)).is_some(), "{}", condition.id); + } + } + assert_eq!(rules_seen.len(), 3, "the builders reach every rule"); + } +} diff --git a/plugins/aws/src/main.rs b/plugins/aws/src/main.rs index 0decba9..68dc257 100644 --- a/plugins/aws/src/main.rs +++ b/plugins/aws/src/main.rs @@ -9,6 +9,8 @@ //! (Instance)-[ASSUMES]->(Role) through its instance profile //! (LoadBalancer)-[IN_VPC]->(Vpc), (LoadBalancer)-[PROTECTED_BY]->(SecurityGroup) //! (LoadBalancer)-[FORWARDS_TO]->(TargetGroup)-[ROUTES_TO]->(Instance) +//! (RouteTable)-[IN_VPC]->(Vpc), (Subnet)-[USES_ROUTE_TABLE]->(RouteTable) +//! (RouteTable)-[ROUTES_VIA {destination, state}]->(InternetGateway) //! ``` //! //! `0.0.0.0/0` and `::/0` are one `Cidr` node named Internet, so "what can the @@ -20,15 +22,20 @@ //! reads saved `aws ... --output json` responses instead of calling AWS, for //! offline imports and tests: `//-.json`, //! and `/iam-list-roles.json`, `/iam-list-instance-profiles.json`, -//! `/sts-get-caller-identity.json`. +//! `/sts-get-caller-identity.json`. A response that is not there makes the +//! sync partial, so that it removes nothing; a service with nothing to report +//! has a file with an empty list. +mod exposure; + +use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::io::Write; use std::path::PathBuf; use std::process::{Command, ExitCode}; use khonsu_plugin_protocol::{ - read_request, ConfigField, Describe, EdgeRecord, Emitter, FieldKind, Message, NodeRecord, Permissions, Request, + read_analyze_request, read_request, ConfigField, Describe, EdgeRecord, Emitter, FieldKind, Message, NodeRecord, Permissions, Request, }; use serde_json::{json, Map, Value}; @@ -66,13 +73,14 @@ fn describe() -> Describe { commands: vec!["aws".into()], notes: vec!["Read-only: ec2:Describe*, elasticloadbalancing:Describe*, iam:List*, sts:GetCallerIdentity".into()], }, - labels: ["Account", "Region", "Vpc", "Subnet", "SecurityGroup", "Cidr", "Instance", "InternetGateway", "LoadBalancer", "TargetGroup", "Role"] + labels: ["Account", "Region", "Vpc", "Subnet", "SecurityGroup", "Cidr", "Instance", "InternetGateway", "RouteTable", "LoadBalancer", "TargetGroup", "Role"] .map(String::from) .to_vec(), - relationship_types: ["IN_REGION", "IN_VPC", "IN_SUBNET", "PROTECTED_BY", "ALLOWS", "ATTACHED_TO", "ASSUMES", "FORWARDS_TO", "ROUTES_TO", "OWNS"] + relationship_types: ["IN_REGION", "IN_VPC", "IN_SUBNET", "PROTECTED_BY", "ALLOWS", "ATTACHED_TO", "ASSUMES", "FORWARDS_TO", "ROUTES_TO", "USES_ROUTE_TABLE", "ROUTES_VIA", "OWNS"] .map(String::from) .to_vec(), supports_incremental: false, + analyses: vec![exposure::info()], } } @@ -82,7 +90,11 @@ enum Source { profile: Option, env: Vec<(String, String)>, }, - Fixture(PathBuf), + Fixture { + dir: PathBuf, + /// Responses asked for and not saved: what this sync could not see. + gaps: RefCell>, + }, } impl Source { @@ -97,7 +109,7 @@ impl Source { if !dir.is_dir() { return Err(format!("saved responses folder not found: {}", dir.display())); } - return Ok(Source::Fixture(dir)); + return Ok(Source::Fixture { dir, gaps: RefCell::default() }); } let mut env = Vec::new(); for (key, var) in [ @@ -116,10 +128,18 @@ impl Source { Ok(Source::Cli { profile, env }) } + /// What this sync could not see. A real account either answers or fails. + fn gaps(&self) -> Vec { + match self { + Source::Fixture { gaps, .. } => gaps.borrow().clone(), + Source::Cli { .. } => Vec::new(), + } + } + /// One read-only call, as JSON. `region` is `None` for global services. fn call(&self, region: Option<&str>, service: &str, operation: &str, extra: &[&str]) -> Result { match self { - Source::Fixture(dir) => { + Source::Fixture { dir, gaps } => { let file = format!("{service}-{operation}.json"); let path = match region { Some(region) => dir.join(region).join(file), @@ -127,8 +147,12 @@ impl Source { }; match std::fs::read_to_string(&path) { Ok(text) => serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display())), - // A response not saved is an empty one: the fixture did not use that service. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), + // Not a fact about the account: the sync goes on, and says it + // did not see this. A service with nothing has an empty file. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + gaps.borrow_mut().push(format!("no saved response for {service} {operation}{}", region.map(|r| format!(" in {r}")).unwrap_or_default())); + Ok(json!({})) + } Err(e) => Err(format!("{}: {e}", path.display())), } } @@ -232,6 +256,15 @@ impl Graph { }); } + /// Subnets are keyed by ARN when AWS gave one, so find one by its ID. + fn subnet_key(&self, subnet_id: &str, scoped: String) -> String { + self.nodes + .values() + .find(|n| n.label == "Subnet" && n.properties.get("subnet_id") == Some(&json!(subnet_id))) + .map(|n| n.id.clone()) + .unwrap_or(scoped) + } + fn emit(self, out: &mut Emitter) -> std::io::Result<(u64, u64)> { let mut nodes = 0; for node in self.nodes.values() { @@ -261,6 +294,50 @@ fn port_range(permission: &Value) -> String { } } +/// What a route sends its traffic to, under whichever field AWS used. +fn route_target(route: &Value) -> Option<&str> { + ["GatewayId", "NatGatewayId", "TransitGatewayId", "VpcPeeringConnectionId", "NetworkInterfaceId", "EgressOnlyInternetGatewayId", "InstanceId"] + .iter() + .find_map(|key| s(route, key)) +} + +/// A route table, the subnets it names, and its routes to internet gateways. +/// Other routes stay out of the graph, apart from where the default routes go. +fn route_table(g: &mut Graph, table: &Value, scoped: &dyn Fn(&str) -> String) { + let Some(table_id) = s(table, "RouteTableId") else { return }; + let default_target = |destination: &str, key: &str| items(table, "Routes").find(|route| s(route, key) == Some(destination)).and_then(route_target).map(|target| json!(target)); + let is_main = items(table, "Associations").any(|a| a.get("Main").and_then(Value::as_bool) == Some(true)); + let id = g.node( + scoped(table_id), + "RouteTable", + props(&[ + ("name", Some(json!(name_tag(table).unwrap_or_else(|| table_id.to_string())))), + ("route_table_id", Some(json!(table_id))), + ("is_main", Some(json!(is_main))), + ("ipv4_default_target", default_target("0.0.0.0/0", "DestinationCidrBlock")), + ("ipv6_default_target", default_target("::/0", "DestinationIpv6CidrBlock")), + ]), + ); + if let Some(vpc_id) = s(table, "VpcId") { + g.edge(&id, "IN_VPC", &scoped(vpc_id), Map::new()); + } + for association in items(table, "Associations") { + if let Some(subnet_id) = s(association, "SubnetId") { + let subnet = g.subnet_key(subnet_id, scoped(subnet_id)); + g.edge(&subnet, "USES_ROUTE_TABLE", &id, Map::new()); + } + } + for route in items(table, "Routes") { + let Some(gateway) = s(route, "GatewayId").filter(|gateway| gateway.starts_with("igw-")) else { continue }; + let Some(destination) = s(route, "DestinationCidrBlock").or_else(|| s(route, "DestinationIpv6CidrBlock")) else { continue }; + // The route is the fact that matters, so it needs a gateway to point + // at even when the response describing gateways is missing. + let gateway_id = g.node(scoped(gateway), "InternetGateway", props(&[("name", Some(json!(gateway)))])); + let properties = props(&[("destination", Some(json!(destination))), ("state", s(route, "State").map(|v| json!(v))), ("rule", Some(json!(destination)))]); + g.edge(&id, "ROUTES_VIA", &gateway_id, properties); + } +} + fn collect(source: &Source, regions: &[String], out: &mut Emitter) -> Result { let io = |e: std::io::Error| e.to_string(); let mut g = Graph::default(); @@ -317,6 +394,9 @@ fn collect(source: &Source, regions: &[String], out: &mut Emitter) } } } + for table in items(&source.call(Some(region), "ec2", "describe-route-tables", &[])?, "RouteTables") { + route_table(&mut g, table, &scoped); + } let groups = source.call(Some(region), "ec2", "describe-security-groups", &[])?; for sg in items(&groups, "SecurityGroups") { let Some(group_id) = s(sg, "GroupId") else { continue }; @@ -366,18 +446,13 @@ fn collect(source: &Source, regions: &[String], out: &mut Emitter) ("state", instance.pointer("/State/Name").cloned()), ("private_ip", s(instance, "PrivateIpAddress").map(|v| json!(v))), ("public_ip", s(instance, "PublicIpAddress").map(|v| json!(v))), + ("ipv6_address", s(instance, "Ipv6Address").map(|v| json!(v))), ("image_id", s(instance, "ImageId").map(|v| json!(v))), ("launched_at", s(instance, "LaunchTime").map(|v| json!(v))), ]), ); if let Some(subnet_id) = s(instance, "SubnetId") { - // Subnets are keyed by ARN when AWS gave one; find it by its ID. - let subnet = g - .nodes - .values() - .find(|n| n.label == "Subnet" && n.properties.get("subnet_id") == Some(&json!(subnet_id))) - .map(|n| n.id.clone()) - .unwrap_or_else(|| scoped(subnet_id)); + let subnet = g.subnet_key(subnet_id, scoped(subnet_id)); g.edge(&id, "IN_SUBNET", &subnet, Map::new()); } for group in items(instance, "SecurityGroups") { @@ -415,7 +490,7 @@ fn collect(source: &Source, regions: &[String], out: &mut Emitter) } let health = match source { // Saved responses keep each group's health under its name. - Source::Fixture(_) => source.call(Some(region), "elbv2", &format!("describe-target-health-{}", s(tg, "TargetGroupName").unwrap_or_default()), &[])?, + Source::Fixture { .. } => source.call(Some(region), "elbv2", &format!("describe-target-health-{}", s(tg, "TargetGroupName").unwrap_or_default()), &[])?, Source::Cli { .. } => source.call(Some(region), "elbv2", "describe-target-health", &["--target-group-arn", arn])?, }; for description in items(&health, "TargetHealthDescriptions") { @@ -464,16 +539,28 @@ fn run(command: &str, out: &mut Emitter, request: Option) }) .map_err(io)?; let (nodes, edges) = graph.emit(out).map_err(io)?; - out.emit(&Message::Done { nodes, edges }).map_err(io) + let gaps = source.gaps(); + out.emit(&Message::Done { nodes, edges, complete: gaps.is_empty(), gaps }).map_err(io) } - other => Err(format!("unknown command {other:?}; expected describe, test or sync")), + "analyze" => { + let request = read_analyze_request().map_err(|e| format!("cannot read the analysis request: {e}"))?; + if request.analysis != exposure::ANALYSIS { + return Err(format!("no analysis {:?}", request.analysis)); + } + let (findings, subjects) = exposure::evaluate(&request); + for finding in findings { + out.emit(&Message::Finding(Box::new(finding))).map_err(io)?; + } + out.emit(&Message::AnalysisDone { subjects }).map_err(io) + } + other => Err(format!("unknown command {other:?}; expected describe, test, sync or analyze")), } } fn main() -> ExitCode { let mut out = Emitter::stdout(); let command = std::env::args().nth(1).unwrap_or_default(); - let request = if command == "describe" { None } else { read_request().ok() }; + let request = if matches!(command.as_str(), "describe" | "analyze") { None } else { read_request().ok() }; match run(&command, &mut out, request) { Ok(()) => ExitCode::SUCCESS, Err(message) => { @@ -554,6 +641,68 @@ mod tests { assert!(edges.iter().all(|e| ids.contains(e.source.as_str()) && ids.contains(e.target.as_str()))); } + #[test] + fn route_tables_record_subnets_gateways_and_where_a_default_route_goes() { + let mut g = Graph::default(); + let scoped = |id: &str| format!("a:r:{id}"); + g.node("arn:subnet-1", "Subnet", props(&[("subnet_id", Some(json!("subnet-1")))])); + let public = json!({ + "RouteTableId": "rtb-1", "VpcId": "vpc-1", + "Associations": [{"SubnetId": "subnet-1", "Main": false}], + "Routes": [ + {"DestinationCidrBlock": "10.0.0.0/16", "GatewayId": "local", "State": "active"}, + {"DestinationCidrBlock": "0.0.0.0/0", "GatewayId": "igw-1", "State": "active"}, + {"DestinationIpv6CidrBlock": "::/0", "GatewayId": "igw-1", "State": "blackhole"}, + ], + }); + route_table(&mut g, &public, &scoped); + let table = &g.nodes["a:r:rtb-1"]; + assert_eq!(table.properties["is_main"], json!(false)); + assert_eq!(table.properties["ipv4_default_target"], json!("igw-1")); + assert!(g.nodes.contains_key("a:r:igw-1"), "a route names its gateway even when no response described it"); + assert!(g.edges.values().any(|e| e.rel_type == "USES_ROUTE_TABLE" && e.source == "arn:subnet-1"), "subnets are found by ID: {:?}", g.edges.keys()); + let routes: Vec<_> = g.edges.values().filter(|e| e.rel_type == "ROUTES_VIA").collect(); + assert_eq!(routes.len(), 2, "both families are kept, with their states"); + assert!(routes.iter().any(|e| e.properties["destination"] == json!("::/0") && e.properties["state"] == json!("blackhole"))); + + let private = json!({ + "RouteTableId": "rtb-2", "VpcId": "vpc-1", + "Associations": [{"Main": true}], + "Routes": [{"DestinationCidrBlock": "0.0.0.0/0", "NatGatewayId": "nat-1", "State": "active"}], + }); + route_table(&mut g, &private, &scoped); + let table = &g.nodes["a:r:rtb-2"]; + assert_eq!(table.properties["is_main"], json!(true)); + assert_eq!(table.properties["ipv4_default_target"], json!("nat-1")); + assert_eq!(g.edges.values().filter(|e| e.rel_type == "ROUTES_VIA" && e.source == "a:r:rtb-2").count(), 0); + } + + #[test] + fn a_response_that_is_not_saved_makes_the_sync_partial_and_says_which() { + let finish = |dir: PathBuf| { + let mut config = Map::new(); + config.insert("fixture_dir".into(), json!(dir.display().to_string())); + let request = Request { protocol: PROTOCOL_VERSION, config, ..Request::default() }; + let mut out = Emitter::new(Vec::new()); + run("sync", &mut out, Some(request)).unwrap(); + let text = String::from_utf8(out.into_inner()).unwrap(); + text.lines().filter_map(|l| parse_line(l).map(|m| m.unwrap())).find_map(|message| match message { + Message::Done { complete, gaps, .. } => Some((complete, gaps)), + _ => None, + }) + }; + let whole = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixture"); + assert_eq!(finish(whole), Some((true, vec![])), "every response is saved, some of them empty"); + + let scratch = std::env::temp_dir().join(format!("aws-gaps-{}", std::process::id())); + std::fs::create_dir_all(&scratch).unwrap(); + let (complete, gaps) = finish(scratch.clone()).unwrap(); + std::fs::remove_dir_all(&scratch).unwrap(); + assert!(!complete); + assert!(gaps.iter().any(|gap| gap == "no saved response for ec2 describe-instances in us-east-1"), "{gaps:?}"); + assert!(gaps.iter().any(|gap| gap == "no saved response for iam list-roles"), "{gaps:?}"); + } + #[test] fn credentials_are_required_without_saved_responses() { let request = Request { diff --git a/plugins/aws/tests/fixture-exposure/iam-list-instance-profiles.json b/plugins/aws/tests/fixture-exposure/iam-list-instance-profiles.json new file mode 100644 index 0000000..c6e4981 --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/iam-list-instance-profiles.json @@ -0,0 +1,4 @@ +{"InstanceProfiles": [ + {"InstanceProfileName": "web-app", "Arn": "arn:aws:iam::123456789012:instance-profile/web-app", "Roles": [{"RoleName": "web-app", "Arn": "arn:aws:iam::123456789012:role/web-app"}]}, + {"InstanceProfileName": "db-backup", "Arn": "arn:aws:iam::123456789012:instance-profile/db-backup", "Roles": [{"RoleName": "db-backup", "Arn": "arn:aws:iam::123456789012:role/db-backup"}]} +]} diff --git a/plugins/aws/tests/fixture-exposure/iam-list-roles.json b/plugins/aws/tests/fixture-exposure/iam-list-roles.json new file mode 100644 index 0000000..4dfbc3f --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/iam-list-roles.json @@ -0,0 +1,4 @@ +{"Roles": [ + {"Path": "/", "RoleName": "web-app", "RoleId": "AROAWEB", "Arn": "arn:aws:iam::123456789012:role/web-app", "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": {"Service": "ec2.amazonaws.com"}, "Action": "sts:AssumeRole"}]}}, + {"Path": "/", "RoleName": "db-backup", "RoleId": "AROADB", "Arn": "arn:aws:iam::123456789012:role/db-backup", "AssumeRolePolicyDocument": {"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": {"Service": "ec2.amazonaws.com"}, "Action": "sts:AssumeRole"}]}} +]} diff --git a/plugins/aws/tests/fixture-exposure/sts-get-caller-identity.json b/plugins/aws/tests/fixture-exposure/sts-get-caller-identity.json new file mode 100644 index 0000000..15805d8 --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/sts-get-caller-identity.json @@ -0,0 +1 @@ +{"UserId": "AIDAEXAMPLE", "Account": "123456789012", "Arn": "arn:aws:iam::123456789012:user/readonly-auditor"} diff --git a/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-instances.json b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-instances.json new file mode 100644 index 0000000..4293935 --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-instances.json @@ -0,0 +1,7 @@ +{"Reservations": [{"Instances": [ + {"InstanceId": "i-web1", "InstanceType": "t3.small", "State": {"Name": "running"}, "SubnetId": "subnet-public", "PrivateIpAddress": "10.0.1.10", "PublicIpAddress": "198.51.100.10", "ImageId": "ami-web", "LaunchTime": "2026-09-01T08:00:00+00:00", "SecurityGroups": [{"GroupId": "sg-web", "GroupName": "web"}], "IamInstanceProfile": {"Arn": "arn:aws:iam::123456789012:instance-profile/web-app"}, "Tags": [{"Key": "Name", "Value": "web-1"}]}, + {"InstanceId": "i-app1", "InstanceType": "t3.small", "State": {"Name": "running"}, "SubnetId": "subnet-private", "PrivateIpAddress": "10.0.2.30", "ImageId": "ami-app", "LaunchTime": "2026-09-01T08:00:00+00:00", "SecurityGroups": [{"GroupId": "sg-app", "GroupName": "app"}], "Tags": [{"Key": "Name", "Value": "app-1"}]}, + {"InstanceId": "i-batch1", "InstanceType": "t3.small", "State": {"Name": "running"}, "SubnetId": "subnet-private", "PrivateIpAddress": "10.0.2.40", "ImageId": "ami-batch", "LaunchTime": "2026-09-01T08:00:00+00:00", "SecurityGroups": [{"GroupId": "sg-batch", "GroupName": "batch"}], "Tags": [{"Key": "Name", "Value": "batch-1"}]}, + {"InstanceId": "i-db1", "InstanceType": "r6g.large", "State": {"Name": "running"}, "SubnetId": "subnet-private", "PrivateIpAddress": "10.0.2.20", "ImageId": "ami-db", "LaunchTime": "2026-09-01T08:00:00+00:00", "SecurityGroups": [{"GroupId": "sg-db", "GroupName": "db"}], "IamInstanceProfile": {"Arn": "arn:aws:iam::123456789012:instance-profile/db-backup"}, "Tags": [{"Key": "Name", "Value": "db-1"}]}, + {"InstanceId": "i-legacy1", "InstanceType": "t2.micro", "State": {"Name": "running"}, "SubnetId": "subnet-legacy", "PrivateIpAddress": "10.9.1.5", "PublicIpAddress": "198.51.100.77", "ImageId": "ami-old", "LaunchTime": "2023-03-01T08:00:00+00:00", "SecurityGroups": [{"GroupId": "sg-legacy", "GroupName": "legacy"}], "Tags": [{"Key": "Name", "Value": "legacy-1"}]} +]}]} diff --git a/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-internet-gateways.json b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-internet-gateways.json new file mode 100644 index 0000000..2d8aacc --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-internet-gateways.json @@ -0,0 +1,4 @@ +{"InternetGateways": [ + {"InternetGatewayId": "igw-shop", "Attachments": [{"State": "available", "VpcId": "vpc-shop"}], "Tags": []}, + {"InternetGatewayId": "igw-legacy", "Attachments": [{"State": "available", "VpcId": "vpc-legacy"}], "Tags": []} +]} diff --git a/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-route-tables.json b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-route-tables.json new file mode 100644 index 0000000..471aa81 --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-route-tables.json @@ -0,0 +1,14 @@ +{"RouteTables": [ + {"RouteTableId": "rtb-public", "VpcId": "vpc-shop", "Tags": [{"Key": "Name", "Value": "public"}], + "Associations": [{"RouteTableAssociationId": "rtbassoc-1", "SubnetId": "subnet-public", "Main": false}], + "Routes": [ + {"DestinationCidrBlock": "10.0.0.0/16", "GatewayId": "local", "State": "active"}, + {"DestinationCidrBlock": "0.0.0.0/0", "GatewayId": "igw-shop", "State": "active"} + ]}, + {"RouteTableId": "rtb-main", "VpcId": "vpc-shop", "Tags": [{"Key": "Name", "Value": "main"}], + "Associations": [{"RouteTableAssociationId": "rtbassoc-2", "Main": true}], + "Routes": [ + {"DestinationCidrBlock": "10.0.0.0/16", "GatewayId": "local", "State": "active"}, + {"DestinationCidrBlock": "0.0.0.0/0", "NatGatewayId": "nat-shop", "State": "active"} + ]} +]} diff --git a/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-security-groups.json b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-security-groups.json new file mode 100644 index 0000000..ae975de --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-security-groups.json @@ -0,0 +1,26 @@ +{"SecurityGroups": [ + {"GroupId": "sg-web", "GroupName": "web", "Description": "web tier", "VpcId": "vpc-shop", + "IpPermissions": [ + {"IpProtocol": "tcp", "FromPort": 443, "ToPort": 443, "IpRanges": [{"CidrIp": "0.0.0.0/0"}], "Ipv6Ranges": [], "UserIdGroupPairs": []} + ]}, + {"GroupId": "sg-lb", "GroupName": "public-lb", "Description": "load balancer", "VpcId": "vpc-shop", + "IpPermissions": [ + {"IpProtocol": "tcp", "FromPort": 443, "ToPort": 443, "IpRanges": [{"CidrIp": "0.0.0.0/0"}], "Ipv6Ranges": [], "UserIdGroupPairs": []} + ]}, + {"GroupId": "sg-app", "GroupName": "app", "Description": "behind the load balancer", "VpcId": "vpc-shop", + "IpPermissions": [ + {"IpProtocol": "tcp", "FromPort": 8080, "ToPort": 8080, "IpRanges": [], "Ipv6Ranges": [], "UserIdGroupPairs": [{"GroupId": "sg-lb"}]} + ]}, + {"GroupId": "sg-batch", "GroupName": "batch", "Description": "opened by mistake", "VpcId": "vpc-shop", + "IpPermissions": [ + {"IpProtocol": "tcp", "FromPort": 22, "ToPort": 22, "IpRanges": [{"CidrIp": "0.0.0.0/0"}], "Ipv6Ranges": [], "UserIdGroupPairs": []} + ]}, + {"GroupId": "sg-db", "GroupName": "db", "Description": "database", "VpcId": "vpc-shop", + "IpPermissions": [ + {"IpProtocol": "tcp", "FromPort": 5432, "ToPort": 5432, "IpRanges": [], "Ipv6Ranges": [], "UserIdGroupPairs": [{"GroupId": "sg-web"}]} + ]}, + {"GroupId": "sg-legacy", "GroupName": "legacy", "Description": "old account", "VpcId": "vpc-legacy", + "IpPermissions": [ + {"IpProtocol": "tcp", "FromPort": 80, "ToPort": 80, "IpRanges": [{"CidrIp": "0.0.0.0/0"}], "Ipv6Ranges": [], "UserIdGroupPairs": []} + ]} +]} diff --git a/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-subnets.json b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-subnets.json new file mode 100644 index 0000000..6bf4916 --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-subnets.json @@ -0,0 +1,5 @@ +{"Subnets": [ + {"SubnetId": "subnet-public", "SubnetArn": "arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", "VpcId": "vpc-shop", "CidrBlock": "10.0.1.0/24", "AvailabilityZone": "us-east-1a", "MapPublicIpOnLaunch": true, "Tags": [{"Key": "Name", "Value": "public-a"}]}, + {"SubnetId": "subnet-private", "SubnetArn": "arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", "VpcId": "vpc-shop", "CidrBlock": "10.0.2.0/24", "AvailabilityZone": "us-east-1a", "MapPublicIpOnLaunch": false, "Tags": [{"Key": "Name", "Value": "private-a"}]}, + {"SubnetId": "subnet-legacy", "SubnetArn": "arn:aws:ec2:us-east-1:123456789012:subnet/subnet-legacy", "VpcId": "vpc-legacy", "CidrBlock": "10.9.1.0/24", "AvailabilityZone": "us-east-1a", "MapPublicIpOnLaunch": true, "Tags": [{"Key": "Name", "Value": "legacy-a"}]} +]} diff --git a/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-vpcs.json b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-vpcs.json new file mode 100644 index 0000000..051d39d --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-vpcs.json @@ -0,0 +1,4 @@ +{"Vpcs": [ + {"VpcId": "vpc-shop", "CidrBlock": "10.0.0.0/16", "IsDefault": false, "Tags": [{"Key": "Name", "Value": "shop"}]}, + {"VpcId": "vpc-legacy", "CidrBlock": "10.9.0.0/16", "IsDefault": false, "Tags": [{"Key": "Name", "Value": "legacy"}]} +]} diff --git a/plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-load-balancers.json b/plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-load-balancers.json new file mode 100644 index 0000000..c972127 --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-load-balancers.json @@ -0,0 +1 @@ +{"LoadBalancers": [{"LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", "DNSName": "shop-123.us-east-1.elb.amazonaws.com", "LoadBalancerName": "shop", "Scheme": "internet-facing", "VpcId": "vpc-shop", "Type": "application", "SecurityGroups": ["sg-lb"]}]} diff --git a/plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-target-groups.json b/plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-target-groups.json new file mode 100644 index 0000000..0bbaf24 --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-target-groups.json @@ -0,0 +1 @@ +{"TargetGroups": [{"TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", "TargetGroupName": "app", "Protocol": "HTTP", "Port": 8080, "VpcId": "vpc-shop", "LoadBalancerArns": ["arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc"]}]} diff --git a/plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-target-health-app.json b/plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-target-health-app.json new file mode 100644 index 0000000..557cfc9 --- /dev/null +++ b/plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-target-health-app.json @@ -0,0 +1 @@ +{"TargetHealthDescriptions": [{"Target": {"Id": "i-app1", "Port": 8080}, "TargetHealth": {"State": "healthy"}}]} diff --git a/plugins/aws/tests/fixture/us-east-1/ec2-describe-route-tables.json b/plugins/aws/tests/fixture/us-east-1/ec2-describe-route-tables.json new file mode 100644 index 0000000..d9f3abe --- /dev/null +++ b/plugins/aws/tests/fixture/us-east-1/ec2-describe-route-tables.json @@ -0,0 +1 @@ +{"RouteTables": []} diff --git a/plugins/csv/src/main.rs b/plugins/csv/src/main.rs index c84c973..7f42e7d 100644 --- a/plugins/csv/src/main.rs +++ b/plugins/csv/src/main.rs @@ -140,6 +140,7 @@ fn describe() -> Describe { labels: Vec::new(), relationship_types: Vec::new(), supports_incremental: false, + analyses: Vec::new(), } } @@ -477,7 +478,7 @@ fn main() -> ExitCode { .map_err(|e| e.to_string()) .and_then(|r| parse_config(&r)) .and_then(|c| sync(&c, &mut out)) - .and_then(|(nodes, edges)| out.emit(&Message::Done { nodes, edges }).map_err(|e| e.to_string())), + .and_then(|(nodes, edges)| out.emit(&Message::Done { nodes, edges, complete: true, gaps: Vec::new() }).map_err(|e| e.to_string())), other => Err(format!("unknown command {other:?}; expected describe, test or sync")), }; match result { diff --git a/skills/khonsugraph/SKILL.md b/skills/khonsugraph/SKILL.md index 3a0057f..46859b2 100644 --- a/skills/khonsugraph/SKILL.md +++ b/skills/khonsugraph/SKILL.md @@ -38,6 +38,22 @@ Use the real `khonsu` executable, never GUI automation or a replacement script. For what Cypher cannot say — who is most central, which groups form, how two nodes connect — use `khonsu analyze ALGORITHM --db DIR` (`summary`, `degree`, `pagerank`, `betweenness`, `closeness`, `components`, `communities`, `clustering`, `kcore`, `path`). It reads the whole graph, or `--label`/`--type`, or a `--cypher` subgraph, and returns the `--top` nodes with their labels and names; algorithms follow networkx. Start with `summary` on an unfamiliar graph. Name the algorithm and the subgraph when you report a result, and say when betweenness was sampled (`sampled_sources`). See [the CLI reference](references/cli.md#graph-analysis). +## Following a chain + +To trace how things connect from a node without loading the graph, use `khonsu explore --db DIR --from NODE`, where `NODE` is a `khonsu_id` or a numeric ID (repeat `--from` for several starts). `--direction out|in|both`, `--type A,B`, `--max-hops`, `--max-nodes` and `--max-edges` bound the walk (at most 16 hops, 2,000 nodes, 5,000 relationships; a request above that is refused). `--to NODE` adds a fewest-hops `path` among what the walk found. Each hop has a `text` such as `Internet -[ROUTES_TO]-> shop`. + +Read `complete` first. When it is false, `limits` says which cap cut the walk, so a missing path means "not within these caps", not "no path": raise the cap and say so. Even a complete walk reports paths in the data: a path is a candidate, not proof that anything can be reached, and no path is not proof of no exposure. `explore` needs the graph free; it fails with kind `locked` while the window has it open. + +## Evidence + +`khonsu evidence --db DIR --node NODE --rel REL` (a `khonsu_id` or a numeric ID, repeat either) says what supports a record: `evidence_id`, `source`, `plugin`, `record_written_at`, `source_last_successful_sync`, `assertion` and its own `properties`. Each provenance field is `{"status":"known","value":…}` or `{"status":"unknown"}`; say unknown when it is, never estimate. `explore --to` adds `path.evidence`, a derived record listing the `supporting` evidence IDs. Cite `evidence_id` when you report a finding. Names and values that look like secrets are masked and listed in `redacted`. `record_written_at` is when a record last changed, not when it was last seen. + +A plugin can offer analyses: `khonsu plugin describe ID` lists them under `analyses`, and `khonsu plugin analyze ID ANALYSIS --db DIR` runs one. On an AWS graph, `khonsu plugin analyze aws exposure --db DIR` gives findings with a `verdict`: `potential_exposure`, `insufficient_data` or `not_indicated`. Report a finding by its `finding_id`, `rule_id`, `path.hops`, `unknowns` and `assumptions`. Say "potential exposure from configuration": never call a resource reachable or safe from a finding. When the report's `complete` is false, say a missing finding means nothing and name the sources in `sources` whose last run wasn't `complete`. Narrow it with `--resource ID` and `--verdict`, and pass a finding's `walk` to `khonsu explore`. See `references/cli.md`. + +To answer "what changed?", take snapshots with `khonsu snapshot save FILE --db DIR` and compare them with `khonsu snapshot diff BEFORE AFTER`. Report `not_seen` as possibly still there, never as deleted, and read `warnings`. See `references/cli.md`. + +To keep a line of inquiry, `khonsu investigation save FILE --db DIR --from NODE --to NODE` writes a file of its own, and `khonsu investigation reopen FILE` runs it again. Read `graph_state` first: when it is `changed`, say so, and read `path_same`, `missing_seeds`, `target_missing` and `unsure` before treating the answer as the saved one. See `references/cli.md`. + ## The window and the graph lease HawDB leases a graph directory to one process. While the KhonsuGraph window has a graph open, `khonsu query` (reads only) and `khonsu check` on that graph go **through the window** — the output carries `"via_window": true` — so you can keep working while the person looks at it. Writes (`--write`) and syncs need the graph free: an error of kind `locked` names the program and pid holding it; ask the person to close the graph rather than deleting lock files. diff --git a/skills/khonsugraph/references/cli.md b/skills/khonsugraph/references/cli.md index 3919ab3..fad1a35 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -37,6 +37,104 @@ khonsu explain --db DIR --cypher "MATCH (n:Host) RETURN n" `{node_count, relationship_count, labels: [{name, count, properties}], relationship_types: [{name, count, properties, patterns: [[source, target]]}], tables: [{name, columns}]}`. Counts are exact; properties come from HawDB's statistics. +## Explore + +```sh +khonsu explore --db graphs/aws --from shop/cidr:internet --to shop/123456789012:us-east-1:i-web1 --type ROUTES_TO,FORWARDS_TO +``` + +| Option | Meaning | Default | +|---|---|---| +| `--from NODE` | A start: a `khonsu_id` or a numeric ID. Repeat for several | Required | +| `--to NODE` | Report a fewest-hops `path` to this node among what was found | None | +| `--direction out\|in\|both` | Along the arrows, against them, or both | `out` | +| `--type A,B` | Follow only these relationship types | Every type | +| `--max-hops N` | Steps from a start, at most 16 | 3 | +| `--max-nodes N` | Nodes returned, starts included, at most 2,000 | 200 | +| `--max-edges N` | Relationships returned, at most 5,000 | 500 | + +The output holds `complete`, `limits` (`hops`, `nodes`, `edges`), `depth_reached`, `starts`, `missing_starts`, `hops` (`depth`, `from`, `to`, `edge`, `rel_type`, `along`, `text`), `graph` and, with `--to`, `path` (`to`, `found`, `hops`). A value above a ceiling is refused, not lowered. + +## Evidence + +```sh +khonsu evidence --db graphs/aws --node shop/123456789012:us-east-1:i-web1 +``` + +`--node` and `--rel` each take a `khonsu_id` or a numeric ID and can be repeated. The output is `{"evidence": [...]}`, in the order: nodes, then relationships. A record that is not there exits 1 with kind `not_found`, and naming no record exits 2. + +| Field | Meaning | +|---|---| +| `evidence_id` | `node:` or `rel:`; `node-internal:` when `stable` is false | +| `kind`, `internal_id`, `types` | What it is: labels, or the relationship type | +| `from`, `to` | A relationship's ends, by evidence ID | +| `source`, `plugin` | What wrote it | +| `record_written_at` | When it last changed, in Unix seconds; can predate the last sync | +| `source_last_successful_sync` | From the source's settings; unknown when its last sync failed | +| `source_snapshot` | The source's last run when it was complete; unknown otherwise | +| `source_coverage` | The source's last sync: `run_id`, `status` (`complete`, `partial`, `failed`, `cancelled`) and any `gaps`. Unknown when the source isn't configured or predates runs | +| `rule_id` | Unknown on a record; known on a finding's `path` | +| `assertion` | `explicit` (a source reported it), `derived`, or `unknown` | +| `properties`, `redacted` | Its own properties, and the names masked as possible secrets | + +## Plugin analyses + +```sh +khonsu plugin describe aws # `analyses` lists what it offers +khonsu plugin analyze aws exposure --db graphs/aws --verdict potential-exposure --pretty +``` + +`--resource ID` takes a `khonsu_id`, `--verdict` takes one of the analysis's verdicts, and `--max-findings` defaults to 200. The top level has `plugin`, `analysis`, `title`, `claim`, `order`, `verdicts`, `rules`, `subjects`, `summary` (counts before any narrowing), `complete` and `truncated` (findings cut by `--max-findings`). When `complete` is false, a missing finding means nothing. Fails with kind `plugin` when the plugin isn't approved, offers no such analysis, or gives an answer that doesn't match the graph. AWS's `exposure` has the verdicts `potential_exposure`, `insufficient_data` and `not_indicated`. + +| Finding field | Meaning | +|---|---| +| `finding_id`, `rule_id` | Stable for the same rule and records | +| `verdict` | One the analysis declares. On AWS `exposure`, none means reachable or safe | +| `resource`, `resource_name` | The node it is about | +| `conditions[]` | `id`, `statement`, `status` (`met`, `not_met`, `unknown`), `detail`, and `fields[]` with `record`, `field`, `source_field`, `value` | +| `unknowns`, `assumptions` | What could not be evaluated, and what the rule does not read | +| `path` | The candidate path as a derived evidence record, or null when its records do not exist | +| `path_refs` | That path as stable references: `edge` and `along` for each hop | +| `walk` | `from`, `to`, `direction`, `types`, `max_hops` for `khonsu explore`, or null | +| `depends_on` | The finding a lateral step rests on | +| `incomplete_sources` | Sources behind it whose last sync wasn't complete. A verdict that would be `not_indicated` becomes `insufficient_data` | +| `contributing`, `evidence` | Evidence IDs of every record used, and those records | + +See `docs/exposure-rules.md` for the rules and a walkthrough. + +## Saved investigations + +```sh +khonsu investigation save web.investigation --db graphs/aws --from shop/cidr:internet --to shop/123456789012:us-east-1:i-web1 --type ROUTES_TO,FORWARDS_TO +khonsu investigation save finding.investigation --db graphs/aws --path-refs "$PATH_REFS_FROM_A_FINDING" +khonsu investigation reopen web.investigation +``` + +`save` takes `explore`'s filters, plus `--name`, `--cypher` or `--sql` (saved as typed), `--dashboard` and `--force`. It writes nothing to the graph, and an existing file exits 1 with kind `exists` without `--force`. `reopen` takes `-d` to use another graph. + +| Reopen field | Meaning | +|---|---| +| `graph_state` | `unchanged`, `changed`, or `not_compared` when the graph is too large to hash and the counts agree | +| `differences` | What changed, in words. Not exhaustive | +| `missing_seeds`, `target_missing` | Saved records that are not in the graph now | +| `unsure` | Records found by an internal ID on a changed graph, which may now be something else | +| `incomplete_sources` | Sources whose last sync wasn't complete, in words | +| `walk`, `path` | The walk run again now, and the path to the saved node | +| `path_same` | Whether the path rests on the saved records; null when none was saved | +| `saved_walk`, `query`, `dashboard` | The saved filters, query and dashboard. A saved query is returned, not run | + +Errors have kind `graph_missing`, `not_found`, `invalid` or `too_new`. See `docs/investigations.md`. + +## Snapshots and diff + +```sh +khonsu snapshot save monday.snapshot.json --db graphs/aws +khonsu snapshot diff monday.snapshot.json tuesday.snapshot.json +khonsu snapshot check monday.snapshot.json --db graphs/aws +``` + +`diff` gives `nodes` and `relationships`, each with `added`, `removed`, `not_seen` and `changed`, plus `node_counts`, `relationship_counts`, `truncated` (cut by `--max-changes`, 1,000 by default) and `warnings`. `removed` means the later snapshot's source was read completely; `not_seen` means it may still exist, so never report that as deleted. `check` gives `graph_state` and `differences`. A graph over 200,000 nodes or relationships fails with kind `too_large`. See `docs/snapshots.md`. + ## Graph analysis HawDB's Cypher has no shortest paths, centrality or communities; `khonsu analyze` computes them in memory, after networkx's definitions (results match networkx on the same graph).