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
196 lines
5.1 KiB
Go
196 lines
5.1 KiB
Go
package web
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
|
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
|
)
|
|
|
|
// auditTestHub builds a wsHub whose logger writes both the main log and the
|
|
// dedicated audit log to a temp directory.
|
|
func auditTestHub(t *testing.T) (wh *wsHub, devices *hub.Hub, auditPath string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
auditPath = filepath.Join(dir, "audit.log")
|
|
lg := logger.New(logger.Config{
|
|
Level: logger.LevelInfo,
|
|
Console: false,
|
|
File: filepath.Join(dir, "server.log"),
|
|
AuditFile: auditPath,
|
|
})
|
|
t.Cleanup(func() { _ = lg.Close() })
|
|
|
|
devices = hub.New()
|
|
wh = newWSHub(wsauth.New(), devices, nil, lg)
|
|
t.Cleanup(wh.stop)
|
|
return wh, devices, auditPath
|
|
}
|
|
|
|
func auditTestClient(wh *wsHub, role string) (*wsClient, string) {
|
|
tok, err := wh.auth.IssueToken("tester", role)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
c := &wsClient{
|
|
send: make(chan wsMsg, 64),
|
|
closed: make(chan struct{}),
|
|
id: newConnID(),
|
|
addr: "203.0.113.5",
|
|
peerAddr: "203.0.113.5",
|
|
connectedAt: time.Now(),
|
|
watchIDs: make(map[string]struct{}),
|
|
watchStartedAt: make(map[string]time.Time),
|
|
keyframeAt: make(map[string]int64),
|
|
}
|
|
wh.register(c)
|
|
return c, tok
|
|
}
|
|
|
|
func readAuditEvents(t *testing.T, path string) []map[string]any {
|
|
t.Helper()
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("open audit log: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var out []map[string]any
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
|
t.Fatalf("bad audit line %q: %v", line, err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
t.Fatalf("scan audit log: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func findAuditEvent(t *testing.T, path, event string) map[string]any {
|
|
t.Helper()
|
|
for _, m := range readAuditEvents(t, path) {
|
|
if m["event"] == event {
|
|
return m
|
|
}
|
|
}
|
|
t.Fatalf("audit event %q not found in %v", event, readAuditEvents(t, path))
|
|
return nil
|
|
}
|
|
|
|
func TestLoginFailureIsAudited(t *testing.T) {
|
|
wh, _, auditPath := auditTestHub(t)
|
|
wh.auth.AddAdminFromPlainPassword("admin", "hunter2")
|
|
c, _ := auditTestClient(wh, "viewer")
|
|
|
|
nonce, err := wsauth.NewNonce()
|
|
if err != nil {
|
|
t.Fatalf("NewNonce: %v", err)
|
|
}
|
|
c.nonce = nonce
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"username": "admin",
|
|
"response": "not-the-right-response",
|
|
"nonce": nonce,
|
|
})
|
|
wh.handleLogin(c, raw)
|
|
|
|
rec := findAuditEvent(t, auditPath, auditLoginFail)
|
|
if rec["reason"] != "invalid_credentials" {
|
|
t.Errorf("reason = %v", rec["reason"])
|
|
}
|
|
if rec["username"] != "admin" {
|
|
t.Errorf("username = %v", rec["username"])
|
|
}
|
|
if rec["user_exists"] != true {
|
|
t.Errorf("user_exists = %v, want true", rec["user_exists"])
|
|
}
|
|
if rec["ip"] != "203.0.113.5" {
|
|
t.Errorf("ip = %v", rec["ip"])
|
|
}
|
|
if _, ok := rec["response"]; ok {
|
|
t.Error("audit record leaked the login response")
|
|
}
|
|
}
|
|
|
|
func TestAdminDenialIsAudited(t *testing.T) {
|
|
wh, _, auditPath := auditTestHub(t)
|
|
c, tok := auditTestClient(wh, "viewer")
|
|
raw, _ := json.Marshal(map[string]any{"token": tok})
|
|
|
|
if wh.requireAdmin(c, raw, "delete_device_result") {
|
|
t.Fatal("viewer passed requireAdmin")
|
|
}
|
|
rec := findAuditEvent(t, auditPath, auditDenied)
|
|
if rec["operation"] != "delete_device" {
|
|
t.Errorf("operation = %v", rec["operation"])
|
|
}
|
|
if rec["role"] != "viewer" || rec["actor"] != "tester" {
|
|
t.Errorf("identity fields = %+v", rec)
|
|
}
|
|
}
|
|
|
|
func TestScreenStartAndOfflineStopAreAudited(t *testing.T) {
|
|
wh, devices, auditPath := auditTestHub(t)
|
|
devices.SetSender(func(*connection.Context, []byte) error { return nil })
|
|
hctx := &connection.Context{RemoteAddr: "10.0.0.9:1234"}
|
|
devices.Register(&hub.Device{ID: "dev1", Name: "Dev"}, hctx)
|
|
|
|
c, tok := auditTestClient(wh, "admin")
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"token": tok, "id": "dev1", "multiWatch": true, "req": "1",
|
|
})
|
|
wh.handleConnect(c, raw)
|
|
|
|
start := findAuditEvent(t, auditPath, auditScreenStart)
|
|
if start["device_id"] != "dev1" || start["mode"] != "started" {
|
|
t.Errorf("screen start = %+v", start)
|
|
}
|
|
|
|
wh.OnDeviceOffline("dev1")
|
|
|
|
stop := findAuditEvent(t, auditPath, auditScreenStop)
|
|
if stop["device_id"] != "dev1" || stop["reason"] != "device_offline" {
|
|
t.Errorf("screen stop = %+v", stop)
|
|
}
|
|
if _, ok := stop["duration_ms"]; !ok {
|
|
t.Error("screen stop has no duration_ms")
|
|
}
|
|
}
|
|
|
|
func TestUnknownCommandRecordedOnce(t *testing.T) {
|
|
wh, _, auditPath := auditTestHub(t)
|
|
c, _ := auditTestClient(wh, "viewer")
|
|
|
|
for i := 0; i < 5; i++ {
|
|
wh.dispatch(c, "definitely_not_a_command", []byte(`{}`))
|
|
}
|
|
count := 0
|
|
for _, m := range readAuditEvents(t, auditPath) {
|
|
if m["event"] == auditUnknownCmd {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("unknown-command audit records = %d, want 1", count)
|
|
}
|
|
if c.statUnknownCmd != 5 {
|
|
t.Fatalf("statUnknownCmd = %d, want 5", c.statUnknownCmd)
|
|
}
|
|
}
|