- Scheme 87.9%
- Shell 4.1%
- Makefile 4%
- Rust 3.3%
- C 0.7%
| .builds | ||
| .jerboa | ||
| bin | ||
| dependency-patches | ||
| docs | ||
| examples | ||
| jsecmon | ||
| llvmir | ||
| scripts | ||
| support | ||
| tests | ||
| typed | ||
| .gitattributes | ||
| .gitignore | ||
| AGENTS.md | ||
| build-binary.ss | ||
| dependency-lock.tsv | ||
| jpkg.sexp | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
| SECURITY.md | ||
jsecmon
A pure-Jerboa reimplementation of secmon, a host security monitor. The goal: no hand-written Rust or C. Security-critical kernels (crypto, secrets, detection scoring) are written in Typed Jerboa and compiled to Rust by jerboa's typed→rust backend; the rest is ordinary Jerboa.
This is a long, incremental port. Each kernel is translated, compiled to Rust, and verified against secmon's own test vectors before it counts as done.
Layout
typed/ Typed Jerboa kernels (.ss) — compile to a Rust crate
tests/ Hand-written Rust tests asserting parity with secmon's vectors
build/rust/ Generated crate (gitignored; `make rust` regenerates)
Build & verify
make rust # typed/*.ss → build/rust (a cargo crate)
make test # regenerate, then cargo test against secmon's vectors
make ffi-demo # build the cdylib + drive two kernels from a Jerboa script
make kernels-check # exercise the (jsecmon kernels) library against the vectors
make triage-check # verify the untyped (jsecmon triage) engine vs secmon vectors
make analytics-check # verify untyped risk-ranking + incident grouping vs vectors
make detect-check # full pipeline: events -> detect -> analytics (all kernels)
make storage-check # SQLite round-trip: store -> query -> detect -> analytics
make threats-check # SQL-aggregation threat rules vs secmon detection vectors
make geoip-check # geoip CSV vectors + geoip-gated impossible_travel detector
make sigma-check # Sigma YAML rule importer vs secmon conversion vectors
make yaml-rules-check # user YAML detection rules (threshold/distinct/sequence/match)
make buffer-check # the agent's encrypted event ring buffer (FIFO + priority eviction)
make dns-sniffer-check # DNS wire-format parser (QNAME/compression/answers) + dedup state
make suspicious-check # SuspiciousPatterns: shell/tool-from-service, revshell + miner
make netconn-check # connection classifier: bad-port, high-port-mult-1000, web→external
make kernmod-check # kernel-module classifier: rootkit substring, short name, no vowels
make selinux-check # SELinux audit-log parser: AVC + boolean/policy/role events
make container-check # container/jail escape mount classifier (host bind, docker sock)
make dns-servers-check # resolv.conf nameserver parse + public-resolver union
make sensitive-path-check # DTrace sensitive-path classifier (passwd/ssh/cron/...)
make dtrace-parse-check # DTrace SECMON|TYPE|... parser + stateful EventParser rows
make dtrace-runtime-check # DTrace direct libdtrace/subprocess runtime shell + EventParser drain
make stealth-check # stealth init helpers: anti-debug parsers/tables + runtime decisions
make ebpf-runtime-check # eBPF support guard + libbpf tracepoint/perf stream shell
make proc-linux-check # Linux /proc parsers: stat ppid+comm, uid, TCP state, net hex IP, environ scan
make freebsd-parse-check # FreeBSD kldstat/ps/address + sockstat & netstat connection lines
make event-meta-check # event-type -> display severity + coarse store-priority u8 tables
make config-check # AgentConfig defaults + from_env merge + platform db/key paths
make privdrop-check # agent post-bind privilege drop: passwd lookup + setgroups/setgid/setuid order
make event-danger-check # mount is_dangerous + capability dangerous_caps + namespace ns_types
make persistence-check # classify_path (-> persistence type) + suspicious-content line scan
make file-change-check # is_suspicious_change: setuid/setgid added, critical files, sensitive dirs
make webshell-check # web-server-spawned suspicious child: name/cmdline classifier + reason
make platform-mounts-check # is_dangerous_path (per-platform exact set) + get_mounts line parsers
make analyze-cli-check # analyze bin: parse_duration_ms + AlertSink::parse + --flag scanners
make collector-cli-check # collector bin: --after/--format/--db + host normalize + hosts-file
make event-summary-check # storage readers: extract_pid/extract_process_name/build_summary
make ioc-check # storage IOC: detect_ioc_type + is_ipv4 + parse_ioc_text + match field routing
make frame-check # server protocol: length-prefixed wire framing (encode/read_length/decode)
make correlate-check # storage detect_*: brute_force/cred_stuffing/dns_tunnel/suspicious_cron cores
make revshell-check # revshell: is_shell/is_c2_port/is_legitimate + classify_connection
make cron-check # cron: per-platform CRON/PERIODIC path tables + systemd/periodic route
make logtamper-check # logtamper: system-log/history tables + classify-tamper (trunc/mtime)
make detection-rules-check # DETECTION_RULES ATT&CK catalog: rule_attack + anomaly_rule_attack
make daemon-telemetry-check # daemon telemetry normalization + httpd/smtpd/sshd early exploit rules
make mux-telemetry-check # obersh-compatible mux telemetry intake + encrypted storage path
make ipaddr-check # lateral: faithful IpAddr parse (v4/v6) + is_internal_ip (RFC1918/fc00)
make auth-check # auth-log parsers: sshd/sudo/su/pam/useradd/userdel/passwd + extract_field
make monitor-process-check # process monitor scan loop: pid diff/classify/emit over fixture provider
make monitor-network-check # network monitor scan loop: listener/connection dedup+classify over fixture
make monitor-files-check # file integrity monitor: detect-change priority + new-file discovery
make monitor-dns-check # dns monitor capture stream + connection-polling fallback
make monitor-podman-check # podman event-stream parser + polling fallback deltas
make monitor-selinux-check # SELinux monitor loop: mode changes + tailed audit events
make monitor-lateral-check # lateral monitor loop: internal service use + port scan tracking
make monitor-webshell-check # webshell monitor loop: suspicious web-server descendants
make monitor-revshell-check # reverse-shell monitor loop: connection + cmdline signals
make monitor-manager-check # monitor manager: boot + one full polling cycle (fixture)
make event-json-check # event JSON serializer: every Rust EventType variant
make collector-check # agent collector loop: boot+tick into a real SQLite store, rows queried back
make agent-server-check # agent poll server: PSK auth, dispatcher, local-store status, socket lifecycle
make checks # every Jerboa-side check in one shot
The C ABI bridge
The generated crate builds as a cdylib exposing each kernel with a C ABI
(jt_<module>_<fn>(ptr, len, …), every Bytes/String argument passed as a
pointer + length). That is the boundary the untyped layer uses: ordinary
Jerboa monitors/storage stay in .ss and call the vetted typed kernels over
that ABI via Chez's foreign-procedure. examples/ffi_bridge.ss proves the
whole path — a Jerboa script load-shared-objects the dylib and calls the
constant-time-eq? and score-cmdline kernels, asserting their results.
jsecmon/kernels.ss is the reusable form of that bridge: an importable
(library (jsecmon kernels) …) that loads the dylib once on import and exports
clean Jerboa wrappers for all 22 kernels (lolbin-score-cmdline, lolbin-match-bits, hex-decode,
host-risk-score, dga-score-domain, bytes-contains?, phantom-rootkit-race?,
…). It hides the C ABI entirely — including the out-param dance for Bytes/String
results (alloc two cells, call, copy the buffer, jt_byte_buffer_free, free the
cells). The untyped I/O layer just does (import (jsecmon kernels)). The repo
root must be on the libdir path so the import resolves to jsecmon/kernels.ss.
examples/kernels_check.ss re-checks the secmon parity vectors through these
wrappers (make kernels-check).
make needs a built Jerboa checkout at $JERBOA (default vendor/jerboa),
whose .chez/bin/scheme and support/typed-rust.ss drive the backend.
Binaries
The shippable artifacts are compiled, self-contained executables — never
scheme --script foo.ss launchers. build-binary.ss takes a bin/*.ss entry
point, whole-program-compiles it (any stdlib library that ships only a .so,
e.g. (jerboa core), comes back in compile-whole-program's missing list and
is bundled into the boot image instead of inlined), bundles the Chez kernel +
stdlib boot, and statically links the Rust kernels from
libjerboa_typed_generated.a — -force_load pulls the archive in whole (the C
main never names the jt_* symbols; Chez resolves them at runtime via
dlsym) and -export_dynamic makes them visible to that dlsym; kernels.ss
does (load-shared-object #f) in this mode. The result is one Mach-O/ELF file
that needs no scheme, no .ss, and no kernel .dylib at runtime.
make keygen builds jsecmon-keygen (the ECIES keypair + PSK generator, port of
secmon-keygen); make telemetry builds the mux daemon-telemetry intake;
make binaries builds all of them. The make *-check vector tests stay as
dev-time .ss scripts (the test harness, not shipped).
| secmon binary | jsecmon | status |
|---|---|---|
bin/keygen |
bin/keygen.ss → make keygen |
✅ compiled binary — creates ECIES public/private keys and a 32-byte PSK from the OS CSPRNG as exclusive, no-follow, descriptor-validated 0600 files. It refuses existing paths and prints no secret values. |
bin/analyze |
bin/analyze.ss → make analyze |
✅ compiled binary — assembles run_detections itself over (jsecmon storage); summary/query/anomalies/first-seen/timeline/retention all wired to the ported detection + analytics layers. |
bin/collector |
bin/collector.ss → make collector |
✅ compiled binary — the agent pull client: PSK handshake (Challenge → ChallengeResponse) over the transport envelope, then get_events_after/status requests; each SerializedEvent is ECIES-decrypted to a SecurityEvent and printed (--format human/json, secmon-faithful) or persisted to SQLite (--db, advancing collector_state). poll/status/watch subcommands. Verified end-to-end over a real TCP socket (examples/fake_agent.ss loopback) and byte-wise without a socket (make collector-pull-check). Loads keys from env/file at runtime, never embedded. |
bin/agent |
bin/agent.ss → make agent |
✅ compiled binary — runtime shell: loads the collector public key + PSK from env/file, opens the local encrypted event store when configured, starts the PSK-authenticated pull server, ECIES-encrypts SecurityEvent bytes into the priority buffer, persists plaintext events locally, emits agent_start/heartbeat, initializes stealth before key/config work, and drops to nobody after privileged resources are open. On Linux it prefers the eBPF stream and falls back to the provider-backed polling set (process, network, files, auth, kernel, scheduled, container, rootkit, podman, selinux, persistence, lateral, logtamper, webshell, revshell, dns). On FreeBSD it wires the DTrace direct/subprocess stream before privilege drop. Verified with jsecmon-collector status/poll against the compiled agent on a real loopback TCP socket; agent-server-check also pins the local-store status fields. |
| daemon telemetry | bin/telemetry.ss → make telemetry |
✅ compiled binary — dedicated mux intake for jerboa-smtp/jerboa-sshd/httpd-style first-party daemons. Listens on SECMON_TELEMETRY_LISTEN (default 127.0.0.1:31338), uses a fixed bounded worker pool with absolute pre-auth/frame deadlines and global/per-source budgets, opens mux MSG-ENCRYPTED frames with the PSK transport key, and stores validated telemetry. |
Port status
Driven by what each module needs from the backend; pure-logic detection first, then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
| secmon module | jsecmon | status |
|---|---|---|
dga::max_consonant_run |
typed/dga.ss |
✅ ported, vectors pass |
dga::shannon_entropy |
typed/dga.ss |
✅ ported, vectors pass |
dga::score_domain (+ score_label_bits, dga_label) |
typed/dga.ss |
✅ full: lowercase + dot-trim + benign-suffix + label split + score; vectors pass. Sibling kernels score-label-bits (ORs one disjoint power-of-2 bit per reason, in the same source order as the weight table — + is bitwise-or since the bits don't overlap) and dga-label (the exact leftmost label scored, "" for a benign CDN suffix / empty label) let the untyped layer recover DgaVerdict.reasons/label/entropy/run/length with no second copy of any scoring decision. |
&str ops (lowercase/ends_with/starts_with/contains/split/whole-word) |
typed/strbytes.ss |
✅ Bytes toolkit, vectors pass — shared by dga/lolbin/sigma |
lolbin::score + severity |
typed/lolbin.ss |
✅ full 25-pattern table + severity buckets, plus match-bits (a u64 bitmask of which patterns fired, bit 0…24 in PATTERNS order) so the diagnostic breakdown needs no second copy of the matchers; vectors pass (JSON-cmdline parse stays in untyped wrapper) |
lolbin::LolScore (match breakdown + JSON) |
jsecmon/lolbin.ss |
✅ untyped layer — the diagnostic companion to the typed kernel: score-cmdline returns a lol-score (total from lolbin-score-cmdline, per-pattern matches decoded from lolbin-match-bits against a static label/score/explanation table — data, not logic), score-json-cmdline adds the JSON-array parse (falls back to the raw string like from_str::<Vec<String>>), lol-severity/lol-label-summary mirror the Rust methods. No matcher is re-implemented, so total and matches can't drift. Rule 11 detect-lolbin-cmdline (storage::detect_lolbin_cmdline) also lives here rather than in correlate.ss — it's the one correlation rule that needs the native scorer, so hosting it beside score-json-cmdline keeps correlate.ss kernel-free: it scores each process_start row's cmdline (row (host ts pid proc-name cmdline exe)), skips rows whose cmdline and exe are both empty (Rust continue), and emits a suspicious_cmdline anomaly seed for totals ≥50 (pname falls back to "?"). make lolbin-check runs secmon's 10 #[test] vectors plus exact-total, label-order, bit↔label pins, and the Rule-11 threshold/skip/"?"-fallback edges (41 cases). |
dga::DgaVerdict (diagnostic verdict + match breakdown) |
jsecmon/dga.ss |
✅ untyped layer — the diagnostic companion to the typed DGA kernel, exactly mirroring jsecmon/lolbin.ss: score-domain-verdict returns a dga-verdict whose score is the headline kernel total, whose reasons are decoded from dga-score-label-bits against a static bit→reason table (data, not logic), and whose entropy/max-consonant-run/length are read off the SAME kernels applied to dga-label — so nothing here recomputes a scoring decision and the breakdown can't drift from the total. Rule 12 detect-dga-domain (storage::detect_dga_domain) also lives here rather than in correlate.ss — like Rule 11 it needs the native scorer, so hosting it beside the verdict keeps correlate.ss kernel-free: it scores each dns_query row ((host ts pid proc-name query-name)), skips a #f/empty query-name (Rust Some(q) if !q.is_empty()), emits a dga_domain seed for label scores ≥60, and dedups per (host, process, scored-label) keeping the first qualifying query (Rust HashMap::or_insert) so a host querying many subdomains of one DGA label alerts once (pname falls back to "?"). make dga-check runs secmon's 5 dga.rs #[test] vectors plus the bit↔reason pins and the Rule-12 threshold/skip/dedup/cross-host/"?"-fallback edges (39 cases). |
analytics::compute_host_risks |
typed/analytics.ss |
✅ risk-score kernel (clamped weighted sum); vectors pass |
analytics grouping + group_incidents |
jsecmon/analytics.ss |
✅ untyped layer — per-host accumulation/sort/top-N driving the risk-score kernel, plus incident dedup/collapse; secmon analytics vectors pass (make analytics-check) |
storage::detect_sequence_pair (kill-chain core) |
jsecmon/analytics.ss |
✅ untyped layer — the pure pairing primitive behind detect_priv_escalation_chain/lateral_after_shell/persistence_after_access/log_cover: given two event streams as (host . ts-ms) lists (the SQL ORDER BY host,timestamp_ms fetch is deferred I/O), pair each A with the first same-host B strictly later and within window-ms — at most one per A (Rust's inner break) — returning ((host …) (a-ts …) (b-ts …) (gap-seconds …)) for the caller to wrap as an Anomaly (format_ts is calendar-deferred). gap-seconds is integer ms/1000 (Rust i64 /). make analytics-check adds window-edge (≤ inclusive), strictly-later, cross-host, first-B-only, multi-A, and empty-stream cases. |
storage::detect_severity_clusters (cluster core) |
jsecmon/analytics.ss |
✅ untyped layer — the pure sliding-window clustering: given critical/high events as (host ts-ms event-type) pre-sorted by host then ts (SQL fetch deferred), slide from each i, greedily take the same-host run with ts ≤ ts_i + window-ms, and when it holds ≥ min-count (5) events emit a cluster then skip past it (Rust i = j), else advance one. Emits ((host …) (window-start …) (window-end …) (count …) (event-types …)) for the caller to format (format_ts deferred). make analytics-check adds exactly-5, only-4, past-edge, run-of-6-then-skip, host-boundary, two-clusters-after-skip, and empty cases. |
storage::detect_frequency_spikes (spike core) |
jsecmon/analytics.ss |
✅ untyped layer — the pure per-(host,event-type) hourly-spike test: given (host event-type hour count) rows (the hourly_counts GROUP BY aggregate is deferred I/O), sum count and tally hours per key, then emit any row whose key average > 0 and whose count strictly exceeds 3× that average. Returns ((host …) (event-type …) (hour …) (count …) (average …) (ratio …)) in input row order for the caller to wrap (parse_hour_to_ms is calendar-deferred); average/ratio are f64 like Rust's total/hours and count/avg. make analytics-check adds 3×-spike, exact-3×-excluded (strict >), flat, avg-0-guard, key-independence, two-group-order, and empty cases. |
storage::detect_kill_chain (chain core) |
jsecmon/analytics.ss |
✅ untyped layer — the pure multi-phase kill-chain detector: given (host ts-ms event-type) rows pre-sorted by host then ts (SQL fetch deferred), slide from each i over the same-host run with ts ≤ ts_i + window-ms, map each type to an ATT&CK-ish phase via event-type->attack-phase (also exported; unmapped types skipped), and when the distinct phases reach min-phases (3) emit a chain then skip past it (Rust i = j), else advance one. Emits ((host …) (window-start …) (window-end …) (phases …) (event-types …)); Rust collects phases from an unordered HashSet, so phases is canonicalized to first-seen order (treat as a set) while event-types keeps phase-mapped types in order. make analytics-check adds the classifier table, three-phases, two-distinct-only, unmapped-skip, host-boundary, window-edge, past-edge, two-chains-after-skip, and empty cases. |
storage::detect_off_hours (predicate) |
jsecmon/analytics.ss |
✅ untyped layer — off-hours?, the decision rule factored out of the SQL WHERE: a critical/high event is off-hours on a weekend or outside 08:00–18:00 UTC (weekday = strftime %w 0=Sun…6=Sat, hour = %H 0–23). The timestamp→(weekday,hour) decomposition is calendar-deferred. make analytics-check adds weekend, midday, and the 08:00/17:00/18:00 boundaries. |
storage::anomaly_rule_attack (tactic tagger) |
jsecmon/analytics.ss |
✅ untyped layer — the pure rule-name→ATT&CK-tactic table detect_anomalies stamps onto each anomaly: kill_chain→TA0001/TA0008/TA0010, off_hours→TA0005, every other rule→none. make analytics-check covers both tagged rules plus untagged/unknown. |
storage::detect_lolbin_cmdline + detect_dga_domain |
jsecmon/detect.ss |
✅ untyped layer — the kernel-driven detection rules: score every process_start cmdline (lolbin) / dns_query (dga) into anomalies above threshold. make detect-check runs the full events→detect→analytics pipeline; all three scoring kernels fire. This is the live pipeline shape (event hash-tables → anomaly hash-tables for (jsecmon analytics)); the faithful storage-Rule ports against secmon's test_detect_* vectors — with the per-pattern lolbin label breakdown and the label-level DGA dedup + reason list — live in jsecmon/lolbin.ss and jsecmon/dga.ss. The live DGA path now keys dedup by (host, process_name, dga-label), matching the Rust storage rule instead of full query name. |
triage classifiers |
typed/triage.ss |
✅ pure predicates (transient-unit?, phantom-rootkit-race?); vectors pass |
triage engine (rules + dispatch) |
jsecmon/triage.ss |
✅ untyped layer — all 18 false-positive rules + first-match engine, in secmon's exact RULES order, dispatch in ordinary Jerboa delegating byte/string classification to the typed kernels; 40 triage vectors pass (make triage-check), incl. the security-relevant negatives (non-sshd reading host keys, systemd impersonated from /tmp, unknown daemon reading passwd). |
triage::compute_triaged_ids (triage-aware mode) |
jsecmon/triage-store.ss |
✅ untyped layer — the bridge above storage+triage: query every in-scope event, triage each, return the sorted benign/expected ID set to drop into a filter's exclude_event_ids. make triage-store-check proves the round-trip — detection then sees only the real attacks. |
storage::yaml_rules (user rules: threshold/distinct/sequence/match) |
jsecmon/yaml-rules.ss |
✅ untyped layer — port of secmon's src/storage/yaml_rules.rs: load YAML rule files ((std text yaml)), validate per type, and run them over a (jsecmon storage) handle into Anomaly row-hashes. A MatchSpec (event_type/severity/process_name/host + json_eq equality + data_contains LIKE) is inlined into SQL (quotes escaped); window strings (10m/1h/…) parse to ms; threshold/distinct are time-bucket GROUP BY/HAVING, sequence pairwise-chains steps within the window in Jerboa. make yaml-rules-check exercises all four types + window parsing + validation. This is the engine the sigma importer feeds. |
sigma (Sigma rule importer) |
jsecmon/sigma.ss |
✅ untyped layer — port of secmon's src/sigma.rs: parse a Sigma YAML rule (via (std text yaml)), map logsource.category → event_type, translate the BTreeMap-first selection's fields (Field → json_eq, Field|contains/startswith/endswith/re → data_contains, Windows field names aliased to Linux JSON paths), level → severity, attack.tNNNN tags → ATT&CK IDs, and render secmon's YamlRule YAML back out. make sigma-check reproduces secmon's four conversion vectors (process_creation, network_connection, unsupported-category skip, safe-name). Pure YAML+strings, untyped. |
psk::constant_time_eq |
typed/psk.ss |
✅ ported, vectors pass |
psk::from_hex (hex codec) |
typed/psk.ss |
✅ hex encode + decode + 32-byte precondition; vectors pass (decode∘encode identity over all 256 byte values) |
stealth::obfuscate (compile-time string XOR) |
typed/obfuscate.ss |
✅ typed kernel — keeps sensitive strings out of the binary in plaintext. Lowers secmon's obfuscate!/obfuscate_bytes! scheme: length-derived wrapping-u8 key (len*31+42 / len*37+13) + XOR. Since XOR preserves length, decode recomputes the key from the buffer — a clean involutive pair, no stored key. make test reproduces secmon's test_obfuscate_roundtrip + test_obfuscated_not_plaintext plus key-derivation and all-256-byte round-trip vectors. |
stealth::{anti_debug,integrity,init} |
jsecmon/stealth.ss |
✅ untyped runtime shell — TracerPid parsing/status-integrity comparison, parent debugger/tool detection tables, timing baseline rule, breakpoint-byte predicate, environment sanitization list, PID-stable process-name masquerade, mlockall, anti-debug watchdog, and best-effort self-integrity hashes are ported. bin/agent.ss initializes stealth before key/config work and starts the watchdog after privilege drop. make stealth-check pins the pure helpers/tables; runtime effects are best-effort through libc FFI and no-op when the platform primitive is unavailable. |
crypto primitives (SHA256/HMAC/HKDF/x25519/AES-256-GCM) |
typed/crypto.ss |
✅ typed kernel — FFI-delegated to vetted RustCrypto crates (sha2/hmac/hkdf/x25519-dalek/aes-gcm), never reimplemented; the backend emits the use … ; block per primitive and content-scans the Cargo deps. Vector-verified against NIST SP 800-38D GCM, RFC 7748/5869/4231, FIPS 180-4 (make test). |
psk key-derivation + proof + transport (from_bytes, compute_proof, encrypt_transport) |
typed/psk.ss + jsecmon/crypto-psk.ss |
✅ typed kernel + untyped orchestration — HKDF-derived auth/transport keys, the SHA256 challenge proof + constant-time verify_proof, and AES-256-GCM transport (12-byte nonce prepended) are typed kernels (make test); (jsecmon crypto-psk) adds the effects they omit — transport-encrypt draws a random nonce, generate-challenge/respond-to-challenge/verify-response add the random challenge nonce + clock + freshness check (now injected for testability). make crypto-psk-check pins the FFI path to the psk_vectors.rs digests then exercises round-trip / randomization / tamper→#f / wrong-key→#f / stale→#f. |
crypto::ecies (x25519 ECDH + HKDF + AES-GCM) |
typed/ecies.ss + jsecmon/crypto-ecies.ss |
✅ typed kernel + untyped orchestration — deterministic ecies-seal/-open are typed kernels (parity vs an independent Python impl, ecies_vectors.rs); (jsecmon crypto-ecies) adds ecies-generate-keypair + ecies-encrypt (random ephemeral keypair + nonce) + ecies-decrypt. Frame = secmon's bincode EncryptedPayload layout: ephemeral_public(32) ‖ nonce(12) ‖ ciphertext_len(u64le) ‖ ciphertext; decrypt also accepts the older jsecmon bare concat for local backward compatibility. make crypto-ecies-check pins x25519 to RFC 7748 + ecies-seal to the reference vector, then bincode-frame layout / round-trip / randomization / wrong-recipient→#f / tamper→#f. |
storage (events table, store/query/filters) |
jsecmon/storage.ss |
✅ untyped layer — SQLite event store on vendored jsqlite: secmon's schema (events + indexes + collector_state), store-event INSERT-OR-IGNORE dedup, and the full EventFilter WHERE builder (host/type/severity/since/until/pid/process_name LIKE/search/exclude_event_ids). query-events returns row hashes with data parsed from JSON, so detect/triage/analytics consume them directly. make storage-check round-trips store→query→detect→analytics (host risk 30, same as detect-check). entity-where/entity-timeline port storage::entity_timeline: the per-category WHERE-group builder (Process = process_name LIKE + six json fields with LIKE %value%; Ip/User/Domain = exact json_extract = over their field sets) plus the assembled oldest-first query, with the filter clauses continuing the positional ?N numbering after the category binds (build-where gained an optional start index). make entity-check pins each category's exact SQL fragment + binds and routes/orders/narrows/limits over a live store (18 cases). |
storage SQL-aggregation detectors (brute_force, credential_stuffing, dns_tunnel, suspicious_cron, recon_port_scan, data_exfil) |
jsecmon/threats.ss |
✅ untyped layer — secmon's run_detections family: the time-bucket GROUP BY/HAVING rules and the two 5-min sliding-window rules, run as SQL (json_extract) over a (jsecmon storage) handle. make threats-check reproduces secmon's six detection-rule test vectors; the companion sequence/statistical families are covered by the rows below. |
storage sequence/chain detectors (priv_escalation_chain, persistence_after_access, log_cover, lateral_after_shell) |
jsecmon/threats.ss |
✅ untyped layer — secmon's detect_sequence_pair family: event A then event B within a window on the same host (auth-success→priv-esc /5min, reverse-shell/webshell→persistence /1h, any-critical→log-tampering /1h, shell→lateral /1h). Reproduces secmon's chain test vectors incl. the outside-window negative. |
storage time-window aggregates (frequency_spike, severity_cluster, off_hours, kill_chain) |
jsecmon/threats.ss |
✅ untyped layer — secmon's full detect_anomalies family: per-(host,event_type) hour count 3x above its own average, 5+ crit/high on a host /5min, crit/high outside 08:00-18:00 UTC weekday (SQLite strftime), and 3+ distinct kill-chain phases /1h. run-anomaly-detections is the dispatcher (frequency_spike first, as secmon runs it). make threats-check covers each with threshold/negative cases. |
geoip (CSV GeoIP/ASN, IPv4+IPv6, binary-search range lookup, is_private) |
jsecmon/geoip.ss |
✅ untyped layer — full port of secmon's src/geoip.rs: parse start,end,country,asn,name CSV rows (v4 + one-::-expanding v6), sort-by-start + binary-search lookup, RFC1918/loopback/link-local/multicast/ULA/CGNAT → synthetic PRIVATE. Pure parsing + integer math + file read, so untyped. make geoip-check runs secmon's geoip vectors. |
storage impossible_travel |
jsecmon/threats.ss |
✅ untyped layer — geoip-gated (reads SECMON_GEOIP_CSV): pair a user's consecutive successful auth_events, fire high when the two source IPs resolve to different countries within SECMON_TRAVEL_GAP_MIN (default 30). Private IPs are dropped before pairing. make geoip-check proves the fire + the gap/same-country/private/cross-user negatives. |
buffer::ring (StoredEvent ring buffer) |
jsecmon/buffer.ss |
✅ untyped layer — port of secmon's src/buffer/ring.rs: the agent's bounded in-memory event ring. FIFO list + monotonic seq numbering, priority eviction (event_severity_u8 table, drop lowest-severity oldest-first, oldest-critical last), seq/time-range polling, FIFO delivery-ack (clear_before), and the little-endian header codec (seq u64 ∥ ts i64 ∥ sev u8 ∥ payload). Pure mechanics, so untyped — the one security step, ECIES payload encryption, is FFI-deferred: the caller hands buffer-store! opaque ciphertext bytes. make buffer-check reproduces secmon's three ring tests (store/seq, priority eviction, FIFO-oldest) + codec round-trip. |
storage correlation rules (detect_*) |
jsecmon/correlate.ss |
✅ untyped layer — secmon's anomaly detectors are "run a SQL query, then map result rows → Anomaly". The SQL fetch (with its json_extract / GROUP BY … HAVING) is the deferred storage I/O; the pure, portable part is the correlation algorithm over the fetched rows + the Anomaly description, reimplemented here over pre-shaped row lists (which is exactly what test_detect_* exercise). Batch 1 = the GROUP-BY rules: detect_brute_force (≥5 failed-auth per user / 10-min bucket), detect_credential_stuffing (≥5 distinct users per remote / 10-min), detect_dns_tunnel (≥50 queries per process / 5-min), detect_suspicious_cron (per-row: scheduled_task_change by a non-root user, missing user → "" ≠ root → kept). timestamp_ms / N is integer division → quotient, bucket anomaly ts is bucket*N; COUNT(*) vs COUNT(DISTINCT) become first-seen-order tallies (treat the set, not order, as significant). Each detector returns "anomaly seeds" (an Anomaly minus the calendar format_ts fields, which the caller adds, and minus the attack tags from anomaly-rule-attack). Batch 2 = the sliding-window / sequence-pair rules over caller-sorted rows: detect_recon_port_scan (≥10 distinct remote ports / 5-min, same process) and detect_data_exfil (≥20 outbound connections / 5-min) share a slide helper reproducing secmon's while i<len { expand j; if hit { i=j } else { i+=1 } }; detect_sequence_pair (event A then first B on the same host with a.ts < b.ts ≤ a.ts+window, one match per A) backs the three chain rules detect_priv_escalation_chain (auth-success→priv-esc / 5-min, caller pre-filters A to success=1), detect_persistence_after_access (reverse_shell or webshell→persistence / 1h — two arms appended), detect_lateral_after_shell (reverse_shell→lateral / 1h); and detect_log_cover (any critical event→log_tampering / 1h, one per critical) carries the event type into the description. gap_seconds is (quotient (- b a) 1000). Batch 3 = the geo-correlation rule detect_impossible_travel (Rule 13): over auth points the caller has already geo-resolved (the GeoDb lookup is the deferred I/O, like the SQL fetch) and sorted by (user, ts), it walks every adjacent pair (Rust i += 1 always) and emits a high seed when the same user logs in from two different countries within min-gap-ms (default 30 min = SECMON_TRAVEL_GAP_MIN, an optional 2nd arg). The PRIVATE/empty-country drop (Rust's filter_map before pairing) is pure, so it stays here; gap_minutes = (quotient ms 60000); the seed carries raw first-seen-ms/second-seen-ms for the caller's format_ts. make correlate-check reproduces all ten test_detect_* (incl. _below_threshold and _outside_window) + the threshold / bucket-boundary / distinct-dedup / window-edge / cross-host / B-before-A edges, plus the impossible-travel country/gap-edge/cross-user/PRIVATE-drop/override edges (76 cases). |
server::protocol (length-prefixed framing) |
jsecmon/frame.ss |
✅ untyped layer — the wire framing the collector↔agent transport wraps every ProtocolMessage in: frame_encode prepends a 4-byte little-endian u32 length to the payload (to_bytes's framing half), frame_read_length reads that header as an LE u32 (read_length), and frame_decode reproduces from_bytes's two guards — < 4 bytes → data too short, < 4 + declared len → incomplete message — then slices out data[4 .. 4+len]. Pure byte mechanics, so untyped; the bincode (de)serialization of the message body is the Rust-specific deferred piece (the caller (de)serializes the payload frame_decode hands back). LE u32 verified to round-trip 0x12345678 and 0xFFFFFFFF. make frame-check pins the byte layout, the encode∘decode round-trip, both error guards, and the empty/zero-length-payload edge. |
monitor::events::SuspiciousPatterns (process-spawn classifier) |
jsecmon/suspicious.ss |
✅ untyped layer — check_suspicious(process, parent): shell-from-service, attack-tool-from-service (name exact-match or exe suffix), reverse-shell command-line patterns, and crypto-miner name/cmdline patterns, in secmon's order, returning the same reason string. Pure string classification like triage. Pins two corners the Rust depends on: a missing parent short-circuits to "clean" before any check, and str::contains is a literal substring test (so python -c.*socket is literal, not a regex). make suspicious-check reproduces secmon's two events.rs tests + the other three signals + both corners. |
monitor::network::NetworkMonitor (connection classifier) |
jsecmon/netconn.ss |
✅ untyped layer — check_suspicious(port, addr, process): known reverse-shell/C2/l33t port, ephemeral port (49152..65535) that is a round multiple of 1000, and a web-server process (nginx/apache/httpd/php-fpm) connecting to a non-private address, in secmon's order with the same reason string. Pure metadata classification. secmon hides the web-server names with obfstr! (same scheme as typed/obfuscate.ss); they decode to these plaintext literals at runtime. Pins the faithfulness quirk that the "private" prefix set is literal {127. 10. 192.168. 172.}, so 172. matches all of 172.x, not just RFC1918 172.16/12. make netconn-check reproduces secmon's two network.rs tests + the full bad-port list + the high-port and web-server rules with private-address negatives. |
monitor::kernel::KernelModuleMonitor (kernel-module classifier) |
jsecmon/kernmod.ss |
✅ untyped layer — is_suspicious_module(name): lower-cased name contains a known-rootkit substring (diamorphine/reptile/hide/rootkit/keylog/…), or a 1-2 char name not on the legitimate-short allow-list (ip dm sd sr nf if), or a >4 char name with no vowel, in secmon's order. Pure string classification like the other classifiers. obfstr!-hidden name lists decode to these plaintext literals. Pins the faithfulness corner that only the substring test lower-cases the name — the short-name and vowel tests use the original case, and the vowel set is both-case aeiouAEIOU. make kernmod-check reproduces secmon's two kernel.rs tests + each signal exercised independently + the case corners. |
monitor::selinux::SELinuxMonitor (audit-log parser) |
jsecmon/selinux.ss |
✅ untyped layer — the line-parsing core: parse_audit_line dispatches on the type= tag (AVC → parse_avc_event, MAC_CONFIG_CHANGE → boolean change, MAC_POLICY_LOAD → policy load, USER_ROLE_CHANGE → role change) into a selinux-event record mirroring SELinuxEventInfo, plus the extract_field helper. A text-format parser yielding a structured record, like the DNS parser, so untyped. secmon's AVC regex is reused verbatim through Jerboa's (std pregexp) pregexp-match (capture order 1=decision 2=permission 3=pid 4=comm 5=scontext 6=tcontext 7=tclass — verified identical). Pins extract_field's quoting/empty/missing-quote corners and the val= default-empty. make selinux-check reproduces secmon's two selinux.rs tests + the dispatcher + all four event kinds. (The I/O — tailing the audit log, mode polling — is the deferred monitor loop.) |
monitor::container::ContainerEscapeMonitor (mount classifier) |
jsecmon/container.ss |
✅ untyped layer — is_suspicious_mount(mount): a mount that starts with /host or /mnt/host, is exactly /, or contains /var/run/docker / /run/docker / devd.pipe (FreeBSD jail), flagging a container/jail escape. Pure string classification like the other monitor classifiers, so untyped; obfstr!-hidden patterns decode to these plaintext literals. make container-check reproduces secmon's test_suspicious_mount_detection + each escape signal + negatives. (Isolation detection and mount/path/cap polling are provider-driven I/O — the deferred monitor loop.) |
monitor::dns::read_dns_servers (resolver-set builder) |
jsecmon/dns-servers.ss |
✅ untyped layer — parse_dns_servers(content): collect each nameserver <ip> entry from resolv.conf text (the 2nd whitespace field of a trimmed line starting with nameserver) and union with the fixed public-resolver set (Google/Cloudflare/Quad9/OpenDNS). Pure parsing, so untyped; the /etc/resolv.conf read is the deferred I/O wrapper (split off like the selinux log tail). Folds tabs/CR to spaces to match Rust's split_whitespace. make dns-servers-check reproduces secmon's test_read_dns_servers (publics always present) + the nameserver parsing with multi-space/tab/indented lines and dedup. |
dtrace::scripts::is_sensitive_path (sensitive-path classifier) |
jsecmon/sensitive-path.ss |
✅ untyped layer — is_sensitive_path(path): a sensitive prefix (passwd/shadow/sudoers/ssh dirs/cron/periodic/ld.so.preload//boot//…), with /home/ special-cased to only /.ssh/ subpaths, else authorized_keys anywhere, else /cron or /periodic. Pure string classification, so untyped; obfstr!-hidden literals decode to these plaintexts. Ported with secmon's loop-with-early-return so the load-bearing corner holds: a /home/ path short-circuits before the authorized_keys check, so /home/user/authorized_keys (no /.ssh/) is not sensitive. make sensitive-path-check reproduces secmon's test_sensitive_path_detection + each signal + that corner. |
dtrace::consumer::EventParser (DTrace line parser) |
jsecmon/dtrace-parse.ss |
✅ untyped layer — parse_dtrace_line(line): split a SECMON|TYPE|… DTrace line on | and dispatch on parts[1] into a per-type structured record (EXEC/EXIT/CONNECT/LISTEN/OPEN/WRITE) with each handler's exact field extraction; a <2-field / unknown-type / too-few-fields line yields no record (#f), matching secmon's return Ok(()) no-ops. dtrace-process-line ports the stateful EventParser: process cache, parent lookup, suspicious EXEC dispatch, process_exit cache removal, CONNECT/LISTEN event rows, and OPEN/WRITE no-event behavior. Numeric fields use .parse().unwrap_or(0) (u32 rejects negatives → 0; exit code is i32), and the EXEC cmdline is split_whitespace. Composes (jsecmon sensitive-path) for the OPEN sensitive? gate. make dtrace-parse-check reproduces secmon's parser tests plus the stateful process-cache/suspicious/network/exit behavior; runtime startup/work loops live in (jsecmon dtrace-runtime). |
dtrace::{mod,consumer} runtime shell |
jsecmon/dtrace-runtime.ss |
✅ untyped runtime shell — ports the FreeBSD DTrace runtime around EventParser: secmon's combined D script, support-check guard ordering (root, /dev/dtrace/dtrace, smoke test), direct libdtrace open/setopt/compile/exec/go/work-loop path, buffered callback line queue, stop/close cleanup, subprocess dtrace -n <script> fallback, stdout line pump, EOF marker, and `SECMON |
platform::{linux,freebsd} (is_dangerous_path + get_mounts parsers) |
jsecmon/platform-mounts.ss |
✅ untyped layer — the pure halves of each IsolationProvider, reads stripped: is_dangerous_path is exact membership in the platform's dangerous-path set (Linux 12 entries incl. /proc/kcore, /dev/mem, the docker/crio/containerd sockets; FreeBSD 6 incl. /dev/io, devd.pipe; unknown platforms empty, per the UnsupportedProvider default), and parse_mounts reproduces each get_mounts line loop — Linux split_whitespace keeping field[1], FreeBSD split(" on ") keeping piece[1] minus a trailing (opts). Pins that membership is exact not prefix (/host/foo is clean), and that the FreeBSD split takes piece[1] of a multi-" on " split (not everything-after-first). obfstr!-hidden lists decode to these plaintexts. Pure — the /proc/self/mounts read / mount exec is the deferred I/O — no native lib; secmon has no #[test] here so make platform-mounts-check asserts against the Rust source. (The container.rs test-only mock is_dangerous_path is #[cfg(test)] scaffolding, not ported.) |
platform::linux (/proc parsers) |
jsecmon/proc-linux.ss |
✅ untyped layer — the pure parsing helpers with the file reads stripped: parse_stat (comm between first ( and last ), ppid the 2nd field after ") "), parse_uid (first Uid: line, 2nd field), hex_to_state (TCP state table → UNKNOWN), parse_ipv4 (little-endian hex → dotted quad), parse_ipv6 (32-hex → 8 groups), parse_addr (HEXADDR:HEXPORT, ipv6 when protocol contains 6). Pure text/number parsing, so untyped. parse_stat/parse_uid use .parse::<u32>().ok() so failure is #f (not 0) and negatives are rejected; parse_ipv4 rejects >0xFFFFFFFF; ports are u16. Also parse_security_environ (from ebpf/loader.rs, but functionally a /proc/[pid]/environ parser): splits the NUL-separated blob, drops empty and non-UTF-8 entries (faithful to std::str::from_utf8(..).ok(), reproduced exception-free by a lossy-decode→re-encode→compare round trip), and keeps any var whose name starts_with one of 19 security-relevant prefixes or whose to_uppercase() does — so a lower-cased path=/ld_… still matches the upper-cased prefixes, while http_proxy/https_proxy match only literally-lower vars. Also parse_module_line (a /proc/modules row: ≥4 ws fields → the same KernelModuleInfo alist as freebsd's parse_kldstat_line, name parts[0], size parts[1] as DECIMAL u64 via .parse().ok() so a non-u64 size is #f but the row still parses, action always Loaded, no loader pid). make proc-linux-check reproduces secmon's five linux.rs tests + ipv6/parse-addr + a comm-with-paren corner + 10 environ-scan cases (invalid-UTF-8 drop, uppercase-arm, trailing NUL) + the module-line rows. (The /proc reads and inode→pid scan are the deferred I/O.) |
ebpf::{events, loader::parse_event} |
jsecmon/ebpf-events.ss |
✅ untyped layer — decodes the packed userspace eBPF Event layout (kind, pid, uid, data1, data2, comm[16], filename[32]) and maps every Rust parse_event kind into the same SecurityEvent-shaped row: exec, exit, connect/accept, file_open, setuid, kmod, ptrace, namespace clone/enter/unshare, mount, and capset. DNS kind stays #f like Rust because DNS is handled by the sniffer; unknown/zero-address connect cases are skipped. make ebpf-events-check pins packed-field decoding, invalid UTF-8 handling, ptrace request names, IP byte order, and all event-kind mappings. |
ebpf::loader runtime shell |
jsecmon/ebpf-runtime.ss |
✅ untyped runtime shell — Linux support guard (/proc/version, root/CAP_BPF approximation, BTF warning parity), eBPF object search, the full 18-program tracepoint attach table, libbpf object load/attach, perf-buffer callback queue, stop cleanup, and drain-through-parse_event are ported. make-linux-monitor-set tries this stream first; while active, monitor-tick drains eBPF process/network/file-open events and suppresses the process/network polling monitors, matching secmon's high-fidelity mode shape while keeping the auxiliary monitors. make ebpf-runtime-check pins the guard/table/stream-drain pieces; live attach still requires Linux, root/CAP_BPF, libbpf, and a secmon.bpf.o object. |
platform::freebsd (parsers) |
jsecmon/freebsd-parse.ss |
✅ untyped layer — the pure parsing helpers with the command/file reads stripped: parse_kldstat_line (≥5 whitespace fields, name is parts[4], size is parts[3] as hex with optional 0x, size None on non-hex via .ok(), action always Loaded) and parse_address (addr:port split at the last :, [ipv6]:port split at the first ], * address → 0.0.0.0, * port → 0). Ports here are DECIMAL u16 (.parse()), unlike Linux's hex /proc/net. Plus parse_ps_line (the ps -axo pid,ppid,uid,comm,args fallback parser: ≥5 ws fields, pid/ppid/uid as u32 via .parse().ok()? so a non-u32 field rejects the whole line, comm is field[3], args is field[4..] re-joined with single spaces). Pure text/number parsing, so untyped. Also parse_sockstat_line (cols USER COMMAND PID FD PROTO LOCAL FOREIGN, ≥7, pid as u32-or-reject, protocol lower-cased, a FOREIGN of exactly *:* short-circuits to ("0.0.0.0" . 0) without parse_address, state = LISTEN when remote is 0.0.0.0/::/port 0 else ESTABLISHED) and parse_netstat_line (cols Proto Recv-Q Send-Q LOCAL FOREIGN [state], ≥5, here *:* does go through parse_address, state = parts[5] or UNKNOWN, no pid/name) — both producing a connection alist mirroring ConnectionInfo. Plus parse_freebsd_status (the procfs /proc/[pid]/status columns: ≥13 ws fields → (name ppid uid), name is parts[0], ppid is parts[2], uid is parts[12] = ruid, both u32-or-reject, trailing group columns ignored). make freebsd-parse-check reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives + the ps-line cases + the sockstat/netstat rows + the status columns traced from source. (The kldstat/sockstat/netstat/ps/status command/file reads are the deferred I/O.) |
event_json + local_store (tables) |
jsecmon/event-meta.ss |
✅ untyped layer — the pure classification tables lifted out of the payload-carrying EventType enum: event_json.rs get_event_json_data's display severity (25 constant arms as a name→severity table, + the 7 payload-dependent arms as named helpers taking the deciding field — auth/privilege_change/mount/capability/podman/selinux/lateral_movement), and local_store.rs event_severity_u8's coarse store priority 0..3, which is an independent scale (e.g. privilege_escalation is critical for display but 0 for the store). secmon has no #[test] here, so make event-meta-check asserts both full tables arm-for-arm against the Rust source. (The JSON payload bodies stay with the I/O layer that owns the event structs.) |
local_store::LocalEventStore |
jsecmon/local-store.ss |
✅ untyped layer — encrypted SQLite local event store whose 32-byte key is exclusively created or opened no-follow and validated by descriptor for type, owner, link count, exact 0600 mode, and length. Unsafe existing keys fail closed; secure-key-check covers permissions, symlink/hardlink/directory, malformed length, wrong-owner policy, and pathname replacement. |
config |
jsecmon/config.ss |
✅ untyped layer — AgentConfig defaults to loopback (127.0.0.1:31337), poll 100ms, and buffer 10000; an explicit SECMON_LISTEN may select a numeric remote interface. |
monitor/events (danger predicates) |
jsecmon/event-danger.ss |
✅ untyped layer — the payload predicates that drive a mount/capability event's severity, lifted off their structs: MountEventInfo::is_dangerous (mount-danger-reason source target → reason string, with the faithful corner that the / source entry's prefix is // so a plain /foo is not flagged, and dangerous targets match exact-only) and CapabilityEventInfo::dangerous_caps (cap_effective bits → cap names in the Rust push order, full u64 so bits 38/39 work). These compute the booleans event-meta's mount/capability severity helpers consume. Plus NamespaceEventInfo::ns_types (ns-types ns-flags → the namespace names whose CLONE_NEW* mask is set, in Rust push order mnt/uts/ipc/user/pid/net/cgroup/time — masks 0x20000/0x04000000…0x40000000/0x80/0x100, not bit indices). Pure, no native lib; make event-danger-check asserts against the Rust source. |
monitor/persistence (helpers) |
jsecmon/persistence.ss |
✅ untyped layer — classify_path (path → PersistenceType symbol via an ordered first-match substring chain; systemd before cron, .timer vs service, and the shell-profile arm == the default) and extract_suspicious_content (first line matching SUSPICIOUS_PATTERNS, returned in original case, truncated to 200 chars + ...). Faithfully preserves secmon's dead-pattern bug: the line is lowercased before contains, so the uppercase patterns NOPASSWD/ALL=(ALL) can never match. Pure — the directory walk + baseline hashing are the deferred I/O — no native lib; make persistence-check asserts against the Rust source. |
monitor/files (FileIntegrityMonitor::is_suspicious_change) |
jsecmon/file-change.ss |
✅ untyped layer — the deciding logic with stat/hashing stripped (modes + change-type + platform passed in): ordered first-match — setuid then setgid bit added (both modes known), exact platform critical file, authorized_keys/cron substrings, then a platform sensitive dir on created only. Pins the order corner that the cron substring precedes the sensitive-dir step, so a created /etc/cron.d/x reports "Cron configuration modified", never the sensitive-dir message; the critical-files/sensitive-dirs sets switch on cfg!(target_os) (linux/freebsd/other). Pure — the stat/SHA-256 baseline is the deferred I/O — no native lib; secmon has no #[test] here so make file-change-check asserts against the Rust source. |
monitor/webshell (WebshellMonitor classifiers) |
jsecmon/webshell.ss |
✅ untyped layer — the three pure deciders with the /proc scan + parent/child PID walk + event emission stripped: is_web_server (lower-cased name substring vs the server list, so php-fpm matches php), is_suspicious_child (process name by exact lower-cased equality — bashx is clean — OR the joined+lowercased cmdline substring-matched against the pattern list), and get_detection_reason (scans only the cmdline patterns, in list order, first match → Suspicious command pattern: {pat}, else the default Web server spawned suspicious process: {name} with the original-case name). obfstr!-hidden lists decode to these plaintext literals. Pins the corner that the reason is chosen by pattern-list order, not cmdline-token order, and that a name-only hit yields the default reason. Pure — the PID walk is the deferred monitor loop — no native lib; secmon has no #[test] here so make webshell-check asserts against the Rust source. |
monitor::dns_sniffer (DNS wire parser + live capture queue) |
jsecmon/dns-sniffer.ss |
✅ untyped runtime shell — the platform-independent half of secmon's src/monitor/dns_sniffer.rs: DNS wire-format parser (QNAME compression-pointer cap, QTYPE table, question + A/AAAA answers), IPv4+UDP header peel for AF_PACKET/SOCK_DGRAM-style IP bytes, Ethernet/VLAN peel for Jerboa pcap bytes, 5s dedup / 30s cleanup, local-port→PID/name callback, and a best-effort pcap-backed live capture stream feeding the monitor. Every bounds check is preserved: truncated/malformed/looping packets yield #f. make dns-sniffer-check reproduces parser/dedup tests; make monitor-dns-check verifies the stream-to-monitor path. |
bin/analyze (CLI parse helpers) |
jsecmon/analyze-cli.ss |
✅ untyped layer — the pure argument parsers of the analyze binary, returning the prelude Result (ok/err) to mirror Rust's Result<_, String> including the exact error text: parse_duration_ms (10m/2h/1d/bare-seconds → ms; splits leading ASCII digits from the unit; empty → empty duration, bad number/leading-non-digit → invalid duration: {s}, bad unit like m5 → unknown duration unit: …; the number must fit i64) and AlertSink::parse (stdout / file:PATH / webhook:URL / syslog / syslog:TAG, first-match in order, remainder taken verbatim so file: → empty path) and parse_alert_sinks (collect every --alert-to <spec>, parsing each and short-circuiting on the first bad spec like Rust's ?; a trailing --alert-to with no value is skipped, and no flags → the empty list — the watch-time default-to-stdout lives in cmd_watch), plus the generic --flag scanners shared across the CLI (parse_flag_value → the arg after the first flag, or #f even when the flag is last; has_flag → membership; is_json_format → the first --format that has a value decides, a trailing --format is skipped). Pure string→Result/bool; the sink dispatch (stdout/file append/curl webhook/logger syslog) and query dispatch are the deferred I/O. secmon has no #[test] here so make analyze-cli-check asserts against the Rust source. (format_ts/format_ts_iso display helpers now live in jsecmon/calendar.ss; only the actual stdout/file/webhook/syslog emission stays deferred.) |
bin/collector (CLI/hosts parse helpers) |
jsecmon/collector-cli.ss |
✅ untyped layer — the pure argument/hosts parsing of the collector binary, with the async polling + ECIES/PSK key loading + SQLite I/O deferred: parse_after_seq (first --after value as u64, unwrap_or(0) so junk/negative/≥2⁶⁴ → 0), parse_format (→ 'json/'human/'quiet; a per-index scan where an unknown --format value does not consume the value — differs from analyze's is_json_format — and the no-flag default is quiet when a --db is present else human), parse_db_path, normalize_host (append :31337 unless the host already contains any :, so bare IPv6 is left as-is, faithfully), collect_positional_hosts (skip the four value flags and their values, drop other -- args, normalize the rest), and parse_hosts_file's pure contents→hosts core (trim, drop blanks/# comments, normalize). secmon has no #[test] here so make collector-cli-check asserts against the Rust source. |
storage event readers (extract_pid / extract_process_name / build_summary) |
jsecmon/event-summary.ss |
✅ untyped layer — the pure readers that turn an event's flat JSON data (a hash table, as string->json-object yields) back into a pid / process name / one-line summary, with the SQLite query + serde plumbing left to storage. Each field is read through a typed getter so only a JSON value of the right type counts (as_u64/as_i64/as_str/as_bool); extract_pid walks pid→source_pid→spawned_pid→web_server_pid and truncates the first hit to u32 (Rust v as u32, so ≥2³² wraps, and pid 0 is a real hit); extract_process_name walks process_name→name→exe→source_process→spawned_process. build_summary reproduces every per-type format with the exact unwrap_or defaults ("?"/0), the process_exit exit-code option (Some(0) still prints (0)), the nested selinux_event perm/class/path-vs-message branches with the 80-char message cap, and the catch-all that scans values in sorted key order (serde's default BTreeMap) for the first string longer than 3 chars (capped at 80) else the event type. make event-summary-check (43 cases) reproduces secmon's test_extract_helpers + test_build_summary and adds every per-type / typed-getter corner derived from the source. |
storage IOC parsing (detect_ioc_type / is_ipv4 / parse_ioc_text) |
jsecmon/ioc.ss |
✅ untyped layer — the pure indicator classifier behind threat-list ingestion (the file read in load_ioc_file is the deferred I/O). detect_ioc_type is first-match ip→hash→domain→process: is_ipv4 (split on ., exactly 4 non-empty ≤3-char all-digit groups — no 0–255 range check, so 999.999.999.999 is still Ip and 1.2.3.4444 is not), then IPv6 (: present and every char hex-or-:), then a 32/40/64-length all-hex Hash (MD5/SHA1/SHA256, case-insensitive), then a .-bearing space-free Domain, else Process. parse_ioc_text trims, drops blanks and # comments, and tags each remaining line (order preserved). ioc_type_json_fields / ioc_type_column_fields expose the per-type JSON-path and column lists match_iocs probes (pure routing data; Process is the only type with a plain column, process_name). make ioc-check reproduces the test_ioc_type_detection #[test] plus the is_ipv4 / parse / field-routing corners. |
monitor/revshell reverse-shell classifiers (classify_connection + helpers) |
jsecmon/revshell.ss |
✅ untyped layer — the pure deciders of the reverse-shell monitor (connection/PID enumeration, event emission, and the dedup set stay in the monitor loop). is_shell / is_revshell_tool are exact lower-cased name membership; is_c2_port tests the 17-port C2 set; is_legitimate_service fires only on 443/8080/8443 and matches a substring of the name. extract_addr_from_cmdline finds /dev/tcp/, splits the remainder on /, and on ≥2 pieces returns (addr . u16-port) (first whitespace token of piece 1, junk/out-of-range→0), else ("unknown" . 0). classify_connection runs checks 1–4 first-match: shell→shell-outbound, C2-port-and-not-legit→known-c2-port, revshell-tool→shell-outbound, any REVSHELL_PATTERNS substring→suspicious-redirect, else #f. revshell.rs has no #[test], so make revshell-check (47 cases) is the spec. |
monitor/cron scheduled-task path tables + classifier |
jsecmon/cron.ss |
✅ untyped layer — the pure pieces of the cron / systemd-timer / periodic monitor (the baseline walk + change detection stay in the loop). secmon keys CRON_PATHS / PERIODIC_PATHS off #[cfg(target_os)], so cron-paths / periodic-paths are functions of a platform symbol ('linux / 'freebsd / 'other) reproducing the three cfg arms verbatim. is-systemd-or-periodic-path is the routing predicate baseline_all uses to decide whether a PERIODIC_PATHS entry is a systemd unit dir vs another cron-like dir — a plain substring test for "systemd" OR "periodic". cron.rs has no #[test], so make cron-check asserts the full tables + the classifier and is the spec. |
monitor/logtamper log-tamper decision core |
jsecmon/logtamper.ss |
✅ untyped layer — the pure pieces of the log-tampering monitor (the fs::metadata polling + size/mtime tracking map stay in the loop). Exposes the SYSTEM_LOGS / HISTORY_FILES constant tables and TRUNCATION_THRESHOLD (1000). is-history-file is any-HISTORY_FILES-substring. classify-tamper old-size new-size old-mtime new-mtime path reproduces check_tampering's Ok-arm in push order: size dropped by > threshold → history-cleared (if a history file) else truncated; mtime went backwards and > 0 → timestamp-modified (both can fire for one file). The deleted case is the fs::metadata Err arm (deferred I/O). logtamper.rs has no #[test], so make logtamper-check is the spec. |
storage detection-rule ATT&CK catalog (DETECTION_RULES / rule_attack / anomaly_rule_attack) |
jsecmon/detection-rules.ss |
✅ untyped layer — the static DETECTION_RULES table (13 rows: name / description / severity / MITRE techniques) and its pure lookups, used to annotate findings with ATT&CK IDs and to list/validate rule names (the SQL detectors that fire these live in (jsecmon threats)). rule-attack finds the row by exact name and returns its attack list, else () (unwrap_or_default); rule-names / rule-known? mirror the listing/validation paths. anomaly-rule-attack is the separate match for the detect_anomalies statistical rules (not in the table): kill_chain → recon/lateral/exfil tactics, off_hours → Defense Evasion, others (frequency_spike, severity_cluster) → (). const has no #[test], so make detection-rules-check asserts every row + both lookups and is the spec. |
monitor/lateral IP parsing + internal-network test (is_internal_ip) |
jsecmon/ipaddr.ss |
✅ untyped layer — is_internal_ip parses ip_str.parse::<IpAddr>() first and only classifies on success, so parse-ipv4 / parse-ipv6 faithfully reproduce Rust std's IpAddr FromStr boundary: IPv4 = exactly 4 octets, 1–3 digits, no leading zeros, ≤255; IPv6 = colon-separated 1–4-digit hex groups with at most one :: (eliding ≥1 zero group) and an optional trailing embedded IPv4 (forbidden before ::). is-internal-ip then mirrors lateral.rs: V4 10/8 · 172.16/12 · 192.168/16 · 127/8, V6 fc00::/7 (seg0 & 0xfe00 == 0xfc00) or loopback. lateral.rs has no #[test]; expectations were generated by a std-only Rust oracle over ~70 inputs, so make ipaddr-check (88 cases) pins the port to real Rust and is the spec. |
monitor/auth log-line parsers (parse_auth_line + 7 sub-parsers) |
jsecmon/auth.ss |
✅ untyped layer — the dispatcher lowercases and routes on substrings (sshd→ssh, sudo, su[/su:, authentication failure→pam, useradd/adduser, userdel/deluser, password changed/passwd), each sub-parser pulling fields with extract_field(line, start, end). Faithful corners: extract_field searches the raw line (case-sensitive) while dispatch/success use the lower-cased copy, so Invalid user admin → username unknown; the SSH "accepted" branch ?-propagates a missing for to #f (no fall-through); and the documented quirks where the username captures TTY=pts/0 / authentication failure; TTY=… / root) are preserved. AuthEventType → 'ssh-key-auth 'login 'failed-login 'sudo-attempt 'su-attempt 'user-created 'user-deleted 'password-change; None → #f. auth.rs has no #[test]; make auth-check (24 cases) derives every expectation from a std-only Rust oracle over the verbatim bodies and is the spec. |
format_ts / format_ts_iso / parse_hour_to_ms / parse_datetime (chrono UTC calendar) |
jsecmon/calendar.ss |
✅ untyped layer — the pure epoch-ms ↔ civil-date boundary the detect/analytics seeds defer to. secmon formats/parses timestamps with chrono always in UTC (DateTime::from_timestamp_millis, NaiveDateTime), which is pure proleptic-Gregorian arithmetic, reproduced exactly with Howard Hinnant's days_from_civil/civil_from_days: format-ts (%Y-%m-%d %H:%M:%S, storage+analyze), format-ts-iso (%Y-%m-%dT%H:%M:%S%.3fZ, analyze), parse-hour-to-ms (%Y-%m-%d %H+:00:00, the frequency-spike inverse of %H decomposition, bad input → 0), and parse-datetime-ms (returns the prelude Result: relative Nh/Nd from an optional at/now, then RFC3339 with Z/±HH:MM offset and optional .fff, then naive ISO assumed-UTC, then date-only; else err with the exact Rust message). Faithful corners: Hinnant's / truncates toward zero (quotient) but the ms→day split must floor (a pre-1970 ms keeps a non-negative ms-of-day), so the parts use exact floor; the export is parse-datetime-ms/char-index to avoid shadowing the prelude's own parse-datetime (a datetime record) and string-index. make calendar-check pins the civil round-trip, both formatters (incl. leap-day, negative-ms flooring), parse_hour_to_ms, and secmon's test_parse_datetime invariants (28 cases). |
monitor::process::ProcessMonitor scan loop |
jsecmon/monitor-process.ss |
✅ untyped layer — secmon's process-monitor scan in pure Jerboa over an injected mon-provider seam (list-pids / get-process / hostname), fixture-tested: diff pid sets → classify each new spawn (check-suspicious delegates to (jsecmon suspicious)) → emit process_start/suspicious_exec; exited pids → process_exit; list-pids error → no-op. Faithfully preserves secmon's continue before insert (a failed get-process is not added to known and is retried), the parent-cache lookup, and the 1000-entry cache prune. make-linux-provider is the thin /proc shell via (jsecmon proc-linux) parsers plus live /proc/[pid]/{exe,cwd} readlinks. make monitor-process-check (30 cases). |
monitor::network::NetworkMonitor scan loop |
jsecmon/monitor-network.ss |
✅ untyped layer — the network-monitor scan over an injected net-provider seam: listeners → listening_port/info (de-dup key proto:laddr:lport); connections → (jsecmon netconn) connection-suspicious? → suspicious_connection/high or network_connection/info (de-dup key proto:laddr:lport:raddr:rport:state). Provider Err skips that half; never ages out (secmon no-op TODO). make-linux-network-provider reads /proc/net/{tcp,udp,tcp6,udp6} via proc-linux parsers and resolves socket inode → (pid . process-name) by scanning /proc/*/fd, enabling the live web-server outbound check. make monitor-network-check (13 cases). |
monitor::files::FileIntegrityMonitor scan loop |
jsecmon/monitor-files.ss |
✅ untyped layer — file-integrity monitor over an injected file-provider seam: baseline-files records initial state; detect-change produces the ordered diff (deleted > created > permission-changed > owner-changed > modified) — pinned secmon quirk: owner-changed reports old_mode = new_mode = new.mode; scan-files re-stats tracked paths + discovers new files in watched dirs (Created), classifying via (jsecmon file-change) is-suspicious-change. make-linux-file-provider now uses POSIX stat mode/uid/gid/mtime and SHA-256 for regular files under 10 MiB; linux-monitored-paths = secmon's Linux PLATFORM_PATHS. make monitor-files-check (18 cases). |
monitor::dns::DnsMonitor sniffer + connection fallback |
jsecmon/dns-sniffer.ss, jsecmon/monitor-dns.ss |
✅ untyped runtime shell — tries the live packet-capture stream first, feeds captured DNS packets through DnsSnifferState for real query names/answers + local-port PID/name lookup, and uses the connection-polling fallback when capture is unavailable/ended. The fallback reuses net-provider's list-connections; keeps only remote-port==53; dedups by pid:remote_addr:protocol:local_port; emits query_name="<unknown>"; and ages entries ≥30 s. make dns-sniffer-check pins the wire parser; make monitor-dns-check covers sniffer delivery/lookup, fallback suppression, fallback polling, dedup, and cleanup. |
monitor::podman::PodmanMonitor |
jsecmon/monitor-podman.ss |
✅ untyped runtime shell — ports the preferred podman events --format json stream (create/start/stop/kill/remove/cleanup/exec/attach, container-only, malformed lines ignored), starts it with the live Linux provider, queues event lines into the synchronous agent tick, and falls back to podman ps -a polling only after spawn failure or stream EOF. make monitor-podman-check covers stream parser actions/ignores, queued stream delivery, EOF-to-polling fallback baseline, and start/remove polling deltas. |
monitor::mod::MonitorManager (boot + poll cycle) |
jsecmon/monitor-manager.ss |
✅ untyped layer — monitor-boot baselines stateful monitors and emits agent_start; monitor-tick runs the full polling set in a fixed order and returns the merged event stream; cleanup-dns-queries runs after tick. make-linux-monitor-set wires the live Linux providers, sharing process/network providers where the Rust monitors do, and prefers the live eBPF stream when available (skipping only process/network polling in that mode). make-freebsd-monitor-set wires the DTrace direct/subprocess stream and suppresses process/network polling while it is active, matching the high-fidelity mode shape. make monitor-manager-check drives the fallback cycle end to end with fixture providers and the DTrace stream drain. |
event_json.rs get_event_json_data |
jsecmon/event-json.ss |
✅ untyped layer — the flat JSON data body for every Rust EventType variant, matching secmon's json!{...} field SETS exactly: suspicious_exec drops cwd/name; listening_port keeps only local side; suspicious_connection drops state/process_name; mount_event drops target/fstype but computes dangerous; namespace/capability derive ns_types/dangerous_capabilities; all enum-ish fields use Rust Debug spelling. Option::None → JSON null via (if #f #f) void sentinel; parent process → nested {pid,name,exe} or null. make event-json-check drives monitor events plus representative eBPF/podman/SELinux/revshell/lateral/webshell rows through the serializer, parses back, and asserts the tricky corners. |
monitor::events::SecurityEvent bincode |
jsecmon/event-codec.ss |
✅ untyped layer — byte-exact bincode reader/writer for the top-level SecurityEvent and all 32 EventType discriminants, with golden secmon bytes for the process/network base cases and round-trip coverage for every remaining variant. This is the compiled agent's event payload codec feeding the encrypted buffer and collector pull path. |
bin/agent storage path (collector loop) |
jsecmon/collector.ss |
✅ untyped layer — ties monitor runtime to storage: collect-event! derives the full storage row (data = event-data-json, summary = build-summary, pid/pname = extract-pid/extract-process-name), assigns a monotonic seq, INSERT-OR-IGNORE dedup; collect-events! drains a batch; run-boot!/run-tick! tie monitor-boot/monitor-tick to the store. run-agent is the thin live shell (real ms clock, platform monitor-set, poll/sleep). make collector-check drives boot+tick of the full fixture monitor set into a real temp SQLite store, queries rows back to assert event count, derived pid/name/summary, parsed data, and dedup. |
server::PollServer |
jsecmon/agent-server.ss |
✅ untyped layer — server-side pull protocol: creates PSK challenge, verifies ChallengeResponse freshness/proof, handles get_events_after, get_events_in_range, status, acknowledge, and ping, serializes ECIES-sealed SerializedEvents from the priority buffer, assigns monotonic event IDs, persists to the optional local encrypted store, and reports local DB count/latest seq in status. make agent-server-check covers direct buffer decrypt, dispatcher requests, local-store status, and socket start/stop lifecycle. |
bin/agent privilege drop |
jsecmon/privdrop.ss, bin/agent.ss |
✅ untyped runtime shell — after binding the poll server, the agent resolves nobody from passwd text and calls setgroups, setgid, then setuid, matching secmon's required order. make privdrop-check verifies lookup strictness and call ordering without changing the test process credentials. |
| live-platform verification limits | — | ⚠️ The provider-backed polling agent path, wire protocol, encrypted local store, ECIES bincode frame, eBPF libbpf runtime shell, DTrace direct/subprocess runtime shells, live DNS capture stream/PID association, live Podman event stream/fallback, stealth init/watchdog, and post-bind nobody privilege drop are ported, wired into the agent, and fixture-checked. This macOS workspace cannot prove the privileged Linux root/libbpf path or FreeBSD root/libdtrace path live; final live parity needs those target OS/root environments. |