This commit is contained in:
David Schroeder
2026-07-25 15:14:23 -05:00
parent e19b23de81
commit 6594aece09
8 changed files with 248 additions and 18 deletions
+72
View File
@@ -0,0 +1,72 @@
package app
import (
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestAuditFiltersFromRequest(t *testing.T) {
t.Parallel()
request := httptest.NewRequest(
"GET",
"/portal?audit_range=7d&audit_event=package_downloaded&audit_actor=taiadmin&audit_hostname=pve01&audit_package=sentinelone-linux&audit_ip=10.10.1.25&audit_details=install-rmm",
nil,
)
filters := auditFiltersFromRequest(request)
expected := auditFilters{
TimeRange: "7d",
EventType: "package_downloaded",
Actor: "taiadmin",
Hostname: "pve01",
PackageSlug: "sentinelone-linux",
SourceIP: "10.10.1.25",
Details: "install-rmm",
}
if !reflect.DeepEqual(filters, expected) {
t.Fatalf("filters = %#v, want %#v", filters, expected)
}
}
func TestAuditFiltersDefaultToThirtyDays(t *testing.T) {
t.Parallel()
request := httptest.NewRequest("GET", "/portal?audit_range=invalid", nil)
if filters := auditFiltersFromRequest(request); filters.TimeRange != "30d" {
t.Fatalf("time range = %q, want 30d", filters.TimeRange)
}
}
func TestAuditQueryUsesPlaceholders(t *testing.T) {
t.Parallel()
filters := auditFilters{
TimeRange: "all",
EventType: "package_downloaded",
Actor: "taiadmin",
Hostname: "pve01",
PackageSlug: "sentinelone-linux",
SourceIP: "10.10.1.25",
Details: "install-rmm",
}
query, arguments := auditQuery(filters)
if strings.Contains(query, filters.Actor) || strings.Contains(query, filters.Hostname) {
t.Fatal("filter values must not be interpolated into the SQL query")
}
if strings.Contains(query, "INTERVAL") {
t.Fatal("all-time query must not include a time restriction")
}
if !strings.HasSuffix(query, "ORDER BY created_at DESC LIMIT 250") {
t.Fatalf("query has unexpected limit: %s", query)
}
expectedArguments := []any{
"package_downloaded",
"taiadmin",
"pve01",
"sentinelone-linux",
"10.10.1.25",
"install-rmm",
}
if !reflect.DeepEqual(arguments, expectedArguments) {
t.Fatalf("arguments = %#v, want %#v", arguments, expectedArguments)
}
}
+105 -7
View File
@@ -31,7 +31,13 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to list installer actions", http.StatusInternalServerError)
return
}
auditEvents, err := s.listAuditEvents(r)
auditFilter := auditFiltersFromRequest(r)
auditEventTypes, err := s.listAuditEventTypes(r)
if err != nil {
http.Error(w, "unable to list audit event types", http.StatusInternalServerError)
return
}
auditEvents, err := s.listAuditEvents(r, auditFilter)
if err != nil {
http.Error(w, "unable to list audit events", http.StatusInternalServerError)
return
@@ -42,6 +48,8 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
CSRFToken: tech.CSRFToken,
Authorizations: authorizations,
AuditEvents: auditEvents,
AuditFilters: auditFilter,
AuditEventTypes: auditEventTypes,
Packages: packages,
Actions: actions,
NewCode: r.URL.Query().Get("code"),
@@ -74,13 +82,102 @@ func (s *Server) listActions(r *http.Request) ([]actionRecord, error) {
return records, rows.Err()
}
func (s *Server) listAuditEvents(r *http.Request) ([]auditRecord, error) {
func auditFiltersFromRequest(r *http.Request) auditFilters {
query := r.URL.Query()
timeRange := strings.TrimSpace(query.Get("audit_range"))
switch timeRange {
case "24h", "7d", "30d", "90d", "all":
default:
timeRange = "30d"
}
return auditFilters{
TimeRange: timeRange,
EventType: limitedFilter(query.Get("audit_event"), 100),
Actor: limitedFilter(query.Get("audit_actor"), 255),
Hostname: limitedFilter(query.Get("audit_hostname"), 255),
PackageSlug: limitedFilter(query.Get("audit_package"), 100),
SourceIP: limitedFilter(query.Get("audit_ip"), 64),
Details: limitedFilter(query.Get("audit_details"), 255),
}
}
func limitedFilter(value string, maxLength int) string {
value = strings.TrimSpace(value)
characters := []rune(value)
if len(characters) > maxLength {
return string(characters[:maxLength])
}
return value
}
func (s *Server) listAuditEventTypes(r *http.Request) ([]string, error) {
rows, err := s.db.QueryContext(
r.Context(),
`SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at
`SELECT DISTINCT event_type
FROM audit_events
ORDER BY created_at DESC
LIMIT 100`,
ORDER BY event_type`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var eventTypes []string
for rows.Next() {
var eventType string
if err := rows.Scan(&eventType); err != nil {
return nil, err
}
eventTypes = append(eventTypes, eventType)
}
return eventTypes, rows.Err()
}
func auditQuery(filters auditFilters) (string, []any) {
query := `SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at
FROM audit_events
WHERE 1 = 1`
var arguments []any
timeClauses := map[string]string{
"24h": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 1 DAY",
"7d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 7 DAY",
"30d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 30 DAY",
"90d": " AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 90 DAY",
}
query += timeClauses[filters.TimeRange]
if filters.EventType != "" {
query += " AND event_type = ?"
arguments = append(arguments, filters.EventType)
}
containsFilters := []struct {
column string
value string
}{
{"actor", filters.Actor},
{"hostname", filters.Hostname},
{"package_slug", filters.PackageSlug},
{"source_ip", filters.SourceIP},
{"details", filters.Details},
}
for _, filter := range containsFilters {
if filter.value == "" {
continue
}
query += " AND LOCATE(?, " + filter.column + ") > 0"
arguments = append(arguments, filter.value)
}
query += " ORDER BY created_at DESC LIMIT 250"
return query, arguments
}
func (s *Server) listAuditEvents(r *http.Request, filters auditFilters) ([]auditRecord, error) {
query, arguments := auditQuery(filters)
rows, err := s.db.QueryContext(
r.Context(),
query,
arguments...,
)
if err != nil {
return nil, err
@@ -147,9 +244,10 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
WHERE h.authorization_id = a.id),
a.created_at, a.expires_at, a.revoked_at,
COALESCE((
SELECT GROUP_CONCAT(ap.display_name
ORDER BY ap.display_name SEPARATOR ', ')
SELECT GROUP_CONCAT(p.display_name
ORDER BY p.display_name SEPARATOR ', ')
FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = a.id
), ''),
COALESCE((
+5 -5
View File
@@ -166,7 +166,7 @@ func (s *Server) exchangeDeploymentCode(
FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = ?
AND ap.package_slug = ?
AND p.slug = ?
AND p.enabled = TRUE`,
authorizationID, request.RequestedPackage,
).Scan(&authorized); err != nil {
@@ -241,7 +241,7 @@ func (s *Server) exchangeDeploymentCode(
rows, err := tx.QueryContext(
r.Context(),
`SELECT ap.package_slug, ap.display_name, ap.package_version, ap.sha256
`SELECT p.slug, p.display_name, p.package_version, p.sha256
FROM authorization_packages ap
JOIN packages p ON p.id = ap.package_id
WHERE ap.authorization_id = ? AND p.enabled = TRUE
@@ -330,8 +330,8 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
var hostname string
err := s.db.QueryRowContext(
r.Context(),
`SELECT p.id, ap.package_slug, ap.display_name, ap.package_name,
ap.package_version, ap.file_name, ap.sha256, p.enabled,
`SELECT p.id, p.slug, p.display_name, p.package_name,
p.package_version, p.file_name, p.sha256, p.enabled,
ds.authorization_id, ah.hostname
FROM download_sessions ds
JOIN authorizations a ON a.id = ds.authorization_id
@@ -343,7 +343,7 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
AND ds.expires_at > UTC_TIMESTAMP(6)
AND a.revoked_at IS NULL
AND a.expires_at > UTC_TIMESTAMP(6)
AND ap.package_slug = ?
AND p.slug = ?
AND p.enabled = TRUE`,
sessionHash[:], slug,
).Scan(
+12
View File
@@ -78,12 +78,24 @@ type auditRecord struct {
CreatedAt time.Time
}
type auditFilters struct {
TimeRange string
EventType string
Actor string
Hostname string
PackageSlug string
SourceIP string
Details string
}
type pageData struct {
Title string
Technician *technician
CSRFToken string
Authorizations []authorizationRecord
AuditEvents []auditRecord
AuditFilters auditFilters
AuditEventTypes []string
Packages []packageRecord
Actions []actionRecord
NewCode string
+11
View File
@@ -218,6 +218,15 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.manual-metadata[open] summary { margin-bottom: 22px; }
.audit-panel { margin-top: 24px; }
.audit-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
padding: 22px 28px;
border-bottom: 1px solid var(--line);
}
.audit-filter-actions { display: flex; align-items: center; gap: 18px; }
.audit-filter-note { grid-column: 1 / -1; }
.audit-table { display: grid; max-height: 520px; overflow: auto; }
.audit-row {
display: grid;
@@ -249,6 +258,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
@media (max-width: 900px) {
.hero, .content-grid, .package-layout { grid-template-columns: 1fr; }
.audit-filters { grid-template-columns: 1fr 1fr; }
.audit-row { grid-template-columns: 1fr 1fr; }
.audit-row > div:last-child { grid-column: 1 / -1; }
.policy-card { width: 100%; }
@@ -260,5 +270,6 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.product-name, .operator > span { display: none; }
.page { width: 94vw; padding-top: 34px; }
.field-row, dl { grid-template-columns: 1fr; }
.audit-filters { grid-template-columns: 1fr; }
.code-reveal { align-items: stretch; flex-direction: column; }
}
+32 -3
View File
@@ -196,7 +196,7 @@
<label>New version <input name="package_version" placeholder="26.2.0.10" required></label>
<label class="toggle"><input type="checkbox" name="enabled" value="1" checked> Enable after upload</label>
<label>Replacement installer <input name="package_file" type="file" required></label>
<p class="form-help">The package ID stays the same. After the replacement is registered, its prior Gitea version is removed. Codes created for the prior version must be revoked and reissued.</p>
<p class="form-help">The package ID stays the same. Existing authorizations automatically use the replacement, and its prior Gitea version is removed.</p>
<button class="button primary" type="submit">Replace current package</button>
<p class="upload-status" data-upload-status aria-live="polite"></p>
</form>
@@ -236,9 +236,38 @@
<div class="panel-heading">
<div>
<p class="eyebrow">Audit trail</p>
<h2>Latest security events</h2>
<h2>Security events</h2>
</div>
</div>
<form action="/portal" method="get" class="audit-filters">
<label>Time range
<select name="audit_range">
<option value="24h" {{if eq .AuditFilters.TimeRange "24h"}}selected{{end}}>Past 24 hours</option>
<option value="7d" {{if eq .AuditFilters.TimeRange "7d"}}selected{{end}}>Past 7 days</option>
<option value="30d" {{if eq .AuditFilters.TimeRange "30d"}}selected{{end}}>Past 30 days</option>
<option value="90d" {{if eq .AuditFilters.TimeRange "90d"}}selected{{end}}>Past 90 days</option>
<option value="all" {{if eq .AuditFilters.TimeRange "all"}}selected{{end}}>All retained events</option>
</select>
</label>
<label>Event type
<select name="audit_event">
<option value="">All event types</option>
{{range .AuditEventTypes}}
<option value="{{.}}" {{if eq $.AuditFilters.EventType .}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</label>
<label>Technician / actor <input name="audit_actor" value="{{.AuditFilters.Actor}}" placeholder="taiadmin"></label>
<label>Hostname <input name="audit_hostname" value="{{.AuditFilters.Hostname}}" placeholder="pve01"></label>
<label>Package ID <input name="audit_package" value="{{.AuditFilters.PackageSlug}}" placeholder="sentinelone-linux"></label>
<label>Source IP <input name="audit_ip" value="{{.AuditFilters.SourceIP}}" placeholder="10.10.1.25"></label>
<label>Details contain <input name="audit_details" value="{{.AuditFilters.Details}}" placeholder="customer or action"></label>
<div class="audit-filter-actions">
<button class="button secondary" type="submit">Apply filters</button>
<a class="text-button" href="/portal?audit_range=30d">Clear filters</a>
</div>
<p class="form-help audit-filter-note">Audit records are retained indefinitely. Up to the newest 250 matching events are shown.</p>
</form>
<div class="audit-table">
{{range .AuditEvents}}
<div class="audit-row">
@@ -257,7 +286,7 @@
</div>
</div>
{{else}}
<p class="empty audit-empty">No security events have been recorded.</p>
<p class="empty audit-empty">No security events match these filters.</p>
{{end}}
</div>
</section>