{"id":51285362,"url":"https://github.com/mhristodor/rustinel-view","last_synced_at":"2026-06-30T05:02:14.108Z","repository":{"id":368282730,"uuid":"1284384964","full_name":"mhristodor/rustinel-view","owner":"mhristodor","description":"Read-only triage UI for Rustinel EDR snapshots: merges alerts and telemetry into one process-aware killchain timeline. Go, embedded Redis, single binary.","archived":false,"fork":false,"pushed_at":"2026-06-29T21:11:08.000Z","size":694,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-29T22:25:21.034Z","etag":null,"topics":["blue-team","dfir","ebpf","ecs","edr","go","golang","incident-response","log-analysis","process-tree","redis","rustinel","security-tools","sigma","soc","threat-hunting","timeline","yara"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"agpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mhristodor.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-29T20:12:49.000Z","updated_at":"2026-06-29T21:11:32.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mhristodor/rustinel-view","commit_stats":null,"previous_names":["mhristodor/rustinel-view"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/mhristodor/rustinel-view","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhristodor%2Frustinel-view","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhristodor%2Frustinel-view/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhristodor%2Frustinel-view/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhristodor%2Frustinel-view/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mhristodor","download_url":"https://codeload.github.com/mhristodor/rustinel-view/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhristodor%2Frustinel-view/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34952850,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-30T02:00:05.919Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["blue-team","dfir","ebpf","ecs","edr","go","golang","incident-response","log-analysis","process-tree","redis","rustinel","security-tools","sigma","soc","threat-hunting","timeline","yara"],"created_at":"2026-06-30T05:02:10.798Z","updated_at":"2026-06-30T05:02:14.100Z","avatar_url":"https://github.com/mhristodor.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n# ▰ rustinel-view\n\n**Read-only killchain triage for [Rustinel](https://github.com/Karib0u/rustinel) EDR snapshots.**\n\nPoint it at an alerts file and an events log; it merges them into one\nprocess-aware timeline you can filter, pivot, and walk in the browser.\n\n![Go](https://img.shields.io/badge/Go-1.26%2B-00ADD8?logo=go\u0026logoColor=white)\n\u0026nbsp;![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)\n\u0026nbsp;![Frontend](https://img.shields.io/badge/frontend-vanilla_JS%2C_no_build_step-3366CC)\n\u0026nbsp;![Mode](https://img.shields.io/badge/mode-read--only-555)\n\n\u003c/div\u003e\n\n![rustinel-view timeline: alerts and events merged into one killchain, with category filters, a query bar, and an activity histogram](assets/timeline.png)\n\n\u003e **A companion tool, not part of Rustinel.** Rustinel does the detection and\n\u003e writes the logs; this reads them after the fact. There is no live agent, no\n\u003e database to stand up, and no API to wire into a SIEM.\n\n## Features\n\n- **One timeline for two sources.** Detections and raw telemetry are merged and time-ordered into a single killchain, not two panes you have to mentally join.\n- **A query language that filters both.** A KQL-style bar with wildcards, numeric comparisons, quoted phrases, and field-presence tests. One expression matches alerts and events through a unified field schema.\n- **Process lineage.** Pivot on any PID to rebuild its process tree: ancestors above, descendant subtree below, with the alerts that fired on each process pinned to its node.\n- **Burst collapse.** Repetitive same-key activity folds into one row with a count and a time span, so a thousand identical DNS lookups don't bury the one that matters.\n- **Two-tier noise filtering.** Unconditional drops at ingest plus a toggle for low-signal sensor chatter, so the default view is already quiet.\n- **Self-contained.** A single static Go binary with an in-process Redis. Nothing to install, nothing written to disk, data gone the moment you quit.\n- **No build step.** The UI is server-rendered HTML plus one hand-written vanilla-JS file — no framework, no bundler, no `node_modules`. A virtual list keeps the live DOM bounded, so the timeline stays smooth across hundreds of thousands of rows.\n\n## How it works\n\nWhen the binary starts it:\n\n1. Boots an in-process Redis ([miniredis](https://github.com/alicebob/miniredis)), so there is nothing to install.\n2. Parses the alerts JSON and the events log into that store.\n3. Decodes one read snapshot up front, so the first page is as fast as the hundredth.\n4. Serves server-rendered HTML fragments. A dependency-free vanilla-JS virtual list in the browser fetches them (`/timeline`, `/count`, `/density`) and mounts only the rows in view.\n\nThe data only lives as long as the process. Quit the binary and it is gone.\nNothing is written back to disk.\n\n## Inputs\n\nRustinel produces two files. Those are the two inputs here:\n\n| Input | Rustinel file | What it is |\n|-------|---------------|------------|\n| alerts | `logs/alerts.json.\u003cdate\u003e` | Detections, one ECS NDJSON document per line |\n| events | `logs/rustinel.log.\u003cdate\u003e` | Operational log; the viewer pulls the `normalized_json=` telemetry lines out of it |\n\nThe events input only has data if Rustinel is logging at `trace`. The\nnormalized telemetry lines are emitted at `trace` and nowhere else. At the\ndefault `info` level the operational log carries no `normalized_json=` lines,\nso the events timeline comes up empty. See step 2 below.\n\n## Build\n\n```sh\ngo build -o rustinel-view ./cmd/rustinel-view   # needs Go 1.26+\n```\n\n## Quick start\n\n**1. Build it** (above).\n\n**2. Turn on trace telemetry in Rustinel.** In `config.toml`:\n\n```toml\n[logging]\nlevel = \"trace\"      # normalized events are trace-level only\ndirectory = \"logs\"\n```\n\n`trace` on everything is noisy. Only the `engine` target needs it, so a target\nfilter keeps the rest at `info`:\n\n```toml\n[logging]\nfilter = \"info,engine=trace\"\ndirectory = \"logs\"\n```\n\n**3. Run Rustinel and do something it can see.**\n\n```sh\nsudo ./rustinel run\n```\n\nIt now writes `logs/alerts.json.\u003cdate\u003e` and `logs/rustinel.log.\u003cdate\u003e`.\n\n**4. Point the viewer at both files.**\n\n```sh\nRV_ALERTS=logs/alerts.json.\u003cdate\u003e \\\nRV_EVENTS=logs/rustinel.log.\u003cdate\u003e \\\n./run.sh\n```\n\nOpen \u003chttp://127.0.0.1:18080/\u003e.\n\n## Walkthrough: a `whoami` detection\n\nRustinel ships a demo Sigma rule that fires on `whoami`. With the agent running\nat trace level, run it:\n\n```sh\nwhoami\n```\n\nYou get:\n\n- an alert in `logs/alerts.json.\u003cdate\u003e` (`rule.name: \"Example - Whoami Execution\"`, `edr.rule.engine: Sigma`, `process.name: whoami`);\n- the events around it in `logs/rustinel.log.\u003cdate\u003e`: the `whoami` process start, its parent shell, and the file and network activity nearby, each a `normalized_json=` line.\n\nLoad both files and open the page. From there you can:\n\n- find the `whoami` alert at the top of the killchain;\n- click it for the full ECS record;\n- open lineage (`GET /lineage/{pid}`) to see which shell spawned `whoami` and what else that shell touched;\n- filter with the query bar, e.g. `name:whoami` or `engine:Sigma severity:Low`.\n\nLineage rebuilds the process tree from one PID, folds the matching alerts onto\neach node, and lets you pivot to any child to keep digging.\n\n![rustinel-view process lineage: the tree rooted at a pivoted process, with per-node alerts inlined and ancestor context above](assets/lineage.png)\n\n## Running it directly\n\n```sh\nrustinel-view [flags] \u003calerts.json\u003e \u003cevents.log\u003e\n```\n\n| Flag | Default | Description |\n|------|---------|-------------|\n| `-addr` | `:8080` | HTTP listen address |\n| `-log-level` | `info` | `debug` \\| `info` \\| `warn` \\| `error` |\n| `-redis-port` | `0` | embedded miniredis port (0 = OS-assigned) |\n| `-no-flush` | `false` | skip `FLUSHALL` at startup (debugging aid) |\n\n### run.sh\n\n`run.sh` builds and launches in one step. Pass the snapshot paths as env vars,\nor drop them in a local, gitignored `.run.env` next to the script:\n\n```sh\n# .run.env\nRV_ALERTS=/path/to/alerts.json\nRV_EVENTS=/path/to/rustinel.log\n# RV_ADDR=0.0.0.0:18080   # expose on the LAN (read the security note first)\n```\n\n```sh\n./run.sh\n```\n\nOverrides: `RV_ALERTS`, `RV_EVENTS`, `RV_ADDR` (default `127.0.0.1:18080`), `RV_LOG`.\n\n## Security\n\nThere is no auth and no TLS. Anyone who can reach the port can read the whole\nEDR snapshot. That is why the default bind is loopback only (`127.0.0.1`). Only\nbind to `0.0.0.0` or a LAN address on a network you trust.\n\n## Query language\n\nA small KQL-style language over the timeline. A query is a boolean expression of\n`field:value` tests plus bare-term substring search, joined with `AND`, `OR`,\n`NOT`, and parentheses. The fields are unified across alerts and events, so one\nexpression filters both kinds of row.\n\nFields:\n\n```\nkind  pid  parent_pid  name  image  parent_image  cmdline  parent_cmdline\nuser  category  action  severity  rule  engine  target  query  record\nsrc  dst  src_port  dst_port  proto\n```\n\nOperators:\n\n| Form | Meaning |\n|------|---------|\n| `field:value` | case-insensitive match (IP fields match exactly) |\n| `field:val*` | wildcard; multiple stars allowed, e.g. `image:*/tmp/*` |\n| `field:\u003eN` `\u003e=` `\u003c` `\u003c=` | numeric comparison, e.g. `dst_port:\u003e1024` |\n| `field:*` | the field is present on this row |\n| `NOT field:*` | the field is absent |\n| `\"quoted phrase\"` | a value with spaces; `\\` escapes the next character |\n| bare term | substring search across every field, e.g. `whoami` or `8.8.8.8` |\n\nCombine with `AND`, `OR`, `NOT`, and parentheses. Adjacent terms are ANDed, so\n`engine:Sigma severity:High` and `engine:Sigma AND severity:High` are the same.\n\nExamples:\n\n```\ncategory:Process AND NOT user:root\nname:py* cmdline:\"-c import socket\"\nengine:Sigma severity:High\nproto:tcp dst_port:\u003e1024 AND NOT dst:10.*\ntarget:/etc/* action:FILE_DELETE\n```\n\n## HTTP routes\n\n| Route | Purpose |\n|-------|---------|\n| `GET /` | main triage page |\n| `GET /timeline` | timeline rows (HTML fragment) |\n| `GET /count` / `GET /density` | result count / time-bucket histogram |\n| `GET /lineage/{pid}` | process-tree lineage for a PID |\n| `GET /detail/{kind}/{id}` | full record detail (`a` alert / `e` event) |\n| `GET /json/{kind}/{id}` | raw normalized JSON for a record |\n| `GET /export` | export the current result set as NDJSON |\n| `GET /query/{schema,check,values}` | query autocomplete and validation |\n\n## Project layout\n\n```\ncmd/rustinel-view/     entrypoint: ingest, warm, serve\ninternal/ingest/       alert + event parsers\ninternal/store/        miniredis-backed store, query language, lineage\ninternal/server/       HTTP handlers, templates, embedded static assets\n```\n\n## Design notes\n\nA few decisions that keep the viewer fast and quiet on real snapshots:\n\n- **Reads don't touch Redis.** miniredis is the source of truth, but queries\n  serve from an immutable, time-ordered decode of the whole dataset held behind\n  an atomic pointer. That turns a per-query re-fetch-and-JSON-decode (~200-400ms)\n  into a slice scan (~5ms). Any write nils the pointer and the next read\n  rebuilds; the snapshot is warmed at startup so even the first page is fast.\n- **The browser keeps a bounded DOM.** The client is a single vanilla-JS file\n  with no build step: it fetches HTML row fragments from `/timeline` and mounts\n  only the rows in (and near) the viewport, so scrolling a snapshot of hundreds\n  of thousands of rows never grows the live `\u003ctr\u003e` count. Static assets are\n  served from the embedded `static/` dir with a content-hash cache-buster.\n- **Burst collapse uses per-category windows.** Adjacent rows that share a\n  collapse key fold into one burst row with a count and a span. The window is\n  sized to how each category actually bursts: 10s for alerts, 5s for DNS and\n  network, 2s for file and process activity.\n- **Noise is filtered in two tiers.** Unambiguous junk (writes to `/dev/null`)\n  is dropped at ingest and never enters the data pool, so it's gone from both\n  the timeline and the lineage tree. Softer noise (root DNS queries, events with\n  every field empty) is dropped by a toggle you can switch off for the raw stream.\n- **Lineage is one pass over the snapshot.** Index every row by PID and parent\n  PID, walk the parent chain up as a single spine, then BFS the descendants with\n  caps (depth 8, 300 nodes) so a fork bomb can't blow up the page. PID reuse is\n  handled by letting the most recently seen parent win.\n- **Event timestamps come from the log prefix.** Telemetry is scraped out of the\n  operational log by cutting each line on `normalized_json=`. The embedded JSON\n  only has second precision, so ordering uses the microsecond RFC3339Nano\n  timestamp from the log line's own prefix instead.\n\n## Built on Rustinel\n\n[Rustinel](https://github.com/Karib0u/rustinel) is an open-source,\ncross-platform EDR (ETW / eBPF / Endpoint Security) with Sigma, YARA, and IOC\ndetection that emits ECS NDJSON alerts. It is licensed under Apache 2.0. All of\nthe detection logic and telemetry that this viewer renders comes from Rustinel;\nrustinel-view just reads its output. Credit for the EDR goes to its author,\n[Karib0u](https://github.com/Karib0u).\n\n## License\n\nCopyright (C) 2026 Mihail Hristodor.\n\nrustinel-view is licensed under the GNU Affero General Public License v3.0. See\n[LICENSE](LICENSE) for the full text. In short: you are free to use, study,\nmodify, and share it, but if you run a modified version as a network service,\nyou have to make your modified source available to its users under the same\nlicense.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmhristodor%2Frustinel-view","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmhristodor%2Frustinel-view","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmhristodor%2Frustinel-view/lists"}