update
This commit is contained in:
@@ -43,6 +43,10 @@ Successful response:
|
|||||||
The first exchange enrolls that host against the authorization. Reusing the
|
The first exchange enrolls that host against the authorization. Reusing the
|
||||||
same code and host fingerprint does not consume another host slot. A different
|
same code and host fingerprint does not consume another host slot. A different
|
||||||
fingerprint consumes one slot until the configured limit is reached.
|
fingerprint consumes one slot until the configured limit is reached.
|
||||||
|
Package authorizations grant access by stable package slug. The version,
|
||||||
|
filename, and checksum in a response are resolved from the current package
|
||||||
|
catalog when the code is exchanged; replacing a package does not require a new
|
||||||
|
authorization code.
|
||||||
When `requested_action` or `requested_package` is present, the broker validates
|
When `requested_action` or `requested_package` is present, the broker validates
|
||||||
that entitlement before enrolling the host. Older clients may omit both fields,
|
that entitlement before enrolling the host. Older clients may omit both fields,
|
||||||
but current clients should always identify what they intend to use.
|
but current clients should always identify what they intend to use.
|
||||||
|
|||||||
+7
-3
@@ -134,9 +134,10 @@ replacement directly to Gitea, calculates SHA-256, updates the catalog, removes
|
|||||||
the prior Gitea version, and writes audit events without buffering the installer
|
the prior Gitea version, and writes audit events without buffering the installer
|
||||||
on local disk.
|
on local disk.
|
||||||
|
|
||||||
An authorization records the exact version and checksum selected when its code
|
An authorization grants access to the stable package ID, not a particular
|
||||||
is created. Revoke and reissue any active code that included a package after
|
version. Existing active codes and download sessions resolve the package's
|
||||||
replacing that package, because the superseded registry version is removed.
|
current registry version and checksum, so they automatically use a replacement
|
||||||
|
after it is registered.
|
||||||
|
|
||||||
The load balancer or Nginx must allow request bodies up to
|
The load balancer or Nginx must allow request bodies up to
|
||||||
`TAPM_MAX_UPLOAD_BYTES` and use a sufficiently long request timeout. The
|
`TAPM_MAX_UPLOAD_BYTES` and use a sufficiently long request timeout. The
|
||||||
@@ -163,6 +164,9 @@ technician's Gitea credentials for package operations.
|
|||||||
## 9. Backup and rotation
|
## 9. Backup and rotation
|
||||||
|
|
||||||
- Include `tapm_broker` in the existing Galera backup policy.
|
- Include `tapm_broker` in the existing Galera backup policy.
|
||||||
|
- Audit records are retained indefinitely unless an administrator establishes a
|
||||||
|
database retention policy. The portal can filter the retained history and
|
||||||
|
displays up to the newest 250 matching events.
|
||||||
- Keep `.env` outside Git and readable only by the service administrator.
|
- Keep `.env` outside Git and readable only by the service administrator.
|
||||||
- Rotate both Gitea package tokens and the OAuth secret if either webserver is
|
- Rotate both Gitea package tokens and the OAuth secret if either webserver is
|
||||||
compromised.
|
compromised.
|
||||||
|
|||||||
@@ -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)
|
http.Error(w, "unable to list installer actions", http.StatusInternalServerError)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "unable to list audit events", http.StatusInternalServerError)
|
http.Error(w, "unable to list audit events", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -42,6 +48,8 @@ func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) {
|
|||||||
CSRFToken: tech.CSRFToken,
|
CSRFToken: tech.CSRFToken,
|
||||||
Authorizations: authorizations,
|
Authorizations: authorizations,
|
||||||
AuditEvents: auditEvents,
|
AuditEvents: auditEvents,
|
||||||
|
AuditFilters: auditFilter,
|
||||||
|
AuditEventTypes: auditEventTypes,
|
||||||
Packages: packages,
|
Packages: packages,
|
||||||
Actions: actions,
|
Actions: actions,
|
||||||
NewCode: r.URL.Query().Get("code"),
|
NewCode: r.URL.Query().Get("code"),
|
||||||
@@ -74,13 +82,102 @@ func (s *Server) listActions(r *http.Request) ([]actionRecord, error) {
|
|||||||
return records, rows.Err()
|
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(
|
rows, err := s.db.QueryContext(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`SELECT event_type, actor, hostname, package_slug, source_ip, details, created_at
|
`SELECT DISTINCT event_type
|
||||||
FROM audit_events
|
FROM audit_events
|
||||||
ORDER BY created_at DESC
|
ORDER BY event_type`,
|
||||||
LIMIT 100`,
|
)
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -147,9 +244,10 @@ func (s *Server) listAuthorizations(r *http.Request) ([]authorizationRecord, err
|
|||||||
WHERE h.authorization_id = a.id),
|
WHERE h.authorization_id = a.id),
|
||||||
a.created_at, a.expires_at, a.revoked_at,
|
a.created_at, a.expires_at, a.revoked_at,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT GROUP_CONCAT(ap.display_name
|
SELECT GROUP_CONCAT(p.display_name
|
||||||
ORDER BY ap.display_name SEPARATOR ', ')
|
ORDER BY p.display_name SEPARATOR ', ')
|
||||||
FROM authorization_packages ap
|
FROM authorization_packages ap
|
||||||
|
JOIN packages p ON p.id = ap.package_id
|
||||||
WHERE ap.authorization_id = a.id
|
WHERE ap.authorization_id = a.id
|
||||||
), ''),
|
), ''),
|
||||||
COALESCE((
|
COALESCE((
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ func (s *Server) exchangeDeploymentCode(
|
|||||||
FROM authorization_packages ap
|
FROM authorization_packages ap
|
||||||
JOIN packages p ON p.id = ap.package_id
|
JOIN packages p ON p.id = ap.package_id
|
||||||
WHERE ap.authorization_id = ?
|
WHERE ap.authorization_id = ?
|
||||||
AND ap.package_slug = ?
|
AND p.slug = ?
|
||||||
AND p.enabled = TRUE`,
|
AND p.enabled = TRUE`,
|
||||||
authorizationID, request.RequestedPackage,
|
authorizationID, request.RequestedPackage,
|
||||||
).Scan(&authorized); err != nil {
|
).Scan(&authorized); err != nil {
|
||||||
@@ -241,7 +241,7 @@ func (s *Server) exchangeDeploymentCode(
|
|||||||
|
|
||||||
rows, err := tx.QueryContext(
|
rows, err := tx.QueryContext(
|
||||||
r.Context(),
|
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
|
FROM authorization_packages ap
|
||||||
JOIN packages p ON p.id = ap.package_id
|
JOIN packages p ON p.id = ap.package_id
|
||||||
WHERE ap.authorization_id = ? AND p.enabled = TRUE
|
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
|
var hostname string
|
||||||
err := s.db.QueryRowContext(
|
err := s.db.QueryRowContext(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
`SELECT p.id, ap.package_slug, ap.display_name, ap.package_name,
|
`SELECT p.id, p.slug, p.display_name, p.package_name,
|
||||||
ap.package_version, ap.file_name, ap.sha256, p.enabled,
|
p.package_version, p.file_name, p.sha256, p.enabled,
|
||||||
ds.authorization_id, ah.hostname
|
ds.authorization_id, ah.hostname
|
||||||
FROM download_sessions ds
|
FROM download_sessions ds
|
||||||
JOIN authorizations a ON a.id = ds.authorization_id
|
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 ds.expires_at > UTC_TIMESTAMP(6)
|
||||||
AND a.revoked_at IS NULL
|
AND a.revoked_at IS NULL
|
||||||
AND a.expires_at > UTC_TIMESTAMP(6)
|
AND a.expires_at > UTC_TIMESTAMP(6)
|
||||||
AND ap.package_slug = ?
|
AND p.slug = ?
|
||||||
AND p.enabled = TRUE`,
|
AND p.enabled = TRUE`,
|
||||||
sessionHash[:], slug,
|
sessionHash[:], slug,
|
||||||
).Scan(
|
).Scan(
|
||||||
|
|||||||
@@ -78,12 +78,24 @@ type auditRecord struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type auditFilters struct {
|
||||||
|
TimeRange string
|
||||||
|
EventType string
|
||||||
|
Actor string
|
||||||
|
Hostname string
|
||||||
|
PackageSlug string
|
||||||
|
SourceIP string
|
||||||
|
Details string
|
||||||
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
Title string
|
Title string
|
||||||
Technician *technician
|
Technician *technician
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
Authorizations []authorizationRecord
|
Authorizations []authorizationRecord
|
||||||
AuditEvents []auditRecord
|
AuditEvents []auditRecord
|
||||||
|
AuditFilters auditFilters
|
||||||
|
AuditEventTypes []string
|
||||||
Packages []packageRecord
|
Packages []packageRecord
|
||||||
Actions []actionRecord
|
Actions []actionRecord
|
||||||
NewCode string
|
NewCode string
|
||||||
|
|||||||
@@ -218,6 +218,15 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
|
|||||||
.manual-metadata[open] summary { margin-bottom: 22px; }
|
.manual-metadata[open] summary { margin-bottom: 22px; }
|
||||||
|
|
||||||
.audit-panel { margin-top: 24px; }
|
.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-table { display: grid; max-height: 520px; overflow: auto; }
|
||||||
.audit-row {
|
.audit-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -249,6 +258,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
|
|||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.hero, .content-grid, .package-layout { grid-template-columns: 1fr; }
|
.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 { grid-template-columns: 1fr 1fr; }
|
||||||
.audit-row > div:last-child { grid-column: 1 / -1; }
|
.audit-row > div:last-child { grid-column: 1 / -1; }
|
||||||
.policy-card { width: 100%; }
|
.policy-card { width: 100%; }
|
||||||
@@ -260,5 +270,6 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
|
|||||||
.product-name, .operator > span { display: none; }
|
.product-name, .operator > span { display: none; }
|
||||||
.page { width: 94vw; padding-top: 34px; }
|
.page { width: 94vw; padding-top: 34px; }
|
||||||
.field-row, dl { grid-template-columns: 1fr; }
|
.field-row, dl { grid-template-columns: 1fr; }
|
||||||
|
.audit-filters { grid-template-columns: 1fr; }
|
||||||
.code-reveal { align-items: stretch; flex-direction: column; }
|
.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>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 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>
|
<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>
|
<button class="button primary" type="submit">Replace current package</button>
|
||||||
<p class="upload-status" data-upload-status aria-live="polite"></p>
|
<p class="upload-status" data-upload-status aria-live="polite"></p>
|
||||||
</form>
|
</form>
|
||||||
@@ -236,9 +236,38 @@
|
|||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Audit trail</p>
|
<p class="eyebrow">Audit trail</p>
|
||||||
<h2>Latest security events</h2>
|
<h2>Security events</h2>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="audit-table">
|
||||||
{{range .AuditEvents}}
|
{{range .AuditEvents}}
|
||||||
<div class="audit-row">
|
<div class="audit-row">
|
||||||
@@ -257,7 +286,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user