Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions app/src/ui/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
11 changes: 10 additions & 1 deletion app/src/ui/title_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,24 @@ 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<AppState>,
workspace: Entity<Workspace>,
_subscriptions: Vec<Subscription>,
}

impl TitleBarView {
pub fn new(state: Entity<AppState>, cx: &mut Context<Self>) -> Self {
pub fn new(state: Entity<AppState>, workspace: Entity<Workspace>, cx: &mut Context<Self>) -> Self {
let subscriptions = vec![
cx.subscribe(&state, |_, _, _: &GraphChanged, cx| cx.notify()),
cx.subscribe(&state, |_, _, _: &OpenStateChanged, cx| cx.notify()),
cx.subscribe(&state, |_, _, _: &SidebarToggled, cx| cx.notify()),
];
Self {
state,
workspace,
_subscriptions: subscriptions,
}
}
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion app/tests/investigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
26 changes: 26 additions & 0 deletions crates/khonsu-core/src/evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,16 @@ pub(crate) fn own_properties(properties: &Map<String, Json>) -> (Map<String, Jso
(own, redacted)
}

/// `properties` as they may be shown: the same values [`Evidence`] masks are
/// masked, and KhonsuGraph's bookkeeping stays.
pub fn masked_properties(properties: &Map<String, Json>) -> Map<String, Json> {
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))
Expand Down Expand Up @@ -459,6 +469,22 @@ mod tests {
assert_eq!(serde_json::to_value(Field::<i64>::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(
Expand Down
32 changes: 29 additions & 3 deletions docs/investigation-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/investigations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 17 additions & 11 deletions docs/exposure-rules.md → plugins/aws/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -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.
Expand All @@ -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:

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/aws/src/exposure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion skills/khonsugraph/references/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading