A minimal, educational EDR / endpoint monitor written in Nim.
Argus observes what happens on a host — process, file and network events — normalizes them into a single typed event model, and runs a declarative, rule-based detection engine that raises alerts mapped to MITRE ATT&CK.
Argus exists to teach how endpoint detection works, not to protect a real fleet. It has no kernel driver, no tamper protection, no anti-evasion, no managed update channel, and no support commitment. A determined adversary can trivially avoid or disable it. Do not rely on it as a security control. Use it to read, to experiment, and to understand the pipeline that real EDRs implement at far greater depth.
- Defensive and observational only. Argus reads telemetry. It never injects into, tampers with, hides from, or modifies other processes. There is no offensive capability in this codebase and none will be added.
- Least privilege, and honest about it. Each collector declares exactly
what it needs and what it can still see without it.
argus doctorprints that report before anything is collected. Argus never asks you to weaken host security. - Normalize early. Raw platform structures stop at the collector boundary.
Everything downstream — engine, rules, sinks — sees only the normalized
Eventtype. - Never lose anything silently. When the bus fills it drops events and counts them; when correlation state hits its cap it evicts and counts that; when a collector cannot start it is named. A monitor that looks healthy while observing nothing is worse than one that admits a gap.
- Handle data responsibly. Command lines and file paths routinely contain personal data. The default watch list is short and explicit rather than "everything, filtered later".
collectors ──▶ normalizer ──▶ event bus ──▶ rule engine ──▶ alert sinks
(platform) (to Event) (bounded, (YAML rules + (json / console
threaded) correlation) / pluggable)
Requires Nim 2.0+.
nimble build # produces ./argus
nimble test # 721 unit tests
nimble lint # type-checks every module
./argus doctor # what can be collected here
./argus rules rules/ # what the pack detects
./argus replay tests/data/sample_trace.jsonl rules/ # test rules deterministically
./argus run --duration 30 # watch this hostOr run the guided walkthrough, which is safe and self-contained:
./scripts/demo.shThe fastest way to see the whole thing work — and the way the rules are tested. No host activity is observed; the trace is a file in the repository.
$ ./argus replay tests/data/sample_trace.jsonl rules/
[HIGH] ARG-PROC-001 Interactive shell spawned by a web server
when 2026-08-01T10:15:32.412Z host web-01
attack T1059.004 · execution https://attack.mitre.org/techniques/T1059/004/
evidence
· process_start /usr/bin/bash (pid 4711)
parent /usr/sbin/nginx (pid 1240)
user www-data (uid 33)
[CRITICAL] ARG-CORR-001 Web server spawned a shell, which then connected out
attack T1071.001 · command-and-control
evidence
· process_start /usr/bin/bash (pid 4711)
· net_connect tcp 203.0.113.10:4444
The last one is the point of the whole project: neither half is damning alone, but the sequence, from the same process, inside a time window, is.
The same trace contains three benign apt writes to /etc. Nothing fires on
them — a test asserts that, because noise is how a real deployment gets muted.
| Collector | Source | Privilege | Limitation |
|---|---|---|---|
| Process | /proc polling |
read /proc |
misses processes shorter-lived than the interval |
| File | inotify (Linux) or directory polling | read the watched paths | inotify watches are not recursive; polling misses churn between scans |
| Network | /proc/net/* + /proc/*/fd |
unprivileged for your own processes; root to attribute all pids | polling misses short-lived connections |
argus doctor reports this for your actual host. On a machine with no /proc
— macOS, for instance — it says so plainly rather than collecting nothing
quietly:
process — unavailable
reason: no /proc on this system; the process collector needs a Linux-style procfs
No collectors can run here. Argus would observe nothing.
Rules are YAML. See rules/ for the 22-rule starter pack and
docs/design.md for the full format.
- id: ARG-PROC-001
name: Interactive shell spawned by a web server
severity: high
attack: { tactic: execution, technique: T1059.004 }
when: { action: process_start }
match:
all:
- { field: actor.exe, op: basename_in, values: [sh, bash, dash, zsh] }
- { field: actor.parentExe, op: basename_in, values: [nginx, httpd, php-fpm] }Nineteen operators (equals, in, regex, glob, path_under,
basename_in, cidr, gt/lt, exists, …), all/any/not composition,
and correlation rules that join a sequence of steps on shared fields inside a
time window.
Everything checkable is checked at load time — unknown field paths,
unknown operators, regexes that do not compile, gt given two values,
duplicate ids. A detection that silently never fires is worse than one that
refuses to load:
rules/01-process.yaml: rule ARG-PROC-001: rules[0].match.field:
unknown field path 'actor.exee'; see docs/design.md for the field table
Every setting has a defensible default, so argus run works with no config at
all. Copy argus.example.yaml to argus.yaml to
override; argus config shows what is actually in effect and where it came
from.
import argus
let rules = loadRuleDir("rules")
let engine = newEngine(rules)
let sinks = newSinkGroup(newConsoleSink(), newJsonFileSink("alerts.jsonl"))
for alert in engine.processAll(loadTrace("trace.jsonl")):
sinks.submit(alert)
sinks.close()All seven milestones are complete. See docs/design.md for
the event schema, collector interface, rule format and design rationale.
| # | Milestone | Status |
|---|---|---|
| 1 | Scaffold, Event model, alert sinks |
✅ |
| 2 | Rule engine: YAML rules, field matching, ATT&CK | ✅ |
| 3 | Collector interface, event bus, process collector | ✅ |
| 4 | File collector (inotify + polling) | ✅ |
| 5 | Network collector with pid correlation | ✅ |
| 6 | Stateful correlation across a time window | ✅ |
| 7 | CLI, config, starter rule pack, demo | ✅ |
- eBPF collection. Richer and cheaper than polling, and a much larger project. The collector interface is designed so it could be added without the engine noticing.
- netlink proc connector. Would catch processes that polling misses, at the
cost of
CAP_NET_ADMINand a lot of struct marshalling. - A second platform. macOS EndpointSecurity and Windows ETW both fit behind the existing collector interface.
Process injection, tampering, hiding, evasion, or any capability whose purpose is to act on another process rather than observe it. Argus reads. That is the whole of its authority.
721 unit tests, no live host activity in any of them.
- Parsers (
/proc/<pid>/stat,/proc/net/tcp, inotify masks) are pure functions tested against real-shaped input, including the cases that break naive implementations — a process legally named(evil) thing, IPv6 addresses stored as four little-endian words. - Collectors take their tree root as a parameter, so the exact production code path runs against a fixture directory. No mocks.
- Rules are tested by replaying recorded JSONL traces, which is deterministic and provokes nothing on the machine running the suite.
nimble testMIT — see LICENSE.