update
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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((
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user