mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* docs(b2-6): enumerate the security-sensitive mutations and their audit coverage
Step 1 of B2-6: the mutation inventory crossed with the 43 non-test
Audit( call sites at 67fdd18d, recorded in the plan's evidence block.
Invite create/revoke (S-02) and plugin install/uninstall have no audit
row; no timeout mutation exists (kick is force_logout / voice_mod_kick).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu
* added new doc
* docs(audit): update changelog and security documentation to include admin panel actions in audit log
* feat(b2-6): audit every security-sensitive mutation, invite and plugin rows first (S-02)
Step 2 of B2-6. TestAuditCoverage_* in service, api and admin drive each
mutation from the plan's inventory against a fake db.AuditStore
(Server/db/audittest) and assert the expected action arrives. Red before
this commit on exactly four rows: invite_create, invite_revoke,
plugin_install, plugin_uninstall.
- InviteService writes invite_create / invite_revoke naming the invite by
id, never by code; RevokeInvite now takes the actor, threaded from the
handler. A failed revoke writes nothing (test).
- The plugin admin handler takes a db.Auditor and writes plugin_install /
plugin_uninstall against the RequireAdminAuth principal
(admin.ActorIDFromContext, exported for that).
- docs/security.md lists the four new actions; CHANGELOG under Unreleased.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu
* test(b2-6): denylist over the recorded audit detail corpus
Step 3 of B2-6. Each TestAuditCoverage_* table now ends with a subtest
that runs audittest.AssertSafeDetails over every entry its rows recorded:
a shape denylist (bcrypt/argon2 hashes, password=/token=/secret=/
recovery-code key-value leaks, otpauth URIs, Bearer credentials) plus the
fixture's own secrets (raw tokens, passwords and hashes, TOTP secrets and
codes, invite codes, message bodies). audittest_test.go proves each class
bites and that ordinary details pass. Zero hits on the corpus at HEAD, so
no call site needed changing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu
* docs(b2-6): record pre-squash SHAs, red/green and denylist evidence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu
* fix(b2-6): audit committed invites on a canceled request; 404 unknown plugin uninstall
Codex P2s on #1441, both test-first:
- CreateInvite read the invite back on the request context, so a cancel
after the insert committed returned an error and skipped invite_create.
The read-back and audit now run on context.WithoutCancel, like the
password-change tail. TestCreateInvite_AuditSurvivesCanceledLookup.
- Registry.UninstallPlugin is idempotent on an unknown id, so the handler
wrote plugin_uninstall for plugins that never existed. It now checks the
row first: 404 and no audit. TestPluginsHandlerUninstallUnknownID.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu
* docs(b2-6): record the Codex review outcome on #1441
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
102 lines
3.2 KiB
Go
102 lines
3.2 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/db/audittest"
|
|
)
|
|
|
|
// TestAuditCoverage_PluginLifecycle is the plugin half of the B2-6 audit
|
|
// table: install and uninstall each emit an audit entry, and neither detail
|
|
// carries anything from the archive beyond the plugin name.
|
|
func TestAuditCoverage_PluginLifecycle(t *testing.T) {
|
|
install := func(t *testing.T) (http.Handler, *db.DB, int64) {
|
|
t.Helper()
|
|
reg, mem := newTestPluginRegistryWithStore(t)
|
|
h := NewPluginAdminHandler(reg, mem, mem)
|
|
body, contentType := buildZipUpload(t, validPluginZip(t))
|
|
req := httptest.NewRequest("POST", "/install", body)
|
|
req.Header.Set("Content-Type", contentType)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("install: status = %d; body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
row, err := mem.GetPluginByName(context.Background(), "hello")
|
|
if err != nil || row == nil {
|
|
t.Fatalf("GetPluginByName: %v", err)
|
|
}
|
|
return h, mem, row.ID
|
|
}
|
|
|
|
rows := []struct {
|
|
name string
|
|
action string
|
|
run func(t *testing.T) *audittest.Recorder
|
|
}{
|
|
{"plugin install", "plugin_install", func(t *testing.T) *audittest.Recorder {
|
|
reg, mem := newTestPluginRegistryWithStore(t)
|
|
h := NewPluginAdminHandler(reg, mem, mem)
|
|
rec := audittest.Install(t, mem)
|
|
body, contentType := buildZipUpload(t, validPluginZip(t))
|
|
req := httptest.NewRequest("POST", "/install", body)
|
|
req.Header.Set("Content-Type", contentType)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("install: status = %d; body = %s", w.Code, w.Body.String())
|
|
}
|
|
return rec
|
|
}},
|
|
{"plugin uninstall", "plugin_uninstall", func(t *testing.T) *audittest.Recorder {
|
|
h, mem, id := install(t)
|
|
rec := audittest.Install(t, mem)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest("DELETE", "/"+strconv.FormatInt(id, 10), nil))
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("uninstall: status = %d; body = %s", w.Code, w.Body.String())
|
|
}
|
|
return rec
|
|
}},
|
|
}
|
|
|
|
var corpus []db.AuditEntry
|
|
for _, row := range rows {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
rec := row.run(t)
|
|
rec.Wait(t, row.action)
|
|
corpus = append(corpus, rec.Entries()...)
|
|
})
|
|
}
|
|
t.Run("detail denylist", func(t *testing.T) {
|
|
if len(corpus) == 0 {
|
|
t.Fatal("no audit entries recorded")
|
|
}
|
|
audittest.AssertSafeDetails(t, corpus)
|
|
})
|
|
}
|
|
|
|
// TestPluginsHandlerUninstallUnknownID pins Codex's P2 on #1441: the registry
|
|
// treats an unknown id as an idempotent no-op, so the handler must answer 404
|
|
// and write no plugin_uninstall row for a plugin that never existed.
|
|
func TestPluginsHandlerUninstallUnknownID(t *testing.T) {
|
|
reg, mem := newTestPluginRegistryWithStore(t)
|
|
h := NewPluginAdminHandler(reg, mem, mem)
|
|
rec := audittest.Install(t, mem)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest("DELETE", "/999", nil))
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
if got := rec.Entries(); len(got) != 0 {
|
|
t.Fatalf("unknown plugin must not audit; got %v", got)
|
|
}
|
|
}
|