From 8b92139000a5f80476d50bb36139cf499fe9a708 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Mon, 2 Nov 2026 15:25:42 +0900 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 1137df953fc72baea0aed3e4e698098bd7318ff9 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Tue, 3 Nov 2026 20:50:07 +0900 Subject: [PATCH 4/4] 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 |