-
-
Notifications
You must be signed in to change notification settings - Fork 30
Write a Module
For: a developer writing their first InnerWarden extension. This is the fast path: a working module in about ten minutes.
A module is how you teach InnerWarden something new without forking it. You bundle a small piece of detection or response, a manifest that describes it, a test, and a short README. Then you validate it, enable it, and run the tests. That is the whole loop.
This page is the quickstart. When you want the full manifest schema, every module type, the AI-assisted authoring checklist, or how to publish to the community registry, go to Module Reference.
A module bundles one or more of these. You do not need all of them: a module that only adds a detector is valid, and so is one that only adds a skill.
- A collector reads a data source (a log file, a subprocess, a socket) and emits
Eventstructs. If your "data source" is an external security tool like Falco or Suricata, use the declarative form in Integration Recipes instead, it is faster. - A detector watches events and emits an
Incidentwhen it sees a pattern (for example, "more than N of this from one IP in 60 seconds"). - A skill is a response action the agent can run when it decides to act (block an IP, suspend a user). Skills are dry-run by default and never auto-execute until the operator opts in.
For the deeper picture of where these sit in the pipeline, see Architecture.
We will build a tiny detector: it fires a High incident when one IP triggers a chosen event kind too many times in a window. Reverse-shell-detector-lite, in spirit.
A module lives under modules/<id>/. The id is kebab-case and globally unique.
modules/my-burst/
module.toml
src/detectors/my_burst.rs
tests/integration.rs
docs/README.md
module.toml is the description InnerWarden reads. This is the minimal version, just enough to validate and enable:
[module]
id = "my-burst"
name = "My Burst Detector"
version = "0.1.0"
description = "Flags an IP that triggers one event kind too many times in a short window"
authors = ["You <you@example.com>"]
license = "Apache-2.0"
tier = "open"
builtin = false
min_innerwarden = "0.15.0"
[provides]
detectors = ["my-burst"]The full set of fields (rules, security, preflights, requires) is documented in Module Reference. You do not need them to get a first detector running.
A detector is a struct with a process(&mut self, event: &Event) -> Option<Incident> method. It keeps its own state (here, a sliding window per IP) between calls.
use innerwarden_core::{Event, Incident, Severity, EntityRef};
use std::collections::HashMap;
pub struct MyBurst {
host: String,
threshold: usize,
window_seconds: u64,
state: HashMap<String, Vec<chrono::DateTime<chrono::Utc>>>,
}
impl MyBurst {
pub fn new(host: impl Into<String>, threshold: usize, window_seconds: u64) -> Self {
Self { host: host.into(), threshold, window_seconds, state: HashMap::new() }
}
pub fn process(&mut self, event: &Event) -> Option<Incident> {
// Return None for anything you do not handle. Detectors are fail-open.
if event.kind != "my.relevant_kind" {
return None;
}
let key = event.entities.iter().find_map(|e| e.as_ip())?.to_string();
let now = event.ts;
let window = chrono::Duration::seconds(self.window_seconds as i64);
let entries = self.state.entry(key.clone()).or_default();
entries.retain(|&ts| now - ts < window); // drop events outside the window FIRST
entries.push(now);
if entries.len() >= self.threshold {
return Some(Incident {
ts: now,
host: self.host.clone(),
incident_id: format!("my_burst:{}:{}", key, now.to_rfc3339()),
severity: Severity::High,
title: format!("Burst from {key}"),
summary: format!("{} events in {}s from {key}", entries.len(), self.window_seconds),
evidence: serde_json::json!({ "count": entries.len(), "key": key }),
recommended_checks: vec!["Check activity from this source IP".into()],
tags: vec!["my-burst".into()],
entities: vec![EntityRef::ip(&key)],
});
}
None
}
}Two rules that the validator and a reviewer will both check:
-
Return
Nonefor unrelated events, neverErr. A detector must not crash the sensor. -
incident_idisdetector_name:entity:iso_timestampand deterministic. Do not use a random UUID, or the same burst would look like a different incident every time.
The exact Event and Incident field names live in Data Formats, and the deeper detector rules (sliding-window correctness, entity types) are in Module Reference.
Every component needs at least one test. The minimum for a detector proves it fires at the threshold and ignores unrelated events:
#[cfg(test)]
mod tests {
use super::*;
fn ev(kind: &str, ip: &str) -> Event {
Event {
ts: chrono::Utc::now(),
host: "test-host".into(),
source: "test".into(),
kind: kind.into(),
severity: Severity::Low,
summary: "test".into(),
details: serde_json::Value::Null,
tags: vec![],
entities: vec![EntityRef::ip(ip)],
}
}
#[test]
fn fires_at_threshold() {
let mut d = MyBurst::new("test-host", 3, 300);
assert!(d.process(&ev("my.relevant_kind", "1.2.3.4")).is_none());
assert!(d.process(&ev("my.relevant_kind", "1.2.3.4")).is_none());
assert!(d.process(&ev("my.relevant_kind", "1.2.3.4")).is_some());
}
#[test]
fn ignores_unrelated() {
let mut d = MyBurst::new("test-host", 3, 300);
for _ in 0..10 {
assert!(d.process(&ev("unrelated.kind", "1.2.3.4")).is_none());
}
}
}docs/README.md is required and must be at least 300 characters with ## Overview, ## Configuration, and ## Security sections. One honest paragraph each: what it does, what you can tune, and the trade-offs (especially the dry-run guidance for skills). The exact required sections are in Module Reference.
These are the real commands. They are all subcommands of innerwarden module. For the full flag list run innerwarden module --help, and see CLI Reference, which owns the command surface.
innerwarden module validate ./modules/my-burst # structure, manifest, security, docs, tests
innerwarden module enable ./modules/my-burst # turn it on for this install
make test # your tests run alongside the suitemodule validate is your friend: it checks the manifest fields, that anything referenced in your rules is actually declared, that skills have no shell-injection patterns, and that the README and tests exist. Fix what it reports before going further.
If you wrote a module someone else published, innerwarden module install <source> fetches it, and innerwarden module list / innerwarden module search show what is available. Those, and module publish, are covered in Module Reference.
module enable registers an external module so InnerWarden knows about it. If you are developing inside the main repo (a builtin = true module whose code lives in crates/), you also wire the detector into the sensor's detector set and call it in the event loop. The exact registration sites (crates/sensor/src/main.rs, the config struct) are spelled out in Module Reference, and the repo is the source of truth for the current file layout.
| Mistake | Do this instead |
|---|---|
Err(...) in a detector on a weird event |
Return None. Detectors are fail-open. |
Random incident_id (UUID) |
format!("my_burst:{ip}:{}", ts.to_rfc3339()), deterministic. |
Building a command with format!("ufw deny {ip}") in a skill |
Pass each argument separately: .arg("deny").arg("from").arg(ip). No shell strings. |
Skipping the dry_run check in a skill |
First line of execute: if dry_run, return a "would do X" message and touch nothing. |
auto_execute = true by default |
Keep it false. The operator decides when a response is allowed to run on its own. |
These last few are about response safety, which is the spine of the whole product. The non-negotiable skill rules and why they exist are in Module Reference and Trust and Safety Invariants.
- Going deeper (full schema, skills, AI-assisted authoring, publishing): Module Reference
- Wrapping an external tool (Falco, Suricata, Wazuh) as a collector: Integration Recipes
- Every command and flag you saw above: CLI Reference
- The safety guarantees your module must not break: Trust and Safety Invariants