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..330678e 100644 --- a/app/src/i18n.rs +++ b/app/src/i18n.rs @@ -332,6 +332,47 @@ 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"), + ("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..04a8b1b 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,14 @@ 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; 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 +133,12 @@ pub struct GraphCanvas { /// Nodes the result held beyond [`NODE_LIMIT`]. hidden: usize, expanding: bool, + explorer: Explorer, + /// Configured sources, read when a selection changes, for the inspector's + /// evidence. + sources: Vec, + /// The canvas shows only one path of the last walk. + path_view: bool, /// The node property shown as each node's caption, before the usual /// name-like ones: a dashboard `graph`'s `caption`. caption_key: Option, @@ -167,6 +179,9 @@ impl GraphCanvas { selected: None, hidden: 0, expanding: false, + explorer: Explorer::default(), + sources: Vec::new(), + path_view: false, caption_key: None, group_key: None, label_mode: LabelMode::Auto, @@ -276,6 +291,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; @@ -348,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), @@ -358,6 +376,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 +425,340 @@ 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); + } + + /// 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; + let border = cx.theme().border; + let mono = cx.theme().mono_font_family.clone(); + let explorer = &self.explorer; + let running = explorer.running.is_some(); + let target = cx.entity().downgrade(); + let current = explorer.direction; + let way = |id: &'static str, label: &'static str, direction: Direction, cx: &mut Context| { + let button = Button::new(id).xsmall().label(tr(label)); + let button = if current == direction { button.primary() } else { button.outline() }; + button.on_click(cx.listener(move |this, _, _, cx| { + this.explorer.direction = direction; + cx.notify(); + })) + }; + let types_label = match explorer.types.len() { + 0 => tr("explore.types_all").to_string(), + count => trf("explore.types_some", &[&count.to_string()]), + }; + let (available, chosen) = (explorer.available.clone(), explorer.types.clone()); + let is_start = explorer.starts.contains(&node); + let path = explorer.walk.as_ref().map(|walk| explorer::explained_path(walk, node)).unwrap_or_default(); + let path_evidence = explorer + .walk + .as_ref() + .and_then(|walk| evidence::for_path(walk, node, &self.sources)) + .map(|record| serde_json::to_string_pretty(&record).expect("evidence serializes")); + + v_flex() + .pt_2() + .mt_1() + .gap_1p5() + .border_t_1() + .border_color(border) + .child(div().text_sm().font_semibold().child(tr("explore.title"))) + .child( + h_flex() + .gap_1() + .child(way("explore-out", "explore.out", Direction::Out, cx)) + .child(way("explore-in", "explore.in", Direction::In, cx)) + .child(way("explore-both", "explore.both", Direction::Both, cx)), + ) + .child( + h_flex() + .gap_1() + .items_center() + .child( + Button::new("explore-fewer") + .xsmall() + .outline() + .icon(IconName::Minus) + .tooltip(tr("explore.fewer_hops")) + .on_click(cx.listener(|this, _, _, cx| { + this.explorer.change_hops(-1); + cx.notify(); + })), + ) + .child(div().text_xs().child(trf("explore.hops", &[&explorer.max_hops.to_string()]))) + .child( + Button::new("explore-more") + .xsmall() + .outline() + .icon(IconName::Plus) + .tooltip(tr("explore.more_hops")) + .on_click(cx.listener(|this, _, _, cx| { + this.explorer.change_hops(1); + cx.notify(); + })), + ), + ) + .child(Button::new("explore-types").xsmall().outline().label(types_label).dropdown_menu(move |mut menu, _, _| { + for name in &available { + let (target, picked) = (target.clone(), name.clone()); + menu = menu.item(PopupMenuItem::new(name.clone()).checked(chosen.contains(name)).on_click(move |_, _, cx| { + target + .update(cx, |canvas, cx| { + canvas.explorer.toggle_type(&picked); + cx.notify(); + }) + .ok(); + })); + } + menu + })) + .child( + h_flex() + .gap_1() + .items_center() + .child( + Button::new("explore-start") + .xsmall() + .outline() + .label(tr(if is_start { "explore.remove_start" } else { "explore.add_start" })) + .on_click(cx.listener(move |this, _, _, cx| { + this.explorer.toggle_start(node); + cx.notify(); + })), + ) + .when(!explorer.starts.is_empty(), |this| { + this.child(div().text_xs().text_color(muted).child(trf("explore.starts", &[&explorer.starts.len().to_string()]))) + }), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("explore-run") + .xsmall() + .primary() + .label(tr("explore.run")) + .loading(running) + .on_click(cx.listener(|this, _, window, cx| this.explore(window, cx))), + ) + .when_some(explorer.running.clone(), |this, cancel| { + this.child( + Button::new("explore-cancel") + .xsmall() + .outline() + .label(tr("explore.cancel")) + .on_click(move |_, _, _| cancel.store(true, Ordering::Relaxed)), + ) + }), + ) + .children(explorer.status_lines().into_iter().map(|(line, warn)| { + div().text_xs().whitespace_normal().text_color(if warn { warning } else { muted }).child(line) + })) + .when(!path.is_empty(), |this| { + this.child(div().pt_1().text_xs().font_semibold().child(tr("explore.path"))) + .children(path.into_iter().map(|hop| { + div().text_xs().whitespace_normal().font_family(mono.clone()).child(hop) + })) + .child(div().text_xs().whitespace_normal().text_color(muted).child(tr("explore.candidate"))) + .when_some(path_evidence, |this, copy| { + this.child( + Button::new("evidence-copy-path") + .xsmall() + .outline() + .label(tr("evidence.copy_path")) + .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()))), + ) + }) + .when(!self.path_view, |this| { + this.child( + Button::new("explore-show-path") + .xsmall() + .outline() + .label(tr("explore.show_path")) + .on_click(cx.listener(move |this, _, _, cx| this.show_path(node, cx))), + ) + }) + }) + .into_any_element() + } + fn render_inspector(&self, cx: &mut Context) -> Option { let selected = self.selected?; let (title, color, subtitle, properties) = match selected { @@ -454,6 +807,14 @@ impl GraphCanvas { .child(div().font_semibold().child(title)), ) .children(subtitle.map(|s| div().text_xs().text_color(muted).font_family(mono.clone()).child(s))) + // Above the properties: a node can have enough of them to push + // a walk's result out of sight. + .when_some(self.selected_node(), |this, id| { + this.child(self.render_explorer(id, cx)).child(div().border_b_1().border_color(cx.theme().border)) + }) + .when_some(self.selected_evidence(selected), |this, record| { + this.child(self.render_evidence(record, cx)).child(div().border_b_1().border_color(cx.theme().border)) + }) .children(rows.into_iter().map(|(key, value)| { let provenance = key.starts_with("khonsu_"); v_flex() @@ -494,6 +855,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 +867,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 +913,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 +1032,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..3933f6f --- /dev/null +++ b/app/src/ui/explorer.rs @@ -0,0 +1,398 @@ +//! 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::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}; + +/// 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() +} + +/// 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())?; + 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 evidence_rows_say_not_supplied_for_what_is_missing() { + let record = khonsu_core::evidence::for_node(&node(1, "by hand"), &[]); + let rows = evidence_rows(&record); + let value = |key: &str| rows.iter().find(|(row, _)| *row == key).unwrap().1.clone(); + assert_eq!(value("evidence.id"), "node-internal:1"); + for key in ["evidence.source", "evidence.plugin", "evidence.written", "evidence.last_sync", "evidence.snapshot", "evidence.assertion"] { + assert_eq!(value(key), tr("evidence.unknown"), "{key}"); + } + } + + #[test] + fn a_walk_starts_from_the_selection_unless_starts_were_added() { + let mut explorer = Explorer::default(); + assert!(explorer.request(None).is_none()); + assert_eq!(explorer.request(Some(7)).unwrap().starts, [7]); + + explorer.toggle_start(2); + explorer.toggle_start(1); + assert_eq!(explorer.request(Some(7)).unwrap().starts, [1, 2]); + assert_eq!(explorer.request(None).unwrap().starts, [1, 2]); + + explorer.toggle_start(1); + assert_eq!(explorer.request(None).unwrap().starts, [2]); + } + + #[test] + fn the_request_carries_the_filters_and_hops_stay_in_range() { + let mut explorer = Explorer { + direction: Direction::In, + ..Explorer::default() + }; + explorer.toggle_type("CALLS"); + explorer.toggle_type("RUNS"); + explorer.toggle_type("RUNS"); + for _ in 0..40 { + explorer.change_hops(1); + } + assert_eq!(explorer.max_hops, MAX_HOPS); + for _ in 0..40 { + explorer.change_hops(-1); + } + assert_eq!(explorer.max_hops, 1); + + let request = explorer.request(Some(1)).unwrap(); + assert_eq!(request.direction, Direction::In); + assert_eq!(request.rel_types, ["CALLS"]); + assert_eq!(request.max_hops, 1); + } + + #[test] + fn going_back_returns_views_newest_first_and_keeps_a_bounded_number() { + let mut explorer = Explorer::default(); + assert!(!explorer.can_go_back()); + let view = |id: i64| View { + nodes: vec![node(id, "x")], + edges: vec![], + walk: None, + selected: None, + }; + for id in 0..(HISTORY as i64 + 5) { + explorer.push_view(view(id)); + } + assert_eq!(explorer.pop_view().unwrap().nodes[0].id, HISTORY as i64 + 4); + let mut remaining = 1; + let mut oldest = None; + while let Some(view) = explorer.pop_view() { + remaining += 1; + oldest = Some(view.nodes[0].id); + } + assert_eq!(remaining, HISTORY); + assert_eq!(oldest, Some(5)); + } + + #[test] + fn a_new_result_forgets_walks_but_keeps_the_filters() { + let mut explorer = Explorer { + walk: Some(walk()), + error: Some("x".into()), + max_hops: 5, + ..Explorer::default() + }; + explorer.toggle_start(1); + explorer.toggle_type("CALLS"); + explorer.push_view(View { nodes: vec![], edges: vec![], walk: None, selected: None }); + explorer.forget_walks(); + assert!(explorer.walk.is_none() && explorer.error.is_none()); + assert!(explorer.starts.is_empty() && !explorer.can_go_back()); + assert_eq!(explorer.max_hops, 5); + assert!(explorer.types.contains("CALLS")); + } + + #[test] + fn a_path_keeps_only_its_own_nodes_and_relationships() { + let path = path_graph(&walk(), 3).unwrap(); + let ids: Vec = path.nodes.iter().map(|node| node.id).collect(); + let edges: Vec = path.edges.iter().map(|edge| edge.id).collect(); + assert_eq!(ids, [1, 2, 3]); + assert_eq!(edges, [10, 11]); + assert_eq!(explained_path(&walk(), 3), ["a -[NEXT]-> b", "b -[NEXT]-> c"]); + + assert!(path_graph(&walk(), 1).is_none(), "a start has no path to show"); + assert!(path_graph(&walk(), 99).is_none(), "not reached"); + assert!(explained_path(&walk(), 99).is_empty()); + } + + #[test] + fn an_empty_or_cut_answer_says_which_it_is() { + let lines = |walk: Exploration, error: Option<&str>| { + let explorer = Explorer { + walk: Some(walk), + error: error.map(String::from), + ..Explorer::default() + }; + explorer.status_lines() + }; + let none = lines(Exploration::default(), None); + assert_eq!(none, [(tr("explore.none").to_string(), false)]); + + // Empty because it was stopped is a warning, not "no relationships". + let cancelled = lines(Exploration { cancelled: true, ..Exploration::default() }, None); + assert!(cancelled.iter().all(|(line, _)| line != tr("explore.none"))); + assert!(cancelled.contains(&(tr("explore.cancelled").to_string(), true))); + + let capped = lines( + Exploration { + limits: vec![Limit::Hops, Limit::Nodes, Limit::Edges], + ..walk() + }, + None, + ); + for key in ["explore.limit.hops", "explore.limit.nodes", "explore.limit.edges"] { + assert!(capped.contains(&(tr(key).to_string(), true)), "{key}"); + } + + let missing = lines(Exploration { missing_starts: vec![9], ..Exploration::default() }, None); + assert!(missing.contains(&(tr("explore.missing_start").to_string(), true))); + + let failed = lines(Exploration::default(), Some("boom")); + assert!(failed[0].1 && failed[0].0.contains("boom")); + } + + #[test] + fn only_an_incomplete_walk_warns_on_the_canvas() { + let with = |walk: Exploration| Explorer { walk: Some(walk), ..Explorer::default() }; + assert!(Explorer::default().warning().is_none()); + assert!(with(walk()).warning().is_none()); + let cut = with(Exploration { limits: vec![Limit::Hops], ..walk() }); + assert_eq!(cut.warning().as_deref(), Some(tr("explore.limit.hops"))); + } +} diff --git a/app/src/ui/mod.rs b/app/src/ui/mod.rs index 2188f77..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..06cd8f8 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -24,6 +24,7 @@ use std::sync::Once; const INTERNET: &str = "shop/cidr:internet"; const WEB_1: &str = "shop/123456789012:us-east-1:i-web1"; const DB_1: &str = "shop/123456789012:us-east-1:i-db1"; +const ALLOWS_443: &str = r#"shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|"\"tcp\":\"443\"""#; /// Questions an investigator asks of the fixture. The name says what each /// result means; several are meant to find nothing. @@ -169,6 +170,12 @@ fn fixture() -> PathBuf { repo().join("plugins/aws/tests/fixture") } +/// The saved responses with a positive, a negative and an undecided case, and +/// route tables. See `docs/exposure-rules.md`. +fn exposure_fixture() -> PathBuf { + repo().join("plugins/aws/tests/fixture-exposure") +} + fn golden_path() -> PathBuf { repo().join("examples/aws/golden.json") } @@ -194,14 +201,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(), ), @@ -252,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(&["plugin", "analyze", "aws", "exposure", "-d", text(&graph)]) + }; normalize(&json!({ "sync": first, @@ -265,9 +279,31 @@ fn investigation(env: &Env) -> Value { "path": path, }, "dashboard": env.ok(&["check", text(&dash), "-d", text(&db)]), + "evidence": { + "web_1": env.ok(&["evidence", "-d", text(&db), "--node", WEB_1]), + "internet_to_web_group": env.ok(&["evidence", "-d", text(&db), "--rel", ALLOWS_443]), + }, + "explore": { + "exposure_of_web_1": env.ok(&[ + "explore", "-d", text(&db), "--from", INTERNET, "--to", WEB_1, + "--type", "ROUTES_TO,FORWARDS_TO", "--max-hops", "4", + ]), + "what_points_at_web_1": env.ok(&["explore", "-d", text(&db), "--from", WEB_1, "--direction", "in", "--max-hops", "1"]), + }, + "exposure": { + // Saved responses with no route tables, as a graph from before they were read. + "shop_fixture": exposure_brief(&env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)])), + "exposure_fixture": exposure_report, + }, })) } +/// (rule, resource, verdict) for every finding, which is what changes meaning. +fn exposure_brief(report: &Value) -> Value { + let findings = report["findings"].as_array().unwrap().iter(); + Value::Array(findings.map(|finding| json!([finding["rule_id"], finding["resource"], finding["verdict"]])).collect()) +} + /// Writes a graph as a zip without the lock files of the process that made it. fn write_snapshot(db: &Path, target: &Path) { fn add(zip: &mut zip::ZipWriter, root: &Path, dir: &Path) { @@ -300,7 +336,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 +385,292 @@ fn candidate_paths_follow_the_arrows_their_hop_cap_and_their_types() { assert_eq!(rules[0][3], json!(DB_1)); } +/// The AWS acceptance case of #32: a typed walk traces the resource, explains +/// each hop, and reports when a cap cut it short. +#[test] +fn explore_traces_a_resource_through_typed_hops() { + let env = Env::new(); + let db = env.synced_graph("graph"); + let trace = ["explore", "-d", text(&db), "--from", INTERNET, "--to", WEB_1, "--type", "ROUTES_TO,FORWARDS_TO"]; + + let out = env.ok(&trace); + assert_eq!(out["path"]["found"], true, "{out}"); + let words: Vec<&str> = out["path"]["hops"].as_array().unwrap().iter().map(|hop| hop["text"].as_str().unwrap()).collect(); + assert_eq!(words, ["Internet -[ROUTES_TO]-> shop", "shop -[FORWARDS_TO]-> web", "web -[ROUTES_TO]-> web-1"]); + assert_eq!(out["complete"], true, "{out}"); + + // Short of the hop it needs, it says the answer is cut, not that no path exists. + let mut short = trace.to_vec(); + short.extend(["--max-hops", "2"]); + let out = env.ok(&short); + assert_eq!(out["path"]["found"], false, "{out}"); + assert_eq!(out["complete"], false, "{out}"); + assert_eq!(out["limits"], json!(["hops"]), "{out}"); + + let (code, out) = env.khonsu(&["explore", "-d", text(&db), "--from", "shop/no-such-resource"]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("not_found")), "{out}"); + let (code, out) = env.khonsu(&["explore", "-d", text(&db), "--from", INTERNET, "--max-hops", "99"]); + assert_ne!(code, 0, "{out}"); +} + +/// #33 on the fixture: where a relationship came from, and the path that +/// rests on it, by IDs a script can keep. +#[test] +fn evidence_is_machine_readable_and_states_what_is_unknown() { + let env = Env::new(); + let db = env.synced_graph("graph"); + + let out = env.ok(&["evidence", "-d", text(&db), "--rel", ALLOWS_443]); + let record = &out["evidence"][0]; + assert_eq!(record["evidence_id"], format!("rel:{ALLOWS_443}"), "{out}"); + assert_eq!(record["kind"], "relationship"); + assert_eq!(record["types"], json!(["ALLOWS"])); + assert_eq!(record["from"]["value"], format!("node:{INTERNET}")); + assert_eq!(record["source"], json!({"status": "known", "value": "shop"})); + assert_eq!(record["assertion"], "explicit"); + assert_eq!(record["properties"]["ports"], "443"); + assert_eq!(record["source_snapshot"], json!({"status": "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}"); +} + +/// The finding about `resource` under `rule`. +fn finding<'a>(report: &'a Value, rule: &str, resource: &str) -> &'a Value { + let wanted = format!("node:{resource}"); + report["findings"] + .as_array() + .unwrap() + .iter() + .find(|finding| finding["rule_id"] == rule && finding["resource"] == wanted) + .unwrap_or_else(|| panic!("no {rule} finding on {resource}: {report}")) +} + +fn condition_status<'a>(finding: &'a Value, id: &str) -> &'a str { + let conditions = finding["conditions"].as_array().unwrap(); + conditions.iter().find(|condition| condition["id"] == id).unwrap_or_else(|| panic!("no {id}: {finding}"))["status"].as_str().unwrap() +} + +const DIRECT: &str = "aws.exposure.direct-instance"; +const BALANCER: &str = "aws.exposure.public-load-balancer"; +const LATERAL: &str = "aws.exposure.security-group-lateral"; + +fn instance(id: &str) -> String { + format!("shop/123456789012:us-east-1:{id}") +} + +/// #35's three cases, spelled out so that blessing a wrong golden file would +/// still have to get past this test. +#[test] +fn the_exposure_fixture_has_a_positive_a_negative_and_an_undecided_case() { + let env = Env::new(); + let db = env.path("graph"); + env.add_source(&db, &exposure_fixture()); + env.ok(&["plugin", "sync", "--source", "shop"]); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + assert_eq!(report["complete"], true); + assert_eq!(report["truncated"], 0); + assert_eq!(report["subjects"], 5); + assert!(report["claim"].as_str().unwrap().contains("does not show that anything is reachable")); + + // Positive: every fact the rule asks for is in the configuration. + let web = finding(&report, DIRECT, &instance("i-web1")); + assert_eq!(web["verdict"], "potential_exposure", "{web}"); + assert_eq!(web["path"]["hops"], json!(["Internet -[ALLOWS]-> web", "web <-[PROTECTED_BY]- web-1"])); + assert_eq!(web["path"]["rule_id"], json!({"status": "known", "value": DIRECT})); + assert_eq!(web["path"]["assertion"], "derived"); + assert!(web["assumptions"].as_array().unwrap().iter().any(|text| text.as_str().unwrap().contains("Network ACLs"))); + // Exact fields, with the name AWS gives them. + let address = web["conditions"].as_array().unwrap().iter().find(|condition| condition["id"] == "public-address").unwrap(); + assert_eq!(address["fields"][0]["field"], "public_ip"); + assert_eq!(address["fields"][0]["source_field"], "ec2 describe-instances: PublicIpAddress"); + assert_eq!(address["fields"][0]["value"], json!({"status": "known", "value": "198.51.100.10"})); + + let app = finding(&report, BALANCER, &instance("i-app1")); + assert_eq!(app["verdict"], "potential_exposure", "{app}"); + assert_eq!(app["path"]["hops"].as_array().unwrap().len(), 3); + let db_1 = finding(&report, LATERAL, &instance("i-db1")); + assert_eq!(db_1["verdict"], "potential_exposure", "{db_1}"); + assert_eq!(db_1["depends_on"], web["finding_id"]); + + // Negative: an open rule, and on its own that is not exposure. + let batch = finding(&report, DIRECT, &instance("i-batch1")); + assert_eq!(condition_status(batch, "open-inbound-rule"), "met"); + assert_eq!(condition_status(batch, "public-address"), "not_met"); + assert_eq!(condition_status(batch, "internet-route"), "not_met", "its subnet uses the main table, whose default goes to a NAT gateway"); + assert_eq!(batch["verdict"], "not_indicated", "{batch}"); + + // Undecided: its VPC's route tables are not in the saved responses. + let legacy = finding(&report, DIRECT, &instance("i-legacy1")); + assert_eq!(condition_status(legacy, "open-inbound-rule"), "met"); + assert_eq!(condition_status(legacy, "public-address"), "met"); + assert_eq!(condition_status(legacy, "internet-route"), "unknown"); + assert_eq!(legacy["verdict"], "insufficient_data", "{legacy}"); + assert_eq!(legacy["unknowns"].as_array().unwrap().len(), 1); + + // The three verdicts are the only words a finding can use. + let verdicts: std::collections::BTreeSet<&str> = report["findings"].as_array().unwrap().iter().map(|finding| finding["verdict"].as_str().unwrap()).collect(); + assert_eq!(verdicts, ["insufficient_data", "not_indicated", "potential_exposure"].into_iter().collect()); + assert_eq!(report["summary"], json!({"potential_exposure": 3, "insufficient_data": 1, "not_indicated": 3})); + + // Filters narrow what is printed, not what is counted. + let only = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db), "--verdict", "insufficient-data"]); + assert_eq!(exposure_brief(&only), json!([[DIRECT, format!("node:{}", instance("i-legacy1")), "insufficient_data"]])); + let one = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db), "--resource", &instance("i-web1")]); + assert_eq!(one["findings"].as_array().unwrap().len(), 1); + let (code, out) = env.khonsu(&["plugin", "analyze", "aws", "exposure", "-d", text(&db), "--resource", "shop/no-such-instance"]); + assert_eq!((code, out["error"]["kind"].as_str()), (1, Some("not_found")), "a mistyped ID is not an empty answer: {out}"); + let cut = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db), "--max-findings", "2"]); + assert_eq!((cut["findings"].as_array().unwrap().len(), cut["truncated"].as_i64()), (2, Some(5))); + assert_eq!(cut["summary"], report["summary"]); +} + +/// A finding leads into the other two tools: its walk finds the resource in +/// Path Explorer, and every record it cites can be looked up as evidence. +#[test] +fn a_finding_opens_in_path_explorer_and_its_records_resolve_as_evidence() { + let env = Env::new(); + let db = env.path("graph"); + env.add_source(&db, &exposure_fixture()); + env.ok(&["plugin", "sync", "--source", "shop"]); + let report = env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + + let mut walked = 0; + for finding in report["findings"].as_array().unwrap() { + let name = &finding["finding_id"]; + let Value::Object(walk) = &finding["walk"] else { + assert!(finding["path"].is_null(), "{finding}"); + continue; + }; + walked += 1; + let types = walk["types"].as_array().unwrap().iter().map(|name| name.as_str().unwrap()).collect::>().join(","); + let hops = walk["max_hops"].to_string(); + let out = env.ok(&[ + "explore", "-d", text(&db), + "--from", walk["from"].as_str().unwrap(), + "--to", walk["to"].as_str().unwrap(), + "--direction", walk["direction"].as_str().unwrap(), + "--type", &types, + "--max-hops", &hops, + ]); + assert_eq!(out["path"]["found"], true, "{name}: {out}"); + + let cited = finding["contributing"].as_array().unwrap(); + let records: Vec<&str> = finding["evidence"].as_array().unwrap().iter().map(|record| record["evidence_id"].as_str().unwrap()).collect(); + assert_eq!(records.len(), cited.len(), "{name}: every record it cites is included"); + for id in cited { + let id = id.as_str().unwrap(); + let (flag, stable) = match id.split_once(':').unwrap() { + ("node", stable) => ("--node", stable), + ("rel", stable) => ("--rel", stable), + other => panic!("{other:?}"), + }; + let found = env.ok(&["evidence", "-d", text(&db), flag, stable]); + assert_eq!(found["evidence"][0]["evidence_id"], id, "{name}"); + } + } + assert!(walked >= 3, "{report}"); +} + +/// 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(&["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(&["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(&["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"); + 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(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)]); + let balancer = finding(&report, BALANCER, WEB_1); + assert_eq!(condition_status(balancer, "target-allows-balancer"), "not_met", "{balancer}"); + assert_eq!(balancer["verdict"], "not_indicated"); + assert_eq!(balancer["path"]["hops"].as_array().unwrap().len(), 3, "the path is still shown"); + assert_eq!(finding(&report, DIRECT, WEB_1)["verdict"], "insufficient_data", "no route tables in these responses"); +} + +#[test] +fn exposure_is_the_same_across_syncs() { + let run = || { + let env = Env::new(); + let db = env.path("graph"); + env.add_source(&db, &exposure_fixture()); + env.ok(&["plugin", "sync", "--source", "shop"]); + normalize(&env.ok(&["plugin", "analyze", "aws", "exposure", "-d", text(&db)])) + }; + assert_eq!(run(), run()); +} + #[test] fn two_syncs_of_the_same_input_agree_and_a_resync_changes_nothing() { let env = Env::new(); @@ -528,8 +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(&["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/evidence.rs b/crates/khonsu-cli/src/evidence.rs new file mode 100644 index 0000000..2cb3b07 --- /dev/null +++ b/crates/khonsu-cli/src/evidence.rs @@ -0,0 +1,67 @@ +//! `khonsu evidence`: what supports a node or relationship. See +//! `khonsu_core::evidence`. + +use clap::Args; +use khonsu_core::evidence::{self, Evidence}; +use khonsu_core::settings::{self, Source}; +use khonsu_core::{AccessMode, Graph, GraphData, Language, QueryOptions}; +use serde_json::{json, Map, Value}; + +use crate::{resolve_db, CliError, DbArg, PROGRAM}; + +#[derive(Args)] +pub struct EvidenceArgs { + #[command(flatten)] + db: DbArg, + /// A node: its `khonsu_id`, or its ID (`id(n)`, `_id` in results). Repeat for several. + #[arg(long)] + node: Vec, + /// A relationship: its `khonsu_id`, or its ID (`id(r)`, `_id` in results). Repeat for several. + #[arg(long)] + rel: Vec, +} + +pub fn evidence(args: EvidenceArgs) -> Result { + if args.node.is_empty() && args.rel.is_empty() { + return Err(CliError::usage("name a record with --node or --rel")); + } + let path = resolve_db(&args.db)?; + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let sources = settings::load_sources(); + let mut records = Vec::new(); + for node in &args.node { + records.push(node_evidence(&mut graph, node, &sources)?); + } + for rel in &args.rel { + records.push(rel_evidence(&mut graph, rel, &sources)?); + } + Ok(json!({ "evidence": records })) +} + +fn node_evidence(graph: &mut Graph, node: &str, sources: &[Source]) -> Result { + let found = lookup(graph, node, "MATCH (n) WHERE id(n) = $id RETURN n LIMIT 1", "MATCH (n {khonsu_id: $id}) RETURN n LIMIT 1")?; + let record = found.nodes.first().ok_or_else(|| CliError::new("not_found", format!("no node {node:?}")))?; + Ok(evidence::for_node(record, sources)) +} + +fn rel_evidence(graph: &mut Graph, rel: &str, sources: &[Source]) -> Result { + let found = lookup( + graph, + rel, + "MATCH (a)-[r]->(b) WHERE id(r) = $id RETURN a, r, b LIMIT 1", + "MATCH (a)-[r]->(b) WHERE r.khonsu_id = $id RETURN a, r, b LIMIT 1", + )?; + let record = found.edges.first().ok_or_else(|| CliError::new("not_found", format!("no relationship {rel:?}")))?; + Ok(evidence::for_edge(record, &found.nodes, sources)) +} + +/// Runs `by_number` when `id` is a number, `by_stable_id` otherwise. +fn lookup(graph: &mut Graph, id: &str, by_number: &str, by_stable_id: &str) -> Result { + let (text, param) = match id.parse::() { + Ok(number) => (by_number, json!(number)), + Err(_) => (by_stable_id, json!(id)), + }; + let params = Map::from_iter([("id".to_string(), param)]); + let result = graph.run(Language::Cypher, text, ¶ms, QueryOptions::default())?; + Ok(result.graph.unwrap_or_default()) +} diff --git a/crates/khonsu-cli/src/explore.rs b/crates/khonsu-cli/src/explore.rs new file mode 100644 index 0000000..314aa4c --- /dev/null +++ b/crates/khonsu-cli/src/explore.rs @@ -0,0 +1,103 @@ +//! `khonsu explore`: a bounded walk from chosen nodes. See +//! `khonsu_core::explore`. + +use std::sync::atomic::AtomicBool; + +use clap::{Args, ValueEnum}; +use khonsu_core::explore::{self, Direction, ExploreRequest}; +use khonsu_core::{evidence, settings}; +use khonsu_core::{AccessMode, Graph, Language, QueryOptions}; +use serde_json::{json, Map, Value}; + +use crate::{resolve_db, CliError, DbArg, PROGRAM}; + +#[derive(Clone, Copy, ValueEnum)] +enum DirectionArg { + /// Along the arrows. + Out, + /// Against the arrows. + In, + Both, +} + +#[derive(Args)] +pub struct ExploreArgs { + #[command(flatten)] + db: DbArg, + /// A node to start from: its stable `khonsu_id`, or its ID (`id(n)`, `_id` in results). Repeat for several. + #[arg(long, required = true)] + from: Vec, + #[arg(long, value_enum, default_value = "out")] + direction: DirectionArg, + /// Only follow relationships of this type. Repeat or separate with commas. + #[arg(long = "type", value_delimiter = ',')] + rel_type: Vec, + /// Steps to walk from a start, at most 16. + #[arg(long, default_value_t = explore::DEFAULT_MAX_HOPS)] + max_hops: u32, + /// Nodes to return, starts included, at most 2,000. + #[arg(long, default_value_t = explore::DEFAULT_MAX_NODES)] + max_nodes: usize, + /// Relationships to return, at most 5,000. + #[arg(long, default_value_t = explore::DEFAULT_MAX_EDGES)] + max_edges: usize, + /// Also report a fewest-hops path to this node among what the walk found. + #[arg(long)] + to: Option, +} + +pub fn explore(args: ExploreArgs) -> Result { + let path = resolve_db(&args.db)?; + let mut graph = Graph::open(&path, AccessMode::ReadOnly, PROGRAM)?; + let starts = args.from.iter().map(|node| node_id(&mut graph, node)).collect::, _>>()?; + let target = args.to.as_deref().map(|node| node_id(&mut graph, node)).transpose()?; + let request = ExploreRequest { + starts, + direction: match args.direction { + DirectionArg::Out => Direction::Out, + DirectionArg::In => Direction::In, + DirectionArg::Both => Direction::Both, + }, + rel_types: args.rel_type, + max_hops: args.max_hops, + max_nodes: args.max_nodes, + max_edges: args.max_edges, + }; + // Nothing cancels a command from outside but ending it. + let result = graph.explore(&request, &AtomicBool::new(false))?; + + let explained = |hop: &explore::Hop| { + let mut value = serde_json::to_value(hop).expect("a hop serializes"); + value["text"] = json!(result.explain(hop)); + value + }; + let mut out = json!({ + "complete": result.is_complete(), + "limits": result.limits, + "depth_reached": result.depth_reached, + "missing_starts": result.missing_starts, + "starts": result.starts, + "hops": result.hops.iter().map(explained).collect::>(), + "graph": result.graph, + }); + if let Some(target) = target { + let found = result.path_to(target); + out["path"] = json!({ + "to": target, + "found": found.is_some(), + "hops": found.unwrap_or_default().into_iter().map(explained).collect::>(), + "evidence": evidence::for_path(&result, target, &settings::load_sources()), + }); + } + Ok(out) +} + +/// `node` as an internal ID: a number is one already, anything else a stable ID. +fn node_id(graph: &mut Graph, node: &str) -> Result { + if let Ok(id) = node.parse() { + return Ok(id); + } + let params = Map::from_iter([("id".to_string(), json!(node))]); + let found = graph.run(Language::Cypher, "MATCH (n {khonsu_id: $id}) RETURN id(n) AS id LIMIT 1", ¶ms, QueryOptions::default())?; + found.rows.first().and_then(|row| row[0].as_i64()).ok_or_else(|| CliError::new("not_found", format!("no node has the khonsu_id {node:?}"))) +} diff --git a/crates/khonsu-cli/src/lib.rs b/crates/khonsu-cli/src/lib.rs index 7c30025..049213c 100644 --- a/crates/khonsu-cli/src/lib.rs +++ b/crates/khonsu-cli/src/lib.rs @@ -10,6 +10,9 @@ mod analyze; mod dash; +mod evidence; +mod explore; +mod plugin_analysis; use clap::{Args, CommandFactory, Parser, Subcommand}; use khonsu_core::plugin::{self, SyncEvent}; @@ -118,6 +121,10 @@ enum Command { Open(dash::OpenArgs), /// Graph algorithms after networkx: PageRank, betweenness, components, communities, paths and more. Analyze(analyze::AnalyzeArgs), + /// Follow relationships from chosen nodes, within caps, and say when a cap or a filter limited the answer. + Explore(explore::ExploreArgs), + /// What supports a node or relationship: its source, plugin and times, with unknowns stated and secrets masked. + Evidence(evidence::EvidenceArgs), } #[derive(Subcommand)] @@ -135,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`). @@ -368,6 +377,8 @@ fn execute(command: Command) -> Result { Command::Presence(args) => dash::presence(args), Command::Open(args) => dash::open(args), Command::Analyze(args) => analyze::analyze(args), + Command::Explore(args) => explore::explore(args), + Command::Evidence(args) => evidence::evidence(args), } } @@ -410,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 new file mode 100644 index 0000000..398ac5e --- /dev/null +++ b/crates/khonsu-core/src/evidence.rs @@ -0,0 +1,498 @@ +//! What supports a node, a relationship or a path: where it came from, when, +//! and whether the source said it or KhonsuGraph worked it out. +//! +//! A value nobody supplied is [`Field::Unknown`], never a guess. Records are +//! meant to be copied out of the app, so they hold no source configuration, and +//! anything that looks like a secret is left out and named in `redacted`. + +use crate::explore::{explain_hop, Exploration, Hop}; +use crate::merge::{ID_PROPERTY, PLUGIN_PROPERTY, SOURCE_PROPERTY, SYNCED_AT_PROPERTY}; +use crate::result::{GraphData, GraphEdge, GraphNode}; +use crate::settings::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(crate) fn node_evidence_id(node: &GraphNode) -> String { + text(&node.properties, ID_PROPERTY).map_or_else(|| format!("node-internal:{}", node.id), |id| format!("node:{id}")) +} + +pub(crate) fn edge_evidence_id(edge: &GraphEdge) -> String { + text(&edge.properties, ID_PROPERTY).map_or_else(|| format!("rel-internal:{}", edge.id), |id| format!("rel:{id}")) +} + +pub fn for_node(node: &GraphNode, sources: &[Source]) -> Evidence { + let (properties, redacted) = own_properties(&node.properties); + let source = text(&node.properties, SOURCE_PROPERTY); + Evidence { + stable: text(&node.properties, ID_PROPERTY).is_some(), + evidence_id: node_evidence_id(node), + kind: Kind::Node, + internal_id: node.id, + types: node.labels.clone(), + from: None, + to: None, + ..provenance(&node.properties, source, sources, properties, redacted) + } +} + +/// `nodes` are looked in for the relationship's ends; an end not among them +/// is unknown. +pub fn for_edge(edge: &GraphEdge, nodes: &[GraphNode], sources: &[Source]) -> Evidence { + let (properties, redacted) = own_properties(&edge.properties); + let source = text(&edge.properties, SOURCE_PROPERTY); + let end = |id: i64| Field::from(nodes.iter().find(|node| node.id == id).map(node_evidence_id)); + Evidence { + stable: text(&edge.properties, ID_PROPERTY).is_some(), + evidence_id: edge_evidence_id(edge), + kind: Kind::Relationship, + internal_id: edge.id, + types: vec![edge.rel_type.clone()], + from: Some(end(edge.source)), + to: Some(end(edge.target)), + ..provenance(&edge.properties, source, sources, properties, redacted) + } +} + +/// The evidence for the path a walk took to `target`, unless it reached it by +/// no hops at all. +pub fn for_path(walk: &Exploration, target: i64, sources: &[Source]) -> Option { + let path = walk.path_to(target).filter(|path| !path.is_empty())?; + let hops: Vec = path.into_iter().cloned().collect(); + Some(for_hops(&hops, &walk.graph, sources, "fewest hops in a bounded walk", Field::Unknown)) +} + +/// The evidence for `hops`, which must not be empty, read from `graph`. A hop +/// that crosses a record twice lists that record once, where it first appears. +pub(crate) fn for_hops(hops: &[Hop], graph: &GraphData, sources: &[Source], derivation: &str, rule_id: Field) -> PathEvidence { + let mut evidence: Vec = Vec::new(); + let mut add = |record: Evidence| { + if !evidence.iter().any(|seen| seen.evidence_id == record.evidence_id) { + evidence.push(record); + } + }; + if let Some(start) = graph.nodes.iter().find(|node| node.id == hops[0].from) { + add(for_node(start, sources)); + } + for hop in hops { + if let Some(edge) = graph.edges.iter().find(|edge| edge.id == hop.edge) { + add(for_edge(edge, &graph.nodes, sources)); + } + if let Some(node) = graph.nodes.iter().find(|node| node.id == hop.to) { + add(for_node(node, sources)); + } + } + let supporting: Vec = evidence.iter().map(|record| record.evidence_id.clone()).collect(); + PathEvidence { + evidence_id: format!("path:{:016x}", fnv1a(&supporting.join("\n"))), + stable: evidence.iter().all(|record| record.stable), + kind: Kind::Path, + assertion: Assertion::Derived, + derivation: derivation.into(), + rule_id, + claim: PATH_CLAIM.into(), + hops: hops.iter().map(|hop| explain_hop(&graph.nodes, hop)).collect(), + supporting, + evidence, + } +} + +/// The fields a node and a relationship share. +fn provenance(properties: &Map, source: Option, sources: &[Source], own: Map, redacted: Vec) -> Evidence { + let 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. +pub(crate) fn own_properties(properties: &Map) -> (Map, Vec) { + let mut redacted = Vec::new(); + let own = properties + .iter() + .filter(|(key, _)| !key.starts_with("khonsu_")) + .map(|(key, value)| { + if is_secret_name(key) { + redacted.push(key.clone()); + return (key.clone(), Json::String(REDACTED.into())); + } + let (clean, masked) = mask_values(value); + if masked { + redacted.push(key.clone()); + } + (key.clone(), clean) + }) + .collect(); + (own, redacted) +} + +fn is_secret_name(name: &str) -> bool { + let plain = name.to_lowercase().replace(['_', '-'], ""); + SECRET_NAMES.iter().any(|secret| plain.contains(secret)) +} + +fn mask_values(value: &Json) -> (Json, bool) { + match value { + Json::String(text) if looks_like_a_secret(text) => (Json::String(REDACTED.into()), true), + Json::Array(items) => { + let cleaned: Vec<(Json, bool)> = items.iter().map(mask_values).collect(); + let masked = cleaned.iter().any(|(_, masked)| *masked); + (Json::Array(cleaned.into_iter().map(|(item, _)| item).collect()), masked) + } + Json::Object(map) => { + let mut masked = false; + let cleaned = map + .iter() + .map(|(key, item)| { + if is_secret_name(key) { + masked = true; + return (key.clone(), Json::String(REDACTED.into())); + } + let (item, inner) = mask_values(item); + masked |= inner; + (key.clone(), item) + }) + .collect(); + (Json::Object(cleaned), masked) + } + other => (other.clone(), false), + } +} + +/// Key material by its shape: AWS access key IDs and PEM blocks. +fn looks_like_a_secret(text: &str) -> bool { + let aws_key = (text.starts_with("AKIA") || text.starts_with("ASIA")) + && text.len() == 20 + && text.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()); + aws_key || text.contains("-----BEGIN") +} + +pub(crate) use khonsu_plugin_protocol::fnv1a; + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::LastSync; + use serde_json::json; + + fn map(value: Json) -> Map { + value.as_object().unwrap().clone() + } + + fn synced(extra: Json) -> Map { + let mut properties = map(json!({ + "khonsu_source": "shop", + "khonsu_plugin": "aws@0.2.0", + "khonsu_synced_at": 1000, + "khonsu_hash": "abc", + })); + properties.extend(map(extra)); + properties + } + + fn source(ok: bool) -> Source { + serde_json::from_value(json!({ + "id": "shop", "name": "shop", "plugin": "aws", "graph": "g", + "config": {"fixture_dir": "/home/someone/private", "profile": "prod"}, + "secret_keys": ["secret_access_key"], + "last_sync": serde_json::to_value(LastSync { at: 2000, ok, nodes: 1, edges: 1, message: String::new() }).unwrap(), + })) + .unwrap() + } + + fn node(id: i64, extra: Json) -> GraphNode { + GraphNode { + id, + labels: vec!["Host".into()], + properties: synced(extra), + } + } + + #[test] + fn a_synced_node_says_where_it_came_from() { + let record = for_node(&node(1, json!({"khonsu_id": "shop/h1", "name": "web"})), &[source(true)]); + assert_eq!(record.evidence_id, "node:shop/h1"); + assert!(record.stable); + assert_eq!(record.source, Field::Known("shop".to_string())); + assert_eq!(record.plugin, Field::Known("aws@0.2.0".to_string())); + assert_eq!(record.record_written_at, Field::Known(1000)); + assert_eq!(record.source_last_successful_sync, Field::Known(2000)); + assert_eq!(record.assertion, Assertion::Explicit); + assert_eq!(record.properties, map(json!({"name": "web"})), "bookkeeping stays out of the properties"); + } + + #[test] + fn what_was_not_supplied_is_unknown_not_made_up() { + let bare = GraphNode { + id: 7, + labels: vec![], + properties: map(json!({"name": "by hand"})), + }; + let record = for_node(&bare, &[source(true)]); + assert_eq!(record.evidence_id, "node-internal:7"); + assert!(!record.stable); + assert_eq!(record.source, Field::Unknown); + assert_eq!(record.plugin, Field::Unknown); + assert_eq!(record.record_written_at, Field::Unknown); + assert_eq!(record.source_last_successful_sync, Field::Unknown); + assert_eq!(record.source_snapshot, Field::Unknown); + assert_eq!(record.rule_id, Field::Unknown); + assert_eq!(record.assertion, Assertion::Unknown); + } + + #[test] + fn a_failed_or_removed_source_gives_no_last_successful_sync() { + let host = node(1, json!({"khonsu_id": "shop/h1"})); + assert_eq!(for_node(&host, &[source(false)]).source_last_successful_sync, Field::Unknown); + assert_eq!(for_node(&host, &[]).source_last_successful_sync, Field::Unknown); + assert_eq!(for_node(&host, &[]).source, Field::Known("shop".to_string()), "the record still names it"); + } + + #[test] + fn a_field_serializes_with_its_status() { + assert_eq!(serde_json::to_value(Field::Known(5)).unwrap(), json!({"status": "known", "value": 5})); + assert_eq!(serde_json::to_value(Field::::Unknown).unwrap(), json!({"status": "unknown"})); + } + + #[test] + fn secrets_are_masked_and_named_and_source_config_stays_out() { + let host = node( + 1, + json!({ + "khonsu_id": "shop/h1", + "name": "web", + "api_token": "abc123", + "DB-Password": "hunter2", + "key_id": "AKIAIOSFODNN7EXAMPLE", + "pem": "-----BEGIN PRIVATE KEY-----", + "nested": {"client_secret": "x", "fine": "y"}, + "list": ["ok", "AKIAIOSFODNN7EXAMPLE"], + }), + ); + let record = for_node(&host, &[source(true)]); + let mut named = record.redacted.clone(); + named.sort(); + assert_eq!(named, ["DB-Password", "api_token", "key_id", "list", "nested", "pem"]); + assert_eq!(record.properties["name"], json!("web")); + assert_eq!(record.properties["nested"]["fine"], json!("y")); + + let text = serde_json::to_string(&record).unwrap(); + for secret in ["abc123", "hunter2", "AKIAIOSFODNN7EXAMPLE", "BEGIN PRIVATE", "\"x\""] { + assert!(!text.contains(secret), "{secret} in {text}"); + } + for config in ["/home/someone/private", "prod", "secret_access_key", "fixture_dir"] { + assert!(!text.contains(config), "{config} in {text}"); + } + } + + fn walk() -> Exploration { + let edge = |id: i64, source: i64, target: i64, stable: &str| GraphEdge { + id, + source, + target, + rel_type: "NEXT".into(), + properties: synced(json!({"khonsu_id": stable})), + }; + let hop = |depth: u32, from: i64, to: i64, edge: i64| Hop { + depth, + from, + to, + edge, + rel_type: "NEXT".into(), + along: true, + }; + Exploration { + starts: vec![1], + graph: GraphData { + nodes: vec![ + node(1, json!({"khonsu_id": "s/a", "name": "a"})), + node(2, json!({"khonsu_id": "s/b", "name": "b"})), + node(3, json!({"khonsu_id": "s/c", "name": "c"})), + ], + edges: vec![edge(10, 1, 2, "s/ab"), edge(11, 2, 3, "s/bc")], + }, + hops: vec![hop(1, 1, 2, 10), hop(2, 2, 3, 11)], + depth_reached: 2, + ..Exploration::default() + } + } + + #[test] + fn a_path_is_derived_and_links_to_the_records_it_rests_on() { + let path = for_path(&walk(), 3, &[]).unwrap(); + assert_eq!(path.assertion, Assertion::Derived); + assert_eq!(path.kind, Kind::Path); + assert_eq!(path.rule_id, Field::Unknown); + assert_eq!(path.supporting, ["node:s/a", "rel:s/ab", "node:s/b", "rel:s/bc", "node:s/c"]); + assert_eq!(path.hops, ["a -[NEXT]-> b", "b -[NEXT]-> c"]); + assert_eq!(path.claim, PATH_CLAIM); + assert!(path.stable); + assert!(path.evidence.iter().all(|record| record.assertion == Assertion::Explicit)); + assert_eq!(path.evidence[1].from, Some(Field::Known("node:s/a".to_string()))); + assert_eq!(path.evidence[1].to, Some(Field::Known("node:s/b".to_string()))); + } + + #[test] + fn the_same_hops_give_the_same_path_id_and_other_hops_another() { + let to_c = for_path(&walk(), 3, &[]).unwrap().evidence_id; + assert_eq!(to_c, for_path(&walk(), 3, &[]).unwrap().evidence_id); + assert_ne!(to_c, for_path(&walk(), 2, &[]).unwrap().evidence_id); + // A hash of known text, so that changing the function shows up here. + assert_eq!(fnv1a("a"), 0xaf63_dc4c_8601_ec8c); + } + + #[test] + fn a_start_or_an_unreached_node_has_no_path() { + assert!(for_path(&walk(), 1, &[]).is_none()); + assert!(for_path(&walk(), 99, &[]).is_none()); + } + + #[test] + fn an_end_outside_the_given_nodes_is_unknown() { + let edge = GraphEdge { + id: 10, + source: 1, + target: 2, + rel_type: "NEXT".into(), + properties: synced(json!({"khonsu_id": "s/ab"})), + }; + let record = for_edge(&edge, &[node(1, json!({"khonsu_id": "s/a"}))], &[]); + assert_eq!(record.from, Some(Field::Known("node:s/a".to_string()))); + assert_eq!(record.to, Some(Field::Unknown)); + } +} diff --git a/crates/khonsu-core/src/explore.rs b/crates/khonsu-core/src/explore.rs new file mode 100644 index 0000000..a59194a --- /dev/null +++ b/crates/khonsu-core/src/explore.rs @@ -0,0 +1,325 @@ +//! Following a suspected chain from chosen nodes without loading the graph. +//! +//! A breadth-first walk with one parameterized query per step, so every cap is +//! enforced here and cancellation is checked between queries. HawDB takes one +//! relationship type on a variable-length pattern and no `type(r)` in a +//! `WHERE`, so types go in the pattern, one query each. +//! +//! What a walk finds is a path in the data, not proof that anything can reach +//! anything: see `docs/investigation-gates.md`. + +use crate::engine::{Graph, QueryError}; +use crate::result::{value_to_json, GraphCollector, GraphData, GraphEdge, GraphNode}; +use hawdb::Value; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub const DEFAULT_MAX_HOPS: u32 = 3; +pub const DEFAULT_MAX_NODES: usize = 200; +pub const DEFAULT_MAX_EDGES: usize = 500; +/// The most a request may ask for, whatever the person types. +pub const MAX_HOPS: u32 = 16; +pub const MAX_NODES: usize = 2000; +pub const MAX_EDGES: usize = 5000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Direction { + /// Along the arrows. + Out, + /// Against the arrows. + In, + Both, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExploreRequest { + pub starts: Vec, + pub direction: Direction, + /// Relationship types to follow; empty follows every type. + pub rel_types: Vec, + pub max_hops: u32, + pub max_nodes: usize, + pub max_edges: usize, +} + +impl ExploreRequest { + pub fn new(starts: Vec) -> Self { + Self { + starts, + direction: Direction::Out, + rel_types: Vec::new(), + max_hops: DEFAULT_MAX_HOPS, + max_nodes: DEFAULT_MAX_NODES, + max_edges: DEFAULT_MAX_EDGES, + } + } + + fn validate(&self) -> Result<(), QueryError> { + let refuse = |message: String| Err(QueryError::new("semantic", message)); + if self.starts.is_empty() { + return refuse("choose at least one node to start from".into()); + } + if self.max_hops == 0 || self.max_hops > MAX_HOPS { + return refuse(format!("hops must be between 1 and {MAX_HOPS}, not {}", self.max_hops)); + } + if self.max_nodes < self.starts.len() || self.max_nodes > MAX_NODES { + return refuse(format!("the node cap must be between {} and {MAX_NODES}, not {}", self.starts.len(), self.max_nodes)); + } + if self.max_edges == 0 || self.max_edges > MAX_EDGES { + return refuse(format!("the relationship cap must be between 1 and {MAX_EDGES}, not {}", self.max_edges)); + } + // A type goes into the query text, since HawDB cannot bind one. + if let Some(bad) = self.rel_types.iter().find(|name| !is_identifier(name)) { + return refuse(format!("{bad:?} is not a relationship type name")); + } + Ok(()) + } +} + +pub(crate) fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// One relationship the walk followed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Hop { + /// Steps from the nearest start: 1 is a start's own relationship. + pub depth: u32, + pub from: i64, + pub to: i64, + pub edge: i64, + pub rel_type: String, + /// True when the walk went along the arrow, false when against it. + pub along: bool, +} + +/// A cap that stopped the walk from showing everything it could reach. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Limit { + Hops, + Nodes, + Edges, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Exploration { + /// The requested starts that are in the graph. + pub starts: Vec, + pub graph: GraphData, + /// In the order they were found, nearest first. + pub hops: Vec, + /// How far the walk found anything. + pub depth_reached: u32, + /// Empty means the walk saw everything its filters allow. + pub limits: Vec, + /// Stopped early on request; `graph` holds what was found until then. + pub cancelled: bool, + /// Requested starts that are not in the graph. + pub missing_starts: Vec, +} + +impl Exploration { + /// Whether the result is everything the filters allow. An incomplete walk + /// that found nothing has not shown that nothing is there. + pub fn is_complete(&self) -> bool { + self.limits.is_empty() && !self.cancelled + } + + /// The hops that first reached `target`, from a start: a fewest-hops path + /// among what the walk found. `None` when it did not reach it, and empty + /// when `target` is a start. + pub fn path_to(&self, target: i64) -> Option> { + if !self.graph.nodes.iter().any(|node| node.id == target) { + return None; + } + // The hop that first reached each node. A walk can arrive back at a + // start around a cycle, which does not count as reaching it. + let mut first: BTreeMap = BTreeMap::new(); + for hop in self.hops.iter().filter(|hop| !self.starts.contains(&hop.to)) { + first.entry(hop.to).or_insert(hop); + } + // Each hop's `from` was reached a step earlier, so this ends at a start. + let mut path = Vec::new(); + let mut at = target; + while let Some(hop) = first.get(&at) { + path.push(*hop); + at = hop.from; + } + path.reverse(); + Some(path) + } + + /// One hop in words, in the direction it was walked: + /// `Internet -[ROUTES_TO]-> shop`, `web-1 <-[ROUTES_TO]- web`. + pub fn explain(&self, hop: &Hop) -> String { + explain_hop(&self.graph.nodes, hop) + } +} + +/// One hop in words, with names from `nodes`. +pub(crate) fn explain_hop(nodes: &[GraphNode], hop: &Hop) -> String { + let name = |id: i64| nodes.iter().find(|node| node.id == id).map(node_name).unwrap_or_else(|| format!("#{id}")); + let (from, to, rel) = (name(hop.from), name(hop.to), &hop.rel_type); + if hop.along { + format!("{from} -[{rel}]-> {to}") + } else { + format!("{from} <-[{rel}]- {to}") + } +} + +pub(crate) fn node_name(node: &GraphNode) -> String { + for key in ["name", "title", "khonsu_id", "id"] { + match node.properties.get(key) { + Some(Json::String(text)) if !text.is_empty() => return text.clone(), + Some(value @ Json::Number(_)) => return value.to_string(), + _ => {} + } + } + format!("{} #{}", node.labels.first().map(String::as_str).unwrap_or("node"), node.id) +} + +impl Graph { + /// Walks from `request.starts`. `cancel` is checked before each query; a + /// query already running finishes first. + pub fn explore(&mut self, request: &ExploreRequest, cancel: &AtomicBool) -> Result { + request.validate()?; + let found = self.nodes_by_id(&request.starts)?; + let mut result = Exploration::default(); + let known: BTreeSet = found.nodes.iter().map(|node| node.id).collect(); + result.missing_starts = request.starts.iter().copied().filter(|id| !known.contains(id)).collect(); + result.missing_starts.sort_unstable(); + result.missing_starts.dedup(); + + let mut nodes: BTreeMap = found.nodes.into_iter().map(|node| (node.id, node)).collect(); + let mut edges: BTreeMap = BTreeMap::new(); + let mut frontier: Vec = known.into_iter().collect(); + result.starts = frontier.clone(); + let mut limits = BTreeSet::new(); + + for depth in 1..=request.max_hops { + if frontier.is_empty() { + break; + } + let Some(walks) = self.step(request, &frontier, cancel)? else { + result.cancelled = true; + break; + }; + let mut next = Vec::new(); + for walk in walks { + if edges.contains_key(&walk.edge.id) { + continue; + } + if edges.len() == request.max_edges { + limits.insert(Limit::Edges); + break; + } + let fresh = !nodes.contains_key(&walk.to.id); + if fresh && nodes.len() == request.max_nodes { + limits.insert(Limit::Nodes); + continue; + } + result.hops.push(Hop { + depth, + from: walk.from, + to: walk.to.id, + edge: walk.edge.id, + rel_type: walk.edge.rel_type.clone(), + along: walk.along, + }); + result.depth_reached = depth; + if fresh { + next.push(walk.to.id); + nodes.insert(walk.to.id, walk.to); + } + edges.insert(walk.edge.id, walk.edge); + } + next.sort_unstable(); + frontier = next; + if !limits.is_empty() { + frontier.clear(); + } + } + + // One step past the last hop, only to say whether the cap cut anything. + if !result.cancelled && !frontier.is_empty() { + match self.step(request, &frontier, cancel)? { + Some(walks) if walks.iter().any(|walk| !edges.contains_key(&walk.edge.id)) => { + limits.insert(Limit::Hops); + } + Some(_) => {} + None => result.cancelled = true, + } + } + + result.limits = limits.into_iter().collect(); + result.graph = GraphData { + nodes: nodes.into_values().collect(), + edges: edges.into_values().collect(), + }; + Ok(result) + } + + /// Every relationship the filters allow out of `frontier`, by edge ID so + /// that a repeat of the same walk gives the same result. `None` when + /// cancelled. Each query can return one row more than the edge cap, which + /// is enough to show the cap was exceeded even when all the others have + /// been seen already. + fn step(&mut self, request: &ExploreRequest, frontier: &[i64], cancel: &AtomicBool) -> Result>, QueryError> { + let types: Vec> = if request.rel_types.is_empty() { + vec![None] + } else { + request.rel_types.iter().map(|name| Some(name.as_str())).collect() + }; + let ways: &[bool] = match request.direction { + Direction::Out => &[true], + Direction::In => &[false], + Direction::Both => &[true, false], + }; + let ids = Value::List(frontier.iter().map(|id| Value::Int(*id)).collect()); + let params = BTreeMap::from([("ids".to_string(), ids)]); + let rows = request.max_edges + 1; + + let mut walks = Vec::new(); + for along in ways.iter().copied() { + for rel_type in &types { + if cancel.load(Ordering::Relaxed) { + return Ok(None); + } + let rel = rel_type.map(|name| format!("r:{name}")).unwrap_or_else(|| "r".into()); + let pattern = if along { format!("(n)-[{rel}]->(m)") } else { format!("(n)<-[{rel}]-(m)") }; + let text = format!("MATCH {pattern} WHERE id(n) IN $ids RETURN n, r, m ORDER BY id(r) LIMIT {rows}"); + let mut collector = GraphCollector::default(); + for row in self.raw_cypher(&text, ¶ms)? { + row.iter().for_each(|value| collector.visit(&value_to_json(value))); + } + let data = collector.finish().unwrap_or_default(); + let by_id: BTreeMap = data.nodes.into_iter().map(|node| (node.id, node)).collect(); + for edge in data.edges { + let (from, to) = if along { (edge.source, edge.target) } else { (edge.target, edge.source) }; + if let Some(node) = by_id.get(&to) { + walks.push(Walk { + from, + to: node.clone(), + edge, + along, + }); + } + } + } + } + walks.sort_by_key(|walk| (walk.edge.id, !walk.along)); + Ok(Some(walks)) + } +} + +struct Walk { + from: i64, + to: GraphNode, + edge: GraphEdge, + along: bool, +} diff --git a/crates/khonsu-core/src/lib.rs b/crates/khonsu-core/src/lib.rs index adf31d5..cdb85bc 100644 --- a/crates/khonsu-core/src/lib.rs +++ b/crates/khonsu-core/src/lib.rs @@ -5,9 +5,12 @@ pub mod analysis; mod cypher_columns; pub mod engine; +pub mod evidence; +pub mod explore; 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-core/tests/explore.rs b/crates/khonsu-core/tests/explore.rs new file mode 100644 index 0000000..c090c90 --- /dev/null +++ b/crates/khonsu-core/tests/explore.rs @@ -0,0 +1,237 @@ +mod common; + +use common::TestDir; +use khonsu_core::explore::{Direction, Exploration, ExploreRequest, Limit}; +use khonsu_core::{Graph, Language, QueryOptions}; +use serde_json::{json, Map}; +use std::collections::BTreeMap; +use std::sync::atomic::AtomicBool; + +/// `a -NEXT-> b -NEXT-> c -NEXT-> a` (a cycle), `b -SIDE-> d`, and `e`, alone. +struct Fixture { + _dir: TestDir, + graph: Graph, + ids: BTreeMap, +} + +impl Fixture { + fn new() -> Self { + let dir = TestDir::new(); + let mut graph = Graph::create(dir.graph(), "test").unwrap(); + let mut run = |text: String| { + graph.run(Language::Cypher, &text, &Map::new(), QueryOptions::default()).unwrap(); + }; + for name in ["a", "b", "c", "d", "e"] { + run(format!("CREATE (:N {{name: '{name}'}})")); + } + for (from, rel, to) in [("a", "NEXT", "b"), ("b", "NEXT", "c"), ("c", "NEXT", "a"), ("b", "SIDE", "d")] { + run(format!("MATCH (x:N {{name: '{from}'}}), (y:N {{name: '{to}'}}) CREATE (x)-[:{rel}]->(y)")); + } + let out = graph + .run(Language::Cypher, "MATCH (n:N) RETURN n.name AS name, id(n) AS id", &Map::new(), QueryOptions::default()) + .unwrap(); + let ids = out.rows.iter().map(|row| (row[0].as_str().unwrap().to_string(), row[1].as_i64().unwrap())).collect(); + Self { _dir: dir, graph, ids } + } + + fn request(&self, starts: &[&str]) -> ExploreRequest { + ExploreRequest::new(starts.iter().map(|name| self.ids[*name]).collect()) + } + + fn walk(&mut self, request: &ExploreRequest) -> Exploration { + self.graph.explore(request, &AtomicBool::new(false)).unwrap() + } +} + +fn names(result: &Exploration) -> Vec { + let mut names: Vec = result.graph.nodes.iter().map(|node| node.properties["name"].as_str().unwrap().to_string()).collect(); + names.sort(); + names +} + +fn explained(result: &Exploration) -> Vec { + result.hops.iter().map(|hop| result.explain(hop)).collect() +} + +#[test] +fn out_follows_the_arrows_and_in_goes_against_them() { + let mut f = Fixture::new(); + let mut request = f.request(&["b"]); + request.max_hops = 1; + + request.direction = Direction::Out; + let out = f.walk(&request); + assert_eq!(names(&out), ["b", "c", "d"]); + assert_eq!(explained(&out), ["b -[NEXT]-> c", "b -[SIDE]-> d"]); + assert!(out.hops.iter().all(|hop| hop.along)); + + request.direction = Direction::In; + let inbound = f.walk(&request); + assert_eq!(names(&inbound), ["a", "b"]); + assert_eq!(explained(&inbound), ["b <-[NEXT]- a"]); + assert!(!inbound.hops[0].along); + + request.direction = Direction::Both; + let both = f.walk(&request); + assert_eq!(names(&both), ["a", "b", "c", "d"]); + assert_eq!(both.hops.len(), 3); +} + +#[test] +fn a_cycle_ends_and_uses_each_relationship_once() { + let mut f = Fixture::new(); + let mut request = f.request(&["a"]); + request.rel_types = vec!["NEXT".into()]; + request.max_hops = 16; + let result = f.walk(&request); + + assert_eq!(names(&result), ["a", "b", "c"]); + assert_eq!(result.hops.len(), 3, "the closing c → a is shown once"); + assert_eq!(result.depth_reached, 3); + assert!(result.is_complete(), "{:?}", result.limits); +} + +#[test] +fn types_filter_what_is_followed() { + let mut f = Fixture::new(); + let mut request = f.request(&["b"]); + request.rel_types = vec!["SIDE".into()]; + assert_eq!(names(&f.walk(&request)), ["b", "d"]); + + request.rel_types = vec!["SIDE".into(), "NEXT".into()]; + request.max_hops = 1; + assert_eq!(names(&f.walk(&request)), ["b", "c", "d"]); +} + +#[test] +fn zero_matches_is_a_complete_empty_answer() { + let mut f = Fixture::new(); + let alone = f.walk(&f.request(&["e"])); + assert_eq!(names(&alone), ["e"]); + assert!(alone.hops.is_empty()); + assert_eq!(alone.depth_reached, 0); + assert!(alone.is_complete()); + + let mut request = f.request(&["a"]); + request.rel_types = vec!["NO_SUCH_TYPE".into()]; + let unknown = f.walk(&request); + assert!(unknown.hops.is_empty()); + assert!(unknown.is_complete()); +} + +#[test] +fn a_start_missing_from_the_graph_is_reported() { + let mut f = Fixture::new(); + let a = f.ids["a"]; + let mut request = ExploreRequest::new(vec![a, 987_654]); + request.max_hops = 1; + let result = f.walk(&request); + assert_eq!(result.missing_starts, [987_654]); + assert_eq!(names(&result), ["a", "b"]); +} + +#[test] +fn several_starts_walk_together() { + let mut f = Fixture::new(); + let mut request = f.request(&["a", "c"]); + request.max_hops = 1; + request.direction = Direction::Out; + let result = f.walk(&request); + assert_eq!(names(&result), ["a", "b", "c"]); + assert_eq!(explained(&result), ["a -[NEXT]-> b", "c -[NEXT]-> a"]); +} + +#[test] +fn each_cap_stops_the_walk_and_says_so() { + let mut f = Fixture::new(); + + let mut hops = f.request(&["a"]); + hops.max_hops = 1; + let result = f.walk(&hops); + assert_eq!(names(&result), ["a", "b"]); + assert_eq!(result.limits, [Limit::Hops]); + assert!(!result.is_complete()); + + let mut nodes = f.request(&["b"]); + nodes.max_nodes = 2; + let result = f.walk(&nodes); + assert_eq!(result.graph.nodes.len(), 2); + assert_eq!(result.limits, [Limit::Nodes]); + + let mut edges = f.request(&["b"]); + edges.max_edges = 1; + let result = f.walk(&edges); + assert_eq!(result.graph.edges.len(), 1); + assert_eq!(result.limits, [Limit::Edges]); + + // Exactly at the caps is not over them. + let mut exact = f.request(&["b"]); + exact.rel_types = vec!["SIDE".into()]; + exact.max_nodes = 2; + exact.max_edges = 1; + exact.max_hops = 1; + let result = f.walk(&exact); + assert_eq!(names(&result), ["b", "d"]); + assert!(result.is_complete(), "{:?}", result.limits); +} + +#[test] +fn requests_beyond_the_ceilings_are_refused_not_clamped() { + let mut f = Fixture::new(); + let cancel = AtomicBool::new(false); + let base = f.request(&["a"]); + for bad in [ + ExploreRequest { max_hops: 0, ..base.clone() }, + ExploreRequest { max_hops: 17, ..base.clone() }, + ExploreRequest { max_nodes: 0, ..base.clone() }, + ExploreRequest { max_nodes: 2001, ..base.clone() }, + ExploreRequest { max_edges: 0, ..base.clone() }, + ExploreRequest { max_edges: 5001, ..base.clone() }, + ExploreRequest { starts: vec![], ..base.clone() }, + ] { + let error = f.graph.explore(&bad, &cancel).unwrap_err(); + assert_eq!(error.kind, "semantic", "{bad:?}"); + } +} + +#[test] +fn a_type_name_cannot_change_the_query() { + let mut f = Fixture::new(); + let mut request = f.request(&["a"]); + request.rel_types = vec!["NEXT]->(m) DETACH DELETE m //".into()]; + let error = f.graph.explore(&request, &AtomicBool::new(false)).unwrap_err(); + assert!(error.message.contains("not a relationship type"), "{}", error.message); + let count = f.graph.run(Language::Cypher, "MATCH (n:N) RETURN count(n) AS c", &Map::new(), QueryOptions::default()).unwrap(); + assert_eq!(count.rows[0][0], json!(5)); +} + +#[test] +fn a_cancelled_walk_says_so_and_is_not_complete() { + let mut f = Fixture::new(); + let result = f.graph.explore(&f.request(&["a"]), &AtomicBool::new(true)).unwrap(); + assert!(result.cancelled); + assert!(!result.is_complete()); + assert_eq!(names(&result), ["a"], "the start is known before the first query"); +} + +#[test] +fn the_same_walk_gives_the_same_result() { + let mut f = Fixture::new(); + let mut request = f.request(&["b"]); + request.direction = Direction::Both; + assert_eq!(f.walk(&request), f.walk(&request)); +} + +#[test] +fn a_path_goes_from_a_start_to_its_target_in_fewest_hops() { + let mut f = Fixture::new(); + let (a, d) = (f.ids["a"], f.ids["d"]); + let result = f.walk(&f.request(&["a"])); + + let path = result.path_to(d).unwrap(); + let words: Vec = path.iter().map(|hop| result.explain(hop)).collect(); + assert_eq!(words, ["a -[NEXT]-> b", "b -[SIDE]-> d"]); + + assert!(result.path_to(a).unwrap().is_empty(), "a start is already there, even on a cycle"); + assert!(result.path_to(f.ids["e"]).is_none(), "not reached"); +} diff --git a/crates/khonsu-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 new file mode 100644 index 0000000..6310a8a --- /dev/null +++ b/docs/exposure-rules.md @@ -0,0 +1,166 @@ +# AWS exposure rules + +`khonsu plugin analyze aws exposure` says which instances an account's +configuration leaves potentially open to the internet, and shows how it decided. +It reads the graph only. It sends no traffic and needs no AWS access once the +graph is synced. + +These rules belong to the AWS plugin, in `plugins/aws/src/exposure.rs`. KhonsuGraph +itself knows nothing about AWS: it sends the plugin records from the graph, checks +the findings that come back against them, and looks up their values and paths. See +[offering an analysis](plugins.md#offering-an-analysis). + +A finding is **potential exposure worked out from configuration**. It does not +show that anything is reachable, and no finding does not show that a resource is +safe. No verdict says "reachable". + +## Verdicts + +Each rule is a list of conditions, each `met`, `not_met` or `unknown`. There is +no score. + +| Verdict | When | How to read it | +|---|---|---| +| `potential_exposure` | Every condition is met | Check it: the configuration allows this path | +| `insufficient_data` | None is unmet, and at least one is unknown | `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`, `Protocol`; the target's groups and their rules | No port, protocol or target groups are recorded, only a CIDR rule covers the port, or no rule lets it in and the balancer has no groups recorded | + +A `TCP_UDP` or `TCP_QUIC` target group carries both, so a rule for either protocol +counts. `UDP`, `QUIC` and `GENEVE` need a UDP rule, and anything else a TCP one. + +### `aws.exposure.security-group-lateral` + +One finding for each instance whose security group allows the +group of an instance with a `potential_exposure` or `insufficient_data` finding. +It follows one step. `depends_on` names the finding it rests on, and its verdict +can be no stronger than that one's. + +## What the rules do not read + +Each finding lists its assumptions, and they include these: + +- Network ACLs, host firewalls, and anything outside the account. +- Whether anything listens on an allowed port. +- Load balancer listeners, so an open group is not shown to lead to a given target group. +- A target's own registered port. The target group's port is used. +- Address family on rules: `0.0.0.0/0` and `::/0` are one Internet record. +- IAM. The rules say nothing about what an instance's role can do. + +Only `0.0.0.0/0` and `::/0` count as the internet. Rules that together cover the +address space, such as `0.0.0.0/1` and `128.0.0.0/1`, are not recognized. + +## A missing response is not a clear result + +A 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 plugin analyze aws exposure --db graphs/exposure --pretty +``` + +Read `summary` first: three potential exposures, one undecided, three not indicated. + +**1. A potential exposure.** Look at one instance: + +```sh +khonsu plugin analyze aws exposure --db graphs/exposure --pretty --resource shop/123456789012:us-east-1:i-web1 +``` + +`web-1` meets all four conditions. Each condition's `fields` give the record, the +stored field and, in `source_field`, the AWS field it came from. `path.hops` shows the candidate path, +`path.rule_id` the rule, and `assumptions` what was not checked. + +**2. Open it in Path Explorer.** A finding's `walk` holds the arguments: + +```sh +khonsu explore --db graphs/exposure --from shop/cidr:internet --to shop/123456789012:us-east-1:i-web1 \ + --direction both --type ALLOWS,PROTECTED_BY --max-hops 2 +``` + +To see more around it, raise `--max-hops` or drop `--type`. + +**3. Check a record.** Every ID in `contributing` can be looked up. Remove the +`node:` or `rel:` prefix: + +```sh +khonsu evidence --db graphs/exposure --node shop/123456789012:us-east-1:i-web1 +``` + +**4. A security group rule that is not exposure.** `batch-1` has port 22 open to +`0.0.0.0/0`. It has no public address, and its subnet's default route goes to a NAT +gateway, so it gets `not_indicated`. The conditions show why, and the open rule +still deserves a look: + +```sh +khonsu plugin analyze aws exposure --db graphs/exposure --pretty --resource shop/123456789012:us-east-1:i-batch1 +``` + +**5. An undecided case.** `legacy-1` has a public address and an open rule, but the +saved responses hold no route tables for its VPC. `unknowns` says so: + +```sh +khonsu plugin analyze aws exposure --db graphs/exposure --pretty --verdict insufficient-data +``` + +**6. One step further in.** `db-1` has no public address, and its group allows +`web-1`'s group on 5432. Its `aws.exposure.security-group-lateral` finding follows +from `web-1`'s through `depends_on`: + +```sh +khonsu plugin analyze aws exposure --db graphs/exposure --pretty --resource shop/123456789012:us-east-1:i-db1 +``` + +The commands above need the graph free. They fail with kind `locked` while the +window has it open. diff --git a/docs/investigation-gates.md b/docs/investigation-gates.md index 23d939b..2d07dec 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -16,12 +16,17 @@ 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 | +| 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 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 @@ -55,11 +60,21 @@ 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. +- **`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. + +- **An exposure finding is not reachability.** It says what configuration allows. + [The rules](exposure-rules.md) list what they do not read. + +Source snapshots are always unknown until #37. ## Baselines diff --git a/docs/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/README.md b/examples/README.md index 587d9ff..22c5e43 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,8 +23,8 @@ khonsu plugin sync --source infra khonsu check examples/infra/blast_radius.dash --db graphs/infra ``` -`aws/exposure.dash` shows what the internet can reach in an AWS account. Try -it on the saved responses that ship with the AWS plugin: +`aws/exposure.dash` shows what an AWS account's configuration opens to the +internet. Try it on the saved responses that ship with the AWS plugin: ```sh khonsu init graphs/aws @@ -40,6 +40,9 @@ profile; the plugin only calls `describe`, `list` and `get` operations. [the investigation gates](../docs/investigation-gates.md) for what is tested and what a result does not prove. +For findings that explain themselves, with the rule, fields and unknowns behind +each, follow the walkthrough in [the exposure rules](../docs/exposure-rules.md). + ## Build a story graph ```sh diff --git a/examples/aws/exposure.dash b/examples/aws/exposure.dash index b634a7e..8ce0994 100644 --- a/examples/aws/exposure.dash +++ b/examples/aws/exposure.dash @@ -1,4 +1,5 @@ -# What the internet can reach in an AWS account, from the official AWS plugin. +# What an AWS account's configuration opens to the internet, from the official +# AWS plugin. For explained findings, run `khonsu plugin analyze aws exposure`. # Build the graph from the saved responses in plugins/aws/tests/fixture, or # from a real account with a read-only profile (see examples/README.md). plot "intro" { diff --git a/examples/aws/golden.json b/examples/aws/golden.json index 353aa9f..c29b8cd 100644 --- a/examples/aws/golden.json +++ b/examples/aws/golden.json @@ -1043,5 +1043,4018 @@ "width": 6 } ] + }, + "evidence": { + "web_1": { + "evidence": [ + { + "evidence_id": "node:shop/123456789012:us-east-1:i-web1", + "stable": true, + "kind": "node", + "internal_id": 1, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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, + "limits": [], + "depth_reached": 3, + "missing_starts": [], + "starts": [ + 14 + ], + "hops": [ + { + "depth": 1, + "from": 14, + "to": 2, + "edge": 22, + "rel_type": "ROUTES_TO", + "along": true, + "text": "Internet -[ROUTES_TO]-> igw-shop" + }, + { + "depth": 1, + "from": 14, + "to": 9, + "edge": 23, + "rel_type": "ROUTES_TO", + "along": true, + "text": "Internet -[ROUTES_TO]-> shop" + }, + { + "depth": 2, + "from": 9, + "to": 10, + "edge": 16, + "rel_type": "FORWARDS_TO", + "along": true, + "text": "shop -[FORWARDS_TO]-> web" + }, + { + "depth": 3, + "from": 10, + "to": 1, + "edge": 19, + "rel_type": "ROUTES_TO", + "along": true, + "text": "web -[ROUTES_TO]-> web-1" + } + ], + "graph": { + "nodes": [ + { + "id": 1, + "labels": [ + "Instance" + ], + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "khonsu_id": "shop/123456789012:us-east-1:i-web1", + "khonsu_source": "shop", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "id": 2, + "labels": [ + "InternetGateway" + ], + "properties": { + "khonsu_id": "shop/123456789012:us-east-1:igw-shop", + "khonsu_source": "shop", + "name": "igw-shop" + } + }, + { + "id": 9, + "labels": [ + "LoadBalancer" + ], + "properties": { + "dns_name": "shop-123.us-east-1.elb.amazonaws.com", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "khonsu_source": "shop", + "name": "shop", + "scheme": "internet-facing", + "type": "application" + } + }, + { + "id": 10, + "labels": [ + "TargetGroup" + ], + "properties": { + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "khonsu_source": "shop", + "name": "web", + "port": 8080, + "protocol": "HTTP" + } + }, + { + "id": 14, + "labels": [ + "Cidr" + ], + "properties": { + "cidr": "0.0.0.0/0", + "khonsu_id": "shop/cidr:internet", + "khonsu_source": "shop", + "name": "Internet" + } + } + ], + "edges": [ + { + "id": 16, + "source": 9, + "target": 10, + "rel_type": "FORWARDS_TO", + "properties": { + "khonsu_hash": "08f44b07b5901a25", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "khonsu_source": "shop" + } + }, + { + "id": 19, + "source": 10, + "target": 1, + "rel_type": "ROUTES_TO", + "properties": { + "health": "healthy", + "khonsu_hash": "a7a7247ee928e602", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "khonsu_source": "shop" + } + }, + { + "id": 22, + "source": 14, + "target": 2, + "rel_type": "ROUTES_TO", + "properties": { + "khonsu_hash": "08f44b07b5901a25", + "khonsu_id": "shop/cidr:internet|ROUTES_TO|123456789012:us-east-1:igw-shop", + "khonsu_source": "shop" + } + }, + { + "id": 23, + "source": 14, + "target": 9, + "rel_type": "ROUTES_TO", + "properties": { + "khonsu_hash": "08f44b07b5901a25", + "khonsu_id": "shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "khonsu_source": "shop" + } + } + ] + }, + "path": { + "to": 1, + "found": true, + "hops": [ + { + "depth": 1, + "from": 14, + "to": 9, + "edge": 23, + "rel_type": "ROUTES_TO", + "along": true, + "text": "Internet -[ROUTES_TO]-> shop" + }, + { + "depth": 2, + "from": 9, + "to": 10, + "edge": 16, + "rel_type": "FORWARDS_TO", + "along": true, + "text": "shop -[FORWARDS_TO]-> web" + }, + { + "depth": 3, + "from": 10, + "to": 1, + "edge": 19, + "rel_type": "ROUTES_TO", + "along": true, + "text": "web -[ROUTES_TO]-> web-1" + } + ], + "evidence": { + "evidence_id": "path:7e78c5aff0fd2860", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "fewest hops in a bounded walk", + "rule_id": { + "status": "unknown" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ROUTES_TO]-> shop", + "shop -[FORWARDS_TO]-> web", + "web -[ROUTES_TO]-> web-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "node:shop/123456789012:us-east-1:i-web1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 14, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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": { + "complete": false, + "limits": [ + "hops" + ], + "depth_reached": 1, + "missing_starts": [], + "starts": [ + 1 + ], + "hops": [ + { + "depth": 1, + "from": 1, + "to": 10, + "edge": 19, + "rel_type": "ROUTES_TO", + "along": false, + "text": "web-1 <-[ROUTES_TO]- web" + } + ], + "graph": { + "nodes": [ + { + "id": 1, + "labels": [ + "Instance" + ], + "properties": { + "image_id": "ami-web", + "instance_id": "i-web1", + "khonsu_id": "shop/123456789012:us-east-1:i-web1", + "khonsu_source": "shop", + "launched_at": "2026-09-01T08:00:00+00:00", + "name": "web-1", + "private_ip": "10.0.1.10", + "public_ip": "198.51.100.10", + "state": "running", + "type": "t3.small" + } + }, + { + "id": 10, + "labels": [ + "TargetGroup" + ], + "properties": { + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def", + "khonsu_source": "shop", + "name": "web", + "port": 8080, + "protocol": "HTTP" + } + } + ], + "edges": [ + { + "id": 19, + "source": 10, + "target": 1, + "rel_type": "ROUTES_TO", + "properties": { + "health": "healthy", + "khonsu_hash": "a7a7247ee928e602", + "khonsu_id": "shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/web/def|ROUTES_TO|123456789012:us-east-1:i-web1", + "khonsu_source": "shop" + } + } + ] + } + } + }, + "exposure": { + "shop_fixture": [ + [ + "aws.exposure.direct-instance", + "node:shop/123456789012:us-east-1:i-web1", + "insufficient_data" + ], + [ + "aws.exposure.security-group-lateral", + "node:shop/123456789012:us-east-1:i-db1", + "insufficient_data" + ], + [ + "aws.exposure.direct-instance", + "node:shop/123456789012:us-east-1:i-db1", + "not_indicated" + ], + [ + "aws.exposure.public-load-balancer", + "node:shop/123456789012:us-east-1:i-web1", + "not_indicated" + ] + ], + "exposure_fixture": { + "analysis": "exposure", + "title": "Exposure", + "claim": "Potential exposure worked out from configuration. No traffic was sent, so this does not show that anything is reachable, and a resource with no finding is not shown to be safe.", + "order": "By verdict (potential_exposure, insufficient_data, not_indicated), then rule ID, then resource ID.", + "verdicts": [ + { + "id": "potential_exposure", + "title": "Potential exposure", + "tone": "attention" + }, + { + "id": "insufficient_data", + "title": "Insufficient data", + "tone": "undecided" + }, + { + "id": "not_indicated", + "title": "Not indicated by this rule", + "tone": "quiet" + } + ], + "rules": [ + { + "rule_id": "aws.exposure.direct-instance", + "title": "Instance open to the internet directly", + "statement": "A running instance with a public address, a security group rule open to the internet, and a route to an internet gateway from its subnet.", + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ] + }, + { + "rule_id": "aws.exposure.public-load-balancer", + "title": "Instance behind an internet-facing load balancer", + "statement": "A running, healthy target of an internet-facing load balancer whose security group is open to the internet, where the target's security group allows the target port from the balancer.", + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "Listeners and their rules are not read, so an open security group is not shown to lead to this target group.", + "The target group's port is used; a target registered on another port is not seen.", + "A CIDR rule is not compared with the balancer's addresses, which are not read." + ] + }, + { + "rule_id": "aws.exposure.security-group-lateral", + "title": "Instance one security group rule behind a potentially exposed one", + "statement": "A running instance whose security group allows traffic from the security group of an instance with a potential exposure finding.", + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "Only one step is followed; use khonsu explore for longer chains.", + "Whether the source instance could be made to send this traffic is not assessed." + ] + } + ], + "subjects": 5, + "summary": { + "insufficient_data": 1, + "not_indicated": 3, + "potential_exposure": 3 + }, + "complete": true, + "findings": [ + { + "finding_id": "finding:f2dfac00f69ee196", + "rule_id": "aws.exposure.direct-instance", + "verdict": "potential_exposure", + "resource": "node:shop/123456789012:us-east-1:i-web1", + "resource_name": "web-1", + "conditions": [ + { + "id": "instance-running", + "statement": "The instance is running.", + "status": "met", + "detail": "The instance is running.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-web1", + "field": "state", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "met", + "detail": "Public address 198.51.100.10.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-web1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "known", + "value": "198.51.100.10" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-web1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "met", + "detail": "Open to the internet on tcp 443.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-web" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "443" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "met", + "detail": "Route table public sends 0.0.0.0/0 to an internet gateway.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:rtb-public", + "field": "ipv4_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "value": { + "status": "known", + "value": "igw-shop" + } + }, + { + "record": "node:shop/123456789012:us-east-1:rtb-public", + "field": "ipv6_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "value": { + "status": "unknown" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:rtb-public|ROUTES_VIA|123456789012:us-east-1:igw-shop|\"0.0.0.0/0\"", + "field": "destination", + "source_field": "ec2 describe-route-tables: Routes[].DestinationCidrBlock", + "value": { + "status": "known", + "value": "0.0.0.0/0" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:rtb-public|ROUTES_VIA|123456789012:us-east-1:igw-shop|\"0.0.0.0/0\"", + "field": "state", + "source_field": "ec2 describe-route-tables: Routes[].State", + "value": { + "status": "known", + "value": "active" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": { + "evidence_id": "path:e58dfab0a5655b39", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.direct-instance" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ALLOWS]-> web", + "web <-[PROTECTED_BY]- web-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "node:shop/123456789012:us-east-1:sg-web", + "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "node:shop/123456789012:us-east-1:i-web1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "along": false + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-web1", + "direction": "both", + "types": [ + "ALLOWS", + "PROTECTED_BY" + ], + "max_hops": 2 + }, + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "node:shop/123456789012:us-east-1:sg-web", + "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "node:shop/123456789012:us-east-1:i-web1", + "node:shop/123456789012:us-east-1:rtb-public", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-public", + "rel:shop/123456789012:us-east-1:rtb-public|ROUTES_VIA|123456789012:us-east-1:igw-shop|\"0.0.0.0/0\"" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "internet-facing", + "statement": "The load balancer is internet-facing.", + "status": "met", + "detail": "The load balancer is internet-facing.", + "fields": [ + { + "record": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "field": "scheme", + "source_field": "elbv2 describe-load-balancers: Scheme", + "value": { + "status": "known", + "value": "internet-facing" + } + } + ] + }, + { + "id": "balancer-open-rule", + "statement": "The load balancer has a security group rule open to the internet.", + "status": "met", + "detail": "Open to the internet on tcp 443.", + "fields": [ + { + "record": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|PROTECTED_BY|123456789012:us-east-1:sg-lb", + "field": "@to", + "source_field": "elbv2 describe-load-balancers: SecurityGroups[]", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-lb" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-lb|\"\\\"tcp\\\":\\\"443\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-lb|\"\\\"tcp\\\":\\\"443\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "443" + } + } + ] + }, + { + "id": "target-healthy", + "statement": "The target is healthy.", + "status": "met", + "detail": "The target was healthy.", + "fields": [ + { + "record": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "field": "health", + "source_field": "elbv2 describe-target-health: TargetHealth.State", + "value": { + "status": "known", + "value": "healthy" + } + } + ] + }, + { + "id": "target-allows-balancer", + "statement": "The target's security group allows the target group's port from the load balancer.", + "status": "met", + "detail": "A rule allows tcp 8080 from the load balancer's group or the internet.", + "fields": [ + { + "record": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "field": "port", + "source_field": "elbv2 describe-target-groups: Port", + "value": { + "status": "known", + "value": 8080 + } + }, + { + "record": "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "field": "protocol", + "source_field": "elbv2 describe-target-groups: Protocol", + "value": { + "status": "known", + "value": "HTTP" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-app" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:sg-lb|ALLOWS|123456789012:us-east-1:sg-app|\"\\\"tcp\\\":\\\"8080\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:sg-lb|ALLOWS|123456789012:us-east-1:sg-app|\"\\\"tcp\\\":\\\"8080\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "8080" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "Listeners and their rules are not read, so an open security group is not shown to lead to this target group.", + "The target group's port is used; a target registered on another port is not seen.", + "A CIDR rule is not compared with the balancer's addresses, which are not read." + ], + "path": { + "evidence_id": "path:ee48d3346c316825", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.public-load-balancer" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ROUTES_TO]-> shop", + "shop -[FORWARDS_TO]-> app", + "app -[ROUTES_TO]-> app-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "node:shop/123456789012:us-east-1:i-app1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "along": true + }, + { + "edge": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "along": true + }, + { + "edge": "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "along": true + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-app1", + "direction": "out", + "types": [ + "FORWARDS_TO", + "ROUTES_TO" + ], + "max_hops": 3 + }, + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ROUTES_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|FORWARDS_TO|arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "node:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/app/def|ROUTES_TO|123456789012:us-east-1:i-app1", + "node:shop/123456789012:us-east-1:i-app1", + "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app", + "rel:shop/123456789012:us-east-1:sg-lb|ALLOWS|123456789012:us-east-1:sg-app|\"\\\"tcp\\\":\\\"8080\\\"\"", + "rel:shop/arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/shop/abc|PROTECTED_BY|123456789012:us-east-1:sg-lb", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-lb|\"\\\"tcp\\\":\\\"443\\\"\"" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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: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, + "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", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "source-potentially-exposed", + "statement": "The source instance has a potential exposure finding.", + "status": "met", + "detail": "web-1 has a potential exposure finding from aws.exposure.direct-instance.", + "fields": [] + }, + { + "id": "group-allows-source", + "statement": "A security group rule on this instance allows the source instance's group.", + "status": "met", + "detail": "Allowed on tcp 5432.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "5432" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "Only one step is followed; use khonsu explore for longer chains.", + "Whether the source instance could be made to send this traffic is not assessed." + ], + "path": { + "evidence_id": "path:b8e817cb02495d22", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.security-group-lateral" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ALLOWS]-> web", + "web <-[PROTECTED_BY]- web-1", + "web-1 -[PROTECTED_BY]-> web", + "web -[ALLOWS]-> db", + "db <-[PROTECTED_BY]- db-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "node:shop/123456789012:us-east-1:sg-web", + "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "node:shop/123456789012:us-east-1:i-web1", + "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "node:shop/123456789012:us-east-1:sg-db", + "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "node:shop/123456789012:us-east-1:i-db1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "along": false + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "along": false + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-db1", + "direction": "both", + "types": [ + "ALLOWS", + "PROTECTED_BY" + ], + "max_hops": 5 + }, + "depends_on": "finding:f2dfac00f69ee196", + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-web|\"\\\"tcp\\\":\\\"443\\\"\"", + "node:shop/123456789012:us-east-1:sg-web", + "rel:shop/123456789012:us-east-1:i-web1|PROTECTED_BY|123456789012:us-east-1:sg-web", + "node:shop/123456789012:us-east-1:i-web1", + "rel:shop/123456789012:us-east-1:sg-web|ALLOWS|123456789012:us-east-1:sg-db|\"\\\"tcp\\\":\\\"5432\\\"\"", + "node:shop/123456789012:us-east-1:sg-db", + "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "node:shop/123456789012:us-east-1:i-db1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "met", + "detail": "Public address 198.51.100.77.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-legacy1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "known", + "value": "198.51.100.77" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-legacy1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "met", + "detail": "Open to the internet on tcp 80.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-legacy" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "80" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "unknown", + "detail": "No route table is recorded for the subnet or as its VPC's main table, so its routes could not be read.", + "fields": [] + } + ], + "unknowns": [ + "The instance's subnet has an active default route to an internet gateway." + ], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": { + "evidence_id": "path:2bdb0f342ca09503", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.direct-instance" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ALLOWS]-> legacy", + "legacy <-[PROTECTED_BY]- legacy-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "node:shop/123456789012:us-east-1:sg-legacy", + "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "node:shop/123456789012:us-east-1:i-legacy1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "along": false + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-legacy1", + "direction": "both", + "types": [ + "ALLOWS", + "PROTECTED_BY" + ], + "max_hops": 2 + }, + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-legacy|\"\\\"tcp\\\":\\\"80\\\"\"", + "node:shop/123456789012:us-east-1:sg-legacy", + "rel:shop/123456789012:us-east-1:i-legacy1|PROTECTED_BY|123456789012:us-east-1:sg-legacy", + "node:shop/123456789012:us-east-1:i-legacy1", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-legacy" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "not_met", + "detail": "AWS reported no public IPv4 or IPv6 address.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-app1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "unknown" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-app1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "not_met", + "detail": "None of its security groups has a rule from 0.0.0.0/0 or ::/0.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-app" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "not_met", + "detail": "Route table main has no active default route to an internet gateway.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv4_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "value": { + "status": "known", + "value": "nat-shop" + } + }, + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv6_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "value": { + "status": "unknown" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": null, + "walk": null, + "contributing": [ + "node:shop/123456789012:us-east-1:i-app1", + "node:shop/123456789012:us-east-1:rtb-main", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "rel:shop/123456789012:us-east-1:i-app1|PROTECTED_BY|123456789012:us-east-1:sg-app" + ], + "evidence": [ + { + "evidence_id": "node:shop/123456789012:us-east-1:i-app1", + "stable": true, + "kind": "node", + "internal_id": 0, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "not_met", + "detail": "AWS reported no public IPv4 or IPv6 address.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-batch1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "unknown" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-batch1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "met", + "detail": "Open to the internet on tcp 22.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-batch" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "field": "protocol", + "source_field": "ec2 describe-security-groups: IpPermissions[].IpProtocol", + "value": { + "status": "known", + "value": "tcp" + } + }, + { + "record": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "field": "ports", + "source_field": "ec2 describe-security-groups: IpPermissions[].FromPort, ToPort", + "value": { + "status": "known", + "value": "22" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "not_met", + "detail": "Route table main has no active default route to an internet gateway.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv4_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "value": { + "status": "known", + "value": "nat-shop" + } + }, + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv6_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "value": { + "status": "unknown" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": { + "evidence_id": "path:cc501cb09671c83c", + "stable": true, + "kind": "path", + "assertion": "derived", + "derivation": "the rule's candidate path", + "rule_id": { + "status": "known", + "value": "aws.exposure.direct-instance" + }, + "claim": "These records connect in the data. This is not proof that anything can reach anything.", + "hops": [ + "Internet -[ALLOWS]-> batch", + "batch <-[PROTECTED_BY]- batch-1" + ], + "supporting": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "node:shop/123456789012:us-east-1:sg-batch", + "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "node:shop/123456789012:us-east-1:i-batch1" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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" + } + } + ] + }, + "path_refs": [ + { + "edge": "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "along": true + }, + { + "edge": "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "along": false + } + ], + "walk": { + "from": "shop/cidr:internet", + "to": "shop/123456789012:us-east-1:i-batch1", + "direction": "both", + "types": [ + "ALLOWS", + "PROTECTED_BY" + ], + "max_hops": 2 + }, + "contributing": [ + "node:shop/cidr:internet", + "rel:shop/cidr:internet|ALLOWS|123456789012:us-east-1:sg-batch|\"\\\"tcp\\\":\\\"22\\\"\"", + "node:shop/123456789012:us-east-1:sg-batch", + "rel:shop/123456789012:us-east-1:i-batch1|PROTECTED_BY|123456789012:us-east-1:sg-batch", + "node:shop/123456789012:us-east-1:i-batch1", + "node:shop/123456789012:us-east-1:rtb-main", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private" + ], + "evidence": [ + { + "evidence_id": "node:shop/cidr:internet", + "stable": true, + "kind": "node", + "internal_id": 25, + "types": [ + "Cidr" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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", + "source_field": "ec2 describe-instances: State.Name", + "value": { + "status": "known", + "value": "running" + } + } + ] + }, + { + "id": "public-address", + "statement": "The instance has a public address.", + "status": "not_met", + "detail": "AWS reported no public IPv4 or IPv6 address.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:i-db1", + "field": "public_ip", + "source_field": "ec2 describe-instances: PublicIpAddress", + "value": { + "status": "unknown" + } + }, + { + "record": "node:shop/123456789012:us-east-1:i-db1", + "field": "ipv6_address", + "source_field": "ec2 describe-instances: Ipv6Address", + "value": { + "status": "unknown" + } + } + ] + }, + { + "id": "open-inbound-rule", + "statement": "The instance has a security group rule open to the internet.", + "status": "not_met", + "detail": "None of its security groups has a rule from 0.0.0.0/0 or ::/0.", + "fields": [ + { + "record": "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db", + "field": "@to", + "source_field": "ec2 describe-instances: SecurityGroups[].GroupId", + "value": { + "status": "known", + "value": "node:shop/123456789012:us-east-1:sg-db" + } + } + ] + }, + { + "id": "internet-route", + "statement": "The instance's subnet has an active default route to an internet gateway.", + "status": "not_met", + "detail": "Route table main has no active default route to an internet gateway.", + "fields": [ + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv4_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationCidrBlock=0.0.0.0/0]", + "value": { + "status": "known", + "value": "nat-shop" + } + }, + { + "record": "node:shop/123456789012:us-east-1:rtb-main", + "field": "ipv6_default_target", + "source_field": "ec2 describe-route-tables: Routes[DestinationIpv6CidrBlock=::/0]", + "value": { + "status": "unknown" + } + } + ] + } + ], + "unknowns": [], + "assumptions": [ + "Network ACLs, host firewalls and anything outside this AWS account are not read.", + "0.0.0.0/0 and ::/0 are one Internet record, so a rule's address family is not compared with the instance's address.", + "Whether anything listens on the allowed ports is not read." + ], + "path": null, + "walk": null, + "contributing": [ + "node:shop/123456789012:us-east-1:i-db1", + "node:shop/123456789012:us-east-1:rtb-main", + "node:shop/arn:aws:ec2:us-east-1:123456789012:subnet/subnet-private", + "rel:shop/123456789012:us-east-1:i-db1|PROTECTED_BY|123456789012:us-east-1:sg-db" + ], + "evidence": [ + { + "evidence_id": "node:shop/123456789012:us-east-1:i-db1", + "stable": true, + "kind": "node", + "internal_id": 2, + "types": [ + "Instance" + ], + "source": { + "status": "known", + "value": "shop" + }, + "source_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/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 0decba9..a8ca3be 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 @@ -22,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}; @@ -66,13 +70,14 @@ fn describe() -> Describe { commands: vec!["aws".into()], notes: vec!["Read-only: ec2:Describe*, elasticloadbalancing:Describe*, iam:List*, sts:GetCallerIdentity".into()], }, - labels: ["Account", "Region", "Vpc", "Subnet", "SecurityGroup", "Cidr", "Instance", "InternetGateway", "LoadBalancer", "TargetGroup", "Role"] + labels: ["Account", "Region", "Vpc", "Subnet", "SecurityGroup", "Cidr", "Instance", "InternetGateway", "RouteTable", "LoadBalancer", "TargetGroup", "Role"] .map(String::from) .to_vec(), - relationship_types: ["IN_REGION", "IN_VPC", "IN_SUBNET", "PROTECTED_BY", "ALLOWS", "ATTACHED_TO", "ASSUMES", "FORWARDS_TO", "ROUTES_TO", "OWNS"] + relationship_types: ["IN_REGION", "IN_VPC", "IN_SUBNET", "PROTECTED_BY", "ALLOWS", "ATTACHED_TO", "ASSUMES", "FORWARDS_TO", "ROUTES_TO", "USES_ROUTE_TABLE", "ROUTES_VIA", "OWNS"] .map(String::from) .to_vec(), supports_incremental: false, + analyses: vec![exposure::info()], } } @@ -232,6 +237,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 +275,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 +375,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 +427,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") { @@ -466,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) => { @@ -554,6 +621,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/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 3a0057f..fd1aa27 100644 --- a/skills/khonsugraph/SKILL.md +++ b/skills/khonsugraph/SKILL.md @@ -38,6 +38,18 @@ Use the real `khonsu` executable, never GUI automation or a replacement script. For what Cypher cannot say — who is most central, which groups form, how two nodes connect — use `khonsu analyze ALGORITHM --db DIR` (`summary`, `degree`, `pagerank`, `betweenness`, `closeness`, `components`, `communities`, `clustering`, `kcore`, `path`). It reads the whole graph, or `--label`/`--type`, or a `--cypher` subgraph, and returns the `--top` nodes with their labels and names; algorithms follow networkx. Start with `summary` on an unfamiliar graph. Name the algorithm and the subgraph when you report a result, and say when betweenness was sampled (`sampled_sources`). See [the CLI reference](references/cli.md#graph-analysis). +## Following a chain + +To trace how things connect from a node without loading the graph, use `khonsu explore --db DIR --from NODE`, where `NODE` is a `khonsu_id` or a numeric ID (repeat `--from` for several starts). `--direction out|in|both`, `--type A,B`, `--max-hops`, `--max-nodes` and `--max-edges` bound the walk (at most 16 hops, 2,000 nodes, 5,000 relationships; a request above that is refused). `--to NODE` adds a fewest-hops `path` among what the walk found. Each hop has a `text` such as `Internet -[ROUTES_TO]-> shop`. + +Read `complete` first. When it is false, `limits` says which cap cut the walk, so a missing path means "not within these caps", not "no path": raise the cap and say so. Even a complete walk reports paths in the data: a path is a candidate, not proof that anything can be reached, and no path is not proof of no exposure. `explore` needs the graph free; it fails with kind `locked` while the window has it open. + +## Evidence + +`khonsu evidence --db DIR --node NODE --rel REL` (a `khonsu_id` or a numeric ID, repeat either) says what supports a record: `evidence_id`, `source`, `plugin`, `record_written_at`, `source_last_successful_sync`, `assertion` and its own `properties`. Each provenance field is `{"status":"known","value":…}` or `{"status":"unknown"}`; say unknown when it is, never estimate. `explore --to` adds `path.evidence`, a derived record listing the `supporting` evidence IDs. Cite `evidence_id` when you report a finding. Names and values that look like secrets are masked and listed in `redacted`. `record_written_at` is when a record last changed, not when it was last seen. + +A plugin can offer analyses: `khonsu plugin describe ID` lists them under `analyses`, and `khonsu plugin analyze ID ANALYSIS --db DIR` runs one. On an AWS graph, `khonsu plugin analyze aws exposure --db DIR` gives findings with a `verdict`: `potential_exposure`, `insufficient_data` or `not_indicated`. Report a finding by its `finding_id`, `rule_id`, `path.hops`, `unknowns` and `assumptions`. Say "potential exposure from configuration": never call a resource reachable or safe from a finding. 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 3919ab3..03efe21 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -37,6 +37,69 @@ khonsu explain --db DIR --cypher "MATCH (n:Host) RETURN n" `{node_count, relationship_count, labels: [{name, count, properties}], relationship_types: [{name, count, properties, patterns: [[source, target]]}], tables: [{name, columns}]}`. Counts are exact; properties come from HawDB's statistics. +## Explore + +```sh +khonsu explore --db graphs/aws --from shop/cidr:internet --to shop/123456789012:us-east-1:i-web1 --type ROUTES_TO,FORWARDS_TO +``` + +| Option | Meaning | Default | +|---|---|---| +| `--from NODE` | A start: a `khonsu_id` or a numeric ID. Repeat for several | Required | +| `--to NODE` | Report a fewest-hops `path` to this node among what was found | None | +| `--direction out\|in\|both` | Along the arrows, against them, or both | `out` | +| `--type A,B` | Follow only these relationship types | Every type | +| `--max-hops N` | Steps from a start, at most 16 | 3 | +| `--max-nodes N` | Nodes returned, starts included, at most 2,000 | 200 | +| `--max-edges N` | Relationships returned, at most 5,000 | 500 | + +The output holds `complete`, `limits` (`hops`, `nodes`, `edges`), `depth_reached`, `starts`, `missing_starts`, `hops` (`depth`, `from`, `to`, `edge`, `rel_type`, `along`, `text`), `graph` and, with `--to`, `path` (`to`, `found`, `hops`). A value above a ceiling is refused, not lowered. + +## Evidence + +```sh +khonsu evidence --db graphs/aws --node shop/123456789012:us-east-1:i-web1 +``` + +`--node` and `--rel` each take a `khonsu_id` or a numeric ID and can be repeated. The output is `{"evidence": [...]}`, in the order: nodes, then relationships. A record that is not there exits 1 with kind `not_found`, and naming no record exits 2. + +| Field | Meaning | +|---|---| +| `evidence_id` | `node:` or `rel:`; `node-internal:` when `stable` is false | +| `kind`, `internal_id`, `types` | What it is: labels, or the relationship type | +| `from`, `to` | A relationship's ends, by evidence ID | +| `source`, `plugin` | What wrote it | +| `record_written_at` | When it last changed, in Unix seconds; can predate the last sync | +| `source_last_successful_sync` | From the source's settings; unknown when its last sync failed | +| `source_snapshot` | Unknown for now | +| `rule_id` | Unknown on a record; known on a finding's `path` | +| `assertion` | `explicit` (a source reported it), `derived`, or `unknown` | +| `properties`, `redacted` | Its own properties, and the names masked as possible secrets | + +## Plugin analyses + +```sh +khonsu plugin describe aws # `analyses` lists what it offers +khonsu plugin analyze aws exposure --db graphs/aws --verdict potential-exposure --pretty +``` + +`--resource ID` takes a `khonsu_id`, `--verdict` takes one of the analysis's verdicts, and `--max-findings` defaults to 200. The top level has `plugin`, `analysis`, `title`, `claim`, `order`, `verdicts`, `rules`, `subjects`, `summary` (counts before any narrowing), `complete` and `truncated` (findings cut by `--max-findings`). When `complete` is false, a missing finding means nothing. Fails with kind `plugin` when the plugin isn't approved, offers no such analysis, or gives an answer that doesn't match the graph. AWS's `exposure` has the verdicts `potential_exposure`, `insufficient_data` and `not_indicated`. + +| Finding field | Meaning | +|---|---| +| `finding_id`, `rule_id` | Stable for the same rule and records | +| `verdict` | One the analysis declares. On AWS `exposure`, none means reachable or safe | +| `resource`, `resource_name` | The node it is about | +| `conditions[]` | `id`, `statement`, `status` (`met`, `not_met`, `unknown`), `detail`, and `fields[]` with `record`, `field`, `source_field`, `value` | +| `unknowns`, `assumptions` | What could not be evaluated, and what the rule does not read | +| `path` | The candidate path as a derived evidence record, or null when its records do not exist | +| `path_refs` | That path as stable references: `edge` and `along` for each hop | +| `walk` | `from`, `to`, `direction`, `types`, `max_hops` for `khonsu explore`, or null | +| `depends_on` | The finding a lateral step rests on | +| `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).