Skip to content

Commit 1b375b6

Browse files
cpoderclaude
andauthored
engine: Varpulis at 4e3dbb5, windows close on event time (varpulis #284, #285) (#3)
A time window in a detect unit closed only when a later event reached that same window: a count per address waited for the same address's next failure, so a brute force that ended in a successful logon was never raised in a running unit. The engine now closes a window when the event time of the types feeding it passes its end, on any event of those types, upstream first. A sequence step that names a derived stream now reads what that stream outputs (over an aggregate it matched the raw events). e2e/detect gains D6: three failures from one address, a success from it, then a logon from another address after the window. On the previous runtime nothing is ever raised; on this one the next logon raises the brute force with its count. D4 now lists three units. Claude-Session: https://claude.ai/code/session_01N3K1TGnWTvwYzt9rKuJXES Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5ab1d12 commit 1b375b6

5 files changed

Lines changed: 83 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ is `0`, minor versions may carry breaking changes — they are called out here.
77

88
## [Unreleased]
99

10+
### Fixed — detect units, from the Varpulis engine (varpulis #284, #285)
11+
- **A time window closes when event time passes its end**, on the next event
12+
of the types feeding it, not only when a later event reaches that same
13+
window. A window over a filtered stream used to wait for the next event
14+
passing the filter, a partitioned one for the next event of the same
15+
partition: a brute force counted per address that ended in a successful
16+
logon never raised its count in a running unit. A window fed by several
17+
types waits for the slowest of them; a type that goes quiet holds back only
18+
the windows it feeds (with `.watermark()` it used to hold back every
19+
window). Windows close upstream first, so a count's alert reaches the
20+
windows and correlations above it in time.
21+
- **A sequence step that names a derived stream reads what that stream
22+
outputs**: over an aggregate it matched the raw events instead of the
23+
results, a second `.where()` of the stream was lost, and a first step's own
24+
filter replaced the stream's instead of adding to it.
25+
1026
## [0.3.1] — 2026-09-23
1127

1228
### Fixed — detect units, from the Varpulis engine (varpulis #277, #279, #281)

‎core/Cargo.lock‎

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎core/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ webpki-roots = "0.26"
2222
# The Varpulis CEP engine, embedded as a library (ADR-0031): compile a VPL
2323
# program, feed it events, publish its emits. No async runtime in its tree —
2424
# its own CI (scripts/check-engine-deps.py) fails if one ever appears.
25-
varpulis-engine = { git = "https://github.com/varpulis/varpulis", rev = "9e92c0bfaa748217edbb571b9fb4992d731ea0bf" }
25+
varpulis-engine = { git = "https://github.com/varpulis/varpulis", rev = "4e3dbb5eca2c985bfc5b4f568d1118a96123ef45" }
2626

2727
[[bin]]
2828
name = "vejas-runtime"

‎docs/book/src/concepts/detects.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ machine it connected to: one alert, on `vx.alerts.lateral`.
5050
`.within()` and window is judged against it, never against the clock, so
5151
a replay of the same events gives the same alerts. A payload without any
5252
of them is stamped on arrival.
53+
- **A window closes when the event time of the types feeding it passes its
54+
end**: on any event of those types, not only one of the window's own. A
55+
brute force counted per address on Security events is raised by the next
56+
Security event of any kind, even when the attacker got in and stopped. A
57+
window fed by several types waits for the slowest; one that no event
58+
follows stays open, so a source that can go quiet should send a heartbeat
59+
of its own type. A stream whose events arrive late declares
60+
`.watermark(out_of_order: 30s)` to hold its windows that much longer.
5361
- **Types.** A string `event_type` in the payload names the event type;
5462
without it, the type is the one the `.from()` binding declares for that
5563
subject. The engine's own decoder does this, the same one every Varpulis

‎e2e/detect/run.sh‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
# come back from the unit's snapshot (round A), and what was
1616
# acked after the last snapshot is replayed from the bus
1717
# (round B) — resume by sequence, ADR-0031 points 4-5
18+
# D6 count close a count per address closes on the next event of its source
19+
# past the window, from any address: a brute force whose
20+
# attacker got in and stopped is still raised
1821
# D3 vpl-check the engine's verdict on the CLI: ok / refused, with exit codes
1922
# D4 topology /topology lists the units under "detects", lang vpl, running
2023
#
@@ -91,6 +94,30 @@ stream LateralMovement = SmbConnect as smb
9194
.to(Bus, topic: "vxt.lateral")
9295
VPL
9396

97+
cat > "$ROOT/detects/bruteforce.vpl" <<'VPL'
98+
connector Bus = nats (
99+
url: "ignored-by-vejas: the bus is NATS_URL"
100+
)
101+
102+
event Auth:
103+
ip: str
104+
status: str
105+
106+
stream Logons = Auth
107+
.from(Bus, topic: "vxt.auth")
108+
109+
stream Failed = Auth
110+
.where(status == "failure")
111+
112+
stream Brute = Failed
113+
.partition_by(ip)
114+
.window(5m)
115+
.aggregate(ip: last(ip), n: count())
116+
.where(n >= 3)
117+
.emit(rule: "brute_force", ip: ip, n: n)
118+
.to(Bus, topic: "vxt.brute")
119+
VPL
120+
94121
start_nats() { "$NATSD" -js -sd "$WORK/js" -a 127.0.0.1 -p "$NATS_P" > /dev/null 2>&1 & NATS_PID=$!; sleep 0.5; }
95122
start_rt() {
96123
VEJAS_ROOT="$ROOT" NATS_URL="$URL" VEJAS_STREAM=TTEST VEJAS_SUBJECT_ROOT=vxt \
@@ -130,14 +157,14 @@ echo "── D4 topology"
130157
deadline=$((SECONDS+10))
131158
while [ "$SECONDS" -lt "$deadline" ]; do
132159
topo=$(curl -s "http://127.0.0.1:$HTTP_P/topology")
133-
[ "$(printf '%s' "$topo" | grep -o '"status":"running"' | wc -l)" -ge 2 ] && break
160+
[ "$(printf '%s' "$topo" | grep -o '"status":"running"' | wc -l)" -ge 3 ] && break
134161
sleep 0.2
135162
done
136-
python3 - "$topo" <<'PY' && ok "two detects listed, lang vpl, running" || bad "topology: $topo"
163+
python3 - "$topo" <<'PY' && ok "three detects listed, lang vpl, running" || bad "topology: $topo"
137164
import json, sys
138165
t=json.loads(sys.argv[1]); d=t.get("detects", [])
139166
names=sorted(x["name"] for x in d)
140-
assert names==["detect:lateral","detect:threshold"], names
167+
assert names==["detect:bruteforce","detect:lateral","detect:threshold"], names
141168
assert all(x["lang"]=="vpl" for x in d), d
142169
assert all(x["status"]=="running" for x in d), [x["status"] for x in d]
143170
PY
@@ -198,6 +225,24 @@ lateral_round backlog 1 12
198225
[ "$got" -eq "$M" ] && ok "the same $M, and no more, when the second step arrives as one backlog" || { bad "backlog: expected $M lateral alerts, got $got"; diag_lateral; }
199226
kill "$SUB_PID" 2>/dev/null; pkill -f "[s]ub vxt.lateral" 2>/dev/null
200227

228+
echo "── D6 a count closes on its source's event time (the attacker stops)"
229+
( timeout 30 "$NATS" -s "$URL" sub vxt.brute --raw > "$WORK/brute.txt" 2>/dev/null ) & SUB_PID=$!
230+
sleep 0.5
231+
auth() { "$NATS" -s "$URL" pub vxt.auth "{\"event_type\":\"Auth\",\"@timestamp\":\"2026-09-21T18:$1Z\",\"ip\":\"$2\",\"status\":\"$3\"}" > /dev/null 2>&1; }
232+
for t in 00:00 00:10 00:20; do auth "$t" 10.0.0.66 failure; done
233+
auth 00:30 10.0.0.66 success # the attacker got in, and stops
234+
sleep 1.5
235+
[ "$(grep -c '^{' "$WORK/brute.txt")" -eq 0 ] && ok "nothing while the window runs" || bad "an alert before the window ended: $(cat "$WORK/brute.txt")"
236+
auth 06:00 10.0.0.7 success # any later logon, from anyone
237+
deadline=$((SECONDS+10)); while [ "$SECONDS" -lt "$deadline" ]; do grep -q '10.0.0.66' "$WORK/brute.txt" && break; sleep 0.2; done
238+
sleep 1
239+
python3 - "$WORK/brute.txt" <<'PY' && ok "the next logon of anyone raises the brute force, with its count" || bad "brute force: $(cat "$WORK/brute.txt")"
240+
import json, sys
241+
alerts=[json.loads(l) for l in open(sys.argv[1]) if l.startswith("{")]
242+
assert len(alerts)==1 and alerts[0]["ip"]=="10.0.0.66" and alerts[0]["n"]==3, alerts
243+
PY
244+
kill "$SUB_PID" 2>/dev/null; pkill -f "[s]ub vxt.brute" 2>/dev/null
245+
201246
echo "── D5 a stateful sequence survives kill -9 (snapshot, resume by sequence)"
202247
( timeout 60 "$NATS" -s "$URL" sub vxt.lateral --raw > "$WORK/lateral5.txt" 2>/dev/null ) & SUB_PID=$!
203248
sleep 0.5

0 commit comments

Comments
 (0)