From 826daa3ab7d4cd11b1a62c279b903e8c59df6c38 Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 30 Jun 2026 05:58:57 -0700 Subject: [PATCH] Add --json output to the check command Co-Authored-By: Claude Opus 4.8 --- enodia_sentinel/cli.py | 14 ++++++++++---- tests/test_check_json.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 tests/test_check_json.py diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index 0a6e540..1873942 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -146,7 +146,7 @@ def _reconcile_list(cfg: Config, store, args) -> int: return 1 if any_stale else 0 -def _cmd_check(cfg: Config) -> int: +def _cmd_check(cfg: Config, as_json: bool = False) -> int: # One-shot: arm everything immediately, force the SUID scan. sentinel = Sentinel(cfg) sentinel.start_time = 0.0 # past the grace window @@ -154,10 +154,15 @@ def _cmd_check(cfg: Config) -> int: if not sentinel.listener_baseline: sentinel.build_baselines() alerts = sentinel.sweep(force_suid=True) + ordered = sorted(alerts, key=lambda x: -x.severity) + if as_json: + import json + print(json.dumps([a.to_dict() for a in ordered], indent=2)) + return 0 if not alerts: print("No alerts.") return 0 - for a in sorted(alerts, key=lambda x: -x.severity): + for a in ordered: print(f"[{a.severity}] {a.signature:<14} {a.detail}") return 0 @@ -172,7 +177,8 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("-c", "--config", help="path to TOML config") sub = parser.add_subparsers(dest="cmd") sub.add_parser("run", help="run the daemon loop (default)") - sub.add_parser("check", help="run detectors once and print alerts") + chk = sub.add_parser("check", help="run detectors once and print alerts") + chk.add_argument("--json", action="store_true", help="emit alerts as JSON") bl = sub.add_parser( "baseline", help="rebuild baselines (default), or accept/revoke/list drift") @@ -259,7 +265,7 @@ def main(argv: list[str] | None = None) -> int: return _cmd_baseline(cfg) return _cmd_reconcile(cfg, args) if args.cmd == "check": - return _cmd_check(cfg) + return _cmd_check(cfg, args.json) if args.cmd == "web": from .web import serve serve(cfg) diff --git a/tests/test_check_json.py b/tests/test_check_json.py new file mode 100644 index 0000000..c5c3cfe --- /dev/null +++ b/tests/test_check_json.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""The `check --json` flag emits a parseable JSON alert array.""" +import io +import json +import unittest +from contextlib import redirect_stdout +from unittest import mock + +from enodia_sentinel import cli +from enodia_sentinel.alert import Alert, Severity +from enodia_sentinel.config import Config + + +class _FakeSentinel: + def __init__(self, cfg): + self.start_time = 1.0 + self.listener_baseline = {"x"} + + def load_baselines(self): + pass + + def build_baselines(self): + pass + + def sweep(self, force_suid=False): + return [Alert(Severity.HIGH, "rsh", "rsh:1", "reverse shell", sid=1001)] + + +class TestCheckJson(unittest.TestCase): + def test_check_json_emits_alert_array(self): + cfg = Config() + with mock.patch.object(cli, "Sentinel", _FakeSentinel): + buf = io.StringIO() + with redirect_stdout(buf): + rc = cli._cmd_check(cfg, True) + self.assertEqual(rc, 0) + data = json.loads(buf.getvalue()) + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["signature"], "rsh") + self.assertEqual(data[0]["sid"], 1001)