From 8b92139000a5f80476d50bb36139cf499fe9a708 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Mon, 2 Nov 2026 15:25:42 +0900 Subject: [PATCH 01/13] Add Path Explorer: bounded walks from chosen nodes The canvas could only add a node's neighbors, one hop and untyped, with no sign when the fixed limit cut the result. An investigator following a suspected chain needs direction, types and caps, and needs to know when the answer is incomplete. Graph::explore walks breadth-first with one parameterized query per step, so caps and cancellation are enforced between queries, and says which cap cut the result. khonsu explore offers it to agents and scripts, and the inspector gets a panel with the hops to the selected node. gpui-graph cannot highlight a set of nodes, so showing a path narrows the canvas to it, and Back returns to earlier views. Refs #32 Co-Authored-By: Claude Opus 5.5 --- app/src/db.rs | 8 + app/src/i18n.rs | 26 ++ app/src/setup.rs | 2 + app/src/ui/canvas.rs | 298 +++++++++++++++++++++- app/src/ui/explorer.rs | 364 +++++++++++++++++++++++++++ app/src/ui/mod.rs | 1 + app/tests/investigation.rs | 41 ++- crates/khonsu-cli/src/explore.rs | 101 ++++++++ crates/khonsu-cli/src/lib.rs | 4 + crates/khonsu-core/src/explore.rs | 322 ++++++++++++++++++++++++ crates/khonsu-core/src/lib.rs | 1 + crates/khonsu-core/tests/explore.rs | 237 +++++++++++++++++ docs/investigation-gates.md | 6 +- examples/aws/golden.json | 284 +++++++++++++++++++++ skills/khonsugraph/SKILL.md | 6 + skills/khonsugraph/references/cli.md | 18 ++ 16 files changed, 1714 insertions(+), 5 deletions(-) create mode 100644 app/src/ui/explorer.rs create mode 100644 crates/khonsu-cli/src/explore.rs create mode 100644 crates/khonsu-core/src/explore.rs create mode 100644 crates/khonsu-core/tests/explore.rs diff --git a/app/src/db.rs b/app/src/db.rs index a5ba9dd..142ccb9 100644 --- a/app/src/db.rs +++ b/app/src/db.rs @@ -2,8 +2,10 @@ //! 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::{AccessMode, Graph, GraphData, GraphSchema, Language, OpenError, QueryError, QueryOptions, QueryResult}; use serde_json::Map; @@ -91,6 +93,12 @@ 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)) +} + 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..5b79ad0 100644 --- a/app/src/i18n.rs +++ b/app/src/i18n.rs @@ -332,6 +332,32 @@ 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"), + ("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…"), 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..c025565 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,12 @@ 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::explore::{Direction, Exploration}; 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 +131,9 @@ pub struct GraphCanvas { /// Nodes the result held beyond [`NODE_LIMIT`]. hidden: usize, expanding: bool, + explorer: Explorer, + /// The canvas shows only one path of the last walk. + path_view: bool, /// 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 +174,8 @@ impl GraphCanvas { selected: None, hidden: 0, expanding: false, + explorer: Explorer::default(), + path_view: false, caption_key: None, group_key: None, label_mode: LabelMode::Auto, @@ -276,6 +285,8 @@ 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.set_path_view(false, cx); self.nodes.clear(); self.edges.clear(); self.selected = None; @@ -358,6 +369,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 +418,270 @@ 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); + } + + /// 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); + } + + 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(); + + 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))), + ) + .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(!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 { @@ -454,6 +730,11 @@ 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)) + }) .children(rows.into_iter().map(|(key, value)| { let provenance = key.starts_with("khonsu_"); v_flex() @@ -494,6 +775,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 +787,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 +833,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 +952,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..f8aa9fb --- /dev/null +++ b/app/src/ui/explorer.rs @@ -0,0 +1,364 @@ +//! 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::BTreeSet; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Limit, DEFAULT_MAX_HOPS, MAX_HOPS}; +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, +} + +/// 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, + 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, + 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.starts.clear(); + self.history.clear(); + } + + /// The first warning 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 { + self.status_lines().into_iter().find(|(_, warn)| *warn).map(|(line, _)| line) + } + + /// 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(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() +} + +/// 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() + } + } + + #[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..44ca9f1 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; diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index f553602..9db2156 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -265,6 +265,13 @@ fn investigation(env: &Env) -> Value { "path": path, }, "dashboard": env.ok(&["check", text(&dash), "-d", text(&db)]), + "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"]), + }, })) } @@ -300,7 +307,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 +356,34 @@ 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}"); +} + #[test] fn two_syncs_of_the_same_input_agree_and_a_resync_changes_nothing() { let env = Env::new(); diff --git a/crates/khonsu-cli/src/explore.rs b/crates/khonsu-cli/src/explore.rs new file mode 100644 index 0000000..cd1a274 --- /dev/null +++ b/crates/khonsu-cli/src/explore.rs @@ -0,0 +1,101 @@ +//! `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::{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::>(), + }); + } + 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/lib.rs b/crates/khonsu-cli/src/lib.rs index 7c30025..b3f3c5f 100644 --- a/crates/khonsu-cli/src/lib.rs +++ b/crates/khonsu-cli/src/lib.rs @@ -10,6 +10,7 @@ mod analyze; mod dash; +mod explore; use clap::{Args, CommandFactory, Parser, Subcommand}; use khonsu_core::plugin::{self, SyncEvent}; @@ -118,6 +119,8 @@ 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), } #[derive(Subcommand)] @@ -368,6 +371,7 @@ 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), } } diff --git a/crates/khonsu-core/src/explore.rs b/crates/khonsu-core/src/explore.rs new file mode 100644 index 0000000..374f649 --- /dev/null +++ b/crates/khonsu-core/src/explore.rs @@ -0,0 +1,322 @@ +//! 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(()) + } +} + +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 { + let name = |id: i64| { + self.graph.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}") + } + } +} + +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/lib.rs b/crates/khonsu-core/src/lib.rs index adf31d5..992a33a 100644 --- a/crates/khonsu-core/src/lib.rs +++ b/crates/khonsu-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod analysis; mod cypher_columns; pub mod engine; +pub mod explore; pub mod lock; pub mod merge; pub mod plugin; 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/docs/investigation-gates.md b/docs/investigation-gates.md index 23d939b..d6d4ffa 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -16,6 +16,7 @@ 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 | @@ -55,8 +56,9 @@ These are written down so that nobody reads an empty result as proof: `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. Evidence provenance and rule names are not in the golden file yet, because they arrive with #33 and #35. diff --git a/examples/aws/golden.json b/examples/aws/golden.json index 353aa9f..9e8699a 100644 --- a/examples/aws/golden.json +++ b/examples/aws/golden.json @@ -1043,5 +1043,289 @@ "width": 6 } ] + }, + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "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" + } + ] + } + }, + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "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_plugin": "aws@0.2.0", + "khonsu_source": "shop" + } + } + ] + } + } } } diff --git a/skills/khonsugraph/SKILL.md b/skills/khonsugraph/SKILL.md index 3a0057f..71052d5 100644 --- a/skills/khonsugraph/SKILL.md +++ b/skills/khonsugraph/SKILL.md @@ -38,6 +38,12 @@ 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. + ## 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..bd1af59 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -37,6 +37,24 @@ 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. + ## 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). From be2b0fcb61bed3d4b103ac2968fd5675d45e7b0d Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Mon, 2 Nov 2026 15:39:23 +0900 Subject: [PATCH 02/13] Add evidence records: what supports a node, relationship or path A relationship on the canvas said nothing about where it came from. An investigator needs the source, the time and whether it was reported or worked out, and needs missing values stated rather than filled in. Each provenance field is explicitly known or unknown. khonsu evidence and explore --to give stable IDs, and the inspector shows the same record with buttons to copy it as JSON. Exports hold no source configuration, and secret-looking names and values are masked and listed. A sync does not rewrite an unchanged relationship, so its timestamp is named record_written_at, separate from the source's last successful sync. Refs #33 Co-Authored-By: Claude Opus 5.5 --- app/src/i18n.rs | 15 + app/src/ui/canvas.rs | 80 +++++ app/src/ui/explorer.rs | 34 ++ app/tests/investigation.rs | 74 +++- crates/khonsu-cli/src/evidence.rs | 67 ++++ crates/khonsu-cli/src/explore.rs | 2 + crates/khonsu-cli/src/lib.rs | 4 + crates/khonsu-core/src/evidence.rs | 488 +++++++++++++++++++++++++++ crates/khonsu-core/src/lib.rs | 1 + docs/investigation-gates.md | 15 +- examples/aws/golden.json | 308 ++++++++++++++++- skills/khonsugraph/SKILL.md | 4 + skills/khonsugraph/references/cli.md | 20 ++ 13 files changed, 1093 insertions(+), 19 deletions(-) create mode 100644 crates/khonsu-cli/src/evidence.rs create mode 100644 crates/khonsu-core/src/evidence.rs diff --git a/app/src/i18n.rs b/app/src/i18n.rs index 5b79ad0..330678e 100644 --- a/app/src/i18n.rs +++ b/app/src/i18n.rs @@ -332,6 +332,21 @@ 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.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"), ("explore.title", "路径探索", "Path Explorer"), ("explore.out", "沿箭头", "Outbound"), ("explore.in", "逆箭头", "Inbound"), diff --git a/app/src/ui/canvas.rs b/app/src/ui/canvas.rs index c025565..04a8b1b 100644 --- a/app/src/ui/canvas.rs +++ b/app/src/ui/canvas.rs @@ -21,7 +21,9 @@ 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::settings::{self, Source}; use khonsu_core::{GraphData, GraphEdge, GraphNode}; use serde_json::Value as Json; @@ -132,6 +134,9 @@ pub struct GraphCanvas { 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 node property shown as each node's caption, before the usual @@ -175,6 +180,7 @@ impl GraphCanvas { hidden: 0, expanding: false, explorer: Explorer::default(), + sources: Vec::new(), path_view: false, caption_key: None, group_key: None, @@ -359,6 +365,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), @@ -540,6 +547,62 @@ impl GraphCanvas { 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() + } + + 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; @@ -564,6 +627,11 @@ impl GraphCanvas { 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() @@ -669,6 +737,15 @@ impl GraphCanvas { 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") @@ -735,6 +812,9 @@ impl GraphCanvas { .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(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() diff --git a/app/src/ui/explorer.rs b/app/src/ui/explorer.rs index f8aa9fb..3933f6f 100644 --- a/app/src/ui/explorer.rs +++ b/app/src/ui/explorer.rs @@ -5,7 +5,9 @@ use std::collections::BTreeSet; use std::sync::atomic::AtomicBool; use std::sync::Arc; +use khonsu_core::evidence::{Assertion, Evidence, Field}; use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Limit, DEFAULT_MAX_HOPS, MAX_HOPS}; +use khonsu_core::util::format_unix_time; use khonsu_core::{GraphData, GraphEdge, GraphNode}; use crate::i18n::{tr, trf}; @@ -161,6 +163,27 @@ pub fn explained_path(walk: &Exploration, target: i64) -> Vec { walk.path_to(target).unwrap_or_default().into_iter().map(|hop| walk.explain(hop)).collect() } +/// 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.snapshot", text(&record.source_snapshot)), + ("evidence.assertion", assertion.to_string()), + ] +} + /// 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())?; @@ -222,6 +245,17 @@ mod tests { } } + #[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(); diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index 9db2156..67e49f8 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. @@ -194,14 +195,14 @@ fn build_plugins() { }); } -/// 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(), "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(), ), @@ -265,6 +266,10 @@ 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, @@ -384,6 +389,69 @@ fn explore_traces_a_resource_through_typed_hops() { 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": "unknown"})); + 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}"); +} + #[test] fn two_syncs_of_the_same_input_agree_and_a_resync_changes_nothing() { let env = Env::new(); 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 index cd1a274..314aa4c 100644 --- a/crates/khonsu-cli/src/explore.rs +++ b/crates/khonsu-cli/src/explore.rs @@ -5,6 +5,7 @@ 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}; @@ -85,6 +86,7 @@ pub fn explore(args: ExploreArgs) -> Result { "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) diff --git a/crates/khonsu-cli/src/lib.rs b/crates/khonsu-cli/src/lib.rs index b3f3c5f..8e68bcb 100644 --- a/crates/khonsu-cli/src/lib.rs +++ b/crates/khonsu-cli/src/lib.rs @@ -10,6 +10,7 @@ mod analyze; mod dash; +mod evidence; mod explore; use clap::{Args, CommandFactory, Parser, Subcommand}; @@ -121,6 +122,8 @@ enum Command { 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), } #[derive(Subcommand)] @@ -372,6 +375,7 @@ fn execute(command: Command) -> Result { Command::Open(args) => dash::open(args), Command::Analyze(args) => analyze::analyze(args), Command::Explore(args) => explore::explore(args), + Command::Evidence(args) => evidence::evidence(args), } } diff --git a/crates/khonsu-core/src/evidence.rs b/crates/khonsu-core/src/evidence.rs new file mode 100644 index 0000000..f3627d7 --- /dev/null +++ b/crates/khonsu-core/src/evidence.rs @@ -0,0 +1,488 @@ +//! 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::Exploration; +use crate::merge::{ID_PROPERTY, PLUGIN_PROPERTY, SOURCE_PROPERTY, SYNCED_AT_PROPERTY}; +use crate::result::{GraphEdge, GraphNode}; +use crate::settings::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", +]; + +/// 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 successfully, in Unix seconds. Unknown when + /// its last sync failed: a failure overwrites the earlier result. + pub source_last_successful_sync: Field, + /// Which snapshot of the source this came from. Not recorded yet. + 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 fn for_node(node: &GraphNode, sources: &[Source]) -> Evidence { + let stable_id = text(&node.properties, ID_PROPERTY); + let (properties, redacted) = own_properties(&node.properties); + let source = text(&node.properties, SOURCE_PROPERTY); + Evidence { + stable: stable_id.is_some(), + evidence_id: stable_id.map_or_else(|| format!("node-internal:{}", node.id), |id| format!("node:{id}")), + 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 stable_id = text(&edge.properties, ID_PROPERTY); + 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| for_node(node, sources).evidence_id)) + }; + Evidence { + stable: stable_id.is_some(), + evidence_id: stable_id.map_or_else(|| format!("rel-internal:{}", edge.id), |id| format!("rel:{id}")), + 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 mut evidence = Vec::new(); + let first = path[0].from; + if let Some(start) = walk.graph.nodes.iter().find(|node| node.id == first) { + evidence.push(for_node(start, sources)); + } + for hop in &path { + if let Some(edge) = walk.graph.edges.iter().find(|edge| edge.id == hop.edge) { + evidence.push(for_edge(edge, &walk.graph.nodes, sources)); + } + if let Some(node) = walk.graph.nodes.iter().find(|node| node.id == hop.to) { + evidence.push(for_node(node, sources)); + } + } + let supporting: Vec = evidence.iter().map(|record| record.evidence_id.clone()).collect(); + Some(PathEvidence { + evidence_id: format!("path:{:016x}", fnv1a(&supporting.join("\n"))), + stable: evidence.iter().all(|record| record.stable), + kind: Kind::Path, + assertion: Assertion::Derived, + derivation: "fewest hops in a bounded walk".into(), + rule_id: Field::Unknown, + claim: PATH_CLAIM.into(), + hops: path.iter().map(|hop| walk.explain(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 last_ok = source + .as_deref() + .and_then(|id| sources.iter().find(|configured| configured.id == id)) + .and_then(|configured| configured.last_sync.as_ref()) + .filter(|sync| sync.ok) + .map(|sync| sync.at); + 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: Field::Unknown, + 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. +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") +} + +/// A hash that stays the same across platforms and Rust versions. +fn fnv1a(text: &str) -> u64 { + text.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::explore::Hop; + use crate::result::GraphData; + 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"); + } + + #[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/lib.rs b/crates/khonsu-core/src/lib.rs index 992a33a..5c01fb5 100644 --- a/crates/khonsu-core/src/lib.rs +++ b/crates/khonsu-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod analysis; mod cypher_columns; pub mod engine; +pub mod evidence; pub mod explore; pub mod lock; pub mod merge; diff --git a/docs/investigation-gates.md b/docs/investigation-gates.md index d6d4ffa..f97ba80 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -20,9 +20,11 @@ cargo test --locked --workspace --test investigation | 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 | +| 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 | | 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. +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 @@ -60,8 +62,15 @@ These are written down so that nobody reads an empty result as proof: Syncs can be cancelled, and a walk stops at its next query, so one slow step still finishes first. -Evidence provenance and rule names are not in the golden file yet, because they -arrive with #33 and #35. +- **`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 own, and is unknown once its last + sync failed. +- **Evidence shows what is stored, not the raw response.** The AWS plugin stores + chosen fields, not the API call each came from. + +Rule names are not in the golden file yet, because they arrive with #35. Source +snapshots are always unknown until #37. ## Baselines diff --git a/examples/aws/golden.json b/examples/aws/golden.json index 9e8699a..2b2347b 100644 --- a/examples/aws/golden.json +++ b/examples/aws/golden.json @@ -1044,6 +1044,79 @@ } ] }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": { + "ports": "443", + "protocol": "tcp", + "rule": "\"tcp\":\"443\"" + } + } + ] + } + }, "explore": { "exposure_of_web_1": { "complete": true, @@ -1102,7 +1175,6 @@ "image_id": "ami-web", "instance_id": "i-web1", "khonsu_id": "shop/123456789012:us-east-1:i-web1", - "khonsu_plugin": "aws@0.2.0", "khonsu_source": "shop", "launched_at": "2026-09-01T08:00:00+00:00", "name": "web-1", @@ -1119,7 +1191,6 @@ ], "properties": { "khonsu_id": "shop/123456789012:us-east-1:igw-shop", - "khonsu_plugin": "aws@0.2.0", "khonsu_source": "shop", "name": "igw-shop" } @@ -1132,7 +1203,6 @@ "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_plugin": "aws@0.2.0", "khonsu_source": "shop", "name": "shop", "scheme": "internet-facing", @@ -1146,7 +1216,6 @@ ], "properties": { "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", - "khonsu_plugin": "aws@0.2.0", "khonsu_source": "shop", "name": "web", "port": 8080, @@ -1161,7 +1230,6 @@ "properties": { "cidr": "0.0.0.0/0", "khonsu_id": "shop/cidr:internet", - "khonsu_plugin": "aws@0.2.0", "khonsu_source": "shop", "name": "Internet" } @@ -1176,7 +1244,6 @@ "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_plugin": "aws@0.2.0", "khonsu_source": "shop" } }, @@ -1189,7 +1256,6 @@ "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_plugin": "aws@0.2.0", "khonsu_source": "shop" } }, @@ -1201,7 +1267,6 @@ "properties": { "khonsu_hash": "08f44b07b5901a25", "khonsu_id": "shop/cidr:internet|ROUTES_TO|123456789012:us-east-1:igw-shop", - "khonsu_plugin": "aws@0.2.0", "khonsu_source": "shop" } }, @@ -1213,7 +1278,6 @@ "properties": { "khonsu_hash": "08f44b07b5901a25", "khonsu_id": "shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", - "khonsu_plugin": "aws@0.2.0", "khonsu_source": "shop" } } @@ -1250,7 +1314,228 @@ "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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": { @@ -1285,7 +1570,6 @@ "image_id": "ami-web", "instance_id": "i-web1", "khonsu_id": "shop/123456789012:us-east-1:i-web1", - "khonsu_plugin": "aws@0.2.0", "khonsu_source": "shop", "launched_at": "2026-09-01T08:00:00+00:00", "name": "web-1", @@ -1302,7 +1586,6 @@ ], "properties": { "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", - "khonsu_plugin": "aws@0.2.0", "khonsu_source": "shop", "name": "web", "port": 8080, @@ -1320,7 +1603,6 @@ "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_plugin": "aws@0.2.0", "khonsu_source": "shop" } } diff --git a/skills/khonsugraph/SKILL.md b/skills/khonsugraph/SKILL.md index 71052d5..b9dd0ba 100644 --- a/skills/khonsugraph/SKILL.md +++ b/skills/khonsugraph/SKILL.md @@ -44,6 +44,10 @@ To trace how things connect from a node without loading the graph, use `khonsu e 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. + ## 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 bd1af59..c5693f5 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -55,6 +55,26 @@ khonsu explore --db graphs/aws --from shop/cidr:internet --to shop/123456789012: 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`, `rule_id` | Unknown for now | +| `assertion` | `explicit` (a source reported it), `derived`, or `unknown` | +| `properties`, `redacted` | Its own properties, and the names masked as possible secrets | + ## 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). From 24d022a7b4c10c199e014503ec4fa5c23c00ec0d Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 02:30:42 +0900 Subject: [PATCH 03/13] Add explainable AWS exposure rules An open security group rule or a walkable path isn't an actionable finding. An investigator needs to know why a resource was flagged, which fields say so, and what was never checked. khonsu exposure evaluates three documented rules. Each condition is met, not met or unknown and names the fields it read. The verdict follows from those alone: potential_exposure, insufficient_data or not_indicated. There is no score, and no verdict says reachable. The AWS plugin now reads route tables and IPv6 addresses, because a public address and an open rule mean little without a route. A record that should exist and doesn't gives unknown, not a clear result, since a missing saved response reads as empty (#38). A finding carries its path as a derived evidence record with its rule ID, plus the arguments to reopen it in khonsu explore. The new cases live in their own fixture, so the shop fixture's answers and the HawDB snapshot stay as they were. Refs #35, #33, #36 Co-Authored-By: Claude Opus 5.5 --- app/tests/investigation.rs | 220 ++ crates/khonsu-cli/src/exposure.rs | 62 + crates/khonsu-cli/src/lib.rs | 4 + crates/khonsu-core/src/evidence.rs | 69 +- crates/khonsu-core/src/explore.rs | 23 +- crates/khonsu-core/src/exposure.rs | 1112 ++++++ crates/khonsu-core/src/lib.rs | 1 + docs/exposure-rules.md | 157 + docs/investigation-gates.md | 7 +- examples/README.md | 7 +- examples/aws/exposure.dash | 3 +- examples/aws/golden.json | 3305 +++++++++++++++++ plugins/aws/src/main.rs | 107 +- .../iam-list-instance-profiles.json | 4 + .../fixture-exposure/iam-list-roles.json | 4 + .../sts-get-caller-identity.json | 1 + .../us-east-1/ec2-describe-instances.json | 7 + .../ec2-describe-internet-gateways.json | 4 + .../us-east-1/ec2-describe-route-tables.json | 14 + .../ec2-describe-security-groups.json | 26 + .../us-east-1/ec2-describe-subnets.json | 5 + .../us-east-1/ec2-describe-vpcs.json | 4 + .../elbv2-describe-load-balancers.json | 1 + .../elbv2-describe-target-groups.json | 1 + .../elbv2-describe-target-health-app.json | 1 + skills/khonsugraph/SKILL.md | 2 + skills/khonsugraph/references/cli.md | 25 +- 27 files changed, 5123 insertions(+), 53 deletions(-) create mode 100644 crates/khonsu-cli/src/exposure.rs create mode 100644 crates/khonsu-core/src/exposure.rs create mode 100644 docs/exposure-rules.md create mode 100644 plugins/aws/tests/fixture-exposure/iam-list-instance-profiles.json create mode 100644 plugins/aws/tests/fixture-exposure/iam-list-roles.json create mode 100644 plugins/aws/tests/fixture-exposure/sts-get-caller-identity.json create mode 100644 plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-instances.json create mode 100644 plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-internet-gateways.json create mode 100644 plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-route-tables.json create mode 100644 plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-security-groups.json create mode 100644 plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-subnets.json create mode 100644 plugins/aws/tests/fixture-exposure/us-east-1/ec2-describe-vpcs.json create mode 100644 plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-load-balancers.json create mode 100644 plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-target-groups.json create mode 100644 plugins/aws/tests/fixture-exposure/us-east-1/elbv2-describe-target-health-app.json diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index 67e49f8..01739ef 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -170,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") } @@ -253,6 +259,13 @@ 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 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(&["exposure", "-d", text(&graph)]) + }; normalize(&json!({ "sync": first, @@ -277,9 +290,20 @@ fn investigation(env: &Env) -> Value { ]), "what_points_at_web_1": env.ok(&["explore", "-d", text(&db), "--from", WEB_1, "--direction", "in", "--max-hops", "1"]), }, + "exposure": { + // Saved responses with no route tables, as a graph from before they were read. + "shop_fixture": exposure_brief(&env.ok(&["exposure", "-d", text(&db)])), + "exposure_fixture": exposure_report, + }, })) } +/// (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) { @@ -452,6 +476,201 @@ fn evidence_for_a_record_written_by_hand_masks_secrets() { 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(&["exposure", "-d", text(&db)]); + assert_eq!(report["complete"], true); + assert_eq!(report["truncated"], 0); + assert_eq!(report["instances"], 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]["aws_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(&["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(&["exposure", "-d", text(&db), "--resource", &instance("i-web1")]); + assert_eq!(one["findings"].as_array().unwrap().len(), 1); + let (code, out) = env.khonsu(&["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(&["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(&["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}"); +} + +/// A response that is missing reads as empty (#38), so the rules must not read +/// the records it would have given as absent from the account. +#[test] +fn a_missing_response_makes_a_finding_undecided_rather_than_clearing_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"]); + assert_eq!(finding(&env.ok(&["exposure", "-d", text(&db)]), DIRECT, &instance("i-web1"))["verdict"], "potential_exposure"); + + std::fs::remove_file(saved.join("us-east-1/ec2-describe-route-tables.json")).unwrap(); + env.ok(&["plugin", "sync", "--source", "shop"]); + let report = env.ok(&["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"); + + 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(&["exposure", "-d", text(&db)]); + let web = finding(&report, DIRECT, &instance("i-web1")); + assert_eq!(web["verdict"], "insufficient_data", "{web}"); + assert_eq!(condition_status(web, "open-inbound-rule"), "unknown"); + assert!(web["path"].is_null(), "{web}"); +} + +/// 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(&["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(&["exposure", "-d", text(&db)])) + }; + assert_eq!(run(), run()); +} + #[test] fn two_syncs_of_the_same_input_agree_and_a_resync_changes_nothing() { let env = Env::new(); @@ -635,6 +854,7 @@ 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(&["exposure", "-d", text(&db)]))), &expected["exposure"]["shop_fixture"]); 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}"); diff --git a/crates/khonsu-cli/src/exposure.rs b/crates/khonsu-cli/src/exposure.rs new file mode 100644 index 0000000..23b52e7 --- /dev/null +++ b/crates/khonsu-cli/src/exposure.rs @@ -0,0 +1,62 @@ +//! `khonsu exposure`: explainable AWS exposure findings. See +//! `khonsu_core::exposure` and `docs/exposure-rules.md`. + +use clap::{Args, ValueEnum}; +use khonsu_core::exposure::{self, Verdict}; +use khonsu_core::{settings, AccessMode, Graph}; +use serde_json::{json, Value}; + +use crate::{resolve_db, CliError, DbArg, PROGRAM}; + +#[derive(Clone, Copy, ValueEnum)] +enum VerdictArg { + PotentialExposure, + InsufficientData, + NotIndicated, +} + +impl From for Verdict { + fn from(verdict: VerdictArg) -> Self { + match verdict { + VerdictArg::PotentialExposure => Verdict::PotentialExposure, + VerdictArg::InsufficientData => Verdict::InsufficientData, + VerdictArg::NotIndicated => Verdict::NotIndicated, + } + } +} + +#[derive(Args)] +pub struct ExposureArgs { + #[command(flatten)] + db: DbArg, + /// Only findings about this resource: its `khonsu_id`. + #[arg(long)] + resource: Option, + /// Only findings with this verdict. + #[arg(long, value_enum)] + verdict: Option, + /// Findings to print; `truncated` counts the rest. + #[arg(long, default_value_t = exposure::DEFAULT_MAX_FINDINGS)] + max_findings: usize, +} + +pub fn exposure(args: ExposureArgs) -> Result { + let path = resolve_db(&args.db)?; + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let report = graph.exposure(&settings::load_sources())?; + let resource = args.resource.map(|id| format!("node:{id}")); + // Every instance gets a finding, so a mistyped ID must not read as "none". + if let Some(id) = &resource { + if !report.findings.iter().any(|finding| finding.resource == *id) { + return Err(CliError::new("not_found", format!("no instance {:?}", id.trim_start_matches("node:")))); + } + } + let verdict = args.verdict.map(Verdict::from); + let (report, truncated) = report.narrowed( + |finding| resource.as_ref().is_none_or(|id| finding.resource == *id) && verdict.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/lib.rs b/crates/khonsu-cli/src/lib.rs index 8e68bcb..a940aca 100644 --- a/crates/khonsu-cli/src/lib.rs +++ b/crates/khonsu-cli/src/lib.rs @@ -12,6 +12,7 @@ mod analyze; mod dash; mod evidence; mod explore; +mod exposure; use clap::{Args, CommandFactory, Parser, Subcommand}; use khonsu_core::plugin::{self, SyncEvent}; @@ -124,6 +125,8 @@ enum Command { Explore(explore::ExploreArgs), /// What supports a node or relationship: its source, plugin and times, with unknowns stated and secrets masked. Evidence(evidence::EvidenceArgs), + /// AWS exposure findings: the rule, records and fields behind each, what is assumed and what is unknown. A finding is potential exposure from configuration, not proof of reachability. + Exposure(exposure::ExposureArgs), } #[derive(Subcommand)] @@ -376,6 +379,7 @@ fn execute(command: Command) -> Result { Command::Analyze(args) => analyze::analyze(args), Command::Explore(args) => explore::explore(args), Command::Evidence(args) => evidence::evidence(args), + Command::Exposure(args) => exposure::exposure(args), } } diff --git a/crates/khonsu-core/src/evidence.rs b/crates/khonsu-core/src/evidence.rs index f3627d7..66d5c81 100644 --- a/crates/khonsu-core/src/evidence.rs +++ b/crates/khonsu-core/src/evidence.rs @@ -5,9 +5,9 @@ //! 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::Exploration; +use crate::explore::{explain_hop, Exploration, Hop}; use crate::merge::{ID_PROPERTY, PLUGIN_PROPERTY, SOURCE_PROPERTY, SYNCED_AT_PROPERTY}; -use crate::result::{GraphEdge, GraphNode}; +use crate::result::{GraphData, GraphEdge, GraphNode}; use crate::settings::Source; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value as Json}; @@ -123,13 +123,20 @@ pub struct PathEvidence { 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 stable_id = text(&node.properties, ID_PROPERTY); let (properties, redacted) = own_properties(&node.properties); let source = text(&node.properties, SOURCE_PROPERTY); Evidence { - stable: stable_id.is_some(), - evidence_id: stable_id.map_or_else(|| format!("node-internal:{}", node.id), |id| format!("node:{id}")), + 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(), @@ -142,15 +149,12 @@ pub fn for_node(node: &GraphNode, sources: &[Source]) -> Evidence { /// `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 stable_id = text(&edge.properties, ID_PROPERTY); 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| for_node(node, sources).evidence_id)) - }; + let end = |id: i64| Field::from(nodes.iter().find(|node| node.id == id).map(node_evidence_id)); Evidence { - stable: stable_id.is_some(), - evidence_id: stable_id.map_or_else(|| format!("rel-internal:{}", edge.id), |id| format!("rel:{id}")), + 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()], @@ -164,32 +168,43 @@ pub fn for_edge(edge: &GraphEdge, nodes: &[GraphNode], sources: &[Source]) -> Ev /// 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 mut evidence = Vec::new(); - let first = path[0].from; - if let Some(start) = walk.graph.nodes.iter().find(|node| node.id == first) { - evidence.push(for_node(start, sources)); + 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 &path { - if let Some(edge) = walk.graph.edges.iter().find(|edge| edge.id == hop.edge) { - evidence.push(for_edge(edge, &walk.graph.nodes, 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) = walk.graph.nodes.iter().find(|node| node.id == hop.to) { - evidence.push(for_node(node, 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(); - Some(PathEvidence { + PathEvidence { evidence_id: format!("path:{:016x}", fnv1a(&supporting.join("\n"))), stable: evidence.iter().all(|record| record.stable), kind: Kind::Path, assertion: Assertion::Derived, - derivation: "fewest hops in a bounded walk".into(), - rule_id: Field::Unknown, + derivation: derivation.into(), + rule_id, claim: PATH_CLAIM.into(), - hops: path.iter().map(|hop| walk.explain(hop)).collect(), + hops: hops.iter().map(|hop| explain_hop(&graph.nodes, hop)).collect(), supporting, evidence, - }) + } } /// The fields a node and a relationship share. @@ -288,15 +303,13 @@ fn looks_like_a_secret(text: &str) -> bool { } /// A hash that stays the same across platforms and Rust versions. -fn fnv1a(text: &str) -> u64 { +pub(crate) fn fnv1a(text: &str) -> u64 { text.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)) } #[cfg(test)] mod tests { use super::*; - use crate::explore::Hop; - use crate::result::GraphData; use crate::settings::LastSync; use serde_json::json; diff --git a/crates/khonsu-core/src/explore.rs b/crates/khonsu-core/src/explore.rs index 374f649..74a19b5 100644 --- a/crates/khonsu-core/src/explore.rs +++ b/crates/khonsu-core/src/explore.rs @@ -157,19 +157,22 @@ impl Exploration { /// 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 { - let name = |id: i64| { - self.graph.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}") - } + 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}") } } -fn node_name(node: &GraphNode) -> String { +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(), diff --git a/crates/khonsu-core/src/exposure.rs b/crates/khonsu-core/src/exposure.rs new file mode 100644 index 0000000..1a93577 --- /dev/null +++ b/crates/khonsu-core/src/exposure.rs @@ -0,0 +1,1112 @@ +//! Explainable AWS 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 [`Verdict::NotIndicated`], otherwise anything unknown gives +//! [`Verdict::InsufficientData`], otherwise [`Verdict::PotentialExposure`]. +//! 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 (#38), so an instance with no +//! security groups or a subnet with no route table gives "unknown". + +use crate::engine::{Graph, QueryError}; +use crate::evidence::{self, Evidence, Field, PathEvidence}; +use crate::explore::{node_name, Direction, Hop}; +use crate::result::{value_to_json, GraphCollector, GraphData, GraphEdge, GraphNode}; +use crate::settings::Source; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value as Json}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const RULE_DIRECT: &str = "aws.exposure.direct-instance"; +pub const RULE_BALANCER: &str = "aws.exposure.public-load-balancer"; +pub const RULE_LATERAL: &str = "aws.exposure.security-group-lateral"; + +/// Findings kept in a report unless asked otherwise; the rest are counted. +pub const DEFAULT_MAX_FINDINGS: usize = 200; +/// Relationships read of each type. A larger graph gives an incomplete report. +const MAX_RELATIONSHIPS: usize = 50_000; +const RELATIONSHIP_TYPES: &[&str] = &["ALLOWS", "PROTECTED_BY", "ROUTES_TO", "FORWARDS_TO", "IN_SUBNET", "IN_VPC", "USES_ROUTE_TABLE", "ROUTES_VIA"]; + +pub 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."; +pub const ORDER: &str = "By verdict (potential_exposure, insufficient_data, not_indicated), then rule ID, then resource ID. There is no risk score."; + +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."; + +/// A rule in words, for a person reading a report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Rule { + pub rule_id: String, + pub title: String, + pub statement: String, + pub assumptions: Vec, +} + +pub fn rules() -> Vec { + let rule = |id: &str, title: &str, statement: &str, assumptions: &[&str]| Rule { + 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.", + ], + ), + ] +} + +/// There is deliberately no "reachable" here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + /// Every condition is met by configuration. + PotentialExposure, + /// None ruled it out, and at least one could not be evaluated. + InsufficientData, + /// A condition is not met, so this rule does not point at it. + NotIndicated, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Met, + NotMet, + Unknown, +} + +/// An exact field a condition read, and what AWS called 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, + pub aws_field: String, + pub value: Field, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Condition { + pub id: String, + pub statement: String, + pub status: Status, + /// 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, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Finding { + /// The same rule and records give the same ID. + pub finding_id: String, + pub rule_id: String, + pub verdict: Verdict, + /// The evidence ID of the resource it is about, and what people call it. + pub resource: String, + pub resource_name: String, + pub conditions: Vec, + /// The statements of conditions that could not be evaluated. + 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 hops, for a later rule to continue from. + #[serde(skip)] + pub hops: Vec, + pub walk: Option, + /// The finding this one rests on, for a lateral step. + #[serde(skip_serializing_if = "Option::is_none")] + pub depends_on: Option, + /// The evidence IDs of every record the conditions read or the path crosses. + pub contributing: Vec, + pub evidence: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Summary { + pub potential_exposure: usize, + pub insufficient_data: usize, + pub not_indicated: usize, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Report { + pub claim: String, + pub order: String, + pub rules: Vec, + /// Instances looked at. + pub instances: usize, + /// Counts every finding, including any cut by a cap. + pub summary: Summary, + /// False when the graph was too large to read whole: a missing finding + /// then means nothing. + pub complete: bool, + 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) + } +} + +impl Graph { + /// Reads what the rules need and evaluates them. + pub fn exposure(&mut self, sources: &[Source]) -> Result { + let (data, complete) = self.read_exposure_records()?; + Ok(evaluate(&data, sources, complete)) + } + + fn read_exposure_records(&mut self) -> Result<(GraphData, bool), QueryError> { + let params = BTreeMap::new(); + let mut collector = GraphCollector::default(); + let mut complete = true; + let mut read = |collector: &mut GraphCollector, text: String, cap: usize| -> Result { + let rows = self.raw_cypher(&text, ¶ms)?; + let whole = rows.len() <= cap; + for row in rows.iter().take(cap) { + row.iter().for_each(|value| collector.visit(&value_to_json(value))); + } + Ok(whole) + }; + // An instance with no relationships still counts as looked at. + complete &= read(&mut collector, format!("MATCH (n:Instance) RETURN n ORDER BY id(n) LIMIT {}", MAX_RELATIONSHIPS + 1), MAX_RELATIONSHIPS)?; + for name in RELATIONSHIP_TYPES { + let text = format!("MATCH (a)-[r:{name}]->(b) RETURN a, r, b ORDER BY id(r) LIMIT {}", MAX_RELATIONSHIPS + 1); + complete &= read(&mut collector, text, MAX_RELATIONSHIPS)?; + } + Ok((collector.finish().unwrap_or_default(), complete)) + } +} + +/// Evaluates every rule over `data`. The result depends on nothing else. +pub fn evaluate(data: &GraphData, sources: &[Source], complete: bool) -> Report { + let index = Index::new(data); + let mut findings = Vec::new(); + for instance in index.with_label("Instance") { + findings.push(direct(&index, instance, sources)); + for (balancer, group, to_target, forwards) in index.balancers_of(instance) { + findings.push(behind_balancer(&index, instance, balancer, group, to_target, forwards, sources)); + } + } + let lateral = lateral_findings(&index, &findings, sources); + findings.extend(lateral); + findings.sort_by(|a, b| (a.verdict, &a.rule_id, &a.resource, &a.finding_id).cmp(&(b.verdict, &b.rule_id, &b.resource, &b.finding_id))); + + let count = |verdict: Verdict| findings.iter().filter(|finding| finding.verdict == verdict).count(); + Report { + claim: CLAIM.into(), + order: ORDER.into(), + rules: rules(), + instances: index.with_label("Instance").len(), + summary: Summary { + potential_exposure: count(Verdict::PotentialExposure), + insufficient_data: count(Verdict::InsufficientData), + not_indicated: count(Verdict::NotIndicated), + }, + complete, + findings, + } +} + +/// Stable lookups over the records, sorted so that every choice is repeatable. +struct Index<'a> { + nodes: BTreeMap, + data: &'a GraphData, + node_ids: BTreeMap, + edge_ids: BTreeMap, + /// Relationships by (node, type), in the order of the far end's stable ID. + out: BTreeMap<(i64, &'a str), Vec<&'a GraphEdge>>, + into: BTreeMap<(i64, &'a str), Vec<&'a GraphEdge>>, +} + +impl<'a> Index<'a> { + fn new(data: &'a GraphData) -> Self { + let nodes: BTreeMap = data.nodes.iter().map(|node| (node.id, node)).collect(); + let mut out: BTreeMap<(i64, &str), Vec<&GraphEdge>> = BTreeMap::new(); + let mut into: BTreeMap<(i64, &str), Vec<&GraphEdge>> = BTreeMap::new(); + for edge in &data.edges { + out.entry((edge.source, &edge.rel_type)).or_default().push(edge); + into.entry((edge.target, &edge.rel_type)).or_default().push(edge); + } + out.values_mut().chain(into.values_mut()).for_each(|edges| edges.sort_by_cached_key(|edge| edge_id(edge))); + Self { + nodes, + data, + node_ids: data.nodes.iter().map(|node| (record_id(node), node)).collect(), + edge_ids: data.edges.iter().map(|edge| (edge_id(edge), edge)).collect(), + out, + into, + } + } + + fn with_label(&self, label: &str) -> Vec<&'a GraphNode> { + let mut found: Vec<&GraphNode> = self.nodes.values().copied().filter(|node| node.labels.iter().any(|name| name == label)).collect(); + found.sort_by_cached_key(|node| record_id(node)); + found + } + + fn out_of(&self, node: i64, rel_type: &'static str) -> &[&'a GraphEdge] { + self.out.get(&(node, rel_type)).map_or(&[], Vec::as_slice) + } + + fn arriving_at(&self, node: i64, rel_type: &'static str) -> &[&'a GraphEdge] { + self.into.get(&(node, rel_type)).map_or(&[], Vec::as_slice) + } + + fn node(&self, id: i64) -> Option<&'a GraphNode> { + self.nodes.get(&id).copied() + } + + /// `ALLOWS` rules into `groups` from the internet. + fn internet_rules(&self, groups: &[&GraphEdge]) -> Vec<&'a GraphEdge> { + groups + .iter() + .flat_map(|protected| self.arriving_at(protected.target, "ALLOWS")) + .filter(|rule| self.node(rule.source).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: &GraphNode) -> Vec<(&'a GraphNode, &'a GraphNode, &'a GraphEdge, &'a GraphEdge)> { + let mut found = Vec::new(); + for to_target in self.arriving_at(instance.id, "ROUTES_TO") { + let Some(group) = self.node(to_target.source).filter(|node| has_label(node, "TargetGroup")) else { continue }; + for forwards in self.arriving_at(group.id, "FORWARDS_TO") { + if let Some(balancer) = self.node(forwards.source) { + 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: &GraphNode) -> Option<&'a GraphNode> { + if let Some(explicit) = self.out_of(subnet.id, "USES_ROUTE_TABLE").first() { + return self.node(explicit.target); + } + let vpc = self.out_of(subnet.id, "IN_VPC").first()?.target; + self.arriving_at(vpc, "IN_VPC") + .iter() + .filter_map(|edge| self.node(edge.source)) + .find(|node| has_label(node, "RouteTable") && node.properties.get("is_main") == Some(&json!(true))) + } +} + +fn has_label(node: &GraphNode, label: &str) -> bool { + node.labels.iter().any(|name| name == label) +} + +fn is_internet(node: &GraphNode) -> bool { + has_label(node, "Cidr") && matches!(node.properties.get("cidr").and_then(Json::as_str), Some("0.0.0.0/0" | "::/0")) +} + +fn record_id(node: &GraphNode) -> String { + evidence::node_evidence_id(node) +} + +fn edge_id(edge: &GraphEdge) -> String { + evidence::edge_evidence_id(edge) +} + +fn node_field(node: &GraphNode, field: &str, aws_field: &str) -> SourceField { + SourceField { + record: record_id(node), + field: field.into(), + aws_field: aws_field.into(), + value: node.properties.get(field).cloned().into(), + } +} + +fn edge_field(edge: &GraphEdge, field: &str, aws_field: &str) -> SourceField { + SourceField { + record: edge_id(edge), + field: field.into(), + aws_field: aws_field.into(), + value: edge.properties.get(field).cloned().into(), + } +} + +/// A rule's ports in words, as the plugin wrote them. +fn ports_of(rule: &GraphEdge) -> String { + let text = |key: &str| rule.properties.get(key).and_then(Json::as_str).unwrap_or("unknown").to_string(); + format!("{} {}", text("protocol"), text("ports")) +} + +/// Whether a security group rule covers `port` on `protocol`. The plugin writes +/// ports as `all`, `443` or `8000-8100`. +fn rule_covers(rule: &GraphEdge, protocol: &str, port: i64) -> bool { + let text = |key: &str| rule.properties.get(key).and_then(Json::as_str).unwrap_or_default(); + if text("protocol") != "all" && text("protocol") != protocol { + return false; + } + let ports = text("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), + } +} + +fn condition(id: &str, statement: &str, status: Status, detail: impl Into, fields: Vec) -> Condition { + Condition { + id: id.into(), + statement: statement.into(), + status, + detail: detail.into(), + fields, + } +} + +fn running(instance: &GraphNode) -> Condition { + let (status, detail) = match instance.properties.get("state").and_then(Json::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![node_field(instance, "state", "ec2 describe-instances: State.Name")]) +} + +/// 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: &[&GraphEdge], aws_groups: &str) -> Condition { + let statement = format!("{whose} has a security group rule open to the internet."); + let rules = index.internet_rules(protected); + let group_fields: Vec = protected + .iter() + .map(|edge| SourceField { + record: edge_id(edge), + field: "PROTECTED_BY".into(), + aws_field: aws_groups.into(), + value: Field::Known(json!(index.node(edge.target).map(record_id))), + }) + .collect(); + 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 = group_fields; + for rule in &rules { + fields.push(edge_field(rule, "protocol", "ec2 describe-security-groups: IpPermissions[].IpProtocol")); + fields.push(edge_field(rule, "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: &[Condition]) -> Verdict { + if conditions.iter().any(|condition| condition.status == Status::NotMet) { + Verdict::NotIndicated + } else if conditions.iter().any(|condition| condition.status == Status::Unknown) { + Verdict::InsufficientData + } else { + Verdict::PotentialExposure + } +} + +fn public_address(instance: &GraphNode) -> Condition { + let fields = vec![ + node_field(instance, "public_ip", "ec2 describe-instances: PublicIpAddress"), + node_field(instance, "ipv6_address", "ec2 describe-instances: Ipv6Address"), + ]; + let addresses: Vec<&str> = ["public_ip", "ipv6_address"].iter().filter_map(|key| instance.properties.get(*key).and_then(Json::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: &GraphNode) -> (Condition, 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.id, "IN_SUBNET").first().and_then(|edge| index.node(edge.target)) else { + return (condition(id, statement, Status::Unknown, "No subnet is recorded for the instance.", vec![]), vec![]); + }; + let subnet_id = record_id(subnet); + 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_id]); + }; + 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.id, "ROUTES_VIA"); + let text = |edge: &GraphEdge, key: &str| edge.properties.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); + let is_v6 = |destination: &str| destination.contains(':'); + // Only routes of the address's own family can carry its traffic. + let active: Vec<&&GraphEdge> = 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![ + node_field(table, "ipv4_default_target", "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]"), + node_field(table, "ipv6_default_target", "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]"), + ]; + for route in routes { + fields.push(edge_field(route, "destination", "ec2 describe-route-tables: Routes[].DestinationCidrBlock")); + fields.push(edge_field(route, "state", "ec2 describe-route-tables: Routes[].State")); + } + let name = node_name(table); + let (status, detail) = if let Some(route) = active.iter().find(|route| wanted.contains(&text(route, "destination").as_str())) { + (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_id, record_id(table)]; + records.extend(routes.iter().map(|route| edge_id(route))); + (condition(id, statement, status, detail, fields), records) +} + +fn direct(index: &Index, instance: &GraphNode, sources: &[Source]) -> Finding { + let protected = index.out_of(instance.id, "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.target, "ALLOWS").iter().find(|rule| index.node(rule.source).is_some_and(is_internet))?; + Some(vec![along(rule, 1), against(edge, 2)]) + }); + let mut records = route_records; + records.push(record_id(instance)); + records.extend(protected.iter().map(|edge| edge_id(edge))); + for rule in index.internet_rules(protected) { + records.push(edge_id(rule)); + } + build(index, instance, RULE_DIRECT, conditions, hops.unwrap_or_default(), records, None, sources) +} + +fn behind_balancer(index: &Index, instance: &GraphNode, balancer: &GraphNode, group: &GraphNode, to_target: &GraphEdge, forwards: &GraphEdge, sources: &[Source]) -> Finding { + let balancer_groups = index.out_of(balancer.id, "PROTECTED_BY"); + let instance_groups = index.out_of(instance.id, "PROTECTED_BY"); + + let (status, detail) = match balancer.properties.get("scheme").and_then(Json::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![node_field(balancer, "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 field = edge_field(to_target, "health", "elbv2 describe-target-health: TargetHealth.State"); + let (status, detail) = match to_target.properties.get("health").and_then(Json::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![field]) + }; + let (allows, allow_records) = target_allows_balancer(index, group, balancer_groups, instance_groups); + let conditions = vec![running(instance), facing, open, health, allows]; + + let opening = index.arriving_at(balancer.id, "ROUTES_TO").iter().find(|edge| index.node(edge.source).is_some_and(is_internet)); + let hops = opening.map_or_else(Vec::new, |edge| vec![along(edge, 1), along(forwards, 2), along(to_target, 3)]); + let mut records = allow_records; + records.extend([record_id(instance), record_id(balancer), record_id(group), edge_id(to_target), edge_id(forwards)]); + records.extend(balancer_groups.iter().map(|edge| edge_id(edge))); + for rule in index.internet_rules(balancer_groups) { + records.push(edge_id(rule)); + } + build(index, instance, RULE_BALANCER, conditions, hops, records, Some(format!("{}\n{}", record_id(balancer), record_id(group))), sources) +} + +/// Whether the target's groups let the balancer in on the target group's port. +fn target_allows_balancer(index: &Index, group: &GraphNode, balancer_groups: &[&GraphEdge], instance_groups: &[&GraphEdge]) -> (Condition, Vec) { + 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 = node_field(group, "port", "elbv2 describe-target-groups: Port"); + let Some(port) = group.properties.get("port").and_then(Json::as_i64) else { + return (condition(id, statement, Status::Unknown, "The target group's port was not supplied.", vec![port_field]), vec![]); + }; + 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]), vec![]); + } + let protocol = match group.properties.get("protocol").and_then(Json::as_str) { + Some("UDP") => "udp", + _ => "tcp", + }; + let from_balancer: BTreeSet = balancer_groups.iter().map(|edge| edge.target).collect(); + let rules: Vec<&GraphEdge> = instance_groups + .iter() + .flat_map(|edge| index.arriving_at(edge.target, "ALLOWS")) + .filter(|rule| rule_covers(rule, protocol, port)) + .copied() + .collect(); + let matching = rules.iter().find(|rule| from_balancer.contains(&rule.source) || index.node(rule.source).is_some_and(is_internet)); + let other_cidr = rules.iter().find(|rule| index.node(rule.source).is_some_and(|node| has_label(node, "Cidr"))); + let chosen = matching.or(other_cidr); + let mut fields = vec![port_field]; + let mut records = Vec::new(); + if let Some(rule) = chosen { + fields.push(edge_field(rule, "ports", "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort")); + records.push(edge_id(rule)); + } + let (status, detail) = if matching.is_some() { + (Status::Met, format!("A rule allows {protocol} {port} from the load balancer's group or the internet.")) + } else if other_cidr.is_some() { + (Status::Unknown, format!("A CIDR rule allows {protocol} {port}, and the load balancer's addresses are not read to compare with it.")) + } else { + (Status::NotMet, format!("No rule on the target's groups allows {protocol} {port} from the load balancer.")) + }; + (condition(id, statement, status, detail, fields), records) +} + +/// 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(index: &Index, findings: &[Finding], sources: &[Source]) -> Vec { + // The strongest finding on each instance, first by verdict then by rule. + let mut best: BTreeMap<&str, &Finding> = BTreeMap::new(); + for finding in findings.iter().filter(|finding| finding.verdict != Verdict::NotIndicated) { + best.entry(finding.resource.as_str()) + .and_modify(|held| { + if (finding.verdict, &finding.rule_id) < (held.verdict, &held.rule_id) { + *held = finding; + } + }) + .or_insert(finding); + } + let mut out = Vec::new(); + for source in index.with_label("Instance") { + let Some(found) = best.get(record_id(source).as_str()) else { continue }; + let mut pairs: BTreeMap)> = BTreeMap::new(); + for &own in index.out_of(source.id, "PROTECTED_BY") { + for &rule in index.out_of(own.target, "ALLOWS") { + for &protected in index.arriving_at(rule.target, "PROTECTED_BY") { + if protected.source != source.id && index.node(protected.source).is_some_and(|node| has_label(node, "Instance")) { + pairs.entry(protected.source).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(index, instance, source, found, own, protected, &rules, sources)); + } + } + out +} + +#[allow(clippy::too_many_arguments)] +fn lateral(index: &Index, instance: &GraphNode, source: &GraphNode, found: &Finding, own: &GraphEdge, protected: &GraphEdge, rules: &[&GraphEdge], sources: &[Source]) -> Finding { + let name = node_name(source); + let (status, detail) = match found.verdict { + Verdict::PotentialExposure => (Status::Met, format!("{name} has a potential exposure finding from {}.", found.rule_id)), + _ => (Status::Unknown, format!("{name} has insufficient data under {}.", found.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(edge_field(rule, "protocol", "ec2 describe-security-groups: IpPermissions[].IpProtocol")); + rule_fields.push(edge_field(rule, "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 rule = rules[0]; + let mut hops = found.hops.clone(); + if !hops.is_empty() { + let at = hops.len() as u32; + hops.extend([along(own, at + 1), along(rule, at + 2), against(protected, at + 3)]); + } + let mut records = vec![record_id(instance), record_id(source), edge_id(own), edge_id(protected)]; + records.extend(rules.iter().map(|rule| edge_id(rule))); + let mut finding = build(index, instance, RULE_LATERAL, conditions, hops, records, Some(record_id(source)), sources); + finding.depends_on = Some(found.finding_id.clone()); + finding +} + +fn along(edge: &GraphEdge, depth: u32) -> Hop { + Hop { + depth, + from: edge.source, + to: edge.target, + edge: edge.id, + rel_type: edge.rel_type.clone(), + along: true, + } +} + +fn against(edge: &GraphEdge, depth: u32) -> Hop { + Hop { + depth, + from: edge.target, + to: edge.source, + edge: edge.id, + rel_type: edge.rel_type.clone(), + along: false, + } +} + +/// How to show `hops` in Path Explorer. +fn walk_hint(index: &Index, hops: &[Hop], instance: &GraphNode) -> Option { + let start = index.node(hops.first()?.from)?; + let name = |node: &GraphNode| { + let id = record_id(node); + id.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(start), + to: name(instance), + direction: if hops.iter().all(|hop| hop.along) { Direction::Out } else { Direction::Both }, + types: types.into_iter().collect(), + max_hops: hops.len() as u32, + }) +} + +#[allow(clippy::too_many_arguments)] +fn build(index: &Index, instance: &GraphNode, rule_id: &str, conditions: Vec, hops: Vec, records: Vec, subject: Option, sources: &[Source]) -> Finding { + let resource = record_id(instance); + let verdict = verdict_of(&conditions); + let unknowns = conditions.iter().filter(|condition| condition.status == Status::Unknown).map(|condition| condition.statement.clone()).collect(); + let path = (!hops.is_empty()).then(|| { + evidence::for_hops(&hops, index.data, sources, "the rule's candidate path", Field::Known(rule_id.to_string())) + }); + let walk = walk_hint(index, &hops, instance); + + // 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: BTreeSet = records.into_iter().filter(|id| !contributing.contains(id)).collect(); + contributing.extend(rest); + let evidence = contributing.iter().filter_map(|id| record_for(index, id, sources)).collect(); + + let id_text = [Some(rule_id.to_string()), Some(resource.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(); + Finding { + finding_id: format!("finding:{:016x}", evidence::fnv1a(&id_text)), + rule_id: rule_id.into(), + verdict, + resource, + resource_name: node_name(instance), + conditions, + unknowns, + assumptions, + path, + hops, + walk, + depends_on: None, + contributing, + evidence, + } +} + +fn record_for(index: &Index, id: &str, sources: &[Source]) -> Option { + if let Some(node) = index.node_ids.get(id) { + return Some(evidence::for_node(node, sources)); + } + let edge = index.edge_ids.get(id)?; + Some(evidence::for_edge(edge, &index.data.nodes, sources)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Map, Value}; + + 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 + } + + /// Builds a graph by stable name, with IDs in order of first mention. + #[derive(Default)] + struct Builder { + data: GraphData, + } + + impl Builder { + fn node(mut self, id: &str, label: &str, extra: Value) -> Self { + let next = self.data.nodes.len() as i64 + 1; + self.data.nodes.push(GraphNode { + id: next, + labels: vec![label.into()], + properties: props(id, extra), + }); + self + } + + fn edge(mut self, source: &str, rel_type: &str, target: &str, extra: Value) -> Self { + let find = |name: &str| self.data.nodes.iter().find(|node| node.properties["khonsu_id"] == json!(name)).unwrap_or_else(|| panic!("no node {name}")).id; + let (source_id, target_id) = (find(source), find(target)); + let next = self.data.edges.len() as i64 + 100; + let id = format!("{source}|{rel_type}|{target}|{extra}"); + self.data.edges.push(GraphEdge { + id: next, + source: source_id, + target: target_id, + rel_type: rel_type.into(), + properties: props(&id, extra), + }); + self + } + + fn report(&self) -> Report { + evaluate(&self.data, &[], true) + } + } + + /// 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.data.edges.retain(|edge| edge.rel_type != rel_type); + builder + } + + fn set(mut builder: Builder, id: &str, key: &str, value: Option) -> Builder { + let node = builder.data.nodes.iter_mut().find(|node| node.properties["khonsu_id"] == json!(id)).unwrap(); + match value { + Some(value) => node.properties.insert(key.into(), value), + None => node.properties.remove(key), + }; + builder + } + + fn only<'a>(report: &'a Report, rule: &str, resource: &str) -> &'a Finding { + let mut found = report.findings.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 status(finding: &Finding, id: &str) -> Status { + finding.conditions.iter().find(|condition| condition.id == id).unwrap_or_else(|| panic!("no condition {id}: {finding:#?}")).status + } + + #[test] + fn every_direct_condition_met_is_a_potential_exposure_with_its_path_and_rule() { + let report = exposed().report(); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(finding.verdict, Verdict::PotentialExposure); + assert!(finding.unknowns.is_empty()); + let path = finding.path.as_ref().unwrap(); + assert_eq!(path.rule_id, Field::Known(RULE_DIRECT.to_string())); + assert_eq!(path.hops, ["Internet -[ALLOWS]-> sg", "sg <-[PROTECTED_BY]- web"]); + assert!(finding.contributing.contains(&"node:rtb".to_string()), "the route is part of the case: {:?}", finding.contributing); + assert!(finding.evidence.iter().any(|record| record.evidence_id == "node:rtb")); + assert!(!finding.assumptions.is_empty()); + let walk = finding.walk.as_ref().unwrap(); + assert_eq!((walk.from.as_str(), walk.to.as_str(), walk.direction, walk.max_hops), ("internet", "web", Direction::Both, 2)); + assert_eq!(walk.types, ["ALLOWS", "PROTECTED_BY"]); + } + + #[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, Verdict::NotIndicated, "{case}"); + } + } + + #[test] + fn no_verdict_ever_says_reachable() { + // The names are the whole vocabulary, so this holds for any input. + for verdict in [Verdict::PotentialExposure, Verdict::InsufficientData, Verdict::NotIndicated] { + assert!(!serde_json::to_string(&verdict).unwrap().contains("reachable")); + } + assert!(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, Verdict::InsufficientData); + 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, Verdict::InsufficientData); + + // 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, Verdict::NotIndicated); + 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, Verdict::PotentialExposure); + + // 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, Verdict::PotentialExposure); + } + + #[test] + fn a_narrower_cidr_is_not_the_internet() { + let mut builder = exposed(); + builder.data.nodes[0].properties.insert("cidr".into(), 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("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"}) + } + + #[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, Verdict::PotentialExposure, "{finding:#?}"); + assert_eq!(finding.path.as_ref().unwrap().hops, ["Internet -[ROUTES_TO]-> lb", "lb -[FORWARDS_TO]-> tg", "tg -[ROUTES_TO]-> app"]); + assert_eq!(finding.walk.as_ref().unwrap().direction, Direction::Out); + assert_eq!(only(&report, RULE_DIRECT, "app").verdict, Verdict::NotIndicated, "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"), "target-allows-balancer"); + 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); + } + + #[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, Verdict::NotIndicated); + + let unhealthy = balanced(app_rule("8080"), "sg-lb", "unhealthy").report(); + let finding = only(&unhealthy, RULE_BALANCER, "app"); + assert_eq!(finding.verdict, Verdict::InsufficientData); + 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, Verdict::PotentialExposure); + assert_eq!(db.depends_on.as_ref(), Some(&web.finding_id)); + assert_eq!( + db.path.as_ref().unwrap().hops, + ["Internet -[ALLOWS]-> sg", "sg <-[PROTECTED_BY]- web", "web -[PROTECTED_BY]-> sg", "sg -[ALLOWS]-> sg-db", "sg-db <-[PROTECTED_BY]- db"] + ); + assert_eq!(only(&report, RULE_DIRECT, "db").verdict, Verdict::NotIndicated); + } + + #[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, Verdict::InsufficientData); + assert_eq!(status(db, "source-potentially-exposed"), Status::Unknown); + + let not_exposed = with_database(set(exposed(), "web", "public_ip", None)).report(); + assert!(not_exposed.findings.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, Verdict::NotIndicated); + } + + #[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 = builder.report(); + let order: Vec<(Verdict, &str, &str)> = report.findings.iter().map(|finding| (finding.verdict, finding.rule_id.as_str(), finding.resource.as_str())).collect(); + assert_eq!( + order, + [ + (Verdict::PotentialExposure, RULE_DIRECT, "node:web"), + (Verdict::PotentialExposure, RULE_LATERAL, "node:db"), + (Verdict::InsufficientData, RULE_DIRECT, "node:idle"), + (Verdict::NotIndicated, RULE_DIRECT, "node:db"), + ], + "{report:#?}" + ); + assert_eq!(report.summary, Summary { potential_exposure: 2, insufficient_data: 1, not_indicated: 1 }); + assert_eq!(report.instances, 3); + + let mut shuffled = builder.data.clone(); + shuffled.nodes.reverse(); + shuffled.edges.reverse(); + assert_eq!(evaluate(&shuffled, &[], true), report); + } + + #[test] + fn the_same_records_keep_their_finding_id_and_a_cut_is_counted() { + let report = exposed().report(); + assert_eq!(report.findings[0].finding_id, exposed().report().findings[0].finding_id); + assert_ne!(report.findings[0].finding_id, only(&with_database(exposed()).report(), RULE_LATERAL, "db").finding_id); + + let (narrow, cut) = with_database(exposed()).report().narrowed(|_| true, 1); + assert_eq!((narrow.findings.len(), cut), (1, 2)); + assert_eq!(narrow.summary.potential_exposure, 2, "the summary still counts everything"); + } + + #[test] + fn secrets_on_a_contributing_record_stay_masked() { + let report = set(exposed(), "web", "api_token", Some(json!("abc123"))).report(); + let text = serde_json::to_string(&report).unwrap(); + assert!(!text.contains("abc123"), "{text}"); + assert!(text.contains("api_token"), "it is named as masked"); + } +} diff --git a/crates/khonsu-core/src/lib.rs b/crates/khonsu-core/src/lib.rs index 5c01fb5..dea1fd7 100644 --- a/crates/khonsu-core/src/lib.rs +++ b/crates/khonsu-core/src/lib.rs @@ -7,6 +7,7 @@ mod cypher_columns; pub mod engine; pub mod evidence; pub mod explore; +pub mod exposure; pub mod lock; pub mod merge; pub mod plugin; diff --git a/docs/exposure-rules.md b/docs/exposure-rules.md new file mode 100644 index 0000000..cc82b30 --- /dev/null +++ b/docs/exposure-rules.md @@ -0,0 +1,157 @@ +# AWS exposure rules + +`khonsu 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. + +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 | `unknowns` says what could not be read; get that data | +| `not_indicated` | At least one is unmet | 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`; the target's group rules | No port or groups are recorded, or only a CIDR rule covers the port | + +### `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 saved response that is missing reads as empty (#38), and a real sync can't tell +either. So 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 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 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 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 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 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 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 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 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. diff --git a/docs/investigation-gates.md b/docs/investigation-gates.md index f97ba80..f8be26e 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -21,6 +21,7 @@ cargo test --locked --workspace --test investigation | 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 | | 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) | | HawDB upgrades | `app/tests/fixtures/graph-hawdb-*.zip` must still give the golden answers and accept a write | Timings, sync times, paths, line numbers and plugin versions are left out of the @@ -69,8 +70,10 @@ These are written down so that nobody reads an empty result as proof: - **Evidence shows what is stored, not the raw response.** The AWS plugin stores chosen fields, not the API call each came from. -Rule names are not in the golden file yet, because they arrive with #35. Source -snapshots are always unknown until #37. +- **An exposure finding is not reachability.** It says what configuration allows. + [The rules](exposure-rules.md) list what they do not read. + +Source snapshots are always unknown until #37. ## Baselines 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..cfee118 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 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 2b2347b..1e15f16 100644 --- a/examples/aws/golden.json +++ b/examples/aws/golden.json @@ -1609,5 +1609,3310 @@ ] } } + }, + "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": { + "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. There is no risk score.", + "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." + ] + } + ], + "instances": 5, + "summary": { + "potential_exposure": 3, + "insufficient_data": 1, + "not_indicated": 3 + }, + "complete": true, + "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", + "aws_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", + "aws_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", + "aws_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": "PROTECTED_BY", + "aws_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", + "aws_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", + "aws_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", + "aws_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", + "aws_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", + "aws_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", + "aws_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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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" + } + } + ] + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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", + "aws_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", + "aws_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": "PROTECTED_BY", + "aws_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", + "aws_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", + "aws_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", + "aws_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", + "aws_field": "elbv2 describe-target-groups: Port", + "value": { + "status": "known", + "value": 8080 + } + }, + { + "record": "rel:shop/123456789012:us-east-1:sg-lb|ALLOWS|123456789012:us-east-1:sg-app|\"\\\"tcp\\\":\\\"8080\\\"\"", + "field": "ports", + "aws_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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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" + } + } + ] + }, + "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: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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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: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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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", + "aws_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", + "aws_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", + "aws_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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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" + } + } + ] + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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", + "aws_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", + "aws_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", + "aws_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": "PROTECTED_BY", + "aws_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", + "aws_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", + "aws_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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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" + } + } + ] + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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", + "aws_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", + "aws_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "unknown" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-app1", + "field": "ipv6_address", + "aws_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": "PROTECTED_BY", + "aws_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", + "aws_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", + "aws_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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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", + "aws_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", + "aws_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "unknown" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-batch1", + "field": "ipv6_address", + "aws_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": "PROTECTED_BY", + "aws_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", + "aws_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", + "aws_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", + "aws_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", + "aws_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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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" + } + } + ] + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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", + "aws_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", + "aws_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "unknown" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-db1", + "field": "ipv6_address", + "aws_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": "PROTECTED_BY", + "aws_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", + "aws_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", + "aws_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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "assertion": "explicit", + "rule_id": { + "status": "unknown" + }, + "properties": {} + } + ] + } + ], + "truncated": 0 + } } } diff --git a/plugins/aws/src/main.rs b/plugins/aws/src/main.rs index 0decba9..a69a434 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 @@ -66,10 +68,10 @@ 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, @@ -232,6 +234,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 +272,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 +372,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 +424,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") { @@ -554,6 +607,42 @@ 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 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/skills/khonsugraph/SKILL.md b/skills/khonsugraph/SKILL.md index b9dd0ba..a02bb0b 100644 --- a/skills/khonsugraph/SKILL.md +++ b/skills/khonsugraph/SKILL.md @@ -48,6 +48,8 @@ Read `complete` first. When it is false, `limits` says which cap cut the walk, s `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. +On an AWS graph, `khonsu 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. Narrow it with `--resource ID` and `--verdict`, and pass a finding's `walk` to `khonsu explore`. 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 c5693f5..b7c6998 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -71,10 +71,33 @@ khonsu evidence --db graphs/aws --node shop/123456789012:us-east-1:i-web1 | `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`, `rule_id` | Unknown for now | +| `source_snapshot` | Unknown for now | +| `rule_id` | Unknown on a record; known on an exposure finding's `path` | | `assertion` | `explicit` (a source reported it), `derived`, or `unknown` | | `properties`, `redacted` | Its own properties, and the names masked as possible secrets | +## AWS exposure findings + +```sh +khonsu exposure --db graphs/aws --verdict potential-exposure --pretty +``` + +`--resource ID` takes a `khonsu_id`, `--verdict` takes `potential-exposure`, `insufficient-data` or `not-indicated`, and `--max-findings` defaults to 200. The top level has `claim`, `order`, `rules`, `instances`, `summary` (counts before any narrowing), `complete` and `truncated` (findings cut by `--max-findings`). When `complete` is false, a missing finding means nothing. + +| Finding field | Meaning | +|---|---| +| `finding_id`, `rule_id` | Stable for the same rule and records | +| `verdict` | `potential_exposure`, `insufficient_data` or `not_indicated`. None means reachable or safe | +| `resource`, `resource_name` | The instance it is about | +| `conditions[]` | `id`, `statement`, `status` (`met`, `not_met`, `unknown`), `detail`, and `fields[]` with `record`, `field`, `aws_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 | +| `walk` | `from`, `to`, `direction`, `types`, `max_hops` for `khonsu explore`, or null | +| `depends_on` | The finding a lateral step rests on | +| `contributing`, `evidence` | Evidence IDs of every record used, and those records | + +See `docs/exposure-rules.md` for the rules and a walkthrough. + ## 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). From c29ee42ce52fc4629b6be1b8ffa24a4e6df40b89 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 02:56:38 +0900 Subject: [PATCH 04/13] Show exposure findings in the graph inspector The rules could only be read as JSON, so someone investigating in the window had to leave it to learn why an instance was flagged. Selecting an instance now offers a check. Each finding shows its verdict, rule, conditions with their AWS fields, candidate path and what wasn't checked. Showing a path narrows the canvas to it as a walk, so Back, Path Explorer and each record's evidence carry on working from there. Refs #35 Co-Authored-By: Claude Opus 5.5 --- app/src/db.rs | 6 ++ app/src/i18n.rs | 29 ++++++ app/src/ui/canvas.rs | 150 +++++++++++++++++++++++++++++ app/src/ui/explorer.rs | 144 +++++++++++++++++++++++++++ crates/khonsu-core/src/exposure.rs | 48 ++++++++- docs/exposure-rules.md | 18 ++++ 6 files changed, 394 insertions(+), 1 deletion(-) diff --git a/app/src/db.rs b/app/src/db.rs index 142ccb9..c9417ec 100644 --- a/app/src/db.rs +++ b/app/src/db.rs @@ -99,6 +99,12 @@ pub fn explore(request: &ExploreRequest, cancel: &AtomicBool) -> Result Result { + let sources = khonsu_core::settings::load_sources(); + with_graph(|g| g.exposure(&sources)) +} + 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 330678e..29140e2 100644 --- a/app/src/i18n.rs +++ b/app/src/i18n.rs @@ -347,6 +347,35 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("evidence.redacted", "已遮盖疑似机密:{}", "Masked as possible secrets: {}"), ("evidence.copy", "复制证据 JSON", "Copy evidence as JSON"), ("evidence.copy_path", "复制路径证据 JSON", "Copy path evidence as JSON"), + ("exposure.title", "暴露分析", "Exposure"), + ("exposure.check", "检查暴露", "Check exposure"), + ("exposure.recheck", "重新检查", "Check again"), + ("exposure.claim", "根据配置推断的潜在暴露。未发送任何流量,因此不能说明资源可达;没有发现也不能说明资源安全。", "Potential exposure worked out from configuration. No traffic was sent, so this does not show that anything is reachable, and no finding does not show that a resource is safe."), + ("exposure.failed", "检查失败:{}", "The check failed: {}"), + ("exposure.incomplete", "图太大,未能读完,所以缺少发现不说明任何问题。", "The graph was too large to read whole, so a missing finding means nothing."), + ("exposure.none", "没有关于此实例的发现。如果刚同步过,请重新检查。", "No findings about this instance. If you just synced, check again."), + ("exposure.verdict.potential", "潜在暴露", "Potential exposure"), + ("exposure.verdict.insufficient", "数据不足", "Insufficient data"), + ("exposure.verdict.not_indicated", "规则未指向", "Not indicated by this rule"), + ("exposure.status.met", "满足", "Met"), + ("exposure.status.not_met", "不满足", "Not met"), + ("exposure.status.unknown", "未知", "Unknown"), + ("exposure.rule.direct", "实例直接向互联网开放", "Instance open to the internet directly"), + ("exposure.rule.balancer", "位于面向互联网的负载均衡器之后", "Behind an internet-facing load balancer"), + ("exposure.rule.lateral", "位于潜在暴露实例之后一条安全组规则", "One security group rule behind a potentially exposed instance"), + ("exposure.cond.running", "实例正在运行", "The instance is running"), + ("exposure.cond.address", "实例有公网地址", "The instance has a public address"), + ("exposure.cond.open_rule", "实例有向互联网开放的安全组规则", "The instance has a security group rule open to the internet"), + ("exposure.cond.route", "子网有通往互联网网关的活动默认路由", "Its subnet has an active default route to an internet gateway"), + ("exposure.cond.facing", "负载均衡器面向互联网", "The load balancer is internet-facing"), + ("exposure.cond.balancer_open", "负载均衡器有向互联网开放的安全组规则", "The load balancer has a security group rule open to the internet"), + ("exposure.cond.healthy", "目标健康", "The target is healthy"), + ("exposure.cond.target_allows", "目标的安全组允许来自负载均衡器的目标端口", "The target's security group allows the target port from the load balancer"), + ("exposure.cond.source", "来源实例有潜在暴露发现", "The source instance has a potential exposure finding"), + ("exposure.cond.group_allows", "安全组规则允许来源实例的安全组", "A security group rule allows the source instance's group"), + ("exposure.assumptions", "未检查", "Not checked"), + ("exposure.show_path", "在画布上显示路径", "Show path on canvas"), + ("exposure.copy", "复制发现 JSON", "Copy finding as JSON"), ("explore.title", "路径探索", "Path Explorer"), ("explore.out", "沿箭头", "Outbound"), ("explore.in", "逆箭头", "Inbound"), diff --git a/app/src/ui/canvas.rs b/app/src/ui/canvas.rs index 04a8b1b..ae4fa23 100644 --- a/app/src/ui/canvas.rs +++ b/app/src/ui/canvas.rs @@ -23,6 +23,7 @@ use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; use khonsu_core::evidence::{self, Evidence}; use khonsu_core::explore::{Direction, Exploration}; +use khonsu_core::exposure::{Status, Verdict}; use khonsu_core::settings::{self, Source}; use khonsu_core::{GraphData, GraphEdge, GraphNode}; use serde_json::Value as Json; @@ -506,6 +507,43 @@ impl GraphCanvas { self.explorer.walk = Some(walk); } + /// Evaluate the exposure rules over the whole graph, not just the canvas. + fn check_exposure(&mut self, window: &mut Window, cx: &mut Context) { + if self.explorer.checking_exposure { + return; + } + self.explorer.checking_exposure = true; + self.explorer.exposure_error = None; + cx.notify(); + cx.spawn_in(window, async move |this, cx| { + let result = smol::unblock(crate::db::exposure).await; + this.update(cx, |this, cx| { + this.explorer.checking_exposure = false; + match result { + Ok(report) => this.explorer.exposure = Some(report), + Err(error) => this.explorer.exposure_error = Some(error.message), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Show a finding's candidate path, as a walk Path Explorer can go on from. + fn show_finding(&mut self, finding_id: &str, target: i64, cx: &mut Context) { + let report = self.explorer.exposure.as_ref(); + let Some(walk) = report.and_then(|report| report.findings.iter().find(|finding| finding.finding_id == finding_id)).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.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 { @@ -592,6 +630,114 @@ impl GraphCanvas { .into_any_element() } + /// What the rules say about a selected instance, and why. Not shown for + /// anything else: the rules are only about instances. + fn render_exposure(&self, id: i64, cx: &mut Context) -> Option { + let node = self.nodes.get(&id).filter(|node| node.labels.iter().any(|label| label == "Instance"))?; + let muted = cx.theme().muted_foreground; + let warning = cx.theme().warning; + let mono = cx.theme().mono_font_family.clone(); + let explorer = &self.explorer; + let findings = explorer.exposure.as_ref().map(|report| explorer::findings_for(report, node)); + + let mut list = Vec::new(); + for (ix, finding) in findings.iter().flatten().enumerate() { + let title = explorer::rule_key(&finding.rule_id).map_or_else(|| finding.rule_id.clone(), |key| tr(key).to_string()); + let copy = serde_json::to_string_pretty(finding).expect("a finding serializes"); + let finding_id = finding.finding_id.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::condition_key(&condition.id).map_or_else(|| condition.statement.clone(), |key| tr(key).to_string()); + let color = if condition.status == Status::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.aws_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 verdict_color = if finding.verdict == Verdict::NotIndicated { muted } else { warning }; + list.push( + v_flex() + .gap_1() + .pt_1() + .child(div().text_xs().font_semibold().text_color(verdict_color).child(tr(explorer::verdict_key(finding.verdict)))) + .child(div().text_xs().whitespace_normal().child(title)) + .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("exposure.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(("exposure-path", ix)) + .xsmall() + .outline() + .label(tr("exposure.show_path")) + .on_click(cx.listener(move |this, _, _, cx| this.show_finding(&finding_id, id, cx))), + ) + }) + .child( + Button::new(("exposure-copy", ix)) + .xsmall() + .outline() + .label(tr("exposure.copy")) + .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()))), + ), + ), + ); + } + + let checked = explorer.exposure.is_some(); + let complete = explorer.exposure.as_ref().is_none_or(|report| report.complete); + let none = findings.as_ref().is_some_and(Vec::is_empty); + Some( + v_flex() + .gap_1() + .child(div().text_sm().font_semibold().child(tr("exposure.title"))) + .child( + Button::new("exposure-check") + .xsmall() + .outline() + .label(tr(if checked { "exposure.recheck" } else { "exposure.check" })) + .loading(explorer.checking_exposure) + .on_click(cx.listener(|this, _, window, cx| this.check_exposure(window, cx))), + ) + .when_some(explorer.exposure_error.clone(), |this, error| { + this.child(div().text_xs().whitespace_normal().text_color(warning).child(trf("exposure.failed", &[&error]))) + }) + .when(checked, |this| this.child(div().text_xs().whitespace_normal().text_color(muted).child(tr("exposure.claim")))) + .when(!complete, |this| this.child(div().text_xs().whitespace_normal().text_color(warning).child(tr("exposure.incomplete")))) + .when(none, |this| this.child(div().text_xs().whitespace_normal().text_color(muted).child(tr("exposure.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)), @@ -787,6 +933,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 exposure = self.selected_node().and_then(|id| self.render_exposure(id, cx)); Some( v_flex() .id("inspector") @@ -812,6 +959,9 @@ impl GraphCanvas { .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(exposure, |this, exposure| { + this.child(exposure).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)) }) diff --git a/app/src/ui/explorer.rs b/app/src/ui/explorer.rs index 3933f6f..3eeea02 100644 --- a/app/src/ui/explorer.rs +++ b/app/src/ui/explorer.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use khonsu_core::evidence::{Assertion, Evidence, Field}; use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Limit, DEFAULT_MAX_HOPS, MAX_HOPS}; +use khonsu_core::exposure::{Finding, Report, Status, Verdict, RULE_BALANCER, RULE_DIRECT, RULE_LATERAL}; use khonsu_core::util::format_unix_time; use khonsu_core::{GraphData, GraphEdge, GraphNode}; @@ -38,6 +39,10 @@ pub struct Explorer { pub walk: Option, /// A walk that failed; the canvas keeps what it had. pub error: Option, + /// The last exposure check, kept until the canvas gets a new result. + pub exposure: Option, + pub checking_exposure: bool, + pub exposure_error: Option, history: Vec, } @@ -52,6 +57,9 @@ impl Default for Explorer { running: None, walk: None, error: None, + exposure: None, + checking_exposure: false, + exposure_error: None, history: Vec::new(), } } @@ -109,6 +117,8 @@ impl Explorer { pub fn forget_walks(&mut self) { self.walk = None; self.error = None; + self.exposure = None; + self.exposure_error = None; self.starts.clear(); self.history.clear(); } @@ -184,6 +194,55 @@ pub fn evidence_rows(record: &Evidence) -> Vec<(&'static str, 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() +} + +pub fn verdict_key(verdict: Verdict) -> &'static str { + match verdict { + Verdict::PotentialExposure => "exposure.verdict.potential", + Verdict::InsufficientData => "exposure.verdict.insufficient", + Verdict::NotIndicated => "exposure.verdict.not_indicated", + } +} + +pub fn status_key(status: Status) -> &'static str { + match status { + Status::Met => "exposure.status.met", + Status::NotMet => "exposure.status.not_met", + Status::Unknown => "exposure.status.unknown", + } +} + +/// A rule's title in the window's language. +pub fn rule_key(rule_id: &str) -> Option<&'static str> { + match rule_id { + RULE_DIRECT => Some("exposure.rule.direct"), + RULE_BALANCER => Some("exposure.rule.balancer"), + RULE_LATERAL => Some("exposure.rule.lateral"), + _ => None, + } +} + +/// A condition's statement in the window's language. +pub fn condition_key(id: &str) -> Option<&'static str> { + Some(match id { + "instance-running" => "exposure.cond.running", + "public-address" => "exposure.cond.address", + "open-inbound-rule" => "exposure.cond.open_rule", + "internet-route" => "exposure.cond.route", + "internet-facing" => "exposure.cond.facing", + "balancer-open-rule" => "exposure.cond.balancer_open", + "target-healthy" => "exposure.cond.healthy", + "target-allows-balancer" => "exposure.cond.target_allows", + "source-potentially-exposed" => "exposure.cond.source", + "group-allows-source" => "exposure.cond.group_allows", + _ => return None, + }) +} + /// 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())?; @@ -245,6 +304,91 @@ mod tests { } } + /// A public balancer in front of `app`, and `db` allowing `app`'s group, so + /// that every rule produces a finding. + fn exposure_report() -> Report { + let at = |id: i64, label: &str, extra: serde_json::Value| GraphNode { + id, + labels: vec![label.into()], + properties: extra.as_object().unwrap().clone(), + }; + let link = |id: i64, source: i64, rel: &str, target: i64, extra: serde_json::Value| GraphEdge { + id, + source, + target, + rel_type: rel.into(), + properties: extra.as_object().unwrap().clone(), + }; + let data = GraphData { + nodes: vec![ + at(1, "Cidr", json!({"khonsu_id": "net", "cidr": "0.0.0.0/0"})), + at(2, "SecurityGroup", json!({"khonsu_id": "sg-lb"})), + at(3, "SecurityGroup", json!({"khonsu_id": "sg-app"})), + at(4, "SecurityGroup", json!({"khonsu_id": "sg-db"})), + at(5, "LoadBalancer", json!({"khonsu_id": "lb", "scheme": "internet-facing"})), + at(6, "TargetGroup", json!({"khonsu_id": "tg", "port": 80})), + at(7, "Instance", json!({"khonsu_id": "app", "name": "app", "state": "running"})), + at(8, "Instance", json!({"khonsu_id": "db", "name": "db", "state": "running"})), + ], + edges: vec![ + link(1, 1, "ROUTES_TO", 5, json!({"khonsu_id": "a"})), + link(2, 1, "ALLOWS", 2, json!({"khonsu_id": "b", "protocol": "tcp", "ports": "80"})), + link(3, 5, "PROTECTED_BY", 2, json!({"khonsu_id": "c"})), + link(4, 5, "FORWARDS_TO", 6, json!({"khonsu_id": "d"})), + link(5, 6, "ROUTES_TO", 7, json!({"khonsu_id": "e", "health": "healthy"})), + link(6, 7, "PROTECTED_BY", 3, json!({"khonsu_id": "f"})), + link(7, 2, "ALLOWS", 3, json!({"khonsu_id": "g", "protocol": "tcp", "ports": "80"})), + link(8, 8, "PROTECTED_BY", 4, json!({"khonsu_id": "h"})), + link(9, 3, "ALLOWS", 4, json!({"khonsu_id": "i", "protocol": "tcp", "ports": "5432"})), + ], + }; + khonsu_core::exposure::evaluate(&data, &[], true) + } + + #[test] + fn every_rule_and_condition_has_words_in_the_window() { + let report = exposure_report(); + assert_eq!(report.summary.potential_exposure, 2, "{report:#?}"); + let rules: BTreeSet<&str> = report.findings.iter().map(|finding| finding.rule_id.as_str()).collect(); + assert_eq!(rules.len(), 3, "the test should reach every rule: {rules:?}"); + for finding in &report.findings { + let title = rule_key(&finding.rule_id).unwrap_or_else(|| panic!("no title for {}", finding.rule_id)); + assert_ne!(tr(title), title, "{title} is not in the table"); + for condition in &finding.conditions { + let key = condition_key(&condition.id).unwrap_or_else(|| panic!("no words for {}", condition.id)); + assert_ne!(tr(key), key, "{key} is not in the table"); + } + } + for key in [Verdict::PotentialExposure, Verdict::InsufficientData, Verdict::NotIndicated].map(verdict_key) { + assert_ne!(tr(key), key); + } + for key in [Status::Met, Status::NotMet, Status::Unknown].map(status_key) { + assert_ne!(tr(key), key); + } + } + + #[test] + fn findings_are_matched_to_the_selected_node_and_a_new_result_drops_them() { + let report = exposure_report(); + let db = GraphNode { + id: 8, + labels: vec!["Instance".into()], + properties: Map::from_iter([("khonsu_id".to_string(), json!("db"))]), + }; + let found = findings_for(&report, &db); + assert!(!found.is_empty()); + assert!(found.iter().all(|finding| finding.resource == "node:db")); + assert!(findings_for(&report, &node(99, "elsewhere")).is_empty()); + + let mut explorer = Explorer { + exposure: Some(report), + exposure_error: Some("x".into()), + ..Explorer::default() + }; + explorer.forget_walks(); + assert!(explorer.exposure.is_none() && explorer.exposure_error.is_none(), "the graph may have changed"); + } + #[test] fn evidence_rows_say_not_supplied_for_what_is_missing() { let record = khonsu_core::evidence::for_node(&node(1, "by hand"), &[]); diff --git a/crates/khonsu-core/src/exposure.rs b/crates/khonsu-core/src/exposure.rs index 1a93577..c8334c4 100644 --- a/crates/khonsu-core/src/exposure.rs +++ b/crates/khonsu-core/src/exposure.rs @@ -14,7 +14,7 @@ use crate::engine::{Graph, QueryError}; use crate::evidence::{self, Evidence, Field, PathEvidence}; -use crate::explore::{node_name, Direction, Hop}; +use crate::explore::{node_name, Direction, Exploration, Hop}; use crate::result::{value_to_json, GraphCollector, GraphData, GraphEdge, GraphNode}; use crate::settings::Source; use serde::{Deserialize, Serialize}; @@ -153,6 +153,9 @@ pub struct Finding { /// The same path as hops, for a later rule to continue from. #[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, for a lateral step. #[serde(skip_serializing_if = "Option::is_none")] @@ -184,6 +187,20 @@ pub struct Report { pub findings: 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() + }) + } +} + 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) { @@ -256,6 +273,7 @@ pub fn evaluate(data: &GraphData, sources: &[Source], complete: bool) -> Report /// Stable lookups over the records, sorted so that every choice is repeatable. struct Index<'a> { nodes: BTreeMap, + edges: BTreeMap, data: &'a GraphData, node_ids: BTreeMap, edge_ids: BTreeMap, @@ -276,6 +294,7 @@ impl<'a> Index<'a> { out.values_mut().chain(into.values_mut()).for_each(|edges| edges.sort_by_cached_key(|edge| edge_id(edge))); Self { nodes, + edges: data.edges.iter().map(|edge| (edge.id, edge)).collect(), data, node_ids: data.nodes.iter().map(|node| (record_id(node), node)).collect(), edge_ids: data.edges.iter().map(|edge| (edge_id(edge), edge)).collect(), @@ -733,6 +752,7 @@ fn build(index: &Index, instance: &GraphNode, rule_id: &str, conditions: Vec = path.as_ref().map(|path| path.supporting.clone()).unwrap_or_default(); @@ -753,6 +773,7 @@ fn build(index: &Index, instance: &GraphNode, rule_id: &str, conditions: Vec 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| index.node(id).cloned()).collect(), + edges: edges.into_iter().filter_map(|id| index.edges.get(&id).map(|edge| (*edge).clone())).collect(), + } +} + fn record_for(index: &Index, id: &str, sources: &[Source]) -> Option { if let Some(node) = index.node_ids.get(id) { return Some(evidence::for_node(node, sources)); @@ -1102,6 +1132,22 @@ mod tests { assert_eq!(narrow.summary.potential_exposure, 2, "the summary still counts everything"); } + #[test] + fn a_finding_becomes_a_walk_that_draws_only_its_own_path() { + let report = with_database(exposed()).report(); + let walk = only(&report, RULE_LATERAL, "db").exploration().unwrap(); + assert_eq!(walk.starts.len(), 1); + assert_eq!(walk.hops.len(), 5); + assert!(walk.is_complete()); + let names: BTreeSet = walk.graph.nodes.iter().map(node_name).collect(); + assert_eq!(names, ["Internet", "db", "sg", "sg-db", "web"].map(String::from).into_iter().collect()); + assert_eq!(walk.graph.edges.len(), 4, "the hop back through a group crosses a relationship already drawn"); + + // Without a path there is nothing to draw. + let none = without_edge(exposed(), "PROTECTED_BY").report(); + assert!(only(&none, RULE_DIRECT, "web").exploration().is_none()); + } + #[test] fn secrets_on_a_contributing_record_stay_masked() { let report = set(exposed(), "web", "api_token", Some(json!("abc123"))).report(); diff --git a/docs/exposure-rules.md b/docs/exposure-rules.md index cc82b30..673f35f 100644 --- a/docs/exposure-rules.md +++ b/docs/exposure-rules.md @@ -155,3 +155,21 @@ khonsu exposure --db graphs/exposure --pretty --resource shop/123456789012:us-ea 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 **Check exposure** in the +inspector. 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**. + +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 `khonsu exposure`. + +In the Chinese interface, verdicts, rule titles and condition statements are +translated. Each condition's detail and the list of what was not checked stay in +English. From 331dab56457a8d1a2b81a197679ff16e6cd3ee92 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 03:12:51 +0900 Subject: [PATCH 05/13] Save investigations to a file and flag changes on reopening A line of inquiry was lost when the window closed, and nothing would have said whether the data underneath it had moved on. khonsu investigation save writes a walk's starts and filters, a chosen path, a query and a dashboard to a versioned file of its own, by stable IDs and with no source configuration. The graph isn't touched. khonsu investigation reopen runs the walk again and compares a fingerprint of the graph that leaves out internal IDs, sync times and plugin versions, so a re-sync that changes nothing stays unchanged and a rebuilt graph matches. It names records that have gone and doesn't substitute others. Refs #34 Co-Authored-By: Claude Opus 5.5 --- app/tests/investigation.rs | 195 ++++++++ crates/khonsu-cli/src/investigation.rs | 189 ++++++++ crates/khonsu-cli/src/lib.rs | 7 + crates/khonsu-core/src/investigation.rs | 589 ++++++++++++++++++++++++ crates/khonsu-core/src/lib.rs | 1 + docs/investigation-gates.md | 3 +- docs/investigations.md | 67 +++ examples/aws/golden.json | 398 ++++++++++++++++ skills/khonsugraph/SKILL.md | 2 + skills/khonsugraph/references/cli.md | 21 + 10 files changed, 1471 insertions(+), 1 deletion(-) create mode 100644 crates/khonsu-cli/src/investigation.rs create mode 100644 crates/khonsu-core/src/investigation.rs create mode 100644 docs/investigations.md diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index 01739ef..ff5f330 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -259,6 +259,9 @@ 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"); @@ -290,6 +293,13 @@ fn investigation(env: &Env) -> Value { ]), "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(&["exposure", "-d", text(&db)])), @@ -298,6 +308,15 @@ fn investigation(env: &Env) -> Value { })) } +/// 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(); @@ -671,6 +690,179 @@ fn exposure_is_the_same_across_syncs() { 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()); +} + #[test] fn two_syncs_of_the_same_input_agree_and_a_resync_changes_nothing() { let env = Env::new(); @@ -855,6 +1047,9 @@ fn graphs_written_by_earlier_hawdb_versions_keep_their_meaning() { 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(&["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}"); diff --git a/crates/khonsu-cli/src/investigation.rs b/crates/khonsu-cli/src/investigation.rs new file mode 100644 index 0000000..745b2ee --- /dev/null +++ b/crates/khonsu-cli/src/investigation.rs @@ -0,0 +1,189 @@ +//! `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, + /// 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 capture = Capture { + name, + query, + walk, + target, + 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, + "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 a940aca..e94a236 100644 --- a/crates/khonsu-cli/src/lib.rs +++ b/crates/khonsu-cli/src/lib.rs @@ -13,6 +13,7 @@ mod dash; mod evidence; mod explore; mod exposure; +mod investigation; use clap::{Args, CommandFactory, Parser, Subcommand}; use khonsu_core::plugin::{self, SyncEvent}; @@ -127,6 +128,11 @@ enum Command { Evidence(evidence::EvidenceArgs), /// AWS exposure findings: the rule, records and fields behind each, what is assumed and what is unknown. A finding is potential exposure from configuration, not proof of reachability. Exposure(exposure::ExposureArgs), + /// 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, + }, } #[derive(Subcommand)] @@ -380,6 +386,7 @@ fn execute(command: Command) -> Result { Command::Explore(args) => explore::explore(args), Command::Evidence(args) => evidence::evidence(args), Command::Exposure(args) => exposure::exposure(args), + Command::Investigation { command } => investigation::investigation(command), } } diff --git a/crates/khonsu-core/src/investigation.rs b/crates/khonsu-core/src/investigation.rs new file mode 100644 index 0000000..224cbee --- /dev/null +++ b/crates/khonsu-core/src/investigation.rs @@ -0,0 +1,589 @@ +//! 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, PathEvidence}; +use crate::explore::{Direction, Exploration, ExploreRequest}; +use crate::merge::{ID_PROPERTY, PLUGIN_PROPERTY, SOURCE_PROPERTY, SYNCED_AT_PROPERTY}; +use crate::result::{value_to_json, GraphCollector, GraphData, GraphNode}; +use crate::settings::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; +/// 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, +} + +/// 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, + #[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 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!("not a saved investigation: 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(VERSION) { + return Err(FileError { + kind: "too_new", + message: format!("it is version {version}, and this build reads up to version {VERSION}; update KhonsuGraph"), + }); + } + serde_json::from_value(value).map_err(|error| invalid(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 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 text = serde_json::to_string_pretty(self).expect("an investigation serializes") + "\n"; + 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, + 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, +} + +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 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))); + } + } + let data = collector.finish().unwrap_or_default(); + let (hash, sources) = content_hash(&data); + Ok(Fingerprint { + nodes, + relationships, + hash: Some(hash), + sources, + }) + } + + /// 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; + 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 = evidence::for_path(&found, target, sources).map(|record| record.supporting).unwrap_or_default(); + path = Some(SavedPath { target: target_ref, supporting }); + } + 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, + }); + } + Ok(Investigation { + format: FORMAT.into(), + version: VERSION, + name: capture.name, + graph: graph.to_path_buf(), + saved_at: unix_now(), + fingerprint: self.fingerprint()?, + 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, differences) = compare(&saved.fingerprint, &now); + + 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(); + + let mut walk = None; + if let Some(plan) = saved.walk.as_ref().filter(|_| !starts.is_empty()) { + 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 = 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 + }); + Ok(Reopened { + graph_state, + differences, + now, + missing_seeds, + target_missing, + unsure, + walk, + target, + path, + path_same, + }) + } + + 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)) + } + } + } +} + +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. +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()] }, + 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 dea1fd7..3228ca9 100644 --- a/crates/khonsu-core/src/lib.rs +++ b/crates/khonsu-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod engine; pub mod evidence; pub mod explore; pub mod exposure; +pub mod investigation; pub mod lock; pub mod merge; pub mod plugin; diff --git a/docs/investigation-gates.md b/docs/investigation-gates.md index f8be26e..1bde65a 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -22,7 +22,8 @@ cargo test --locked --workspace --test investigation | Cycles | A shortest path and a variable-length pattern both end on a cycle | | 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) | -| HawDB upgrades | `app/tests/fixtures/graph-hawdb-*.zip` must still give the golden answers and accept a write | +| 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) | +| 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. diff --git a/docs/investigations.md b/docs/investigations.md new file mode 100644 index 0000000..9578e25 --- /dev/null +++ b/docs/investigations.md @@ -0,0 +1,67 @@ +# 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 +``` + +## 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. + +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. It also has no record of what the source held when saved. +Comparing source snapshots is #37. + +## 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. + +## Limits + +- Saving and reopening need the graph free. They fail with kind `locked` while the + window has it open. +- The window doesn't save or reopen these files yet. diff --git a/examples/aws/golden.json b/examples/aws/golden.json index 1e15f16..1d2b3e2 100644 --- a/examples/aws/golden.json +++ b/examples/aws/golden.json @@ -1610,6 +1610,404 @@ } } }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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_snapshot": { + "status": "unknown" + }, + "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": [ [ diff --git a/skills/khonsugraph/SKILL.md b/skills/khonsugraph/SKILL.md index a02bb0b..250e2c6 100644 --- a/skills/khonsugraph/SKILL.md +++ b/skills/khonsugraph/SKILL.md @@ -50,6 +50,8 @@ Read `complete` first. When it is false, `limits` says which cap cut the walk, s On an AWS graph, `khonsu 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. Narrow it with `--resource ID` and `--verdict`, and pass a finding's `walk` to `khonsu explore`. 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 b7c6998..2140bad 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -98,6 +98,27 @@ khonsu exposure --db graphs/aws --verdict potential-exposure --pretty 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 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 | +| `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`. + ## 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). From 7e486f661212445407e05b03ee89cd3f6fdac110 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 03:25:23 +0900 Subject: [PATCH 06/13] Keep stale nodes from surviving a sync in the same second A full sync removes the nodes stamped before its own start, in whole seconds. Two syncs in one second shared a stamp, so a node gone from the source stayed in the graph while its relationships were removed, with nothing to say so. Each sync's stamp is now later than any its source has written. A stamp can run a second or so ahead of the clock after quick repeat syncs. An older test slept to avoid this, and no longer needs to. Refs #34 Co-Authored-By: Claude Opus 5.5 --- crates/khonsu-core/src/merge.rs | 14 ++++++++++++++ crates/khonsu-core/src/plugin.rs | 4 ++-- crates/khonsu-core/tests/merge.rs | 23 ++++++++++++++++++++++- crates/khonsu-core/tests/sync.rs | 2 -- 4 files changed, 38 insertions(+), 5 deletions(-) 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..63054cc 100644 --- a/crates/khonsu-core/src/plugin.rs +++ b/crates/khonsu-core/src/plugin.rs @@ -7,7 +7,7 @@ //! running executable (`plugins//` or a bare `khonsu-plugin-` binary). use crate::engine::{Graph, QueryError}; -use crate::merge::{EdgePattern, MergeStats, Merger}; +use crate::merge::{next_sync_stamp, EdgePattern, MergeStats, Merger}; use crate::settings::{self, LastSync, Source}; use crate::{secrets, util}; use khonsu_plugin_protocol::{ @@ -642,7 +642,7 @@ pub fn sync_source( } 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); 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..1f4260c 100644 --- a/crates/khonsu-core/tests/sync.rs +++ b/crates/khonsu-core/tests/sync.rs @@ -114,13 +114,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(); From cdf9b25c1acea933cddcd9d940c3124e3adc3779 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 03:53:20 +0900 Subject: [PATCH 07/13] Save and reopen investigations in the window Saved investigations could only be used from the command line, and only while the window didn't hold the graph. Path Explorer can now save the current filters, walk and chosen path, and File > Open investigation or khonsu open reopens one in a new tab, opening its graph first if needed. The query goes in the editor without running. Everything that differs from when it was saved shows as warnings on the canvas, since a lost path mustn't wait for someone to select a node. At startup the last graph is still opening, and a second open was dropped without a word, so reopening waits for it. Showing a finding's path now also sets the filters that find it, so saving from there means the same thing. Refs #34 Co-Authored-By: Claude Opus 5.5 --- app/src/app.rs | 10 +- app/src/db.rs | 16 +++ app/src/i18n.rs | 13 +++ app/src/ui/canvas.rs | 93 +++++++++++++++- app/src/ui/explorer.rs | 136 +++++++++++++++++++++++- app/src/ui/mod.rs | 88 +++++++++++++++ app/src/ui/results.rs | 28 +++++ app/src/ui/workspace.rs | 24 ++++- crates/khonsu-cli/src/dash.rs | 2 +- crates/khonsu-core/src/investigation.rs | 2 + docs/investigations.md | 23 +++- 11 files changed, 424 insertions(+), 11 deletions(-) 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 c9417ec..146014b 100644 --- a/app/src/db.rs +++ b/app/src/db.rs @@ -6,6 +6,7 @@ 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::{AccessMode, Graph, GraphData, GraphSchema, Language, OpenError, QueryError, QueryOptions, QueryResult}; use serde_json::Map; @@ -99,6 +100,21 @@ pub fn explore(request: &ExploreRequest, cancel: &AtomicBool) -> Result 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))) +} + /// Evaluates the exposure rules, holding the graph while it reads. pub fn exposure() -> Result { let sources = khonsu_core::settings::load_sources(); diff --git a/app/src/i18n.rs b/app/src/i18n.rs index 29140e2..250dc01 100644 --- a/app/src/i18n.rs +++ b/app/src/i18n.rs @@ -347,6 +347,19 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("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: {}"), ("exposure.title", "暴露分析", "Exposure"), ("exposure.check", "检查暴露", "Check exposure"), ("exposure.recheck", "重新检查", "Check again"), diff --git a/app/src/ui/canvas.rs b/app/src/ui/canvas.rs index ae4fa23..3e15f81 100644 --- a/app/src/ui/canvas.rs +++ b/app/src/ui/canvas.rs @@ -24,6 +24,7 @@ use gpui_kit::*; use khonsu_core::evidence::{self, Evidence}; use khonsu_core::explore::{Direction, Exploration}; use khonsu_core::exposure::{Status, Verdict}; +use khonsu_core::investigation::{Capture, Investigation, Reopened, SavedQuery, EXTENSION}; use khonsu_core::settings::{self, Source}; use khonsu_core::{GraphData, GraphEdge, GraphNode}; use serde_json::Value as Json; @@ -140,6 +141,8 @@ pub struct GraphCanvas { 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, @@ -183,6 +186,7 @@ impl GraphCanvas { explorer: Explorer::default(), sources: Vec::new(), path_view: false, + query: None, caption_key: None, group_key: None, label_mode: LabelMode::Auto, @@ -507,6 +511,77 @@ impl GraphCanvas { 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 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, + 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 let Some(target) = reopened.target.filter(|_| reopened.path.is_some()) { + self.show_path(target, cx); + } + cx.notify(); + } + /// Evaluate the exposure rules over the whole graph, not just the canvas. fn check_exposure(&mut self, window: &mut Window, cx: &mut Context) { if self.explorer.checking_exposure { @@ -533,12 +608,21 @@ impl GraphCanvas { /// Show a finding's candidate path, as a walk Path Explorer can go on from. fn show_finding(&mut self, finding_id: &str, target: i64, cx: &mut Context) { let report = self.explorer.exposure.as_ref(); - let Some(walk) = report.and_then(|report| report.findings.iter().find(|finding| finding.finding_id == finding_id)).and_then(|finding| finding.exploration()) else { + 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 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); + // 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)); @@ -864,6 +948,13 @@ impl GraphCanvas { .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") diff --git a/app/src/ui/explorer.rs b/app/src/ui/explorer.rs index 3eeea02..f400907 100644 --- a/app/src/ui/explorer.rs +++ b/app/src/ui/explorer.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use khonsu_core::evidence::{Assertion, Evidence, Field}; use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Limit, DEFAULT_MAX_HOPS, MAX_HOPS}; use khonsu_core::exposure::{Finding, Report, Status, Verdict, RULE_BALANCER, RULE_DIRECT, RULE_LATERAL}; +use khonsu_core::investigation::{GraphState, Investigation, RecordRef, Reopened}; use khonsu_core::util::format_unix_time; use khonsu_core::{GraphData, GraphEdge, GraphNode}; @@ -22,6 +23,62 @@ pub struct View { 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, +} + +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, + } + } + + /// 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))); + 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; @@ -43,6 +100,8 @@ pub struct Explorer { pub exposure: Option, pub checking_exposure: bool, pub exposure_error: Option, + /// The reopened investigation this walk came from. + pub restored: Option, history: Vec, } @@ -60,6 +119,7 @@ impl Default for Explorer { exposure: None, checking_exposure: false, exposure_error: None, + restored: None, history: Vec::new(), } } @@ -119,20 +179,25 @@ impl Explorer { self.error = None; self.exposure = None; self.exposure_error = None; + self.restored = None; self.starts.clear(); self.history.clear(); } - /// The first warning about the last walk, for the canvas to show when no - /// node is selected and the panel is out of sight. + /// 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 { - self.status_lines().into_iter().find(|(_, warn)| *warn).map(|(line, _)| line) + 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)); } @@ -389,6 +454,71 @@ mod tests { assert!(explorer.exposure.is_none() && explorer.exposure_error.is_none(), "the graph may have 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), + } + } + + #[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()); + } + #[test] fn evidence_rows_say_not_supplied_for_what_is_missing() { let record = khonsu_core::evidence::for_node(&node(1, "by hand"), &[]); diff --git a/app/src/ui/mod.rs b/app/src/ui/mod.rs index 44ca9f1..5413351 100644 --- a/app/src/ui/mod.rs +++ b/app/src/ui/mod.rs @@ -24,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; @@ -37,6 +38,7 @@ gpui_kit::actions!( ExplainQuery, SaveSpec, OpenDashboard, + OpenInvestigation, ZoomIn, ZoomOut, ZoomReset, @@ -159,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), @@ -225,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; @@ -246,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. @@ -267,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/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/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-core/src/investigation.rs b/crates/khonsu-core/src/investigation.rs index 224cbee..bdc9978 100644 --- a/crates/khonsu-core/src/investigation.rs +++ b/crates/khonsu-core/src/investigation.rs @@ -23,6 +23,8 @@ 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; diff --git a/docs/investigations.md b/docs/investigations.md index 9578e25..8ef1097 100644 --- a/docs/investigations.md +++ b/docs/investigations.md @@ -60,8 +60,25 @@ 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 -- Saving and reopening need the graph free. They fail with kind `locked` while the - window has it open. -- The window doesn't save or reopen these files yet. +- `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. From e590912af59cb406da491c552192d14139ecfc60 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 04:11:13 +0900 Subject: [PATCH 08/13] Record how complete each sync was, and remove only after a complete one A saved response that was missing read as an empty one, so a re-sync deleted what it used to describe and nothing said so. An investigation could then read an unfinished import as an all-clear. A plugin can now finish with complete: false and say what it didn't enumerate. The AWS plugin does so for a response that isn't saved. A full sync removes records and moves the cursor only when it was complete. Each source keeps its latest runs, with ID, times, status, plugin version and gaps. Evidence, exposure findings and reopened investigations state the coverage they rest on. A finding on an incomplete source can't be not_indicated, since what was missed could be what would have met a condition; what the source did report still stands. Values of a source's secrets are masked in what its plugin says. The shop fixture gains an explicitly empty route tables response, so its answers stay as they were. Refs #38 Co-Authored-By: Claude Opus 5.5 --- app/src/i18n.rs | 8 +- app/src/ui/explorer.rs | 19 +- app/src/ui/sidebar.rs | 2 + app/src/ui/sources.rs | 1 + app/tests/investigation.rs | 259 ++++++- crates/khonsu-cli/src/investigation.rs | 1 + crates/khonsu-cli/src/lib.rs | 1 + crates/khonsu-core/src/evidence.rs | 82 ++- crates/khonsu-core/src/exposure.rs | 121 ++- crates/khonsu-core/src/investigation.rs | 14 +- crates/khonsu-core/src/plugin.rs | 96 ++- crates/khonsu-core/src/rebuild.rs | 1 + crates/khonsu-core/src/settings.rs | 75 +- crates/khonsu-core/tests/sync.rs | 1 + .../examples/scripted.rs | 48 ++ crates/khonsu-plugin-protocol/src/lib.rs | 16 + docs/exposure-rules.md | 22 +- docs/investigation-gates.md | 16 +- docs/investigations.md | 2 + docs/plugins.md | 13 + examples/aws/golden.json | 694 ++++++++++++++++++ plugins/aws/src/main.rs | 62 +- .../us-east-1/ec2-describe-route-tables.json | 1 + plugins/csv/src/main.rs | 2 +- skills/khonsugraph/SKILL.md | 2 +- skills/khonsugraph/references/cli.md | 3 + 26 files changed, 1485 insertions(+), 77 deletions(-) create mode 100644 crates/khonsu-plugin-protocol/examples/scripted.rs create mode 100644 plugins/aws/tests/fixture/us-east-1/ec2-describe-route-tables.json diff --git a/app/src/i18n.rs b/app/src/i18n.rs index 250dc01..780ad9f 100644 --- a/app/src/i18n.rs +++ b/app/src/i18n.rs @@ -339,6 +339,11 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("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"), @@ -365,7 +370,7 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("exposure.recheck", "重新检查", "Check again"), ("exposure.claim", "根据配置推断的潜在暴露。未发送任何流量,因此不能说明资源可达;没有发现也不能说明资源安全。", "Potential exposure worked out from configuration. No traffic was sent, so this does not show that anything is reachable, and no finding does not show that a resource is safe."), ("exposure.failed", "检查失败:{}", "The check failed: {}"), - ("exposure.incomplete", "图太大,未能读完,所以缺少发现不说明任何问题。", "The graph was too large to read whole, so a missing finding means nothing."), + ("exposure.incomplete", "图太大未能读完,或有数据源最近一次同步不完整,所以缺少发现不说明任何问题。", "The graph was too large to read whole, or a source's last sync was not complete, so a missing finding means nothing."), ("exposure.none", "没有关于此实例的发现。如果刚同步过,请重新检查。", "No findings about this instance. If you just synced, check again."), ("exposure.verdict.potential", "潜在暴露", "Potential exposure"), ("exposure.verdict.insufficient", "数据不足", "Insufficient data"), @@ -461,6 +466,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/ui/explorer.rs b/app/src/ui/explorer.rs index f400907..689f1d4 100644 --- a/app/src/ui/explorer.rs +++ b/app/src/ui/explorer.rs @@ -5,7 +5,8 @@ use std::collections::BTreeSet; use std::sync::atomic::AtomicBool; use std::sync::Arc; -use khonsu_core::evidence::{Assertion, Evidence, Field}; +use khonsu_core::evidence::{Assertion, Evidence, Field, SourceCoverage}; +use khonsu_core::settings::RunStatus; use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Limit, DEFAULT_MAX_HOPS, MAX_HOPS}; use khonsu_core::exposure::{Finding, Report, Status, Verdict, RULE_BALANCER, RULE_DIRECT, RULE_LATERAL}; use khonsu_core::investigation::{GraphState, Investigation, RecordRef, Reopened}; @@ -35,6 +36,7 @@ pub struct Restored { pub target_missing: bool, pub unsure: Vec, pub path_same: Option, + pub incomplete_sources: Vec, } impl Restored { @@ -48,6 +50,7 @@ impl Restored { target_missing: reopened.target_missing, unsure: reopened.unsure.clone(), path_same: reopened.path_same, + incomplete_sources: reopened.incomplete_sources.clone(), } } @@ -63,6 +66,7 @@ impl Restored { 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)); } @@ -238,6 +242,17 @@ 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(); @@ -254,6 +269,7 @@ pub fn evidence_rows(record: &Evidence) -> Vec<(&'static str, String)> { ("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()), ] @@ -464,6 +480,7 @@ mod tests { target_missing: false, unsure: vec![], path_same: Some(true), + incomplete_sources: vec![], } } 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/tests/investigation.rs b/app/tests/investigation.rs index ff5f330..303e755 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -198,6 +198,11 @@ 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()); }); } @@ -208,7 +213,7 @@ 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" | "khonsu_plugin" | "plugin" | "file" | "line" | "record_written_at" | "source_last_successful_sync")) + .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(), ), @@ -632,32 +637,48 @@ fn a_finding_opens_in_path_explorer_and_its_records_resolve_as_evidence() { assert!(walked >= 3, "{report}"); } -/// A response that is missing reads as empty (#38), so the rules must not read -/// the records it would have given as absent from the account. +/// #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); - env.ok(&["plugin", "sync", "--source", "shop"]); - assert_eq!(finding(&env.ok(&["exposure", "-d", text(&db)]), DIRECT, &instance("i-web1"))["verdict"], "potential_exposure"); - - std::fs::remove_file(saved.join("us-east-1/ec2-describe-route-tables.json")).unwrap(); - env.ok(&["plugin", "sync", "--source", "shop"]); + let out = env.ok(&["plugin", "sync", "--source", "shop"]); + assert_eq!(out["run"]["status"], "partial", "{out}"); let report = env.ok(&["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(&["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(&["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"], "insufficient_data", "{web}"); - assert_eq!(condition_status(web, "open-inbound-rule"), "unknown"); - assert!(web["path"].is_null(), "{web}"); + 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 @@ -985,12 +1006,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); @@ -1000,10 +1019,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(&["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] @@ -1025,6 +1243,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, diff --git a/crates/khonsu-cli/src/investigation.rs b/crates/khonsu-cli/src/investigation.rs index 745b2ee..889280c 100644 --- a/crates/khonsu-cli/src/investigation.rs +++ b/crates/khonsu-cli/src/investigation.rs @@ -169,6 +169,7 @@ fn reopen(args: ReopenArgs) -> Result { "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, diff --git a/crates/khonsu-cli/src/lib.rs b/crates/khonsu-cli/src/lib.rs index e94a236..066c6fc 100644 --- a/crates/khonsu-cli/src/lib.rs +++ b/crates/khonsu-cli/src/lib.rs @@ -522,6 +522,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-core/src/evidence.rs b/crates/khonsu-core/src/evidence.rs index 66d5c81..ff122ed 100644 --- a/crates/khonsu-core/src/evidence.rs +++ b/crates/khonsu-core/src/evidence.rs @@ -8,7 +8,7 @@ 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::Source; +use crate::settings::{RunStatus, Source}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value as Json}; @@ -18,6 +18,28 @@ 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")] @@ -85,9 +107,10 @@ pub struct Evidence { /// 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 successfully, in Unix seconds. Unknown when - /// its last sync failed: a failure overwrites the earlier result. + /// 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, /// Which snapshot of the source this came from. Not recorded yet. pub source_snapshot: Field, pub assertion: Assertion, @@ -209,12 +232,14 @@ pub(crate) fn for_hops(hops: &[Hop], graph: &GraphData, sources: &[Source], deri /// The fields a node and a relationship share. fn provenance(properties: &Map, source: Option, sources: &[Source], own: Map, redacted: Vec) -> Evidence { - let last_ok = source - .as_deref() - .and_then(|id| sources.iter().find(|configured| configured.id == id)) - .and_then(|configured| configured.last_sync.as_ref()) - .filter(|sync| sync.ok) - .map(|sync| sync.at); + 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, @@ -228,6 +253,7 @@ fn provenance(properties: &Map, source: Option, sources: & 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_coverage: coverage, source_snapshot: Field::Unknown, rule_id: Field::Unknown, properties: own, @@ -386,6 +412,44 @@ mod tests { 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"); + + 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})); diff --git a/crates/khonsu-core/src/exposure.rs b/crates/khonsu-core/src/exposure.rs index c8334c4..389caf1 100644 --- a/crates/khonsu-core/src/exposure.rs +++ b/crates/khonsu-core/src/exposure.rs @@ -13,10 +13,11 @@ //! security groups or a subnet with no route table gives "unknown". use crate::engine::{Graph, QueryError}; -use crate::evidence::{self, Evidence, Field, PathEvidence}; +use crate::evidence::{self, Evidence, Field, PathEvidence, SourceCoverage}; use crate::explore::{node_name, Direction, Exploration, Hop}; use crate::result::{value_to_json, GraphCollector, GraphData, GraphEdge, GraphNode}; -use crate::settings::Source; +use crate::merge::SOURCE_PROPERTY; +use crate::settings::{RunStatus, Source}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as Json}; use std::collections::{BTreeMap, BTreeSet}; @@ -90,7 +91,8 @@ pub fn rules() -> Vec { pub enum Verdict { /// Every condition is met by configuration. PotentialExposure, - /// None ruled it out, and at least one could not be evaluated. + /// None ruled it out and at least one could not be evaluated, or a source + /// behind its records was not read completely. InsufficientData, /// A condition is not met, so this rule does not point at it. NotIndicated, @@ -160,6 +162,9 @@ pub struct Finding { /// The finding this one rests on, for a lateral step. #[serde(skip_serializing_if = "Option::is_none")] pub depends_on: Option, + /// Sources behind its records whose last sync was not complete. + #[serde(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, @@ -181,9 +186,11 @@ pub struct Report { pub instances: usize, /// Counts every finding, including any cut by a cap. pub summary: Summary, - /// False when the graph was too large to read whole: a missing finding - /// then means nothing. + /// 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, } @@ -215,7 +222,15 @@ impl Graph { /// Reads what the rules need and evaluates them. pub fn exposure(&mut self, sources: &[Source]) -> Result { let (data, complete) = self.read_exposure_records()?; - Ok(evaluate(&data, sources, complete)) + let mut report = evaluate(&data, sources, complete); + // A source with nothing in the graph may be why: its sync can have + // failed before writing anything. + let here = crate::engine::normalize_path(self.path()); + for source in sources.iter().filter(|source| crate::engine::normalize_path(&source.graph) == here) { + report.sources.entry(source.id.clone()).or_insert_with(|| evidence::coverage_of(&source.id, sources)); + } + report.complete &= report.sources.values().all(|coverage| !is_incomplete(coverage)); + Ok(report) } fn read_exposure_records(&mut self) -> Result<(GraphData, bool), QueryError> { @@ -255,6 +270,15 @@ pub fn evaluate(data: &GraphData, sources: &[Source], complete: bool) -> Report findings.sort_by(|a, b| (a.verdict, &a.rule_id, &a.resource, &a.finding_id).cmp(&(b.verdict, &b.rule_id, &b.resource, &b.finding_id))); let count = |verdict: Verdict| findings.iter().filter(|finding| finding.verdict == verdict).count(); + let named: BTreeSet<&str> = data + .nodes + .iter() + .map(|node| &node.properties) + .chain(data.edges.iter().map(|edge| &edge.properties)) + .filter_map(|properties| properties.get(SOURCE_PROPERTY).and_then(Json::as_str)) + .collect(); + let coverage: BTreeMap> = named.into_iter().map(|source| (source.to_string(), evidence::coverage_of(source, sources))).collect(); + let complete = complete && coverage.values().all(|coverage| !is_incomplete(coverage)); Report { claim: CLAIM.into(), order: ORDER.into(), @@ -266,10 +290,17 @@ pub fn evaluate(data: &GraphData, sources: &[Source], complete: bool) -> Report not_indicated: count(Verdict::NotIndicated), }, complete, + sources: coverage, findings, } } +/// 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) +} + /// Stable lookups over the records, sorted so that every choice is repeatable. struct Index<'a> { nodes: BTreeMap, @@ -746,8 +777,8 @@ fn walk_hint(index: &Index, hops: &[Hop], instance: &GraphNode) -> Option, hops: Vec, records: Vec, subject: Option, sources: &[Source]) -> Finding { let resource = record_id(instance); - let verdict = verdict_of(&conditions); - let unknowns = conditions.iter().filter(|condition| condition.status == Status::Unknown).map(|condition| condition.statement.clone()).collect(); + let mut verdict = verdict_of(&conditions); + let mut unknowns: Vec = conditions.iter().filter(|condition| condition.status == Status::Unknown).map(|condition| condition.statement.clone()).collect(); let path = (!hops.is_empty()).then(|| { evidence::for_hops(&hops, index.data, sources, "the rule's candidate path", Field::Known(rule_id.to_string())) }); @@ -758,7 +789,22 @@ fn build(index: &Index, instance: &GraphNode, rule_id: &str, conditions: Vec = path.as_ref().map(|path| path.supporting.clone()).unwrap_or_default(); let rest: BTreeSet = records.into_iter().filter(|id| !contributing.contains(id)).collect(); contributing.extend(rest); - let evidence = contributing.iter().filter_map(|id| record_for(index, id, sources)).collect(); + let evidence: Vec = contributing.iter().filter_map(|id| record_for(index, id, sources)).collect(); + + // 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 behind: BTreeSet<&str> = evidence.iter().filter_map(|record| record.source.known().map(String::as_str)).collect(); + let mut incomplete_sources = Vec::new(); + for source in behind { + let coverage = evidence::coverage_of(source, sources); + if let (true, Some(coverage)) = (is_incomplete(&coverage), coverage.known()) { + incomplete_sources.push(source.to_string()); + unknowns.push(format!("Source {source}'s last sync ({}) was {}, so records may be missing or out of date.", coverage.run_id, coverage.status.word())); + } + } + if !incomplete_sources.is_empty() && verdict == Verdict::NotIndicated { + verdict = Verdict::InsufficientData; + } let id_text = [Some(rule_id.to_string()), Some(resource.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(); @@ -776,6 +822,7 @@ fn build(index: &Index, instance: &GraphNode, rule_id: &str, conditions: Vec Builder { + for node in &mut builder.data.nodes { + node.properties.insert("khonsu_source".into(), json!("shop")); + } + for edge in &mut builder.data.edges { + edge.properties.insert("khonsu_source".into(), json!("shop")); + } + builder + } + + fn source_with(status: RunStatus) -> Source { + serde_json::from_value(json!({ + "id": "shop", "name": "shop", "plugin": "aws", "graph": "g", + "runs": [{ + "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, + }], + })) + .unwrap() + } + + #[test] + fn a_partial_source_cannot_clear_anything() { + // No public address, so the rule would not point here on complete data. + let builder = tagged(set(exposed(), "web", "public_ip", None)); + let whole = evaluate(&builder.data, &[source_with(RunStatus::Complete)], true); + assert_eq!(only(&whole, RULE_DIRECT, "web").verdict, Verdict::NotIndicated); + assert!(whole.complete); + + for status in [RunStatus::Partial, RunStatus::Failed, RunStatus::Cancelled] { + let report = evaluate(&builder.data, &[source_with(status)], true); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(finding.verdict, Verdict::InsufficientData, "{status:?}"); + assert_eq!(finding.incomplete_sources, ["shop"]); + assert!(finding.unknowns.iter().any(|text| text.contains("shop#1") && text.contains(status.word())), "{:?}", finding.unknowns); + assert!(!report.complete, "{status:?}"); + } + } + + #[test] + fn what_a_partial_source_did_report_still_counts() { + let report = evaluate(&tagged(exposed()).data, &[source_with(RunStatus::Partial)], true); + let finding = only(&report, RULE_DIRECT, "web"); + assert_eq!(finding.verdict, Verdict::PotentialExposure); + assert_eq!(finding.incomplete_sources, ["shop"], "and it says what that rests on"); + } + + #[test] + fn a_source_with_no_record_of_its_runs_is_not_called_incomplete() { + let report = evaluate(&tagged(set(exposed(), "web", "public_ip", None)).data, &[], true); + assert_eq!(only(&report, RULE_DIRECT, "web").verdict, Verdict::NotIndicated); + assert!(report.complete); + assert_eq!(report.sources["shop"], Field::Unknown); + } + #[test] fn a_finding_becomes_a_walk_that_draws_only_its_own_path() { let report = with_database(exposed()).report(); diff --git a/crates/khonsu-core/src/investigation.rs b/crates/khonsu-core/src/investigation.rs index bdc9978..db33964 100644 --- a/crates/khonsu-core/src/investigation.rs +++ b/crates/khonsu-core/src/investigation.rs @@ -12,7 +12,7 @@ use crate::evidence::{self, PathEvidence}; use crate::explore::{Direction, Exploration, ExploreRequest}; use crate::merge::{ID_PROPERTY, PLUGIN_PROPERTY, SOURCE_PROPERTY, SYNCED_AT_PROPERTY}; use crate::result::{value_to_json, GraphCollector, GraphData, GraphNode}; -use crate::settings::Source; +use crate::settings::{RunStatus, Source}; use crate::util::unix_now; use hawdb::Value; use serde::{Deserialize, Serialize}; @@ -195,6 +195,8 @@ pub struct Reopened { 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, } impl Graph { @@ -320,7 +322,17 @@ impl Graph { 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, diff --git a/crates/khonsu-core/src/plugin.rs b/crates/khonsu-core/src/plugin.rs index 63054cc..2264fe0 100644 --- a/crates/khonsu-core/src/plugin.rs +++ b/crates/khonsu-core/src/plugin.rs @@ -8,7 +8,7 @@ use crate::engine::{Graph, QueryError}; use crate::merge::{next_sync_stamp, EdgePattern, MergeStats, Merger}; -use crate::settings::{self, LastSync, Source}; +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,8 +530,10 @@ 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 } => { @@ -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. @@ -646,7 +660,16 @@ pub fn sync_source( 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 +682,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 +722,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 +736,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 +757,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/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/tests/sync.rs b/crates/khonsu-core/tests/sync.rs index 1f4260c..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(); diff --git a/crates/khonsu-plugin-protocol/examples/scripted.rs b/crates/khonsu-plugin-protocol/examples/scripted.rs new file mode 100644 index 0000000..84c4b54 --- /dev/null +++ b/crates/khonsu-plugin-protocol/examples/scripted.rs @@ -0,0 +1,48 @@ +//! 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, + }; + 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..9b3c4bf 100644 --- a/crates/khonsu-plugin-protocol/src/lib.rs +++ b/crates/khonsu-plugin-protocol/src/lib.rs @@ -181,10 +181,26 @@ 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 }, } +fn default_complete() -> bool { + true +} + +fn is_true(value: &bool) -> bool { + *value +} + /// Helper for plugin authors: writes messages as JSON lines to stdout. pub struct Emitter { out: W, diff --git a/docs/exposure-rules.md b/docs/exposure-rules.md index 673f35f..ff26117 100644 --- a/docs/exposure-rules.md +++ b/docs/exposure-rules.md @@ -16,8 +16,8 @@ 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 | `unknowns` says what could not be read; get that data | -| `not_indicated` | At least one is unmet | This rule does not point here. Another route may exist | +| `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. @@ -75,16 +75,24 @@ 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 saved response that is missing reads as empty (#38), and a real sync can't tell -either. So 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 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 false when the graph held more than 50,000 records of one type. A +`complete` is also false when the graph held more than 50,000 records of one type. A missing finding then means nothing. ## Walkthrough diff --git a/docs/investigation-gates.md b/docs/investigation-gates.md index 1bde65a..a843455 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -23,6 +23,7 @@ cargo test --locked --workspace --test investigation | 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) | | 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 | | 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 @@ -52,9 +53,14 @@ 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`. @@ -66,8 +72,8 @@ These are written down so that nobody reads an empty result as proof: - **`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 own, and is unknown once its last - sync failed. + `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. diff --git a/docs/investigations.md b/docs/investigations.md index 8ef1097..19625dd 100644 --- a/docs/investigations.md +++ b/docs/investigations.md @@ -31,6 +31,8 @@ A changed answer doesn't replace the saved one silently: 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. diff --git a/docs/plugins.md b/docs/plugins.md index 6f45803..a93a78f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -127,6 +127,19 @@ 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. + ## Stable IDs and merging Every node and relationship carries an `id` that must stay the same from one diff --git a/examples/aws/golden.json b/examples/aws/golden.json index 1d2b3e2..2e31777 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": { @@ -1059,6 +1079,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1101,6 +1128,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1352,6 +1386,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1384,6 +1425,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1405,6 +1453,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1439,6 +1494,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1460,6 +1522,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1493,6 +1562,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1516,6 +1592,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1822,6 +1905,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1854,6 +1944,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1875,6 +1972,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1909,6 +2013,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1930,6 +2041,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1963,6 +2081,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -1986,6 +2111,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#2", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2074,6 +2206,15 @@ "not_indicated": 3 }, "complete": true, + "sources": { + "shop": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + } + }, "findings": [ { "finding_id": "finding:f2dfac00f69ee196", @@ -2244,6 +2385,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2276,6 +2424,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2301,6 +2456,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2334,6 +2496,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2355,6 +2524,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2408,6 +2584,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2440,6 +2623,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2465,6 +2655,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2498,6 +2695,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2519,6 +2723,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2549,6 +2760,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2575,6 +2793,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2610,6 +2835,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2790,6 +3022,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2822,6 +3061,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2843,6 +3089,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2877,6 +3130,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2898,6 +3158,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2931,6 +3198,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -2954,6 +3228,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3008,6 +3289,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3040,6 +3328,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3061,6 +3356,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3095,6 +3397,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3116,6 +3425,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3149,6 +3465,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3172,6 +3495,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3209,6 +3539,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3242,6 +3579,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3271,6 +3615,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3392,6 +3743,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3424,6 +3782,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3449,6 +3814,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3482,6 +3854,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3503,6 +3882,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3541,6 +3927,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3566,6 +3959,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3599,6 +3999,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3620,6 +4027,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3674,6 +4088,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3706,6 +4127,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3731,6 +4159,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3764,6 +4199,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3785,6 +4227,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3823,6 +4272,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3848,6 +4304,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3881,6 +4344,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -3902,6 +4372,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4056,6 +4533,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4088,6 +4572,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4113,6 +4604,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4146,6 +4644,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4167,6 +4672,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4218,6 +4730,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4250,6 +4769,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4275,6 +4801,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4308,6 +4841,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4329,6 +4869,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4359,6 +4906,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4494,6 +5048,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4523,6 +5084,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4549,6 +5117,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4584,6 +5159,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4745,6 +5327,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4777,6 +5366,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4802,6 +5398,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4835,6 +5438,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4856,6 +5466,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4907,6 +5524,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4939,6 +5563,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4964,6 +5595,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -4997,6 +5635,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -5018,6 +5663,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -5047,6 +5699,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -5073,6 +5732,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -5208,6 +5874,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -5237,6 +5910,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -5263,6 +5943,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, @@ -5298,6 +5985,13 @@ "status": "known", "value": "shop" }, + "source_coverage": { + "status": "known", + "value": { + "run_id": "shop#1", + "status": "complete" + } + }, "source_snapshot": { "status": "unknown" }, diff --git a/plugins/aws/src/main.rs b/plugins/aws/src/main.rs index a69a434..9980a3d 100644 --- a/plugins/aws/src/main.rs +++ b/plugins/aws/src/main.rs @@ -22,8 +22,11 @@ //! 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. +use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::io::Write; use std::path::PathBuf; @@ -84,7 +87,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 { @@ -99,7 +106,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 [ @@ -118,10 +125,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), @@ -129,8 +144,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())), } } @@ -468,7 +487,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") { @@ -517,7 +536,8 @@ 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")), } @@ -643,6 +663,32 @@ mod tests { 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/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..af5ded8 100644 --- a/plugins/csv/src/main.rs +++ b/plugins/csv/src/main.rs @@ -477,7 +477,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 250e2c6..e301b47 100644 --- a/skills/khonsugraph/SKILL.md +++ b/skills/khonsugraph/SKILL.md @@ -48,7 +48,7 @@ Read `complete` first. When it is false, `limits` says which cap cut the walk, s `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. -On an AWS graph, `khonsu 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. Narrow it with `--resource ID` and `--verdict`, and pass a finding's `walk` to `khonsu explore`. See `references/cli.md`. +On an AWS graph, `khonsu 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 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`. diff --git a/skills/khonsugraph/references/cli.md b/skills/khonsugraph/references/cli.md index 2140bad..537fc82 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -72,6 +72,7 @@ khonsu evidence --db graphs/aws --node shop/123456789012:us-east-1:i-web1 | `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` | Unknown for now | +| `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 an exposure finding's `path` | | `assertion` | `explicit` (a source reported it), `derived`, or `unknown` | | `properties`, `redacted` | Its own properties, and the names masked as possible secrets | @@ -94,6 +95,7 @@ khonsu exposure --db graphs/aws --verdict potential-exposure --pretty | `path` | The candidate path as a derived evidence record, or null when its records do not exist | | `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. @@ -113,6 +115,7 @@ khonsu investigation reopen web.investigation | `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 | From 32c1c565cea95c6ae9b246aad08e1e6c7ce76553 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 04:23:53 +0900 Subject: [PATCH 09/13] Add snapshots and a diff that only confirms what it can An investigator couldn't ask what changed, because a graph only holds its latest state. khonsu snapshot save keeps a graph's records in a file by stable IDs, and khonsu snapshot diff compares two. A record gone from the later snapshot counts as removed only when its source's last sync was complete there. Otherwise it's listed as not seen, since it may still exist. A saved investigation pins the source runs it was evaluated against, and names them when reopened on a graph that has changed. Evidence's source_snapshot now gives the last complete run. Refs #37 Co-Authored-By: Claude Opus 5.5 --- app/tests/fixtures/snapshot-v1-after.json | 436 ++++++++++++++ app/tests/fixtures/snapshot-v1-before.json | 446 +++++++++++++++ app/tests/investigation.rs | 182 +++++- crates/khonsu-cli/src/lib.rs | 7 + crates/khonsu-cli/src/snapshot.rs | 89 +++ crates/khonsu-core/src/evidence.rs | 12 +- crates/khonsu-core/src/investigation.rs | 124 ++-- crates/khonsu-core/src/lib.rs | 1 + crates/khonsu-core/src/snapshot.rs | 626 +++++++++++++++++++++ docs/investigation-gates.md | 2 +- docs/investigations.md | 7 +- docs/snapshots.md | 70 +++ examples/aws/golden.json | 285 ++++++---- skills/khonsugraph/SKILL.md | 2 + skills/khonsugraph/references/cli.md | 12 +- 15 files changed, 2155 insertions(+), 146 deletions(-) create mode 100644 app/tests/fixtures/snapshot-v1-after.json create mode 100644 app/tests/fixtures/snapshot-v1-before.json create mode 100644 crates/khonsu-cli/src/snapshot.rs create mode 100644 crates/khonsu-core/src/snapshot.rs create mode 100644 docs/snapshots.md 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 303e755..5546964 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -453,7 +453,7 @@ fn evidence_is_machine_readable_and_states_what_is_unknown() { 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": "unknown"})); + 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"); @@ -884,6 +884,186 @@ fn a_file_it_cannot_use_and_a_bad_request_fail_with_a_reason() { 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(); diff --git a/crates/khonsu-cli/src/lib.rs b/crates/khonsu-cli/src/lib.rs index 066c6fc..67e911d 100644 --- a/crates/khonsu-cli/src/lib.rs +++ b/crates/khonsu-cli/src/lib.rs @@ -14,6 +14,7 @@ mod evidence; mod explore; mod exposure; mod investigation; +mod snapshot; use clap::{Args, CommandFactory, Parser, Subcommand}; use khonsu_core::plugin::{self, SyncEvent}; @@ -133,6 +134,11 @@ enum Command { #[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)] @@ -387,6 +393,7 @@ fn execute(command: Command) -> Result { Command::Evidence(args) => evidence::evidence(args), Command::Exposure(args) => exposure::exposure(args), Command::Investigation { command } => investigation::investigation(command), + Command::Snapshot { command } => snapshot::snapshot(command), } } 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 index ff122ed..5f077e1 100644 --- a/crates/khonsu-core/src/evidence.rs +++ b/crates/khonsu-core/src/evidence.rs @@ -111,7 +111,8 @@ pub struct Evidence { pub source_last_successful_sync: Field, /// How much of the source its last sync covered. pub source_coverage: Field, - /// Which snapshot of the source this came from. Not recorded yet. + /// 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. @@ -253,8 +254,8 @@ fn provenance(properties: &Map, source: Option, sources: & 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, - source_snapshot: Field::Unknown, rule_id: Field::Unknown, properties: own, redacted, @@ -267,7 +268,7 @@ fn text(properties: &Map, key: &str) -> Option { /// `properties` without KhonsuGraph's bookkeeping, and with secrets masked, /// along with the names of those masked. -fn own_properties(properties: &Map) -> (Map, Vec) { +pub(crate) fn own_properties(properties: &Map) -> (Map, Vec) { let mut redacted = Vec::new(); let own = properties .iter() @@ -440,6 +441,11 @@ mod tests { 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)); diff --git a/crates/khonsu-core/src/investigation.rs b/crates/khonsu-core/src/investigation.rs index db33964..574b3cd 100644 --- a/crates/khonsu-core/src/investigation.rs +++ b/crates/khonsu-core/src/investigation.rs @@ -8,7 +8,7 @@ //! `docs/investigations.md`. use crate::engine::{Graph, QueryError}; -use crate::evidence::{self, PathEvidence}; +use crate::evidence::{self, Field, PathEvidence, SourceCoverage}; use crate::explore::{Direction, Exploration, ExploreRequest}; use crate::merge::{ID_PROPERTY, PLUGIN_PROPERTY, SOURCE_PROPERTY, SYNCED_AT_PROPERTY}; use crate::result::{value_to_json, GraphCollector, GraphData, GraphNode}; @@ -81,6 +81,9 @@ pub struct Investigation { 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)] @@ -115,44 +118,59 @@ impl Investigation { } pub fn parse(text: &str) -> 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!("not a saved investigation: 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(VERSION) { - return Err(FileError { - kind: "too_new", - message: format!("it is version {version}, and this build reads up to version {VERSION}; update KhonsuGraph"), - }); - } - serde_json::from_value(value).map_err(|error| invalid(format!("its contents are not usable: {error}"))) + 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 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 text = serde_json::to_string_pretty(self).expect("an investigation serializes") + "\n"; - 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) - }) + 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. @@ -214,13 +232,7 @@ impl Graph { sources: Vec::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))); - } - } - let data = collector.finish().unwrap_or_default(); + let data = self.read_all_records()?; let (hash, sources) = content_hash(&data); Ok(Fingerprint { nodes, @@ -230,6 +242,18 @@ impl Graph { }) } + /// 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); @@ -259,13 +283,16 @@ impl Graph { 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: self.fingerprint()?, + fingerprint, + pinned_runs, query: capture.query, walk, path, @@ -277,7 +304,17 @@ impl Graph { /// 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, differences) = compare(&saved.fingerprint, &now); + 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(); @@ -374,7 +411,7 @@ impl Graph { } } -fn compare(saved: &Fingerprint, now: &Fingerprint) -> (GraphState, Vec) { +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)); @@ -404,7 +441,7 @@ fn compare(saved: &Fingerprint, now: &Fingerprint) -> (GraphState, Vec) /// 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. -fn content_hash(data: &GraphData) -> (String, Vec) { +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()); @@ -467,6 +504,7 @@ mod tests { 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, diff --git a/crates/khonsu-core/src/lib.rs b/crates/khonsu-core/src/lib.rs index 3228ca9..718ade8 100644 --- a/crates/khonsu-core/src/lib.rs +++ b/crates/khonsu-core/src/lib.rs @@ -17,6 +17,7 @@ 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/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/docs/investigation-gates.md b/docs/investigation-gates.md index a843455..4577a27 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -24,6 +24,7 @@ cargo test --locked --workspace --test investigation | 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) | | 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 @@ -80,7 +81,6 @@ These are written down so that nobody reads an empty result as proof: - **An exposure finding is not reachability.** It says what configuration allows. [The rules](exposure-rules.md) list what they do not read. -Source snapshots are always unknown until #37. ## Baselines diff --git a/docs/investigations.md b/docs/investigations.md index 19625dd..0276cfe 100644 --- a/docs/investigations.md +++ b/docs/investigations.md @@ -45,8 +45,11 @@ matches. It covers the whole graph, not just the records you saved, so an unrela change also reads as `changed`. The hash is for noticing change, not for security: someone who wants to can -produce a matching one. It also has no record of what the source held when saved. -Comparing source snapshots is #37. +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 diff --git a/docs/snapshots.md b/docs/snapshots.md new file mode 100644 index 0000000..f3382ae --- /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 `khonsu exposure` on the graph, 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/aws/golden.json b/examples/aws/golden.json index 2e31777..550d664 100644 --- a/examples/aws/golden.json +++ b/examples/aws/golden.json @@ -1087,7 +1087,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1136,7 +1137,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1394,7 +1396,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1433,7 +1436,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1461,7 +1465,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1502,7 +1507,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1530,7 +1536,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1570,7 +1577,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1600,7 +1608,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1913,7 +1922,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1952,7 +1962,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -1980,7 +1991,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -2021,7 +2033,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -2049,7 +2062,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -2089,7 +2103,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -2119,7 +2134,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#2" }, "assertion": "explicit", "rule_id": { @@ -2393,7 +2409,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2432,7 +2449,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2464,7 +2482,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2504,7 +2523,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2532,7 +2552,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2592,7 +2613,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2631,7 +2653,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2663,7 +2686,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2703,7 +2727,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2731,7 +2756,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2768,7 +2794,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2801,7 +2828,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -2843,7 +2871,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3030,7 +3059,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3069,7 +3099,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3097,7 +3128,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3138,7 +3170,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3166,7 +3199,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3206,7 +3240,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3236,7 +3271,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3297,7 +3333,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3336,7 +3373,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3364,7 +3402,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3405,7 +3444,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3433,7 +3473,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3473,7 +3514,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3503,7 +3545,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3547,7 +3590,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3587,7 +3631,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3623,7 +3668,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3751,7 +3797,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3790,7 +3837,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3822,7 +3870,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3862,7 +3911,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3890,7 +3940,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3935,7 +3986,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -3967,7 +4019,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4007,7 +4060,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4035,7 +4089,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4096,7 +4151,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4135,7 +4191,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4167,7 +4224,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4207,7 +4265,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4235,7 +4294,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4280,7 +4340,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4312,7 +4373,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4352,7 +4414,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4380,7 +4443,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4541,7 +4605,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4580,7 +4645,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4612,7 +4678,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4652,7 +4719,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4680,7 +4748,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4738,7 +4807,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4777,7 +4847,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4809,7 +4880,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4849,7 +4921,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4877,7 +4950,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -4914,7 +4988,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5056,7 +5131,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5092,7 +5168,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5125,7 +5202,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5167,7 +5245,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5335,7 +5414,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5374,7 +5454,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5406,7 +5487,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5446,7 +5528,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5474,7 +5557,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5532,7 +5616,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5571,7 +5656,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5603,7 +5689,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5643,7 +5730,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5671,7 +5759,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5707,7 +5796,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5740,7 +5830,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5882,7 +5973,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5918,7 +6010,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5951,7 +6044,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { @@ -5993,7 +6087,8 @@ } }, "source_snapshot": { - "status": "unknown" + "status": "known", + "value": "shop#1" }, "assertion": "explicit", "rule_id": { diff --git a/skills/khonsugraph/SKILL.md b/skills/khonsugraph/SKILL.md index e301b47..527169b 100644 --- a/skills/khonsugraph/SKILL.md +++ b/skills/khonsugraph/SKILL.md @@ -50,6 +50,8 @@ Read `complete` first. When it is false, `limits` says which cap cut the walk, s On an AWS graph, `khonsu 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 diff --git a/skills/khonsugraph/references/cli.md b/skills/khonsugraph/references/cli.md index 537fc82..68910cb 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -71,7 +71,7 @@ khonsu evidence --db graphs/aws --node shop/123456789012:us-east-1:i-web1 | `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` | Unknown for now | +| `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 an exposure finding's `path` | | `assertion` | `explicit` (a source reported it), `derived`, or `unknown` | @@ -122,6 +122,16 @@ khonsu investigation reopen web.investigation 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). From 1137df953fc72baea0aed3e4e698098bd7318ff9 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 20:50:07 +0900 Subject: [PATCH 10/13] Move the AWS exposure rules into the AWS plugin KhonsuGraph is a general graph workbench, but its core held AWS semantics: EC2, security group, load balancer and route table rules, and a general `khonsu exposure` command for them. Every new provider would have added more. Plugins can now offer analyses, an optional part of the protocol. The host reads a bounded part of the graph and sends it with stable references to each record. The plugin answers with findings that point at those records. The host checks every reference, looks up field values itself, and rebuilds each path from the graph, so a plugin can't misquote a record or claim a connection that isn't there. A bad answer is refused whole, since dropping a finding would read as that resource having none. Nothing in the host names a provider's labels, rules or verdicts. The three rules, their field mappings, assumptions and verdict policy are now in plugins/aws/src/exposure.rs, run with `khonsu plugin analyze aws exposure`. Finding IDs, rule IDs, verdicts, order and condition text are unchanged. In the output, `aws_field` is now `source_field`, `instances` is now `subjects`, and a relationship's end reads as the field `@to`. Three false readings found in review are fixed, with regression tests: - Missing balancer group data read as "the target's rules don't allow it", clearing the finding. It's now unknown. - A TCP_UDP target group was treated as TCP only, so a UDP rule didn't count. - The target's security group membership wasn't in the rule's evidence. An example analyzer with nothing to do with AWS shows the interface, and tests how the host handles an answer that misbehaves. Refs #55, #35 Co-Authored-By: Claude Opus 5.5 --- app/tests/investigation.rs | 167 ++- crates/khonsu-cli/src/exposure.rs | 62 - crates/khonsu-cli/src/lib.rs | 8 +- crates/khonsu-cli/src/plugin_analysis.rs | 59 + crates/khonsu-core/src/evidence.rs | 7 +- crates/khonsu-core/src/explore.rs | 2 +- crates/khonsu-core/src/exposure.rs | 1112 ----------------- crates/khonsu-core/src/lib.rs | 2 +- crates/khonsu-core/src/plugin.rs | 102 +- crates/khonsu-core/src/plugin_analysis.rs | 677 ++++++++++ .../examples/analyzer.rs | 118 ++ crates/khonsu-plugin-protocol/src/lib.rs | 261 ++++ docs/exposure-rules.md | 29 +- docs/investigation-gates.md | 1 + docs/plugins.md | 91 ++ examples/aws/exposure.dash | 2 +- examples/aws/golden.json | 256 +++- plugins/aws/src/exposure.rs | 1007 +++++++++++++++ plugins/aws/src/main.rs | 20 +- plugins/csv/src/main.rs | 1 + skills/khonsugraph/SKILL.md | 2 +- skills/khonsugraph/references/cli.md | 16 +- 22 files changed, 2715 insertions(+), 1287 deletions(-) delete mode 100644 crates/khonsu-cli/src/exposure.rs create mode 100644 crates/khonsu-cli/src/plugin_analysis.rs delete mode 100644 crates/khonsu-core/src/exposure.rs create mode 100644 crates/khonsu-core/src/plugin_analysis.rs create mode 100644 crates/khonsu-plugin-protocol/examples/analyzer.rs create mode 100644 plugins/aws/src/exposure.rs diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index 01739ef..06cd8f8 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -264,7 +264,7 @@ fn investigation(env: &Env) -> Value { let graph = other.path("graph"); other.add_source(&graph, &exposure_fixture()); other.ok(&["plugin", "sync", "--source", "shop"]); - other.ok(&["exposure", "-d", text(&graph)]) + other.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&graph)]) }; normalize(&json!({ @@ -292,7 +292,7 @@ fn investigation(env: &Env) -> Value { }, "exposure": { // Saved responses with no route tables, as a graph from before they were read. - "shop_fixture": exposure_brief(&env.ok(&["exposure", "-d", text(&db)])), + "shop_fixture": exposure_brief(&env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)])), "exposure_fixture": exposure_report, }, })) @@ -508,10 +508,10 @@ fn the_exposure_fixture_has_a_positive_a_negative_and_an_undecided_case() { let db = env.path("graph"); env.add_source(&db, &exposure_fixture()); env.ok(&["plugin", "sync", "--source", "shop"]); - let report = env.ok(&["exposure", "-d", text(&db)]); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); assert_eq!(report["complete"], true); assert_eq!(report["truncated"], 0); - assert_eq!(report["instances"], 5); + 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. @@ -524,7 +524,7 @@ fn the_exposure_fixture_has_a_positive_a_negative_and_an_undecided_case() { // 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]["aws_field"], "ec2 describe-instances: PublicIpAddress"); + 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")); @@ -555,13 +555,13 @@ fn the_exposure_fixture_has_a_positive_a_negative_and_an_undecided_case() { 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(&["exposure", "-d", text(&db), "--verdict", "insufficient-data"]); + 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(&["exposure", "-d", text(&db), "--resource", &instance("i-web1")]); + 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(&["exposure", "-d", text(&db), "--resource", "shop/no-such-instance"]); + 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(&["exposure", "-d", text(&db), "--max-findings", "2"]); + 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"]); } @@ -574,7 +574,7 @@ fn a_finding_opens_in_path_explorer_and_its_records_resolve_as_evidence() { let db = env.path("graph"); env.add_source(&db, &exposure_fixture()); env.ok(&["plugin", "sync", "--source", "shop"]); - let report = env.ok(&["exposure", "-d", text(&db)]); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); let mut walked = 0; for finding in report["findings"].as_array().unwrap() { @@ -623,18 +623,18 @@ fn a_missing_response_makes_a_finding_undecided_rather_than_clearing_it() { let db = env.path("graph"); env.add_source(&db, &saved); env.ok(&["plugin", "sync", "--source", "shop"]); - assert_eq!(finding(&env.ok(&["exposure", "-d", text(&db)]), DIRECT, &instance("i-web1"))["verdict"], "potential_exposure"); + assert_eq!(finding(&env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]), DIRECT, &instance("i-web1"))["verdict"], "potential_exposure"); std::fs::remove_file(saved.join("us-east-1/ec2-describe-route-tables.json")).unwrap(); env.ok(&["plugin", "sync", "--source", "shop"]); - let report = env.ok(&["exposure", "-d", text(&db)]); + 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"); 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(&["exposure", "-d", text(&db)]); + 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, "open-inbound-rule"), "unknown"); @@ -651,7 +651,7 @@ fn a_candidate_path_is_not_a_finding_until_the_rules_conditions_hold() { 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(&["exposure", "-d", text(&db)]); + 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"); @@ -666,7 +666,7 @@ fn exposure_is_the_same_across_syncs() { let db = env.path("graph"); env.add_source(&db, &exposure_fixture()); env.ok(&["plugin", "sync", "--source", "shop"]); - normalize(&env.ok(&["exposure", "-d", text(&db)])) + normalize(&env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)])) }; assert_eq!(run(), run()); } @@ -854,9 +854,144 @@ 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(&["exposure", "-d", text(&db)]))), &expected["exposure"]["shop_fixture"]); + assert_same(&format!("{name}: exposure"), &normalize(&exposure_brief(&env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]))), &expected["exposure"]["shop_fixture"]); 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}"); +} diff --git a/crates/khonsu-cli/src/exposure.rs b/crates/khonsu-cli/src/exposure.rs deleted file mode 100644 index 23b52e7..0000000 --- a/crates/khonsu-cli/src/exposure.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! `khonsu exposure`: explainable AWS exposure findings. See -//! `khonsu_core::exposure` and `docs/exposure-rules.md`. - -use clap::{Args, ValueEnum}; -use khonsu_core::exposure::{self, Verdict}; -use khonsu_core::{settings, AccessMode, Graph}; -use serde_json::{json, Value}; - -use crate::{resolve_db, CliError, DbArg, PROGRAM}; - -#[derive(Clone, Copy, ValueEnum)] -enum VerdictArg { - PotentialExposure, - InsufficientData, - NotIndicated, -} - -impl From for Verdict { - fn from(verdict: VerdictArg) -> Self { - match verdict { - VerdictArg::PotentialExposure => Verdict::PotentialExposure, - VerdictArg::InsufficientData => Verdict::InsufficientData, - VerdictArg::NotIndicated => Verdict::NotIndicated, - } - } -} - -#[derive(Args)] -pub struct ExposureArgs { - #[command(flatten)] - db: DbArg, - /// Only findings about this resource: its `khonsu_id`. - #[arg(long)] - resource: Option, - /// Only findings with this verdict. - #[arg(long, value_enum)] - verdict: Option, - /// Findings to print; `truncated` counts the rest. - #[arg(long, default_value_t = exposure::DEFAULT_MAX_FINDINGS)] - max_findings: usize, -} - -pub fn exposure(args: ExposureArgs) -> Result { - let path = resolve_db(&args.db)?; - let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; - let report = graph.exposure(&settings::load_sources())?; - let resource = args.resource.map(|id| format!("node:{id}")); - // Every instance gets a finding, so a mistyped ID must not read as "none". - if let Some(id) = &resource { - if !report.findings.iter().any(|finding| finding.resource == *id) { - return Err(CliError::new("not_found", format!("no instance {:?}", id.trim_start_matches("node:")))); - } - } - let verdict = args.verdict.map(Verdict::from); - let (report, truncated) = report.narrowed( - |finding| resource.as_ref().is_none_or(|id| finding.resource == *id) && verdict.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/lib.rs b/crates/khonsu-cli/src/lib.rs index a940aca..049213c 100644 --- a/crates/khonsu-cli/src/lib.rs +++ b/crates/khonsu-cli/src/lib.rs @@ -12,7 +12,7 @@ mod analyze; mod dash; mod evidence; mod explore; -mod exposure; +mod plugin_analysis; use clap::{Args, CommandFactory, Parser, Subcommand}; use khonsu_core::plugin::{self, SyncEvent}; @@ -125,8 +125,6 @@ enum Command { Explore(explore::ExploreArgs), /// What supports a node or relationship: its source, plugin and times, with unknowns stated and secrets masked. Evidence(evidence::EvidenceArgs), - /// AWS exposure findings: the rule, records and fields behind each, what is assumed and what is unknown. A finding is potential exposure from configuration, not proof of reachability. - Exposure(exposure::ExposureArgs), } #[derive(Subcommand)] @@ -144,6 +142,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`). @@ -379,7 +379,6 @@ fn execute(command: Command) -> Result { Command::Analyze(args) => analyze::analyze(args), Command::Explore(args) => explore::explore(args), Command::Evidence(args) => evidence::evidence(args), - Command::Exposure(args) => exposure::exposure(args), } } @@ -422,6 +421,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 })) 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-core/src/evidence.rs b/crates/khonsu-core/src/evidence.rs index 66d5c81..398ac5e 100644 --- a/crates/khonsu-core/src/evidence.rs +++ b/crates/khonsu-core/src/evidence.rs @@ -241,7 +241,7 @@ fn text(properties: &Map, key: &str) -> Option { /// `properties` without KhonsuGraph's bookkeeping, and with secrets masked, /// along with the names of those masked. -fn own_properties(properties: &Map) -> (Map, Vec) { +pub(crate) fn own_properties(properties: &Map) -> (Map, Vec) { let mut redacted = Vec::new(); let own = properties .iter() @@ -302,10 +302,7 @@ fn looks_like_a_secret(text: &str) -> bool { aws_key || text.contains("-----BEGIN") } -/// A hash that stays the same across platforms and Rust versions. -pub(crate) fn fnv1a(text: &str) -> u64 { - text.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)) -} +pub(crate) use khonsu_plugin_protocol::fnv1a; #[cfg(test)] mod tests { diff --git a/crates/khonsu-core/src/explore.rs b/crates/khonsu-core/src/explore.rs index 74a19b5..a59194a 100644 --- a/crates/khonsu-core/src/explore.rs +++ b/crates/khonsu-core/src/explore.rs @@ -79,7 +79,7 @@ impl ExploreRequest { } } -fn is_identifier(name: &str) -> bool { +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 == '_') } diff --git a/crates/khonsu-core/src/exposure.rs b/crates/khonsu-core/src/exposure.rs deleted file mode 100644 index 1a93577..0000000 --- a/crates/khonsu-core/src/exposure.rs +++ /dev/null @@ -1,1112 +0,0 @@ -//! Explainable AWS 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 [`Verdict::NotIndicated`], otherwise anything unknown gives -//! [`Verdict::InsufficientData`], otherwise [`Verdict::PotentialExposure`]. -//! 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 (#38), so an instance with no -//! security groups or a subnet with no route table gives "unknown". - -use crate::engine::{Graph, QueryError}; -use crate::evidence::{self, Evidence, Field, PathEvidence}; -use crate::explore::{node_name, Direction, Hop}; -use crate::result::{value_to_json, GraphCollector, GraphData, GraphEdge, GraphNode}; -use crate::settings::Source; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value as Json}; -use std::collections::{BTreeMap, BTreeSet}; - -pub const RULE_DIRECT: &str = "aws.exposure.direct-instance"; -pub const RULE_BALANCER: &str = "aws.exposure.public-load-balancer"; -pub const RULE_LATERAL: &str = "aws.exposure.security-group-lateral"; - -/// Findings kept in a report unless asked otherwise; the rest are counted. -pub const DEFAULT_MAX_FINDINGS: usize = 200; -/// Relationships read of each type. A larger graph gives an incomplete report. -const MAX_RELATIONSHIPS: usize = 50_000; -const RELATIONSHIP_TYPES: &[&str] = &["ALLOWS", "PROTECTED_BY", "ROUTES_TO", "FORWARDS_TO", "IN_SUBNET", "IN_VPC", "USES_ROUTE_TABLE", "ROUTES_VIA"]; - -pub 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."; -pub const ORDER: &str = "By verdict (potential_exposure, insufficient_data, not_indicated), then rule ID, then resource ID. There is no risk score."; - -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."; - -/// A rule in words, for a person reading a report. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Rule { - pub rule_id: String, - pub title: String, - pub statement: String, - pub assumptions: Vec, -} - -pub fn rules() -> Vec { - let rule = |id: &str, title: &str, statement: &str, assumptions: &[&str]| Rule { - 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.", - ], - ), - ] -} - -/// There is deliberately no "reachable" here. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Verdict { - /// Every condition is met by configuration. - PotentialExposure, - /// None ruled it out, and at least one could not be evaluated. - InsufficientData, - /// A condition is not met, so this rule does not point at it. - NotIndicated, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Status { - Met, - NotMet, - Unknown, -} - -/// An exact field a condition read, and what AWS called 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, - pub aws_field: String, - pub value: Field, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Condition { - pub id: String, - pub statement: String, - pub status: Status, - /// 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, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Finding { - /// The same rule and records give the same ID. - pub finding_id: String, - pub rule_id: String, - pub verdict: Verdict, - /// The evidence ID of the resource it is about, and what people call it. - pub resource: String, - pub resource_name: String, - pub conditions: Vec, - /// The statements of conditions that could not be evaluated. - 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 hops, for a later rule to continue from. - #[serde(skip)] - pub hops: Vec, - pub walk: Option, - /// The finding this one rests on, for a lateral step. - #[serde(skip_serializing_if = "Option::is_none")] - pub depends_on: Option, - /// The evidence IDs of every record the conditions read or the path crosses. - pub contributing: Vec, - pub evidence: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct Summary { - pub potential_exposure: usize, - pub insufficient_data: usize, - pub not_indicated: usize, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Report { - pub claim: String, - pub order: String, - pub rules: Vec, - /// Instances looked at. - pub instances: usize, - /// Counts every finding, including any cut by a cap. - pub summary: Summary, - /// False when the graph was too large to read whole: a missing finding - /// then means nothing. - pub complete: bool, - 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) - } -} - -impl Graph { - /// Reads what the rules need and evaluates them. - pub fn exposure(&mut self, sources: &[Source]) -> Result { - let (data, complete) = self.read_exposure_records()?; - Ok(evaluate(&data, sources, complete)) - } - - fn read_exposure_records(&mut self) -> Result<(GraphData, bool), QueryError> { - let params = BTreeMap::new(); - let mut collector = GraphCollector::default(); - let mut complete = true; - let mut read = |collector: &mut GraphCollector, text: String, cap: usize| -> Result { - let rows = self.raw_cypher(&text, ¶ms)?; - let whole = rows.len() <= cap; - for row in rows.iter().take(cap) { - row.iter().for_each(|value| collector.visit(&value_to_json(value))); - } - Ok(whole) - }; - // An instance with no relationships still counts as looked at. - complete &= read(&mut collector, format!("MATCH (n:Instance) RETURN n ORDER BY id(n) LIMIT {}", MAX_RELATIONSHIPS + 1), MAX_RELATIONSHIPS)?; - for name in RELATIONSHIP_TYPES { - let text = format!("MATCH (a)-[r:{name}]->(b) RETURN a, r, b ORDER BY id(r) LIMIT {}", MAX_RELATIONSHIPS + 1); - complete &= read(&mut collector, text, MAX_RELATIONSHIPS)?; - } - Ok((collector.finish().unwrap_or_default(), complete)) - } -} - -/// Evaluates every rule over `data`. The result depends on nothing else. -pub fn evaluate(data: &GraphData, sources: &[Source], complete: bool) -> Report { - let index = Index::new(data); - let mut findings = Vec::new(); - for instance in index.with_label("Instance") { - findings.push(direct(&index, instance, sources)); - for (balancer, group, to_target, forwards) in index.balancers_of(instance) { - findings.push(behind_balancer(&index, instance, balancer, group, to_target, forwards, sources)); - } - } - let lateral = lateral_findings(&index, &findings, sources); - findings.extend(lateral); - findings.sort_by(|a, b| (a.verdict, &a.rule_id, &a.resource, &a.finding_id).cmp(&(b.verdict, &b.rule_id, &b.resource, &b.finding_id))); - - let count = |verdict: Verdict| findings.iter().filter(|finding| finding.verdict == verdict).count(); - Report { - claim: CLAIM.into(), - order: ORDER.into(), - rules: rules(), - instances: index.with_label("Instance").len(), - summary: Summary { - potential_exposure: count(Verdict::PotentialExposure), - insufficient_data: count(Verdict::InsufficientData), - not_indicated: count(Verdict::NotIndicated), - }, - complete, - findings, - } -} - -/// Stable lookups over the records, sorted so that every choice is repeatable. -struct Index<'a> { - nodes: BTreeMap, - data: &'a GraphData, - node_ids: BTreeMap, - edge_ids: BTreeMap, - /// Relationships by (node, type), in the order of the far end's stable ID. - out: BTreeMap<(i64, &'a str), Vec<&'a GraphEdge>>, - into: BTreeMap<(i64, &'a str), Vec<&'a GraphEdge>>, -} - -impl<'a> Index<'a> { - fn new(data: &'a GraphData) -> Self { - let nodes: BTreeMap = data.nodes.iter().map(|node| (node.id, node)).collect(); - let mut out: BTreeMap<(i64, &str), Vec<&GraphEdge>> = BTreeMap::new(); - let mut into: BTreeMap<(i64, &str), Vec<&GraphEdge>> = BTreeMap::new(); - for edge in &data.edges { - out.entry((edge.source, &edge.rel_type)).or_default().push(edge); - into.entry((edge.target, &edge.rel_type)).or_default().push(edge); - } - out.values_mut().chain(into.values_mut()).for_each(|edges| edges.sort_by_cached_key(|edge| edge_id(edge))); - Self { - nodes, - data, - node_ids: data.nodes.iter().map(|node| (record_id(node), node)).collect(), - edge_ids: data.edges.iter().map(|edge| (edge_id(edge), edge)).collect(), - out, - into, - } - } - - fn with_label(&self, label: &str) -> Vec<&'a GraphNode> { - let mut found: Vec<&GraphNode> = self.nodes.values().copied().filter(|node| node.labels.iter().any(|name| name == label)).collect(); - found.sort_by_cached_key(|node| record_id(node)); - found - } - - fn out_of(&self, node: i64, rel_type: &'static str) -> &[&'a GraphEdge] { - self.out.get(&(node, rel_type)).map_or(&[], Vec::as_slice) - } - - fn arriving_at(&self, node: i64, rel_type: &'static str) -> &[&'a GraphEdge] { - self.into.get(&(node, rel_type)).map_or(&[], Vec::as_slice) - } - - fn node(&self, id: i64) -> Option<&'a GraphNode> { - self.nodes.get(&id).copied() - } - - /// `ALLOWS` rules into `groups` from the internet. - fn internet_rules(&self, groups: &[&GraphEdge]) -> Vec<&'a GraphEdge> { - groups - .iter() - .flat_map(|protected| self.arriving_at(protected.target, "ALLOWS")) - .filter(|rule| self.node(rule.source).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: &GraphNode) -> Vec<(&'a GraphNode, &'a GraphNode, &'a GraphEdge, &'a GraphEdge)> { - let mut found = Vec::new(); - for to_target in self.arriving_at(instance.id, "ROUTES_TO") { - let Some(group) = self.node(to_target.source).filter(|node| has_label(node, "TargetGroup")) else { continue }; - for forwards in self.arriving_at(group.id, "FORWARDS_TO") { - if let Some(balancer) = self.node(forwards.source) { - 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: &GraphNode) -> Option<&'a GraphNode> { - if let Some(explicit) = self.out_of(subnet.id, "USES_ROUTE_TABLE").first() { - return self.node(explicit.target); - } - let vpc = self.out_of(subnet.id, "IN_VPC").first()?.target; - self.arriving_at(vpc, "IN_VPC") - .iter() - .filter_map(|edge| self.node(edge.source)) - .find(|node| has_label(node, "RouteTable") && node.properties.get("is_main") == Some(&json!(true))) - } -} - -fn has_label(node: &GraphNode, label: &str) -> bool { - node.labels.iter().any(|name| name == label) -} - -fn is_internet(node: &GraphNode) -> bool { - has_label(node, "Cidr") && matches!(node.properties.get("cidr").and_then(Json::as_str), Some("0.0.0.0/0" | "::/0")) -} - -fn record_id(node: &GraphNode) -> String { - evidence::node_evidence_id(node) -} - -fn edge_id(edge: &GraphEdge) -> String { - evidence::edge_evidence_id(edge) -} - -fn node_field(node: &GraphNode, field: &str, aws_field: &str) -> SourceField { - SourceField { - record: record_id(node), - field: field.into(), - aws_field: aws_field.into(), - value: node.properties.get(field).cloned().into(), - } -} - -fn edge_field(edge: &GraphEdge, field: &str, aws_field: &str) -> SourceField { - SourceField { - record: edge_id(edge), - field: field.into(), - aws_field: aws_field.into(), - value: edge.properties.get(field).cloned().into(), - } -} - -/// A rule's ports in words, as the plugin wrote them. -fn ports_of(rule: &GraphEdge) -> String { - let text = |key: &str| rule.properties.get(key).and_then(Json::as_str).unwrap_or("unknown").to_string(); - format!("{} {}", text("protocol"), text("ports")) -} - -/// Whether a security group rule covers `port` on `protocol`. The plugin writes -/// ports as `all`, `443` or `8000-8100`. -fn rule_covers(rule: &GraphEdge, protocol: &str, port: i64) -> bool { - let text = |key: &str| rule.properties.get(key).and_then(Json::as_str).unwrap_or_default(); - if text("protocol") != "all" && text("protocol") != protocol { - return false; - } - let ports = text("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), - } -} - -fn condition(id: &str, statement: &str, status: Status, detail: impl Into, fields: Vec) -> Condition { - Condition { - id: id.into(), - statement: statement.into(), - status, - detail: detail.into(), - fields, - } -} - -fn running(instance: &GraphNode) -> Condition { - let (status, detail) = match instance.properties.get("state").and_then(Json::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![node_field(instance, "state", "ec2 describe-instances: State.Name")]) -} - -/// 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: &[&GraphEdge], aws_groups: &str) -> Condition { - let statement = format!("{whose} has a security group rule open to the internet."); - let rules = index.internet_rules(protected); - let group_fields: Vec = protected - .iter() - .map(|edge| SourceField { - record: edge_id(edge), - field: "PROTECTED_BY".into(), - aws_field: aws_groups.into(), - value: Field::Known(json!(index.node(edge.target).map(record_id))), - }) - .collect(); - 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 = group_fields; - for rule in &rules { - fields.push(edge_field(rule, "protocol", "ec2 describe-security-groups: IpPermissions[].IpProtocol")); - fields.push(edge_field(rule, "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: &[Condition]) -> Verdict { - if conditions.iter().any(|condition| condition.status == Status::NotMet) { - Verdict::NotIndicated - } else if conditions.iter().any(|condition| condition.status == Status::Unknown) { - Verdict::InsufficientData - } else { - Verdict::PotentialExposure - } -} - -fn public_address(instance: &GraphNode) -> Condition { - let fields = vec![ - node_field(instance, "public_ip", "ec2 describe-instances: PublicIpAddress"), - node_field(instance, "ipv6_address", "ec2 describe-instances: Ipv6Address"), - ]; - let addresses: Vec<&str> = ["public_ip", "ipv6_address"].iter().filter_map(|key| instance.properties.get(*key).and_then(Json::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: &GraphNode) -> (Condition, 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.id, "IN_SUBNET").first().and_then(|edge| index.node(edge.target)) else { - return (condition(id, statement, Status::Unknown, "No subnet is recorded for the instance.", vec![]), vec![]); - }; - let subnet_id = record_id(subnet); - 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_id]); - }; - 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.id, "ROUTES_VIA"); - let text = |edge: &GraphEdge, key: &str| edge.properties.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); - let is_v6 = |destination: &str| destination.contains(':'); - // Only routes of the address's own family can carry its traffic. - let active: Vec<&&GraphEdge> = 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![ - node_field(table, "ipv4_default_target", "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]"), - node_field(table, "ipv6_default_target", "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]"), - ]; - for route in routes { - fields.push(edge_field(route, "destination", "ec2 describe-route-tables: Routes[].DestinationCidrBlock")); - fields.push(edge_field(route, "state", "ec2 describe-route-tables: Routes[].State")); - } - let name = node_name(table); - let (status, detail) = if let Some(route) = active.iter().find(|route| wanted.contains(&text(route, "destination").as_str())) { - (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_id, record_id(table)]; - records.extend(routes.iter().map(|route| edge_id(route))); - (condition(id, statement, status, detail, fields), records) -} - -fn direct(index: &Index, instance: &GraphNode, sources: &[Source]) -> Finding { - let protected = index.out_of(instance.id, "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.target, "ALLOWS").iter().find(|rule| index.node(rule.source).is_some_and(is_internet))?; - Some(vec![along(rule, 1), against(edge, 2)]) - }); - let mut records = route_records; - records.push(record_id(instance)); - records.extend(protected.iter().map(|edge| edge_id(edge))); - for rule in index.internet_rules(protected) { - records.push(edge_id(rule)); - } - build(index, instance, RULE_DIRECT, conditions, hops.unwrap_or_default(), records, None, sources) -} - -fn behind_balancer(index: &Index, instance: &GraphNode, balancer: &GraphNode, group: &GraphNode, to_target: &GraphEdge, forwards: &GraphEdge, sources: &[Source]) -> Finding { - let balancer_groups = index.out_of(balancer.id, "PROTECTED_BY"); - let instance_groups = index.out_of(instance.id, "PROTECTED_BY"); - - let (status, detail) = match balancer.properties.get("scheme").and_then(Json::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![node_field(balancer, "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 field = edge_field(to_target, "health", "elbv2 describe-target-health: TargetHealth.State"); - let (status, detail) = match to_target.properties.get("health").and_then(Json::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![field]) - }; - let (allows, allow_records) = target_allows_balancer(index, group, balancer_groups, instance_groups); - let conditions = vec![running(instance), facing, open, health, allows]; - - let opening = index.arriving_at(balancer.id, "ROUTES_TO").iter().find(|edge| index.node(edge.source).is_some_and(is_internet)); - let hops = opening.map_or_else(Vec::new, |edge| vec![along(edge, 1), along(forwards, 2), along(to_target, 3)]); - let mut records = allow_records; - records.extend([record_id(instance), record_id(balancer), record_id(group), edge_id(to_target), edge_id(forwards)]); - records.extend(balancer_groups.iter().map(|edge| edge_id(edge))); - for rule in index.internet_rules(balancer_groups) { - records.push(edge_id(rule)); - } - build(index, instance, RULE_BALANCER, conditions, hops, records, Some(format!("{}\n{}", record_id(balancer), record_id(group))), sources) -} - -/// Whether the target's groups let the balancer in on the target group's port. -fn target_allows_balancer(index: &Index, group: &GraphNode, balancer_groups: &[&GraphEdge], instance_groups: &[&GraphEdge]) -> (Condition, Vec) { - 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 = node_field(group, "port", "elbv2 describe-target-groups: Port"); - let Some(port) = group.properties.get("port").and_then(Json::as_i64) else { - return (condition(id, statement, Status::Unknown, "The target group's port was not supplied.", vec![port_field]), vec![]); - }; - 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]), vec![]); - } - let protocol = match group.properties.get("protocol").and_then(Json::as_str) { - Some("UDP") => "udp", - _ => "tcp", - }; - let from_balancer: BTreeSet = balancer_groups.iter().map(|edge| edge.target).collect(); - let rules: Vec<&GraphEdge> = instance_groups - .iter() - .flat_map(|edge| index.arriving_at(edge.target, "ALLOWS")) - .filter(|rule| rule_covers(rule, protocol, port)) - .copied() - .collect(); - let matching = rules.iter().find(|rule| from_balancer.contains(&rule.source) || index.node(rule.source).is_some_and(is_internet)); - let other_cidr = rules.iter().find(|rule| index.node(rule.source).is_some_and(|node| has_label(node, "Cidr"))); - let chosen = matching.or(other_cidr); - let mut fields = vec![port_field]; - let mut records = Vec::new(); - if let Some(rule) = chosen { - fields.push(edge_field(rule, "ports", "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort")); - records.push(edge_id(rule)); - } - let (status, detail) = if matching.is_some() { - (Status::Met, format!("A rule allows {protocol} {port} from the load balancer's group or the internet.")) - } else if other_cidr.is_some() { - (Status::Unknown, format!("A CIDR rule allows {protocol} {port}, and the load balancer's addresses are not read to compare with it.")) - } else { - (Status::NotMet, format!("No rule on the target's groups allows {protocol} {port} from the load balancer.")) - }; - (condition(id, statement, status, detail, fields), records) -} - -/// 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(index: &Index, findings: &[Finding], sources: &[Source]) -> Vec { - // The strongest finding on each instance, first by verdict then by rule. - let mut best: BTreeMap<&str, &Finding> = BTreeMap::new(); - for finding in findings.iter().filter(|finding| finding.verdict != Verdict::NotIndicated) { - best.entry(finding.resource.as_str()) - .and_modify(|held| { - if (finding.verdict, &finding.rule_id) < (held.verdict, &held.rule_id) { - *held = finding; - } - }) - .or_insert(finding); - } - let mut out = Vec::new(); - for source in index.with_label("Instance") { - let Some(found) = best.get(record_id(source).as_str()) else { continue }; - let mut pairs: BTreeMap)> = BTreeMap::new(); - for &own in index.out_of(source.id, "PROTECTED_BY") { - for &rule in index.out_of(own.target, "ALLOWS") { - for &protected in index.arriving_at(rule.target, "PROTECTED_BY") { - if protected.source != source.id && index.node(protected.source).is_some_and(|node| has_label(node, "Instance")) { - pairs.entry(protected.source).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(index, instance, source, found, own, protected, &rules, sources)); - } - } - out -} - -#[allow(clippy::too_many_arguments)] -fn lateral(index: &Index, instance: &GraphNode, source: &GraphNode, found: &Finding, own: &GraphEdge, protected: &GraphEdge, rules: &[&GraphEdge], sources: &[Source]) -> Finding { - let name = node_name(source); - let (status, detail) = match found.verdict { - Verdict::PotentialExposure => (Status::Met, format!("{name} has a potential exposure finding from {}.", found.rule_id)), - _ => (Status::Unknown, format!("{name} has insufficient data under {}.", found.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(edge_field(rule, "protocol", "ec2 describe-security-groups: IpPermissions[].IpProtocol")); - rule_fields.push(edge_field(rule, "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 rule = rules[0]; - let mut hops = found.hops.clone(); - if !hops.is_empty() { - let at = hops.len() as u32; - hops.extend([along(own, at + 1), along(rule, at + 2), against(protected, at + 3)]); - } - let mut records = vec![record_id(instance), record_id(source), edge_id(own), edge_id(protected)]; - records.extend(rules.iter().map(|rule| edge_id(rule))); - let mut finding = build(index, instance, RULE_LATERAL, conditions, hops, records, Some(record_id(source)), sources); - finding.depends_on = Some(found.finding_id.clone()); - finding -} - -fn along(edge: &GraphEdge, depth: u32) -> Hop { - Hop { - depth, - from: edge.source, - to: edge.target, - edge: edge.id, - rel_type: edge.rel_type.clone(), - along: true, - } -} - -fn against(edge: &GraphEdge, depth: u32) -> Hop { - Hop { - depth, - from: edge.target, - to: edge.source, - edge: edge.id, - rel_type: edge.rel_type.clone(), - along: false, - } -} - -/// How to show `hops` in Path Explorer. -fn walk_hint(index: &Index, hops: &[Hop], instance: &GraphNode) -> Option { - let start = index.node(hops.first()?.from)?; - let name = |node: &GraphNode| { - let id = record_id(node); - id.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(start), - to: name(instance), - direction: if hops.iter().all(|hop| hop.along) { Direction::Out } else { Direction::Both }, - types: types.into_iter().collect(), - max_hops: hops.len() as u32, - }) -} - -#[allow(clippy::too_many_arguments)] -fn build(index: &Index, instance: &GraphNode, rule_id: &str, conditions: Vec, hops: Vec, records: Vec, subject: Option, sources: &[Source]) -> Finding { - let resource = record_id(instance); - let verdict = verdict_of(&conditions); - let unknowns = conditions.iter().filter(|condition| condition.status == Status::Unknown).map(|condition| condition.statement.clone()).collect(); - let path = (!hops.is_empty()).then(|| { - evidence::for_hops(&hops, index.data, sources, "the rule's candidate path", Field::Known(rule_id.to_string())) - }); - let walk = walk_hint(index, &hops, instance); - - // 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: BTreeSet = records.into_iter().filter(|id| !contributing.contains(id)).collect(); - contributing.extend(rest); - let evidence = contributing.iter().filter_map(|id| record_for(index, id, sources)).collect(); - - let id_text = [Some(rule_id.to_string()), Some(resource.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(); - Finding { - finding_id: format!("finding:{:016x}", evidence::fnv1a(&id_text)), - rule_id: rule_id.into(), - verdict, - resource, - resource_name: node_name(instance), - conditions, - unknowns, - assumptions, - path, - hops, - walk, - depends_on: None, - contributing, - evidence, - } -} - -fn record_for(index: &Index, id: &str, sources: &[Source]) -> Option { - if let Some(node) = index.node_ids.get(id) { - return Some(evidence::for_node(node, sources)); - } - let edge = index.edge_ids.get(id)?; - Some(evidence::for_edge(edge, &index.data.nodes, sources)) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::{Map, Value}; - - 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 - } - - /// Builds a graph by stable name, with IDs in order of first mention. - #[derive(Default)] - struct Builder { - data: GraphData, - } - - impl Builder { - fn node(mut self, id: &str, label: &str, extra: Value) -> Self { - let next = self.data.nodes.len() as i64 + 1; - self.data.nodes.push(GraphNode { - id: next, - labels: vec![label.into()], - properties: props(id, extra), - }); - self - } - - fn edge(mut self, source: &str, rel_type: &str, target: &str, extra: Value) -> Self { - let find = |name: &str| self.data.nodes.iter().find(|node| node.properties["khonsu_id"] == json!(name)).unwrap_or_else(|| panic!("no node {name}")).id; - let (source_id, target_id) = (find(source), find(target)); - let next = self.data.edges.len() as i64 + 100; - let id = format!("{source}|{rel_type}|{target}|{extra}"); - self.data.edges.push(GraphEdge { - id: next, - source: source_id, - target: target_id, - rel_type: rel_type.into(), - properties: props(&id, extra), - }); - self - } - - fn report(&self) -> Report { - evaluate(&self.data, &[], true) - } - } - - /// 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.data.edges.retain(|edge| edge.rel_type != rel_type); - builder - } - - fn set(mut builder: Builder, id: &str, key: &str, value: Option) -> Builder { - let node = builder.data.nodes.iter_mut().find(|node| node.properties["khonsu_id"] == json!(id)).unwrap(); - match value { - Some(value) => node.properties.insert(key.into(), value), - None => node.properties.remove(key), - }; - builder - } - - fn only<'a>(report: &'a Report, rule: &str, resource: &str) -> &'a Finding { - let mut found = report.findings.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 status(finding: &Finding, id: &str) -> Status { - finding.conditions.iter().find(|condition| condition.id == id).unwrap_or_else(|| panic!("no condition {id}: {finding:#?}")).status - } - - #[test] - fn every_direct_condition_met_is_a_potential_exposure_with_its_path_and_rule() { - let report = exposed().report(); - let finding = only(&report, RULE_DIRECT, "web"); - assert_eq!(finding.verdict, Verdict::PotentialExposure); - assert!(finding.unknowns.is_empty()); - let path = finding.path.as_ref().unwrap(); - assert_eq!(path.rule_id, Field::Known(RULE_DIRECT.to_string())); - assert_eq!(path.hops, ["Internet -[ALLOWS]-> sg", "sg <-[PROTECTED_BY]- web"]); - assert!(finding.contributing.contains(&"node:rtb".to_string()), "the route is part of the case: {:?}", finding.contributing); - assert!(finding.evidence.iter().any(|record| record.evidence_id == "node:rtb")); - assert!(!finding.assumptions.is_empty()); - let walk = finding.walk.as_ref().unwrap(); - assert_eq!((walk.from.as_str(), walk.to.as_str(), walk.direction, walk.max_hops), ("internet", "web", Direction::Both, 2)); - assert_eq!(walk.types, ["ALLOWS", "PROTECTED_BY"]); - } - - #[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, Verdict::NotIndicated, "{case}"); - } - } - - #[test] - fn no_verdict_ever_says_reachable() { - // The names are the whole vocabulary, so this holds for any input. - for verdict in [Verdict::PotentialExposure, Verdict::InsufficientData, Verdict::NotIndicated] { - assert!(!serde_json::to_string(&verdict).unwrap().contains("reachable")); - } - assert!(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, Verdict::InsufficientData); - 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, Verdict::InsufficientData); - - // 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, Verdict::NotIndicated); - 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, Verdict::PotentialExposure); - - // 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, Verdict::PotentialExposure); - } - - #[test] - fn a_narrower_cidr_is_not_the_internet() { - let mut builder = exposed(); - builder.data.nodes[0].properties.insert("cidr".into(), 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("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"}) - } - - #[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, Verdict::PotentialExposure, "{finding:#?}"); - assert_eq!(finding.path.as_ref().unwrap().hops, ["Internet -[ROUTES_TO]-> lb", "lb -[FORWARDS_TO]-> tg", "tg -[ROUTES_TO]-> app"]); - assert_eq!(finding.walk.as_ref().unwrap().direction, Direction::Out); - assert_eq!(only(&report, RULE_DIRECT, "app").verdict, Verdict::NotIndicated, "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"), "target-allows-balancer"); - 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); - } - - #[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, Verdict::NotIndicated); - - let unhealthy = balanced(app_rule("8080"), "sg-lb", "unhealthy").report(); - let finding = only(&unhealthy, RULE_BALANCER, "app"); - assert_eq!(finding.verdict, Verdict::InsufficientData); - 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, Verdict::PotentialExposure); - assert_eq!(db.depends_on.as_ref(), Some(&web.finding_id)); - assert_eq!( - db.path.as_ref().unwrap().hops, - ["Internet -[ALLOWS]-> sg", "sg <-[PROTECTED_BY]- web", "web -[PROTECTED_BY]-> sg", "sg -[ALLOWS]-> sg-db", "sg-db <-[PROTECTED_BY]- db"] - ); - assert_eq!(only(&report, RULE_DIRECT, "db").verdict, Verdict::NotIndicated); - } - - #[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, Verdict::InsufficientData); - assert_eq!(status(db, "source-potentially-exposed"), Status::Unknown); - - let not_exposed = with_database(set(exposed(), "web", "public_ip", None)).report(); - assert!(not_exposed.findings.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, Verdict::NotIndicated); - } - - #[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 = builder.report(); - let order: Vec<(Verdict, &str, &str)> = report.findings.iter().map(|finding| (finding.verdict, finding.rule_id.as_str(), finding.resource.as_str())).collect(); - assert_eq!( - order, - [ - (Verdict::PotentialExposure, RULE_DIRECT, "node:web"), - (Verdict::PotentialExposure, RULE_LATERAL, "node:db"), - (Verdict::InsufficientData, RULE_DIRECT, "node:idle"), - (Verdict::NotIndicated, RULE_DIRECT, "node:db"), - ], - "{report:#?}" - ); - assert_eq!(report.summary, Summary { potential_exposure: 2, insufficient_data: 1, not_indicated: 1 }); - assert_eq!(report.instances, 3); - - let mut shuffled = builder.data.clone(); - shuffled.nodes.reverse(); - shuffled.edges.reverse(); - assert_eq!(evaluate(&shuffled, &[], true), report); - } - - #[test] - fn the_same_records_keep_their_finding_id_and_a_cut_is_counted() { - let report = exposed().report(); - assert_eq!(report.findings[0].finding_id, exposed().report().findings[0].finding_id); - assert_ne!(report.findings[0].finding_id, only(&with_database(exposed()).report(), RULE_LATERAL, "db").finding_id); - - let (narrow, cut) = with_database(exposed()).report().narrowed(|_| true, 1); - assert_eq!((narrow.findings.len(), cut), (1, 2)); - assert_eq!(narrow.summary.potential_exposure, 2, "the summary still counts everything"); - } - - #[test] - fn secrets_on_a_contributing_record_stay_masked() { - let report = set(exposed(), "web", "api_token", Some(json!("abc123"))).report(); - let text = serde_json::to_string(&report).unwrap(); - assert!(!text.contains("abc123"), "{text}"); - assert!(text.contains("api_token"), "it is named as masked"); - } -} diff --git a/crates/khonsu-core/src/lib.rs b/crates/khonsu-core/src/lib.rs index dea1fd7..cdb85bc 100644 --- a/crates/khonsu-core/src/lib.rs +++ b/crates/khonsu-core/src/lib.rs @@ -7,10 +7,10 @@ mod cypher_columns; pub mod engine; pub mod evidence; pub mod explore; -pub mod exposure; pub mod lock; pub mod merge; pub mod plugin; +pub mod plugin_analysis; pub mod rebuild; pub mod result; pub mod schema; diff --git a/crates/khonsu-core/src/plugin.rs b/crates/khonsu-core/src/plugin.rs index 0ef2c3a..bb91144 100644 --- a/crates/khonsu-core/src/plugin.rs +++ b/crates/khonsu-core/src/plugin.rs @@ -528,7 +528,7 @@ pub fn run_sync( 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(); @@ -618,6 +618,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,12 +727,7 @@ 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; diff --git a/crates/khonsu-core/src/plugin_analysis.rs b/crates/khonsu-core/src/plugin_analysis.rs new file mode 100644 index 0000000..607efd9 --- /dev/null +++ b/crates/khonsu-core/src/plugin_analysis.rs @@ -0,0 +1,677 @@ +//! 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}; +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::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, + /// 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: a missing finding then + /// means nothing. + pub complete: bool, + 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, +} + +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 }) + } +} + +/// 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) -> 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), + 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(), + properties: evidence::own_properties(&edge.properties).0, + }) + }); + AnalyzeRequest { + protocol: PROTOCOL_VERSION, + analysis: info.id.clone(), + nodes: nodes.collect(), + edges: edges.collect(), + complete: input.complete, + } +} + +/// 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); + 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(); + 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: input.complete, + 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(); + + 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(), + 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, + } + } + + 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_source".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}"); + } + + #[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-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/src/lib.rs b/crates/khonsu-plugin-protocol/src/lib.rs index 980e90e..ac436eb 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,196 @@ 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, +} + +#[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, + #[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, + #[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)] @@ -183,6 +379,18 @@ pub enum Message { edges: u64, }, 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 } /// Helper for plugin authors: writes messages as JSON lines to stdout. @@ -241,6 +449,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 +488,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 index cc82b30..6310a8a 100644 --- a/docs/exposure-rules.md +++ b/docs/exposure-rules.md @@ -1,8 +1,14 @@ # AWS exposure rules -`khonsu 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. +`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 @@ -50,7 +56,10 @@ traffic to it. | `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`; the target's group rules | No port or groups are recorded, or only a CIDR rule covers the port | +| `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` @@ -98,7 +107,7 @@ 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 exposure --db graphs/exposure --pretty +khonsu plugin analyze aws exposure --db graphs/exposure --pretty ``` Read `summary` first: three potential exposures, one undecided, three not indicated. @@ -106,11 +115,11 @@ Read `summary` first: three potential exposures, one undecided, three not indica **1. A potential exposure.** Look at one instance: ```sh -khonsu exposure --db graphs/exposure --pretty --resource shop/123456789012:us-east-1:i-web1 +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 the AWS field it came from. `path.hops` shows the candidate path, +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: @@ -135,14 +144,14 @@ gateway, so it gets `not_indicated`. The conditions show why, and the open rule still deserves a look: ```sh -khonsu exposure --db graphs/exposure --pretty --resource shop/123456789012:us-east-1:i-batch1 +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 exposure --db graphs/exposure --pretty --verdict insufficient-data +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 @@ -150,7 +159,7 @@ khonsu exposure --db graphs/exposure --pretty --verdict insufficient-data from `web-1`'s through `depends_on`: ```sh -khonsu exposure --db graphs/exposure --pretty --resource shop/123456789012:us-east-1:i-db1 +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 diff --git a/docs/investigation-gates.md b/docs/investigation-gates.md index f8be26e..2d07dec 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -22,6 +22,7 @@ cargo test --locked --workspace --test investigation | Cycles | A shortest path and a variable-length pattern both end on a cycle | | 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 | | HawDB upgrades | `app/tests/fixtures/graph-hawdb-*.zip` must still give the golden answers and accept a write | Timings, sync times, paths, line numbers and plugin versions are left out of the diff --git a/docs/plugins.md b/docs/plugins.md index 6f45803..9d4f966 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -127,6 +127,97 @@ 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. +## 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. + +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/examples/aws/exposure.dash b/examples/aws/exposure.dash index cfee118..8ce0994 100644 --- a/examples/aws/exposure.dash +++ b/examples/aws/exposure.dash @@ -1,5 +1,5 @@ # What an AWS account's configuration opens to the internet, from the official -# AWS plugin. For explained findings, run `khonsu exposure`. +# 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 1e15f16..c29b8cd 100644 --- a/examples/aws/golden.json +++ b/examples/aws/golden.json @@ -1634,8 +1634,27 @@ ] ], "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. There is no risk score.", + "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", @@ -1669,11 +1688,11 @@ ] } ], - "instances": 5, + "subjects": 5, "summary": { - "potential_exposure": 3, "insufficient_data": 1, - "not_indicated": 3 + "not_indicated": 3, + "potential_exposure": 3 }, "complete": true, "findings": [ @@ -1693,7 +1712,7 @@ { "record": "node:shop/123456789012:us-east-1:i-web1", "field": "state", - "aws_field": "ec2 describe-instances: State.Name", + "source_field": "ec2 describe-instances: State.Name", "value": { "status": "known", "value": "running" @@ -1710,7 +1729,7 @@ { "record": "node:shop/123456789012:us-east-1:i-web1", "field": "public_ip", - "aws_field": "ec2 describe-instances: PublicIpAddress", + "source_field": "ec2 describe-instances: PublicIpAddress", "value": { "status": "known", "value": "198.51.100.10" @@ -1719,7 +1738,7 @@ { "record": "node:shop/123456789012:us-east-1:i-web1", "field": "ipv6_address", - "aws_field": "ec2 describe-instances: Ipv6Address", + "source_field": "ec2 describe-instances: Ipv6Address", "value": { "status": "unknown" } @@ -1734,8 +1753,8 @@ "fields": [ { "record": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", - "field": "PROTECTED_BY", - "aws_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", "value": { "status": "known", "value": "node:shop/123456789012:us-east-1:sg-web" @@ -1744,7 +1763,7 @@ { "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", "field": "protocol", - "aws_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", "value": { "status": "known", "value": "tcp" @@ -1753,7 +1772,7 @@ { "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", "field": "ports", - "aws_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", "value": { "status": "known", "value": "443" @@ -1770,7 +1789,7 @@ { "record": "node:shop/123456789012:us-east-1:rtb-public", "field": "ipv4_default_target", - "aws_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", "value": { "status": "known", "value": "igw-shop" @@ -1779,7 +1798,7 @@ { "record": "node:shop/123456789012:us-east-1:rtb-public", "field": "ipv6_default_target", - "aws_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", "value": { "status": "unknown" } @@ -1787,7 +1806,7 @@ { "record": "rel:shop/123456789012:us-east-1:rtb-public|ROUTES_VIA|123456789012:us-east-1:igw-shop|\"0.0.0.0/0\"", "field": "destination", - "aws_field": "ec2 describe-route-tables: Routes[].DestinationCidrBlock", + "source_field": "ec2 describe-route-tables: Routes[].DestinationCidrBlock", "value": { "status": "known", "value": "0.0.0.0/0" @@ -1796,7 +1815,7 @@ { "record": "rel:shop/123456789012:us-east-1:rtb-public|ROUTES_VIA|123456789012:us-east-1:igw-shop|\"0.0.0.0/0\"", "field": "state", - "aws_field": "ec2 describe-route-tables: Routes[].State", + "source_field": "ec2 describe-route-tables: Routes[].State", "value": { "status": "known", "value": "active" @@ -1977,6 +1996,16 @@ } ] }, + "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", @@ -2243,7 +2272,7 @@ { "record": "node:shop/123456789012:us-east-1:i-app1", "field": "state", - "aws_field": "ec2 describe-instances: State.Name", + "source_field": "ec2 describe-instances: State.Name", "value": { "status": "known", "value": "running" @@ -2260,7 +2289,7 @@ { "record": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", "field": "scheme", - "aws_field": "elbv2 describe-load-balancers: Scheme", + "source_field": "elbv2 describe-load-balancers: Scheme", "value": { "status": "known", "value": "internet-facing" @@ -2276,8 +2305,8 @@ "fields": [ { "record": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|PROTECTED_BY|123456789012:us-east-1:sg-lb", - "field": "PROTECTED_BY", - "aws_field": "elbv2 describe-load-balancers: SecurityGroups[]", + "field": "@to", + "source_field": "elbv2 describe-load-balancers: SecurityGroups[]", "value": { "status": "known", "value": "node:shop/123456789012:us-east-1:sg-lb" @@ -2286,7 +2315,7 @@ { "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-lb|\"\\\"tcp\\\":\\\"443\\\"\"", "field": "protocol", - "aws_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", "value": { "status": "known", "value": "tcp" @@ -2295,7 +2324,7 @@ { "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-lb|\"\\\"tcp\\\":\\\"443\\\"\"", "field": "ports", - "aws_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", "value": { "status": "known", "value": "443" @@ -2312,7 +2341,7 @@ { "record": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", "field": "health", - "aws_field": "elbv2 describe-target-health: TargetHealth.State", + "source_field": "elbv2 describe-target-health: TargetHealth.State", "value": { "status": "known", "value": "healthy" @@ -2329,16 +2358,43 @@ { "record": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", "field": "port", - "aws_field": "elbv2 describe-target-groups: 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", - "aws_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", "value": { "status": "known", "value": "8080" @@ -2575,6 +2631,20 @@ } ] }, + "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", @@ -2593,6 +2663,7 @@ "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\\\"\"" @@ -2791,6 +2862,35 @@ "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_snapshot": { + "status": "unknown" + }, + "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, @@ -2904,7 +3004,7 @@ { "record": "node:shop/123456789012:us-east-1:i-db1", "field": "state", - "aws_field": "ec2 describe-instances: State.Name", + "source_field": "ec2 describe-instances: State.Name", "value": { "status": "known", "value": "running" @@ -2928,7 +3028,7 @@ { "record": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", "field": "protocol", - "aws_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", "value": { "status": "known", "value": "tcp" @@ -2937,7 +3037,7 @@ { "record": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", "field": "ports", - "aws_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", "value": { "status": "known", "value": "5432" @@ -3241,6 +3341,28 @@ } ] }, + "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", @@ -3539,7 +3661,7 @@ { "record": "node:shop/123456789012:us-east-1:i-legacy1", "field": "state", - "aws_field": "ec2 describe-instances: State.Name", + "source_field": "ec2 describe-instances: State.Name", "value": { "status": "known", "value": "running" @@ -3556,7 +3678,7 @@ { "record": "node:shop/123456789012:us-east-1:i-legacy1", "field": "public_ip", - "aws_field": "ec2 describe-instances: PublicIpAddress", + "source_field": "ec2 describe-instances: PublicIpAddress", "value": { "status": "known", "value": "198.51.100.77" @@ -3565,7 +3687,7 @@ { "record": "node:shop/123456789012:us-east-1:i-legacy1", "field": "ipv6_address", - "aws_field": "ec2 describe-instances: Ipv6Address", + "source_field": "ec2 describe-instances: Ipv6Address", "value": { "status": "unknown" } @@ -3580,8 +3702,8 @@ "fields": [ { "record": "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", - "field": "PROTECTED_BY", - "aws_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", "value": { "status": "known", "value": "node:shop/123456789012:us-east-1:sg-legacy" @@ -3590,7 +3712,7 @@ { "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", "field": "protocol", - "aws_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", "value": { "status": "known", "value": "tcp" @@ -3599,7 +3721,7 @@ { "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", "field": "ports", - "aws_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", "value": { "status": "known", "value": "80" @@ -3789,6 +3911,16 @@ } ] }, + "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", @@ -3994,7 +4126,7 @@ { "record": "node:shop/123456789012:us-east-1:i-app1", "field": "state", - "aws_field": "ec2 describe-instances: State.Name", + "source_field": "ec2 describe-instances: State.Name", "value": { "status": "known", "value": "running" @@ -4011,7 +4143,7 @@ { "record": "node:shop/123456789012:us-east-1:i-app1", "field": "public_ip", - "aws_field": "ec2 describe-instances: PublicIpAddress", + "source_field": "ec2 describe-instances: PublicIpAddress", "value": { "status": "unknown" } @@ -4019,7 +4151,7 @@ { "record": "node:shop/123456789012:us-east-1:i-app1", "field": "ipv6_address", - "aws_field": "ec2 describe-instances: Ipv6Address", + "source_field": "ec2 describe-instances: Ipv6Address", "value": { "status": "unknown" } @@ -4034,8 +4166,8 @@ "fields": [ { "record": "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app", - "field": "PROTECTED_BY", - "aws_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", "value": { "status": "known", "value": "node:shop/123456789012:us-east-1:sg-app" @@ -4052,7 +4184,7 @@ { "record": "node:shop/123456789012:us-east-1:rtb-main", "field": "ipv4_default_target", - "aws_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", "value": { "status": "known", "value": "nat-shop" @@ -4061,7 +4193,7 @@ { "record": "node:shop/123456789012:us-east-1:rtb-main", "field": "ipv6_default_target", - "aws_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", "value": { "status": "unknown" } @@ -4213,7 +4345,7 @@ { "record": "node:shop/123456789012:us-east-1:i-batch1", "field": "state", - "aws_field": "ec2 describe-instances: State.Name", + "source_field": "ec2 describe-instances: State.Name", "value": { "status": "known", "value": "running" @@ -4230,7 +4362,7 @@ { "record": "node:shop/123456789012:us-east-1:i-batch1", "field": "public_ip", - "aws_field": "ec2 describe-instances: PublicIpAddress", + "source_field": "ec2 describe-instances: PublicIpAddress", "value": { "status": "unknown" } @@ -4238,7 +4370,7 @@ { "record": "node:shop/123456789012:us-east-1:i-batch1", "field": "ipv6_address", - "aws_field": "ec2 describe-instances: Ipv6Address", + "source_field": "ec2 describe-instances: Ipv6Address", "value": { "status": "unknown" } @@ -4253,8 +4385,8 @@ "fields": [ { "record": "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", - "field": "PROTECTED_BY", - "aws_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", "value": { "status": "known", "value": "node:shop/123456789012:us-east-1:sg-batch" @@ -4263,7 +4395,7 @@ { "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", "field": "protocol", - "aws_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", "value": { "status": "known", "value": "tcp" @@ -4272,7 +4404,7 @@ { "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", "field": "ports", - "aws_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", "value": { "status": "known", "value": "22" @@ -4289,7 +4421,7 @@ { "record": "node:shop/123456789012:us-east-1:rtb-main", "field": "ipv4_default_target", - "aws_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", "value": { "status": "known", "value": "nat-shop" @@ -4298,7 +4430,7 @@ { "record": "node:shop/123456789012:us-east-1:rtb-main", "field": "ipv6_default_target", - "aws_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", "value": { "status": "unknown" } @@ -4477,6 +4609,16 @@ } ] }, + "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", @@ -4708,7 +4850,7 @@ { "record": "node:shop/123456789012:us-east-1:i-db1", "field": "state", - "aws_field": "ec2 describe-instances: State.Name", + "source_field": "ec2 describe-instances: State.Name", "value": { "status": "known", "value": "running" @@ -4725,7 +4867,7 @@ { "record": "node:shop/123456789012:us-east-1:i-db1", "field": "public_ip", - "aws_field": "ec2 describe-instances: PublicIpAddress", + "source_field": "ec2 describe-instances: PublicIpAddress", "value": { "status": "unknown" } @@ -4733,7 +4875,7 @@ { "record": "node:shop/123456789012:us-east-1:i-db1", "field": "ipv6_address", - "aws_field": "ec2 describe-instances: Ipv6Address", + "source_field": "ec2 describe-instances: Ipv6Address", "value": { "status": "unknown" } @@ -4748,8 +4890,8 @@ "fields": [ { "record": "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", - "field": "PROTECTED_BY", - "aws_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", "value": { "status": "known", "value": "node:shop/123456789012:us-east-1:sg-db" @@ -4766,7 +4908,7 @@ { "record": "node:shop/123456789012:us-east-1:rtb-main", "field": "ipv4_default_target", - "aws_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", "value": { "status": "known", "value": "nat-shop" @@ -4775,7 +4917,7 @@ { "record": "node:shop/123456789012:us-east-1:rtb-main", "field": "ipv6_default_target", - "aws_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", "value": { "status": "unknown" } diff --git a/plugins/aws/src/exposure.rs b/plugins/aws/src/exposure.rs new file mode 100644 index 0000000..3483bbe --- /dev/null +++ b/plugins/aws/src/exposure.rs @@ -0,0 +1,1007 @@ +//! 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, 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. +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(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(instance, RULE_DIRECT, conditions, hops.unwrap_or_default(), records, None) +} + +fn behind_balancer(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(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(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(instance, source, found, own, protected, &rules)); + } + } + out +} + +fn lateral(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(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, +} + +fn build(instance: &AnalysisNode, rule_id: &str, conditions: Vec, hops: Vec, records: Vec, subject: Option) -> Built { + let unknowns: Vec = conditions.iter().filter(|condition| condition.status == Status::Unknown).map(|condition| condition.statement.clone()).collect(); + 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_of(&conditions).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(&index, instance)); + for (balancer, group, to_target, forwards) in index.balancers_of(instance) { + findings.push(behind_balancer(&index, instance, balancer, group, to_target, forwards)); + } + } + let lateral = lateral_findings(&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, 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(), 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))); + } + + #[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 a69a434..a8ca3be 100644 --- a/plugins/aws/src/main.rs +++ b/plugins/aws/src/main.rs @@ -24,13 +24,15 @@ //! and `/iam-list-roles.json`, `/iam-list-instance-profiles.json`, //! `/sts-get-caller-identity.json`. +mod exposure; + 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}; @@ -75,6 +77,7 @@ fn describe() -> Describe { .map(String::from) .to_vec(), supports_incremental: false, + analyses: vec![exposure::info()], } } @@ -519,14 +522,25 @@ fn run(command: &str, out: &mut Emitter, request: Option) let (nodes, edges) = graph.emit(out).map_err(io)?; out.emit(&Message::Done { nodes, edges }).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) => { diff --git a/plugins/csv/src/main.rs b/plugins/csv/src/main.rs index c84c973..24347cf 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(), } } diff --git a/skills/khonsugraph/SKILL.md b/skills/khonsugraph/SKILL.md index a02bb0b..fd1aa27 100644 --- a/skills/khonsugraph/SKILL.md +++ b/skills/khonsugraph/SKILL.md @@ -48,7 +48,7 @@ Read `complete` first. When it is false, `limits` says which cap cut the walk, s `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. -On an AWS graph, `khonsu 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. Narrow it with `--resource ID` and `--verdict`, and pass a finding's `walk` to `khonsu explore`. See `references/cli.md`. +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. Narrow it with `--resource ID` and `--verdict`, and pass a finding's `walk` to `khonsu explore`. See `references/cli.md`. ## The window and the graph lease diff --git a/skills/khonsugraph/references/cli.md b/skills/khonsugraph/references/cli.md index b7c6998..03efe21 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -72,26 +72,28 @@ khonsu evidence --db graphs/aws --node shop/123456789012:us-east-1:i-web1 | `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` | Unknown for now | -| `rule_id` | Unknown on a record; known on an exposure finding's `path` | +| `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 | -## AWS exposure findings +## Plugin analyses ```sh -khonsu exposure --db graphs/aws --verdict potential-exposure --pretty +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 `potential-exposure`, `insufficient-data` or `not-indicated`, and `--max-findings` defaults to 200. The top level has `claim`, `order`, `rules`, `instances`, `summary` (counts before any narrowing), `complete` and `truncated` (findings cut by `--max-findings`). When `complete` is false, a missing finding means nothing. +`--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` | `potential_exposure`, `insufficient_data` or `not_indicated`. None means reachable or safe | -| `resource`, `resource_name` | The instance it is about | -| `conditions[]` | `id`, `statement`, `status` (`met`, `not_met`, `unknown`), `detail`, and `fields[]` with `record`, `field`, `aws_field`, `value` | +| `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 | | `contributing`, `evidence` | Evidence IDs of every record used, and those records | From a280b8ea829fa6032fe42f40ee2a1fb4c2c54555 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 20:54:21 +0900 Subject: [PATCH 11/13] Show plugin analyses in the inspector, in the plugin's own words The inspector called the exposure evaluator in core, matched AWS rule constants to find its titles, and only appeared on nodes labelled Instance. It now asks plugins what they offer and shows each analysis on nodes with a label it declares. Titles, verdicts, rules and condition text all come from the plugin, including their Chinese, and a verdict's tone sets how it is shown. The graph is held only while its records are read, so a plugin that takes a while doesn't block the window, and Stop ends one. A plugin not approved as it is now isn't started just to ask what it offers. "Show path on canvas" draws what the host rebuilt from the plugin's references, which also replaces the fields that were skipped in JSON. A test checks that the command and the library the window calls give the same findings, and another that the window's code names no provider's labels or rules. Refs #55, #35 Co-Authored-By: Claude Opus 5.5 --- app/src/db.rs | 14 ++- app/src/i18n.rs | 41 ++----- app/src/ui/canvas.rs | 168 ++++++++++++++++++--------- app/src/ui/explorer.rs | 228 +++++++++++++++++++------------------ app/tests/investigation.rs | 27 +++++ docs/exposure-rules.md | 19 ++-- 6 files changed, 292 insertions(+), 205 deletions(-) diff --git a/app/src/db.rs b/app/src/db.rs index c9417ec..ba8c666 100644 --- a/app/src/db.rs +++ b/app/src/db.rs @@ -6,6 +6,8 @@ use std::sync::atomic::AtomicBool; use std::sync::{LazyLock, Mutex, PoisonError}; use khonsu_core::explore::{Exploration, ExploreRequest}; +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; @@ -99,10 +101,16 @@ pub fn explore(request: &ExploreRequest, cancel: &AtomicBool) -> Result Result { +/// 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(); - with_graph(|g| g.exposure(&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 { diff --git a/app/src/i18n.rs b/app/src/i18n.rs index 29140e2..2d60be7 100644 --- a/app/src/i18n.rs +++ b/app/src/i18n.rs @@ -347,35 +347,18 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("evidence.redacted", "已遮盖疑似机密:{}", "Masked as possible secrets: {}"), ("evidence.copy", "复制证据 JSON", "Copy evidence as JSON"), ("evidence.copy_path", "复制路径证据 JSON", "Copy path evidence as JSON"), - ("exposure.title", "暴露分析", "Exposure"), - ("exposure.check", "检查暴露", "Check exposure"), - ("exposure.recheck", "重新检查", "Check again"), - ("exposure.claim", "根据配置推断的潜在暴露。未发送任何流量,因此不能说明资源可达;没有发现也不能说明资源安全。", "Potential exposure worked out from configuration. No traffic was sent, so this does not show that anything is reachable, and no finding does not show that a resource is safe."), - ("exposure.failed", "检查失败:{}", "The check failed: {}"), - ("exposure.incomplete", "图太大,未能读完,所以缺少发现不说明任何问题。", "The graph was too large to read whole, so a missing finding means nothing."), - ("exposure.none", "没有关于此实例的发现。如果刚同步过,请重新检查。", "No findings about this instance. If you just synced, check again."), - ("exposure.verdict.potential", "潜在暴露", "Potential exposure"), - ("exposure.verdict.insufficient", "数据不足", "Insufficient data"), - ("exposure.verdict.not_indicated", "规则未指向", "Not indicated by this rule"), - ("exposure.status.met", "满足", "Met"), - ("exposure.status.not_met", "不满足", "Not met"), - ("exposure.status.unknown", "未知", "Unknown"), - ("exposure.rule.direct", "实例直接向互联网开放", "Instance open to the internet directly"), - ("exposure.rule.balancer", "位于面向互联网的负载均衡器之后", "Behind an internet-facing load balancer"), - ("exposure.rule.lateral", "位于潜在暴露实例之后一条安全组规则", "One security group rule behind a potentially exposed instance"), - ("exposure.cond.running", "实例正在运行", "The instance is running"), - ("exposure.cond.address", "实例有公网地址", "The instance has a public address"), - ("exposure.cond.open_rule", "实例有向互联网开放的安全组规则", "The instance has a security group rule open to the internet"), - ("exposure.cond.route", "子网有通往互联网网关的活动默认路由", "Its subnet has an active default route to an internet gateway"), - ("exposure.cond.facing", "负载均衡器面向互联网", "The load balancer is internet-facing"), - ("exposure.cond.balancer_open", "负载均衡器有向互联网开放的安全组规则", "The load balancer has a security group rule open to the internet"), - ("exposure.cond.healthy", "目标健康", "The target is healthy"), - ("exposure.cond.target_allows", "目标的安全组允许来自负载均衡器的目标端口", "The target's security group allows the target port from the load balancer"), - ("exposure.cond.source", "来源实例有潜在暴露发现", "The source instance has a potential exposure finding"), - ("exposure.cond.group_allows", "安全组规则允许来源实例的安全组", "A security group rule allows the source instance's group"), - ("exposure.assumptions", "未检查", "Not checked"), - ("exposure.show_path", "在画布上显示路径", "Show path on canvas"), - ("exposure.copy", "复制发现 JSON", "Copy finding as JSON"), + ("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, 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"), diff --git a/app/src/ui/canvas.rs b/app/src/ui/canvas.rs index ae4fa23..311b275 100644 --- a/app/src/ui/canvas.rs +++ b/app/src/ui/canvas.rs @@ -23,7 +23,8 @@ use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; use khonsu_core::evidence::{self, Evidence}; use khonsu_core::explore::{Direction, Exploration}; -use khonsu_core::exposure::{Status, Verdict}; +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; @@ -293,6 +294,7 @@ impl GraphCanvas { /// 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(); @@ -507,21 +509,50 @@ impl GraphCanvas { self.explorer.walk = Some(walk); } - /// Evaluate the exposure rules over the whole graph, not just the canvas. - fn check_exposure(&mut self, window: &mut Window, cx: &mut Context) { - if self.explorer.checking_exposure { + /// 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; } - self.explorer.checking_exposure = true; - self.explorer.exposure_error = None; + 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 result = smol::unblock(crate::db::exposure).await; + let stop = cancel.clone(); + let result = smol::unblock(move || crate::db::run_analysis(&offered, &stop)).await; this.update(cx, |this, cx| { - this.explorer.checking_exposure = false; + // 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(report) => this.explorer.exposure = Some(report), - Err(error) => this.explorer.exposure_error = Some(error.message), + Ok(Some(report)) => { + this.explorer.reports.insert(key, report); + } + Ok(None) => {} + Err(error) => { + this.explorer.analysis_errors.insert(key, error); + } } cx.notify(); }) @@ -531,8 +562,8 @@ impl GraphCanvas { } /// Show a finding's candidate path, as a walk Path Explorer can go on from. - fn show_finding(&mut self, finding_id: &str, target: i64, cx: &mut Context) { - let report = self.explorer.exposure.as_ref(); + fn show_finding(&mut self, key: &str, finding_id: &str, target: i64, cx: &mut Context) { + let report = self.explorer.reports.get(key); let Some(walk) = report.and_then(|report| report.findings.iter().find(|finding| finding.finding_id == finding_id)).and_then(|finding| finding.exploration()) else { return; }; @@ -630,28 +661,45 @@ impl GraphCanvas { .into_any_element() } - /// What the rules say about a selected instance, and why. Not shown for - /// anything else: the rules are only about instances. - fn render_exposure(&self, id: i64, cx: &mut Context) -> Option { - let node = self.nodes.get(&id).filter(|node| node.labels.iter().any(|label| label == "Instance"))?; + /// 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 findings = explorer.exposure.as_ref().map(|report| explorer::findings_for(report, node)); + 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 title = explorer::rule_key(&finding.rule_id).map_or_else(|| finding.rule_id.clone(), |key| tr(key).to_string()); + 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::condition_key(&condition.id).map_or_else(|| condition.statement.clone(), |key| tr(key).to_string()); - let color = if condition.status == Status::Met { muted } else { warning }; + 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( @@ -660,7 +708,7 @@ impl GraphCanvas { .whitespace_normal() .font_family(mono.clone()) .text_color(muted) - .child(format!("{} = {}", field.aws_field, crate::ui::results::cell_text(value))), + .child(format!("{} = {}", field.source_field, crate::ui::results::cell_text(value))), ) }); v_flex() @@ -673,20 +721,21 @@ impl GraphCanvas { .child(div().text_xs().whitespace_normal().text_color(color).child(condition.detail.clone())) .children(fields.collect::>()) }); - let verdict_color = if finding.verdict == Verdict::NotIndicated { muted } else { warning }; + 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(tr(explorer::verdict_key(finding.verdict)))) - .child(div().text_xs().whitespace_normal().child(title)) + .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("exposure.assumptions"))) + .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() @@ -694,48 +743,55 @@ impl GraphCanvas { .flex_wrap() .when(drawable, |this| { this.child( - Button::new(("exposure-path", ix)) + Button::new(("analysis-path", button)) .xsmall() .outline() - .label(tr("exposure.show_path")) - .on_click(cx.listener(move |this, _, _, cx| this.show_finding(&finding_id, id, cx))), + .label(tr("analysis.show_path")) + .on_click(cx.listener(move |this, _, _, cx| this.show_finding(&finding_key, &finding_id, id, cx))), ) }) .child( - Button::new(("exposure-copy", ix)) + Button::new(("analysis-copy", button)) .xsmall() .outline() - .label(tr("exposure.copy")) + .label(tr("analysis.copy")) .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()))), ), ), ); } - let checked = explorer.exposure.is_some(); - let complete = explorer.exposure.as_ref().is_none_or(|report| report.complete); + 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); - Some( - v_flex() - .gap_1() - .child(div().text_sm().font_semibold().child(tr("exposure.title"))) - .child( - Button::new("exposure-check") - .xsmall() - .outline() - .label(tr(if checked { "exposure.recheck" } else { "exposure.check" })) - .loading(explorer.checking_exposure) - .on_click(cx.listener(|this, _, window, cx| this.check_exposure(window, cx))), - ) - .when_some(explorer.exposure_error.clone(), |this, error| { - this.child(div().text_xs().whitespace_normal().text_color(warning).child(trf("exposure.failed", &[&error]))) - }) - .when(checked, |this| this.child(div().text_xs().whitespace_normal().text_color(muted).child(tr("exposure.claim")))) - .when(!complete, |this| this.child(div().text_xs().whitespace_normal().text_color(warning).child(tr("exposure.incomplete")))) - .when(none, |this| this.child(div().text_xs().whitespace_normal().text_color(muted).child(tr("exposure.none")))) - .children(list) - .into_any_element(), - ) + 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 { @@ -933,7 +989,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 exposure = self.selected_node().and_then(|id| self.render_exposure(id, cx)); + let analyses = self.selected_node().and_then(|id| self.render_analyses(id, cx)); Some( v_flex() .id("inspector") @@ -959,8 +1015,8 @@ impl GraphCanvas { .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(exposure, |this, exposure| { - this.child(exposure).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)) diff --git a/app/src/ui/explorer.rs b/app/src/ui/explorer.rs index 3eeea02..c3b406d 100644 --- a/app/src/ui/explorer.rs +++ b/app/src/ui/explorer.rs @@ -1,13 +1,14 @@ //! 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::BTreeSet; -use std::sync::atomic::AtomicBool; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use khonsu_core::evidence::{Assertion, Evidence, Field}; use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Limit, DEFAULT_MAX_HOPS, MAX_HOPS}; -use khonsu_core::exposure::{Finding, Report, Status, Verdict, RULE_BALANCER, RULE_DIRECT, RULE_LATERAL}; +use khonsu_core::plugin_analysis::{Finding, Offered, Report}; +use khonsu_core::protocol::{AnalysisInfo, ConditionStatus}; use khonsu_core::util::format_unix_time; use khonsu_core::{GraphData, GraphEdge, GraphNode}; @@ -39,10 +40,14 @@ pub struct Explorer { pub walk: Option, /// A walk that failed; the canvas keeps what it had. pub error: Option, - /// The last exposure check, kept until the canvas gets a new result. - pub exposure: Option, - pub checking_exposure: bool, - pub exposure_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, history: Vec, } @@ -57,9 +62,10 @@ impl Default for Explorer { running: None, walk: None, error: None, - exposure: None, - checking_exposure: false, - exposure_error: None, + analyses: Vec::new(), + reports: BTreeMap::new(), + checking: BTreeMap::new(), + analysis_errors: BTreeMap::new(), history: Vec::new(), } } @@ -117,8 +123,12 @@ impl Explorer { pub fn forget_walks(&mut self) { self.walk = None; self.error = None; - self.exposure = None; - self.exposure_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.starts.clear(); self.history.clear(); } @@ -200,47 +210,34 @@ pub fn findings_for<'a>(report: &'a Report, node: &GraphNode) -> Vec<&'a Finding report.findings.iter().filter(|finding| finding.resource == resource).collect() } -pub fn verdict_key(verdict: Verdict) -> &'static str { - match verdict { - Verdict::PotentialExposure => "exposure.verdict.potential", - Verdict::InsufficientData => "exposure.verdict.insufficient", - Verdict::NotIndicated => "exposure.verdict.not_indicated", - } +/// 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) } -pub fn status_key(status: Status) -> &'static str { - match status { - Status::Met => "exposure.status.met", - Status::NotMet => "exposure.status.not_met", - Status::Unknown => "exposure.status.unknown", - } +/// 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 rule's title in the window's language. -pub fn rule_key(rule_id: &str) -> Option<&'static str> { - match rule_id { - RULE_DIRECT => Some("exposure.rule.direct"), - RULE_BALANCER => Some("exposure.rule.balancer"), - RULE_LATERAL => Some("exposure.rule.lateral"), - _ => None, - } +/// 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 condition's statement in the window's language. -pub fn condition_key(id: &str) -> Option<&'static str> { - Some(match id { - "instance-running" => "exposure.cond.running", - "public-address" => "exposure.cond.address", - "open-inbound-rule" => "exposure.cond.open_rule", - "internet-route" => "exposure.cond.route", - "internet-facing" => "exposure.cond.facing", - "balancer-open-rule" => "exposure.cond.balancer_open", - "target-healthy" => "exposure.cond.healthy", - "target-allows-balancer" => "exposure.cond.target_allows", - "source-potentially-exposed" => "exposure.cond.source", - "group-allows-source" => "exposure.cond.group_allows", - _ => return None, - }) +/// 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`. @@ -304,89 +301,100 @@ mod tests { } } - /// A public balancer in front of `app`, and `db` allowing `app`'s group, so - /// that every rule produces a finding. - fn exposure_report() -> Report { - let at = |id: i64, label: &str, extra: serde_json::Value| GraphNode { - id, - labels: vec![label.into()], - properties: extra.as_object().unwrap().clone(), - }; - let link = |id: i64, source: i64, rel: &str, target: i64, extra: serde_json::Value| GraphEdge { - id, - source, - target, - rel_type: rel.into(), - properties: extra.as_object().unwrap().clone(), - }; - let data = GraphData { - nodes: vec![ - at(1, "Cidr", json!({"khonsu_id": "net", "cidr": "0.0.0.0/0"})), - at(2, "SecurityGroup", json!({"khonsu_id": "sg-lb"})), - at(3, "SecurityGroup", json!({"khonsu_id": "sg-app"})), - at(4, "SecurityGroup", json!({"khonsu_id": "sg-db"})), - at(5, "LoadBalancer", json!({"khonsu_id": "lb", "scheme": "internet-facing"})), - at(6, "TargetGroup", json!({"khonsu_id": "tg", "port": 80})), - at(7, "Instance", json!({"khonsu_id": "app", "name": "app", "state": "running"})), - at(8, "Instance", json!({"khonsu_id": "db", "name": "db", "state": "running"})), - ], - edges: vec![ - link(1, 1, "ROUTES_TO", 5, json!({"khonsu_id": "a"})), - link(2, 1, "ALLOWS", 2, json!({"khonsu_id": "b", "protocol": "tcp", "ports": "80"})), - link(3, 5, "PROTECTED_BY", 2, json!({"khonsu_id": "c"})), - link(4, 5, "FORWARDS_TO", 6, json!({"khonsu_id": "d"})), - link(5, 6, "ROUTES_TO", 7, json!({"khonsu_id": "e", "health": "healthy"})), - link(6, 7, "PROTECTED_BY", 3, json!({"khonsu_id": "f"})), - link(7, 2, "ALLOWS", 3, json!({"khonsu_id": "g", "protocol": "tcp", "ports": "80"})), - link(8, 8, "PROTECTED_BY", 4, json!({"khonsu_id": "h"})), - link(9, 3, "ALLOWS", 4, json!({"khonsu_id": "i", "protocol": "tcp", "ports": "5432"})), - ], - }; - khonsu_core::exposure::evaluate(&data, &[], true) + 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(), + } } - #[test] - fn every_rule_and_condition_has_words_in_the_window() { - let report = exposure_report(); - assert_eq!(report.summary.potential_exposure, 2, "{report:#?}"); - let rules: BTreeSet<&str> = report.findings.iter().map(|finding| finding.rule_id.as_str()).collect(); - assert_eq!(rules.len(), 3, "the test should reach every rule: {rules:?}"); - for finding in &report.findings { - let title = rule_key(&finding.rule_id).unwrap_or_else(|| panic!("no title for {}", finding.rule_id)); - assert_ne!(tr(title), title, "{title} is not in the table"); - for condition in &finding.conditions { - let key = condition_key(&condition.id).unwrap_or_else(|| panic!("no words for {}", condition.id)); - assert_ne!(tr(key), key, "{key} is not in the table"); - } + 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, + findings: vec![finding], } - for key in [Verdict::PotentialExposure, Verdict::InsufficientData, Verdict::NotIndicated].map(verdict_key) { - assert_ne!(tr(key), key); + } + + #[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()); } - for key in [Status::Met, Status::NotMet, Status::Unknown].map(status_key) { - assert_ne!(tr(key), key); + } + + /// 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 = exposure_report(); + let report = report(); let db = GraphNode { id: 8, - labels: vec!["Instance".into()], + labels: vec!["Thing".into()], properties: Map::from_iter([("khonsu_id".to_string(), json!("db"))]), }; let found = findings_for(&report, &db); - assert!(!found.is_empty()); - assert!(found.iter().all(|finding| finding.resource == "node: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 { - exposure: Some(report), - exposure_error: Some("x".into()), + 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.exposure.is_none() && explorer.exposure_error.is_none(), "the graph may have changed"); + 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"); } #[test] diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index 06cd8f8..b0e302c 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -995,3 +995,30 @@ fn a_plugin_with_no_analyses_still_syncs_and_says_it_has_none() { 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"); +} diff --git a/docs/exposure-rules.md b/docs/exposure-rules.md index 32f2610..7b6b62f 100644 --- a/docs/exposure-rules.md +++ b/docs/exposure-rules.md @@ -167,18 +167,23 @@ window has it open. ## In the window -Select an instance on a graph canvas, then choose **Check exposure** in the -inspector. 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**. +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 `khonsu exposure`. +- **Copy finding as JSON** copies the same record as the command. In the Chinese interface, verdicts, rule titles and condition statements are -translated. Each condition's detail and the list of what was not checked stay in -English. +translated, by the plugin. Each condition's detail and the list of what was not +checked stay in English. From 8f61b3da6dee0fb68fc88460b6cc19a0d0014915 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 21:05:39 +0900 Subject: [PATCH 12/13] Keep a finding's exact path when saving it as an investigation Saving after "Show path on canvas" stored only the walk's filters, and reopening walked again. That finds the fewest hops, which isn't always the finding's path: a lateral finding's five hops came back as three, with nothing saying so. A saved path can now hold the stable references a finding's path crossed the process boundary as. Reopening looks those records up instead of walking, and if one has gone or they no longer connect, path_same is false and the walk runs instead. The window saves them for the finding path on the canvas, and `khonsu investigation save --path-refs` takes a finding's path_refs. Older files and walks are read and saved as before. Refs #55, #34 Co-Authored-By: Claude Opus 5.5 --- app/src/ui/canvas.rs | 9 ++- app/src/ui/explorer.rs | 39 ++++++++- app/tests/investigation.rs | 57 +++++++++++++ crates/khonsu-cli/src/investigation.rs | 9 +++ crates/khonsu-core/src/investigation.rs | 102 ++++++++++++++++++++++-- docs/investigations.md | 12 +++ skills/khonsugraph/references/cli.md | 1 + 7 files changed, 219 insertions(+), 10 deletions(-) diff --git a/app/src/ui/canvas.rs b/app/src/ui/canvas.rs index 30f4a98..11062f2 100644 --- a/app/src/ui/canvas.rs +++ b/app/src/ui/canvas.rs @@ -530,6 +530,7 @@ impl GraphCanvas { 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); @@ -547,6 +548,7 @@ impl GraphCanvas { query, walk: Some(request), target, + path_refs, dashboard: None, }; let saved = crate::db::capture_investigation(capture).map_err(|error| error.message)?; @@ -578,7 +580,10 @@ impl GraphCanvas { self.explorer.restored = Some(explorer::Restored::new(saved, &reopened)); self.explorer.walk = reopened.walk; self.load_relationship_types(window, cx); - if let Some(target) = reopened.target.filter(|_| reopened.path.is_some()) { + 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(); @@ -641,12 +646,14 @@ impl GraphCanvas { 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 { diff --git a/app/src/ui/explorer.rs b/app/src/ui/explorer.rs index 91807af..da03bc4 100644 --- a/app/src/ui/explorer.rs +++ b/app/src/ui/explorer.rs @@ -6,10 +6,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use khonsu_core::evidence::{Assertion, Evidence, Field}; -use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Limit, DEFAULT_MAX_HOPS, MAX_HOPS}; +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}; +use khonsu_core::protocol::{AnalysisInfo, ConditionStatus, HopRef}; use khonsu_core::util::format_unix_time; use khonsu_core::{GraphData, GraphEdge, GraphNode}; @@ -107,6 +107,8 @@ pub struct Explorer { 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, } @@ -126,6 +128,7 @@ impl Default for Explorer { checking: BTreeMap::new(), analysis_errors: BTreeMap::new(), restored: None, + finding_path: None, history: Vec::new(), } } @@ -190,6 +193,7 @@ impl Explorer { cancel.store(true, Ordering::Relaxed); } self.restored = None; + self.finding_path = None; self.starts.clear(); self.history.clear(); } @@ -275,6 +279,18 @@ pub fn findings_for<'a>(report: &'a Report, node: &GraphNode) -> Vec<&'a Finding 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) @@ -527,6 +543,25 @@ mod tests { 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"), &[]); diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index e895fdf..756c6fe 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -1217,3 +1217,60 @@ fn the_command_and_the_library_the_window_uses_give_the_same_findings() { 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 file = env.path("lateral.investigation"); + env.ok(&["investigation", "save", text(&file), "-d", text(&db), "--path-refs", &serde_json::to_string(&lateral["path_refs"]).unwrap()]); + let gone = lateral["path_refs"][3]["edge"].as_str().unwrap().strip_prefix("rel:").unwrap(); + env.ok(&["query", "-d", text(&db), "--write", "--cypher", "MATCH ()-[r:ALLOWS]->() WHERE r.khonsu_id = $id DELETE r", "-p", &format!("id={}", serde_json::to_string(gone).unwrap())]); + 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/investigation.rs b/crates/khonsu-cli/src/investigation.rs index 745b2ee..9f75822 100644 --- a/crates/khonsu-cli/src/investigation.rs +++ b/crates/khonsu-cli/src/investigation.rs @@ -53,6 +53,10 @@ pub struct SaveArgs { /// 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, @@ -115,11 +119,16 @@ fn save(args: SaveArgs) -> Result { _ => 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))?; diff --git a/crates/khonsu-core/src/investigation.rs b/crates/khonsu-core/src/investigation.rs index bdc9978..7afa169 100644 --- a/crates/khonsu-core/src/investigation.rs +++ b/crates/khonsu-core/src/investigation.rs @@ -8,9 +8,10 @@ //! `docs/investigations.md`. use crate::engine::{Graph, QueryError}; -use crate::evidence::{self, PathEvidence}; -use crate::explore::{Direction, Exploration, ExploreRequest}; +use crate::evidence::{self, Field, PathEvidence}; +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::Source; use crate::util::unix_now; @@ -59,6 +60,10 @@ 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 @@ -163,6 +168,9 @@ pub struct Capture { 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, } @@ -195,6 +203,9 @@ pub struct Reopened { pub path: Option, /// Whether the path rests on the saved records. `None` when none was saved. pub path_same: Option, + /// 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 { @@ -236,6 +247,15 @@ impl Graph { } 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() { @@ -245,8 +265,11 @@ impl Graph { 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 = evidence::for_path(&found, target, sources).map(|record| record.supporting).unwrap_or_default(); - path = Some(SavedPath { target: target_ref, supporting }); + 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, @@ -303,8 +326,13 @@ impl Graph { }; let target_missing = saved.path.is_some() && target.is_none(); - let mut walk = None; - if let Some(plan) = saved.walk.as_ref().filter(|_| !starts.is_empty()) { + // 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, @@ -315,7 +343,10 @@ impl Graph { }; walk = Some(self.explore(&request, cancel)?); } - let path = walk.as_ref().zip(target).and_then(|(walk, target)| evidence::for_path(walk, target, sources)); + 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 @@ -331,9 +362,50 @@ impl Graph { 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 @@ -362,6 +434,22 @@ impl Graph { } } +/// 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, + } +} + fn compare(saved: &Fingerprint, now: &Fingerprint) -> (GraphState, Vec) { let mut differences = Vec::new(); if saved.nodes != now.nodes { diff --git a/docs/investigations.md b/docs/investigations.md index 8ef1097..1ca5e4d 100644 --- a/docs/investigations.md +++ b/docs/investigations.md @@ -11,6 +11,18 @@ khonsu investigation save web.investigation --db graphs/aws \ 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 diff --git a/skills/khonsugraph/references/cli.md b/skills/khonsugraph/references/cli.md index 7c22a1a..a6951ca 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -104,6 +104,7 @@ See `docs/exposure-rules.md` for the rules and a walkthrough. ```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 ``` From 3f7eea39e4807cfd851eeac82239ea7b1edcdacf Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 21:09:09 +0900 Subject: [PATCH 13/13] Test a removed record with a real re-sync The test deleted a relationship with a query that matched nothing, so it never saw a changed graph. Change the saved responses and sync instead, as a real account would change. Refs #55 Co-Authored-By: Claude Opus 5.5 --- app/tests/investigation.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index 756c6fe..2f27481 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -1253,10 +1253,24 @@ fn a_findings_path_can_be_saved_and_reopened_as_it_was() { 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(&db), "--path-refs", &serde_json::to_string(&lateral["path_refs"]).unwrap()]); - let gone = lateral["path_refs"][3]["edge"].as_str().unwrap().strip_prefix("rel:").unwrap(); - env.ok(&["query", "-d", text(&db), "--write", "--cypher", "MATCH ()-[r:ALLOWS]->() WHERE r.khonsu_id = $id DELETE r", "-p", &format!("id={}", serde_json::to_string(gone).unwrap())]); + 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}");