secubox-deb/packages/secubox-toolbox-ng/cmd/sbx-sentinel/wiring_test.go
CyberMind 62e8631f2b
feat(sentinel): C2/botnet auto-learning — high-precision, report-only (Closes #826) (#827)
* docs: spec — Sentinel C2/botnet auto-learning (high-precision, report-only) (ref #823)

* docs: implementation plan — Sentinel C2/botnet auto-learning (ref #823)

* feat(sentinel): C2 autolearn false-positive allowlist gate (ref #826)

* fix(sentinel): serialize c2allow Add + robust atomicWriteFile (relative-path + temp cleanup) (ref #826)

* feat(sentinel): C2 autolearn corroborating signals (rare/non-browser-JA/DGA) (ref #826)

* feat(sentinel): C2 autolearn candidate lifecycle + persistence (ref #826)

* fix(sentinel): c2cand deep-copy snapshots (persist/snapshot/record race) + evict order + device cap (ref #826)

* feat(sentinel): C2 autolearn orchestrator — learned set + report-only verdicts (ref #826)

* feat(sentinel): wire C2 learner into daemon + /c2 endpoints + seeded allowlist (ref #826)

* fix(sentinel): make /c2/allow writable in packaged deploy (RW path + ownership) + sanitize Add (ref #826)

POST /c2/allow could never write in production: sbx-sentinel.service runs
User=secubox-toolbox under ProtectSystem=strict + ReadOnlyPaths=/etc/secubox,
and the seeded c2-allow.txt ships root:root. Fix both halves: nest
ReadWritePaths=/etc/secubox/sentinel under the existing ReadOnlyPaths=/etc/secubox
in the unit, and chown/chmod 0750 the sentinel/ subdir + file (never the shared
/etc/secubox parent) in postinst's configure branch, fail-safe.

Also: fix the /c2/allow doc comment (form/query only, not JSON — r.FormValue
never parses a body), and sanitize C2Allow.Add against newline injection now
that host is network-reachable.

* feat(toolbox): WebUI C2 appris sub-view + Ignorer allowlist proxy (ref #826)

* fix(toolbox): C2 view XSS (data-host not onclick) + normalize candidate signal maps (ref #826)

* fix(sentinel): bound c2 beaconing/learned maps + drop promoted from candidates + reject bare-TLD allow (ref #826)

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-07-07 07:46:19 +02:00

99 lines
3.3 KiB
Go

// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
// Source-Disclosed License — All rights reserved except as expressly granted.
// See LICENCE-CMSD-1.0.md for terms.
package main
import (
"os"
"path/filepath"
"testing"
"github.com/CyberMind-FR/secubox-deb/secubox-toolbox-ng/internal/sentinel"
)
// TestBuildAnalyzersReturnsThree asserts the production analyzer construction
// wires exactly the three real engines (spyware, the behavioral engine
// wrapped in the #826 C2 auto-learn orchestrator, YARA) — this is the path
// defaultConfig()/main() use, distinct from the tests' injected stubAnalyzer
// path.
func TestBuildAnalyzersReturnsThree(t *testing.T) {
// Empty/missing dirs are best-effort (no error) — NewLoader tolerates
// them — so the happy path returns all three analyzers with no error.
analyzers, err := buildAnalyzers("", "", nil)
if err != nil {
t.Fatalf("buildAnalyzers returned error on empty dirs: %v", err)
}
if len(analyzers) != 3 {
t.Fatalf("expected 3 analyzers, got %d", len(analyzers))
}
var haveSpyware, haveC2Learner, haveYara bool
for _, a := range analyzers {
switch a.(type) {
case *sentinel.Spyware:
haveSpyware = true
case *sentinel.C2Learner:
haveC2Learner = true
case *sentinel.YaraEngine:
haveYara = true
}
}
if !haveSpyware || !haveC2Learner || !haveYara {
t.Fatalf("missing an analyzer type: spyware=%v c2learner=%v yara=%v", haveSpyware, haveC2Learner, haveYara)
}
}
// TestBuildAnalyzersLoadsBasePack asserts buildAnalyzers actually wires a
// working pack Loader into the spyware analyzer: a base pack on disk with a
// known spyware domain produces a verdict for a matching mirrored flow.
func TestBuildAnalyzersLoadsBasePack(t *testing.T) {
dir := t.TempDir()
pack := `{"version":"1","iocs":[{"type":"domain","value":"pegasus.example","class":"spyware_pegasus","severity":95,"source":"amnesty-mvt","action":"block"}]}`
if err := os.WriteFile(filepath.Join(dir, "base.json"), []byte(pack), 0o644); err != nil {
t.Fatal(err)
}
analyzers, err := buildAnalyzers(dir, "", nil)
if err != nil {
t.Fatalf("buildAnalyzers: %v", err)
}
msg := sentinel.MirrorMsg{Meta: sentinel.FlowMeta{Host: "pegasus.example", MacHash: "dev1"}}
var got *sentinel.Verdict
for _, a := range analyzers {
if vs := a.Analyze(msg); len(vs) > 0 {
got = vs[0]
break
}
}
if got == nil {
t.Fatal("expected a verdict from the spyware analyzer for a known base-pack domain")
}
if got.Class != sentinel.ClassSpywarePegasus {
t.Fatalf("unexpected class: %v", got.Class)
}
}
// TestDefaultConfigWiresPipeline asserts defaultConfig() (the main() path)
// produces a runnable Config with the analyzer pipeline populated.
func TestDefaultConfigWiresPipeline(t *testing.T) {
// Point the pack dir at a temp (empty) dir so we don't depend on the
// installed /usr/share path in a CI sandbox.
t.Setenv("SENTINEL_PACK_DIR", t.TempDir())
t.Setenv("SENTINEL_OVERLAY_DIR", t.TempDir())
t.Setenv("SENTINEL_TTL", "24h")
cfg := defaultConfig()
if len(cfg.Analyzers) != 3 {
t.Fatalf("expected 3 analyzers wired, got %d", len(cfg.Analyzers))
}
if cfg.TTL.Hours() != 24 {
t.Fatalf("expected TTL 24h from env, got %s", cfg.TTL)
}
if cfg.SocketPath == "" || cfg.DBPath == "" {
t.Fatal("expected socket + db paths defaulted")
}
}