Add a dedicated security audit trail to the Go server. Login attempts, authorization denials, WebSocket lifecycle, remote screen and terminal sessions, clipboard operations, device authentication and admin mutations now emit stable JSON-line events (audit=true, event=<name>) to a separate logs/audit.log, mirrored to server.log. The path is configurable through YAMA_AUDIT_LOG and the separate file can be switched off. Records never contain passcodes, password hashes, tokens or clipboard contents, and untrusted string fields are stripped of control characters and truncated so they cannot forge log lines. Expose the trail to administrators through a query_audit WebSocket command and an Audit Log page in the web UI. The page filters by time range, event (exact or prefix), actor, IP, device id, keyword and warning level, pages newest-first, and stays hidden unless the server advertises the auditLog capability, so older C++ servers degrade cleanly. Each query scans at most the newest 32 MB of the current file to bound latency, records itself as admin.audit.query, and tolerates torn or non-audit lines. Add unit tests for event emission and redaction, query filtering, paging, malformed lines, missing or disabled audit files, and handler authorization. Co-Authored-By: deepseek-flash
295 lines
10 KiB
Go
295 lines
10 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
|
)
|
|
|
|
// newAuditTestHubWithoutAuditFile builds a hub whose logger has no dedicated
|
|
// audit file, mirroring a server started with YAMA_AUDIT_LOG=off.
|
|
func newAuditTestHubWithoutAuditFile(t *testing.T, dir string) (*wsHub, string) {
|
|
t.Helper()
|
|
lg := logger.New(logger.Config{
|
|
Level: logger.LevelInfo,
|
|
Console: false,
|
|
File: filepath.Join(dir, "server.log"),
|
|
})
|
|
t.Cleanup(func() { _ = lg.Close() })
|
|
wh := newWSHub(wsauth.New(), hub.New(), nil, lg)
|
|
t.Cleanup(wh.stop)
|
|
return wh, ""
|
|
}
|
|
|
|
func readQueuedJSON(t *testing.T, c *wsClient) map[string]any {
|
|
t.Helper()
|
|
select {
|
|
case m := <-c.send:
|
|
var v map[string]any
|
|
if err := json.Unmarshal(m.data, &v); err != nil {
|
|
t.Fatalf("queued reply is not JSON: %q: %v", m.data, err)
|
|
}
|
|
return v
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for a queued reply")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// writeAuditFixture writes JSON-lines audit records (oldest first) and returns
|
|
// the file path. Times are UTC RFC3339 so the tests are timezone-independent.
|
|
func writeAuditFixture(t *testing.T, records []map[string]any) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "audit.log")
|
|
var b strings.Builder
|
|
for _, rec := range records {
|
|
line, err := json.Marshal(rec)
|
|
if err != nil {
|
|
t.Fatalf("marshal fixture: %v", err)
|
|
}
|
|
b.Write(line)
|
|
b.WriteByte('\n')
|
|
}
|
|
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
|
t.Fatalf("write fixture: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func auditFixtureRecords() []map[string]any {
|
|
at := func(hhmm string) string {
|
|
tm, err := time.Parse("2006-01-02T15:04", "2026-10-18T"+hhmm)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return tm.Format(time.RFC3339)
|
|
}
|
|
return []map[string]any{
|
|
{"level": "info", "time": at("10:00"), "audit": true, "event": "web.login.success", "actor": "admin", "ip": "203.0.113.1"},
|
|
{"level": "warn", "time": at("10:01"), "audit": true, "event": "web.login.failure", "actor": "alice", "ip": "203.0.113.2", "reason": "invalid_credentials"},
|
|
{"level": "info", "time": at("10:02"), "audit": true, "event": "remote.screen.start", "actor": "admin", "role": "admin", "ip": "203.0.113.1", "device_id": "dev1"},
|
|
{"level": "info", "time": at("10:03"), "audit": true, "event": "admin.user.delete", "actor": "admin", "ip": "203.0.113.1", "target": "bob"},
|
|
}
|
|
}
|
|
|
|
func runAuditQuery(t *testing.T, path string, q auditQueryRequest) auditQueryResult {
|
|
t.Helper()
|
|
q.Limit = clampAuditLimit(q.Limit)
|
|
q.Offset = clampAuditOffset(q.Offset)
|
|
res, err := queryAuditLog(path, q)
|
|
if err != nil {
|
|
t.Fatalf("queryAuditLog: %v", err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func eventNames(res auditQueryResult) []string {
|
|
out := make([]string, 0, len(res.Events))
|
|
for _, e := range res.Events {
|
|
out = append(out, auditStringField(e, "event"))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestAuditQueryNoFilterNewestFirst(t *testing.T) {
|
|
path := writeAuditFixture(t, auditFixtureRecords())
|
|
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
|
|
if res.Total != 4 || len(res.Events) != 4 {
|
|
t.Fatalf("total=%d events=%d, want 4/4", res.Total, len(res.Events))
|
|
}
|
|
got := eventNames(res)
|
|
want := []string{"admin.user.delete", "remote.screen.start", "web.login.failure", "web.login.success"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("events = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAuditQueryEventFilters(t *testing.T) {
|
|
path := writeAuditFixture(t, auditFixtureRecords())
|
|
cases := []struct {
|
|
filter string
|
|
want []string
|
|
}{
|
|
{"web.", []string{"web.login.failure", "web.login.success"}},
|
|
{"web.*", []string{"web.login.failure", "web.login.success"}},
|
|
{"web.login.failure", []string{"web.login.failure"}},
|
|
{"remote.*", []string{"remote.screen.start"}},
|
|
}
|
|
for _, tc := range cases {
|
|
res := runAuditQuery(t, path, auditQueryRequest{Event: tc.filter, Limit: 100})
|
|
got := eventNames(res)
|
|
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
|
t.Errorf("filter %q → %v, want %v", tc.filter, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuditQueryFieldFilters(t *testing.T) {
|
|
path := writeAuditFixture(t, auditFixtureRecords())
|
|
from, _ := time.Parse(time.RFC3339, "2026-10-18T10:01:00Z")
|
|
to, _ := time.Parse(time.RFC3339, "2026-10-18T10:02:00Z")
|
|
|
|
cases := []struct {
|
|
name string
|
|
q auditQueryRequest
|
|
want []string
|
|
}{
|
|
{"actor case-insensitive", auditQueryRequest{Actor: "ALICE", Limit: 100}, []string{"web.login.failure"}},
|
|
{"ip", auditQueryRequest{IP: "203.0.113.2", Limit: 100}, []string{"web.login.failure"}},
|
|
{"device", auditQueryRequest{DeviceID: "dev1", Limit: 100}, []string{"remote.screen.start"}},
|
|
{"warn only", auditQueryRequest{WarnOnly: true, Limit: 100}, []string{"web.login.failure"}},
|
|
{"keyword target", auditQueryRequest{Keyword: "BOB", Limit: 100}, []string{"admin.user.delete"}},
|
|
{"time range", auditQueryRequest{
|
|
From: from.Format(time.RFC3339), To: to.Format(time.RFC3339), Limit: 100,
|
|
}, []string{"remote.screen.start", "web.login.failure"}},
|
|
}
|
|
for _, tc := range cases {
|
|
res := runAuditQuery(t, path, tc.q)
|
|
got := eventNames(res)
|
|
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
|
t.Errorf("%s → %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuditQueryOffsetAndLimit(t *testing.T) {
|
|
path := writeAuditFixture(t, auditFixtureRecords())
|
|
res := runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 1})
|
|
if res.Total != 4 {
|
|
t.Fatalf("total = %d, want 4", res.Total)
|
|
}
|
|
got := eventNames(res)
|
|
want := []string{"remote.screen.start", "web.login.failure"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("events = %v, want %v", got, want)
|
|
}
|
|
|
|
// Offsetting past the end yields no events but keeps the total.
|
|
res = runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 4})
|
|
if res.Total != 4 || len(res.Events) != 0 {
|
|
t.Fatalf("offset past end: total=%d events=%v", res.Total, eventNames(res))
|
|
}
|
|
}
|
|
|
|
func TestAuditQuerySkipsNonAuditAndTornLines(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "audit.log")
|
|
content := `{"event":"device.online","actor":"x"}
|
|
not json at all
|
|
{"level":"info","time":"2026-10-18T10:00:00Z","audit":true,"event":"web.login.success","actor":"admin"}`
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
|
|
if res.Total != 1 || len(res.Events) != 1 {
|
|
t.Fatalf("total=%d events=%v, want the single valid record", res.Total, eventNames(res))
|
|
}
|
|
}
|
|
|
|
func TestAuditQueryMissingFile(t *testing.T) {
|
|
res := runAuditQuery(t, filepath.Join(t.TempDir(), "does-not-exist.log"), auditQueryRequest{Limit: 10})
|
|
if res.FileExists {
|
|
t.Error("FileExists = true for a missing file")
|
|
}
|
|
if res.Total != 0 || len(res.Events) != 0 {
|
|
t.Errorf("total=%d events=%v, want empty", res.Total, eventNames(res))
|
|
}
|
|
if res.Events == nil {
|
|
t.Error("Events is nil; JSON would encode null instead of []")
|
|
}
|
|
}
|
|
|
|
func TestAuditQueryClampsPaging(t *testing.T) {
|
|
if got := clampAuditLimit(0); got != auditQueryDefaultLimit {
|
|
t.Errorf("limit(0) = %d, want default %d", got, auditQueryDefaultLimit)
|
|
}
|
|
if got := clampAuditLimit(-3); got != auditQueryDefaultLimit {
|
|
t.Errorf("limit(-3) = %d, want default %d", got, auditQueryDefaultLimit)
|
|
}
|
|
if got := clampAuditLimit(9999); got != auditQueryMaxLimit {
|
|
t.Errorf("limit(9999) = %d, want max %d", got, auditQueryMaxLimit)
|
|
}
|
|
if got := clampAuditLimit(50); got != 50 {
|
|
t.Errorf("limit(50) = %d, want 50", got)
|
|
}
|
|
if got := clampAuditOffset(-5); got != 0 {
|
|
t.Errorf("offset(-5) = %d, want 0", got)
|
|
}
|
|
if got := clampAuditOffset(1 << 30); got != auditQueryMaxOffset {
|
|
t.Errorf("offset(huge) = %d, want max %d", got, auditQueryMaxOffset)
|
|
}
|
|
}
|
|
|
|
func TestQueryAuditHandlerAdminOnly(t *testing.T) {
|
|
wh, _, _ := auditTestHub(t)
|
|
viewer, tok := auditTestClient(wh, "viewer")
|
|
raw, _ := json.Marshal(map[string]any{"token": tok})
|
|
wh.handleQueryAudit(viewer, raw)
|
|
|
|
msg := readQueuedJSON(t, viewer)
|
|
if msg["cmd"] != "audit_result" || msg["ok"] != false {
|
|
t.Fatalf("viewer reply = %+v", msg)
|
|
}
|
|
if _, ok := msg["events"]; ok {
|
|
t.Error("denied viewer got events")
|
|
}
|
|
}
|
|
|
|
func TestQueryAuditHandlerReturnsEvents(t *testing.T) {
|
|
wh, _, _ := auditTestHub(t)
|
|
// Two records written directly through the same logger the hub queries.
|
|
wh.log.Audit("web.login.success", map[string]any{"actor": "admin", "ip": "203.0.113.1"})
|
|
wh.log.Audit("remote.screen.start", map[string]any{"actor": "admin", "device_id": "dev1"})
|
|
|
|
admin, tok := auditTestClient(wh, "admin")
|
|
raw, _ := json.Marshal(map[string]any{"token": tok, "limit": 10})
|
|
wh.handleQueryAudit(admin, raw)
|
|
|
|
msg := readQueuedJSON(t, admin)
|
|
if msg["cmd"] != "audit_result" || msg["ok"] != true {
|
|
t.Fatalf("reply = %+v", msg)
|
|
}
|
|
events, ok := msg["events"].([]any)
|
|
if !ok || len(events) < 2 {
|
|
t.Fatalf("events = %#v, want at least the two written records", msg["events"])
|
|
}
|
|
// requireAuth writes a web.session.resume record for this same request, so
|
|
// that one legitimately appears newer. Assert the two written records are
|
|
// present and still newest-first relative to each other.
|
|
var names []string
|
|
for _, e := range events {
|
|
if m, ok := e.(map[string]any); ok {
|
|
names = append(names, auditStringField(m, "event"))
|
|
}
|
|
}
|
|
joined := strings.Join(names, ",")
|
|
if !strings.Contains(joined, "remote.screen.start") || !strings.Contains(joined, "web.login.success") {
|
|
t.Fatalf("events = %v, want both written records", names)
|
|
}
|
|
if strings.Index(joined, "remote.screen.start") > strings.Index(joined, "web.login.success") {
|
|
t.Fatalf("events = %v, want newest-first order", names)
|
|
}
|
|
if msg["truncated"] != false || msg["file_exists"] != true {
|
|
t.Errorf("meta = %+v", msg)
|
|
}
|
|
}
|
|
|
|
func TestQueryAuditHandlerDisabledFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
wh, _ := newAuditTestHubWithoutAuditFile(t, dir)
|
|
admin, tok := auditTestClient(wh, "admin")
|
|
raw, _ := json.Marshal(map[string]any{"token": tok})
|
|
wh.handleQueryAudit(admin, raw)
|
|
|
|
msg := readQueuedJSON(t, admin)
|
|
if msg["ok"] != false {
|
|
t.Fatalf("reply = %+v, want ok:false when the audit file is disabled", msg)
|
|
}
|
|
}
|