diff --git a/app/src/app.rs b/app/src/app.rs index 254d3cf..04e3b16 100644 --- a/app/src/app.rs +++ b/app/src/app.rs @@ -39,7 +39,7 @@ impl KhonsuApp { let state = cx.new(AppState::new); let workspace = cx.new(|cx| Workspace::new(state.clone(), window, cx)); let sidebar = cx.new(|cx| Sidebar::new(state.clone(), workspace.clone(), window, cx)); - let title_bar = cx.new(|cx| TitleBarView::new(state.clone(), cx)); + let title_bar = cx.new(|cx| TitleBarView::new(state.clone(), workspace.clone(), cx)); let status_bar = cx.new(|cx| StatusBarView::new(state.clone(), cx)); cx.subscribe(&state, |_, _, _: &state::SidebarToggled, cx| cx.notify()).detach(); cx.subscribe(&state, |_, _, _: &state::GraphChanged, cx| cx.notify()).detach(); diff --git a/app/src/ui/canvas.rs b/app/src/ui/canvas.rs index 11062f2..ceb30fb 100644 --- a/app/src/ui/canvas.rs +++ b/app/src/ui/canvas.rs @@ -1082,6 +1082,8 @@ impl GraphCanvas { ) } }; + // The evidence above says what it masked, so the list must not show it. + let properties = khonsu_core::evidence::masked_properties(properties); let mut rows: Vec<(&String, &Json)> = properties.iter().collect(); // Provenance last: it says where a value came from, not what it is. rows.sort_by_key(|(k, _)| (k.starts_with("khonsu_"), k.to_string())); diff --git a/app/src/ui/title_bar.rs b/app/src/ui/title_bar.rs index d63153a..fd48f99 100644 --- a/app/src/ui/title_bar.rs +++ b/app/src/ui/title_bar.rs @@ -12,14 +12,16 @@ use gpui_kit::*; use crate::i18n::{tr, Language}; use crate::state::{AppState, GraphChanged, OpenStateChanged, SidebarToggled}; +use crate::ui::workspace::Workspace; pub struct TitleBarView { state: Entity, + workspace: Entity, _subscriptions: Vec, } impl TitleBarView { - pub fn new(state: Entity, cx: &mut Context) -> Self { + pub fn new(state: Entity, workspace: Entity, cx: &mut Context) -> Self { let subscriptions = vec![ cx.subscribe(&state, |_, _, _: &GraphChanged, cx| cx.notify()), cx.subscribe(&state, |_, _, _: &OpenStateChanged, cx| cx.notify()), @@ -27,6 +29,7 @@ impl TitleBarView { ]; Self { state, + workspace, _subscriptions: subscriptions, } } @@ -54,6 +57,7 @@ impl TitleBarView { fn render_graph_menu(&self, opening: bool, has_graph: bool, read_only: bool) -> impl IntoElement { let state = self.state.clone(); + let workspace = self.workspace.clone(); Button::new("graph-menu") .ghost() .compact() @@ -67,6 +71,8 @@ impl TitleBarView { let ro_state = state.clone(); let source_state = state.clone(); let close_state = state.clone(); + let investigation_state = state.clone(); + let investigation_workspace = workspace.clone(); menu.item( PopupMenuItem::new(tr("title_bar.open_graph")) .icon(IconName::FolderOpen) @@ -77,6 +83,9 @@ impl TitleBarView { .icon(IconName::Plus) .on_click(move |_, window, cx| crate::ui::pick_graph(new_state.clone(), true, window, cx)), ) + .item(PopupMenuItem::new(tr("investigation.open")).on_click(move |_, window, cx| { + crate::ui::pick_investigation(investigation_state.clone(), investigation_workspace.clone(), window, cx) + })) .when(has_graph, |menu| { menu.separator() .item( diff --git a/app/tests/investigation.rs b/app/tests/investigation.rs index e17b02e..6f7840a 100644 --- a/app/tests/investigation.rs +++ b/app/tests/investigation.rs @@ -171,7 +171,7 @@ fn fixture() -> PathBuf { } /// The saved responses with a positive, a negative and an undecided case, and -/// route tables. See `docs/exposure-rules.md`. +/// route tables. See `plugins/aws/README.md`. fn exposure_fixture() -> PathBuf { repo().join("plugins/aws/tests/fixture-exposure") } diff --git a/crates/khonsu-core/src/evidence.rs b/crates/khonsu-core/src/evidence.rs index 9e78cab..18a0e79 100644 --- a/crates/khonsu-core/src/evidence.rs +++ b/crates/khonsu-core/src/evidence.rs @@ -288,6 +288,16 @@ pub(crate) fn own_properties(properties: &Map) -> (Map) -> Map { + let (mut shown, _) = own_properties(properties); + for (key, value) in properties.iter().filter(|(key, _)| key.starts_with("khonsu_")) { + shown.insert(key.clone(), value.clone()); + } + shown +} + fn is_secret_name(name: &str) -> bool { let plain = name.to_lowercase().replace(['_', '-'], ""); SECRET_NAMES.iter().any(|secret| plain.contains(secret)) @@ -459,6 +469,22 @@ mod tests { assert_eq!(serde_json::to_value(Field::::Unknown).unwrap(), json!({"status": "unknown"})); } + #[test] + fn shown_properties_mask_what_evidence_masks_and_keep_bookkeeping() { + let properties = map(json!({ + "name": "web", + "api_token": "SYNTHETIC_NOT_A_SECRET", + "note": "AKIAABCDEFGHIJKLMNOP", + "khonsu_id": "shop/web", + })); + let shown = masked_properties(&properties); + assert_eq!(shown["name"], json!("web")); + assert_eq!(shown["api_token"], json!(REDACTED)); + assert_eq!(shown["note"], json!(REDACTED)); + assert_eq!(shown["khonsu_id"], json!("shop/web")); + assert_eq!(shown.len(), properties.len()); + } + #[test] fn secrets_are_masked_and_named_and_source_config_stays_out() { let host = node( diff --git a/docs/investigation-gates.md b/docs/investigation-gates.md index 01e8863..322621d 100644 --- a/docs/investigation-gates.md +++ b/docs/investigation-gates.md @@ -21,7 +21,7 @@ cargo test --locked --workspace --test investigation | Corrupt input | A malformed response, a missing folder, a bad dashboard and a damaged graph each fail with a reason | | Cycles | A shortest path and a variable-length pattern both end on a cycle | | Evidence | `khonsu evidence` and `explore --to` give stable IDs, state what is unknown, and mask secrets; the golden file holds a node and a relationship | -| Exposure rules | `plugins/aws/tests/fixture-exposure` has a positive, a negative and an undecided case; the golden file holds its whole report, and rule IDs and verdicts for the shop fixture. See [the rules](exposure-rules.md) | +| 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](../plugins/aws/README.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, a failure, an endless line and output that isn't the protocol are each refused; one that hangs is ended; a changed plugin doesn't run; a plugin with no analyses goes on syncing | | Saved investigations | Reopening restores a walk and path on unchanged data, and flags a changed source, a missing record, a missing graph and a bad file; the golden file holds a reopened walk and the fixture's fingerprint. See [saved investigations](investigations.md) | | Sync coverage | A scripted plugin fails midway, stops without `done`, reports gaps, sends duplicates and missing endpoints, and moves its cursor; none of it removes records or moves the cursor unless the sync was complete | @@ -114,5 +114,31 @@ not investigated. The two-hop query reported about 19 s for 1,948,728 paths. These figures describe one machine and one build. They are not targets or performance claims. -**Not measured:** release builds, macOS, and UI responsiveness. The window has no -scripted way to measure responsiveness yet. +### Release, Windows + +Measured on 2026-11-03 at commit `10d29e2` on the same machine and HawDB version, +with a `--release` build. Figures are the median of 3 runs, measured the same way, +except the sync, which ran once. Disk caches weren't cleared, so these aren't +cold-start figures. + +| Graph | Operation | Time | Peak memory | +|---|---|---:|---:| +| AWS exposure fixture (27 nodes, 45 relationships) | `analyze summary` | 175 ms | 22 MiB | +| | `check exposure.dash` | 179 ms | 25 MiB | +| | `shortestPath` query | 185 ms | 22 MiB | +| | `plugin analyze aws exposure` | 502 ms | 24 MiB | +| | `explore`, both directions, 2 hops, 2 types | 202 ms | 24 MiB | +| | `evidence` on one node | 203 ms | 22 MiB | +| Audit story (5,881 nodes, 35,592 relationships) | Sync into a new graph, one run | 4.2 s | 213 MiB | +| | `count(n)` query | 1.2 s | 131 MiB | +| | Two-hop `count` query | 5.1 s | 133 MiB | +| | `analyze summary` | 2.6 s | 178 MiB | +| | `analyze pagerank` | 2.2 s | 170 MiB | +| | `check trust_network.dash` | 1.6 s | 139 MiB | + +Don't read these as a speed-up from the debug table: the AWS graph is a different +fixture, and the two-hop query counted 2,301,858 paths here against 1,948,728 there. + +**Not measured:** macOS, UI responsiveness (input to visible change, frame rate, a +busy window) and how long a cancel takes under load. The window has no scripted way +to measure responsiveness yet. diff --git a/docs/investigations.md b/docs/investigations.md index 827595c..6f062cb 100644 --- a/docs/investigations.md +++ b/docs/investigations.md @@ -83,7 +83,7 @@ Select a node on a graph canvas, then choose **Save investigation…** in Path Explorer. It keeps the current filters, the last walk's starting points, and the path to the selected node if that walk reached it, along with the tab's query. -**File > Open investigation…** or `khonsu open web.investigation` reopens one in a +**Graph > Open investigation…** in the title bar (also **File** in the macOS menu bar) or `khonsu open web.investigation` reopens one in a new tab, and opens its graph first if another one is open. The query goes in the editor without running. The canvas shows the saved path, with **Back** leading to the whole walk, and Path Explorer shows the filters. Anything that differs from diff --git a/docs/plugins.md b/docs/plugins.md index 4769be2..f1d00a3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -149,7 +149,7 @@ values from the source's secret fields are masked before they are kept. 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 +plugin's [exposure rules](../plugins/aws/README.md) are one, and `crates/khonsu-plugin-protocol/examples/analyzer.rs` is a small one with nothing to do with AWS. diff --git a/examples/README.md b/examples/README.md index 22c5e43..2da0dac 100644 --- a/examples/README.md +++ b/examples/README.md @@ -41,7 +41,7 @@ profile; the plugin only calls `describe`, `list` and `get` operations. 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). +each, follow the walkthrough in [the exposure rules](../plugins/aws/README.md). ## Build a story graph diff --git a/docs/exposure-rules.md b/plugins/aws/README.md similarity index 93% rename from docs/exposure-rules.md rename to plugins/aws/README.md index 0de3063..aa1e1ce 100644 --- a/docs/exposure-rules.md +++ b/plugins/aws/README.md @@ -1,4 +1,10 @@ -# AWS exposure rules +# AWS plugin + +An example of what a plugin can do: it syncs an AWS account into a graph, and offers +one analysis, the exposure rules below. It is maintained, and no new rules or +resource types are planned. KhonsuGraph's core and window don't depend on it. + +## 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. @@ -8,13 +14,13 @@ 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). +[offering an analysis](../../docs/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 +### Verdicts Each rule is a list of conditions, each `met`, `not_met` or `unknown`. There is no score. @@ -28,9 +34,9 @@ no score. Findings are ordered by verdict, then rule ID, then resource ID, so the same graph always gives the same order. -## Rules +### Rules -### `aws.exposure.direct-instance` +#### `aws.exposure.direct-instance` Every instance gets one finding. @@ -45,7 +51,7 @@ A subnet with no route table of its own uses its VPC's main table. A route count when it is active, goes to an internet gateway, and matches the family of the instance's address. -### `aws.exposure.public-load-balancer` +#### `aws.exposure.public-load-balancer` Each target gets one finding per load balancer and target group that sends traffic to it. @@ -61,14 +67,14 @@ traffic to it. 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` +#### `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 +### What the rules do not read Each finding lists its assumptions, and they include these: @@ -82,7 +88,7 @@ Each finding lists its assumptions, and they include these: 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 missing response is not a clear result A record that should exist and doesn't makes a condition `unknown`, not `not_met`. A graph synced before route tables were read gives `insufficient_data` on the direct @@ -104,7 +110,7 @@ still there. `complete` is also false when the graph held more than 50,000 records of one type. A missing finding then means nothing. -## Walkthrough +### 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 @@ -173,7 +179,7 @@ khonsu plugin analyze aws exposure --db graphs/exposure --pretty --resource shop The commands above need the graph free. They fail with kind `locked` while the window has it open. -## In the window +### In the window Select an instance on a graph canvas, then choose **Run analysis** under Exposure in the inspector. The window offers it because the AWS plugin says it can speak about diff --git a/plugins/aws/src/exposure.rs b/plugins/aws/src/exposure.rs index d5842eb..23dd0c0 100644 --- a/plugins/aws/src/exposure.rs +++ b/plugins/aws/src/exposure.rs @@ -6,7 +6,7 @@ //! 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`. +//! here sends traffic. See `plugins/aws/README.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 diff --git a/skills/khonsugraph/references/cli.md b/skills/khonsugraph/references/cli.md index fad1a35..48f9b01 100644 --- a/skills/khonsugraph/references/cli.md +++ b/skills/khonsugraph/references/cli.md @@ -100,7 +100,7 @@ khonsu plugin analyze aws exposure --db graphs/aws --verdict potential-exposure | `incomplete_sources` | Sources behind it whose last sync wasn't complete. A verdict that would be `not_indicated` becomes `insufficient_data` | | `contributing`, `evidence` | Evidence IDs of every record used, and those records | -See `docs/exposure-rules.md` for the rules and a walkthrough. +See `plugins/aws/README.md` for the rules and a walkthrough. ## Saved investigations