This commit is contained in:
2026-07-26 10:33:50 -05:00
parent 60a739a1aa
commit 0ed0fb5817
8 changed files with 35 additions and 63 deletions
+3 -1
View File
@@ -77,6 +77,9 @@ func TestAuditQueryUsesPlaceholders(t *testing.T) {
if strings.Contains(query, "INTERVAL") { if strings.Contains(query, "INTERVAL") {
t.Fatal("all-time query must not include a time restriction") t.Fatal("all-time query must not include a time restriction")
} }
if strings.Contains(query, "lan_ip") {
t.Fatal("audit query must use the single source_ip column")
}
if !strings.HasSuffix(query, "ORDER BY ae.created_at DESC LIMIT ?") { if !strings.HasSuffix(query, "ORDER BY ae.created_at DESC LIMIT ?") {
t.Fatalf("query has unexpected limit: %s", query) t.Fatalf("query has unexpected limit: %s", query)
} }
@@ -88,7 +91,6 @@ func TestAuditQueryUsesPlaceholders(t *testing.T) {
"sentinelone-linux", "sentinelone-linux",
"install-rmm", "install-rmm",
"10.10.1.25", "10.10.1.25",
"10.10.1.25",
250, 250,
} }
if !reflect.DeepEqual(arguments, expectedArguments) { if !reflect.DeepEqual(arguments, expectedArguments) {
+6 -24
View File
@@ -207,7 +207,7 @@ func (s *Server) listAuditEventTypes(r *http.Request) ([]string, error) {
func auditQuery(filters auditFilters, limit int) (string, []any) { func auditQuery(filters auditFilters, limit int) (string, []any) {
query := `SELECT ae.event_type, COALESCE(a.customer_label, ''), ae.actor, query := `SELECT ae.event_type, COALESCE(a.customer_label, ''), ae.actor,
ae.hostname, ae.package_slug, ae.source_ip, ae.lan_ip, ae.hostname, ae.package_slug, ae.source_ip,
ae.details, ae.created_at ae.details, ae.created_at
FROM audit_events ae FROM audit_events ae
LEFT JOIN authorizations a ON a.id = ae.authorization_id LEFT JOIN authorizations a ON a.id = ae.authorization_id
@@ -244,8 +244,8 @@ func auditQuery(filters auditFilters, limit int) (string, []any) {
arguments = append(arguments, filter.value) arguments = append(arguments, filter.value)
} }
if filters.SourceIP != "" { if filters.SourceIP != "" {
query += " AND (LOCATE(?, ae.source_ip) > 0 OR LOCATE(?, ae.lan_ip) > 0)" query += " AND LOCATE(?, ae.source_ip) > 0"
arguments = append(arguments, filters.SourceIP, filters.SourceIP) arguments = append(arguments, filters.SourceIP)
} }
query += " ORDER BY ae.created_at DESC LIMIT ?" query += " ORDER BY ae.created_at DESC LIMIT ?"
arguments = append(arguments, limit) arguments = append(arguments, limit)
@@ -274,7 +274,6 @@ func (s *Server) listAuditEvents(r *http.Request, filters auditFilters, limit in
&record.Hostname, &record.Hostname,
&record.PackageSlug, &record.PackageSlug,
&record.SourceIP, &record.SourceIP,
&record.LANIP,
&record.Details, &record.Details,
&record.CreatedAt, &record.CreatedAt,
); err != nil { ); err != nil {
@@ -623,23 +622,6 @@ func (s *Server) audit(
packageSlug string, packageSlug string,
sourceIP string, sourceIP string,
details string, details string,
) error {
return s.auditWithNetwork(
ctx, eventType, user, authorizationID, hostname, packageSlug,
sourceIP, "", details,
)
}
func (s *Server) auditWithNetwork(
ctx context.Context,
eventType string,
user string,
authorizationID *uint64,
hostname string,
packageSlug string,
sourceIP string,
lanIP string,
details string,
) error { ) error {
var authID any var authID any
if authorizationID != nil { if authorizationID != nil {
@@ -649,9 +631,9 @@ func (s *Server) auditWithNetwork(
ctx, ctx,
`INSERT INTO audit_events `INSERT INTO audit_events
(event_type, actor, authorization_id, hostname, package_slug, (event_type, actor, authorization_id, hostname, package_slug,
source_ip, lan_ip, details) source_ip, details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
eventType, user, authID, hostname, packageSlug, sourceIP, lanIP, details, eventType, user, authID, hostname, packageSlug, sourceIP, details,
) )
return err return err
} }
+16 -31
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -17,7 +16,6 @@ type exchangeRequest struct {
Code string `json:"code"` Code string `json:"code"`
HostFingerprint string `json:"host_fingerprint"` HostFingerprint string `json:"host_fingerprint"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
LANIP string `json:"lan_ip,omitempty"`
RequestedAction string `json:"requested_action,omitempty"` RequestedAction string `json:"requested_action,omitempty"`
RequestedPackage string `json:"requested_package,omitempty"` RequestedPackage string `json:"requested_package,omitempty"`
} }
@@ -59,17 +57,8 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
request.Code = normalizeCode(request.Code) request.Code = normalizeCode(request.Code)
request.HostFingerprint = strings.TrimSpace(request.HostFingerprint) request.HostFingerprint = strings.TrimSpace(request.HostFingerprint)
request.Hostname = strings.TrimSpace(request.Hostname) request.Hostname = strings.TrimSpace(request.Hostname)
request.LANIP = strings.TrimSpace(request.LANIP)
request.RequestedAction = strings.TrimSpace(request.RequestedAction) request.RequestedAction = strings.TrimSpace(request.RequestedAction)
request.RequestedPackage = strings.TrimSpace(request.RequestedPackage) request.RequestedPackage = strings.TrimSpace(request.RequestedPackage)
if request.LANIP != "" {
parsedLANIP := net.ParseIP(request.LANIP)
if parsedLANIP == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "lan_ip must be a valid IP address"})
return
}
request.LANIP = parsedLANIP.String()
}
if request.Code == "" || len(request.HostFingerprint) < 16 || if request.Code == "" || len(request.HostFingerprint) < 16 ||
request.Hostname == "" || len(request.Hostname) > 255 || request.Hostname == "" || len(request.Hostname) > 255 ||
(request.RequestedAction != "" && !validSlug(request.RequestedAction)) || (request.RequestedAction != "" && !validSlug(request.RequestedAction)) ||
@@ -86,12 +75,12 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
status = http.StatusForbidden status = http.StatusForbidden
message = "authorization is invalid, expired, revoked, or at its host limit" message = "authorization is invalid, expired, revoked, or at its host limit"
} }
_ = s.auditWithNetwork(r.Context(), "code_exchange_failed", "", nil, _ = s.audit(r.Context(), "code_exchange_failed", "", nil,
request.Hostname, "", sourceIP, request.LANIP, err.Error()) request.Hostname, "", sourceIP, err.Error())
writeJSON(w, status, map[string]string{"error": message}) writeJSON(w, status, map[string]string{"error": message})
return return
} }
_ = s.auditWithNetwork( _ = s.audit(
r.Context(), r.Context(),
"code_exchanged", "code_exchanged",
"", "",
@@ -99,7 +88,6 @@ func (s *Server) handleExchange(w http.ResponseWriter, r *http.Request) {
request.Hostname, request.Hostname,
request.RequestedPackage, request.RequestedPackage,
sourceIP, sourceIP,
request.LANIP,
"requested_action="+request.RequestedAction, "requested_action="+request.RequestedAction,
) )
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
@@ -212,9 +200,9 @@ func (s *Server) exchangeDeploymentCode(
result, err := tx.ExecContext( result, err := tx.ExecContext(
r.Context(), r.Context(),
`INSERT INTO authorization_hosts `INSERT INTO authorization_hosts
(authorization_id, host_fingerprint, hostname, lan_ip) (authorization_id, host_fingerprint, hostname)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?)`,
authorizationID, fingerprintHash[:], request.Hostname, request.LANIP, authorizationID, fingerprintHash[:], request.Hostname,
) )
if err != nil { if err != nil {
return response, 0, err return response, 0, err
@@ -228,10 +216,9 @@ func (s *Server) exchangeDeploymentCode(
r.Context(), r.Context(),
`UPDATE authorization_hosts `UPDATE authorization_hosts
SET hostname = ?, SET hostname = ?,
lan_ip = COALESCE(NULLIF(?, ''), lan_ip),
last_seen_at = UTC_TIMESTAMP(6) last_seen_at = UTC_TIMESTAMP(6)
WHERE id = ?`, WHERE id = ?`,
request.Hostname, request.LANIP, hostID, request.Hostname, hostID,
) )
if err != nil { if err != nil {
return response, 0, err return response, 0, err
@@ -343,12 +330,11 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
var packageInfo packageRecord var packageInfo packageRecord
var authorizationID uint64 var authorizationID uint64
var hostname string var hostname string
var lanIP string
err := s.db.QueryRowContext( err := s.db.QueryRowContext(
r.Context(), r.Context(),
`SELECT p.id, p.slug, p.display_name, p.package_name, `SELECT p.id, p.slug, p.display_name, p.package_name,
p.package_version, p.file_name, p.sha256, p.enabled, p.package_version, p.file_name, p.sha256, p.enabled,
ds.authorization_id, ah.hostname, ah.lan_ip 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
JOIN authorization_hosts ah ON ah.id = ds.authorization_host_id JOIN authorization_hosts ah ON ah.id = ds.authorization_host_id
@@ -373,7 +359,6 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
&packageInfo.Enabled, &packageInfo.Enabled,
&authorizationID, &authorizationID,
&hostname, &hostname,
&lanIP,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "package is not authorized"}) writeJSON(w, http.StatusForbidden, map[string]string{"error": "package is not authorized"})
@@ -402,15 +387,15 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
upstreamRequest.SetBasicAuth(s.cfg.GiteaPackageUser, s.cfg.GiteaPackageToken) upstreamRequest.SetBasicAuth(s.cfg.GiteaPackageUser, s.cfg.GiteaPackageToken)
upstreamResponse, err := s.packageClient.Do(upstreamRequest) upstreamResponse, err := s.packageClient.Do(upstreamRequest)
if err != nil { if err != nil {
_ = s.auditWithNetwork(r.Context(), "package_download_failed", "", _ = s.audit(r.Context(), "package_download_failed", "",
&authorizationID, hostname, slug, s.clientIP(r), lanIP, err.Error()) &authorizationID, hostname, slug, s.clientIP(r), err.Error())
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry is unavailable"}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry is unavailable"})
return return
} }
defer upstreamResponse.Body.Close() defer upstreamResponse.Body.Close()
if upstreamResponse.StatusCode != http.StatusOK { if upstreamResponse.StatusCode != http.StatusOK {
_ = s.auditWithNetwork(r.Context(), "package_download_failed", "", _ = s.audit(r.Context(), "package_download_failed", "",
&authorizationID, hostname, slug, s.clientIP(r), lanIP, upstreamResponse.Status) &authorizationID, hostname, slug, s.clientIP(r), upstreamResponse.Status)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry rejected the request"}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "package registry rejected the request"})
return return
} }
@@ -421,10 +406,10 @@ func (s *Server) handlePackageDownload(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, upstreamResponse.Body); err != nil { if _, err := io.Copy(w, upstreamResponse.Body); err != nil {
_ = s.auditWithNetwork(r.Context(), "package_download_failed", "", _ = s.audit(r.Context(), "package_download_failed", "",
&authorizationID, hostname, slug, s.clientIP(r), lanIP, err.Error()) &authorizationID, hostname, slug, s.clientIP(r), err.Error())
return return
} }
_ = s.auditWithNetwork(r.Context(), "package_downloaded", "", _ = s.audit(r.Context(), "package_downloaded", "",
&authorizationID, hostname, slug, s.clientIP(r), lanIP, "") &authorizationID, hostname, slug, s.clientIP(r), "")
} }
-1
View File
@@ -75,7 +75,6 @@ type auditRecord struct {
Hostname string Hostname string
PackageSlug string PackageSlug string
SourceIP string SourceIP string
LANIP string
Details string Details string
CreatedAt time.Time CreatedAt time.Time
} }
-1
View File
@@ -73,7 +73,6 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) {
CustomerLabel: "Acme cluster refresh", CustomerLabel: "Acme cluster refresh",
Hostname: "pve01", Hostname: "pve01",
SourceIP: "203.0.113.10", SourceIP: "203.0.113.10",
LANIP: "10.10.1.25",
CreatedAt: time.Now(), CreatedAt: time.Now(),
}} }}
if err := templates.ExecuteTemplate(io.Discard, "audit.html", data); err != nil { if err := templates.ExecuteTemplate(io.Discard, "audit.html", data); err != nil {
+1 -1
View File
@@ -42,7 +42,7 @@
<label>User <input name="audit_user" value="{{.AuditFilters.User}}" placeholder="taiadmin"></label> <label>User <input name="audit_user" value="{{.AuditFilters.User}}" placeholder="taiadmin"></label>
<label>Hostname <input name="audit_hostname" value="{{.AuditFilters.Hostname}}" placeholder="pve01"></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>Package ID <input name="audit_package" value="{{.AuditFilters.PackageSlug}}" placeholder="sentinelone-linux"></label>
<label>LAN or WAN IP <input name="audit_ip" value="{{.AuditFilters.SourceIP}}" placeholder="10.10.1.25"></label> <label>IP address <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> <label>Details contain <input name="audit_details" value="{{.AuditFilters.Details}}" placeholder="customer or action"></label>
<div class="audit-filter-actions"> <div class="audit-filter-actions">
<button class="button secondary" type="submit">Apply filters</button> <button class="button secondary" type="submit">Apply filters</button>
+2 -4
View File
@@ -29,7 +29,7 @@
<span>Event</span> <span>Event</span>
<span>Customer / deployment</span> <span>Customer / deployment</span>
<span>User / host / package</span> <span>User / host / package</span>
<span>WAN / LAN / details</span> <span>IP / Details</span>
</div> </div>
{{range .AuditEvents}} {{range .AuditEvents}}
<div class="audit-row"> <div class="audit-row">
@@ -46,9 +46,7 @@
{{if .PackageSlug}}<span>{{.PackageSlug}}</span>{{end}} {{if .PackageSlug}}<span>{{.PackageSlug}}</span>{{end}}
</div> </div>
<div> <div>
{{if .SourceIP}}<span>WAN: {{.SourceIP}}</span>{{end}} {{if .SourceIP}}<span>{{.SourceIP}}</span>{{else}}<span></span>{{end}}
{{if .LANIP}}<span>LAN: {{.LANIP}}</span>{{end}}
{{if and (not .SourceIP) (not .LANIP)}}<span></span>{{end}}
{{if .Details}}<small>{{.Details}}</small>{{end}} {{if .Details}}<small>{{.Details}}</small>{{end}}
</div> </div>
</div> </div>
+7
View File
@@ -0,0 +1,7 @@
ALTER TABLE authorization_hosts
DROP COLUMN IF EXISTS lan_ip;
ALTER TABLE audit_events
DROP COLUMN IF EXISTS lan_ip;
INSERT IGNORE INTO schema_migrations (version) VALUES (4);