diff --git a/internal/daemon/ui_manager.go b/internal/daemon/ui_manager.go index 19cdbb7b..45315898 100644 --- a/internal/daemon/ui_manager.go +++ b/internal/daemon/ui_manager.go @@ -165,6 +165,12 @@ func (m *UIManager) NotifyTlsTerminationFailed(ev any) { m.Client.NotifyTlsTerminationFailed(ev) } +// NotifyMinPackageAge sends an informational minimum-package-age log entry to +// the UI. This path intentionally does not trigger native popup notifications. +func (m *UIManager) NotifyMinPackageAge(ev any) { + m.Client.NotifyMinPackageAge(ev) +} + // NotifyPermissionsUpdated sends the latest permissions to the UI. func (m *UIManager) NotifyPermissionsUpdated(perms any) { m.Client.NotifyPermissionsUpdated(perms) diff --git a/internal/ingress/events.go b/internal/ingress/events.go index 1db4f85d..9a08316d 100644 --- a/internal/ingress/events.go +++ b/internal/ingress/events.go @@ -27,6 +27,19 @@ type TlsTerminationFailedEvent struct { Error string `json:"error"` } +// MinPackageAgeEvent represents a passive log entry emitted when the proxy +// suppresses versions that do not meet the minimum package age policy. +// Unlike BlockEvent, this is intended for the Logs tab only and should not +// trigger any native popup notification. +type MinPackageAgeEvent struct { + ID string `json:"id,omitempty"` + TsMs int64 `json:"ts_ms"` + Ecosystem string `json:"ecosystem,omitempty"` + Artifact Artifact `json:"artifact,omitempty"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` +} + type EcosystemExceptions struct { AllowedPackages []string `json:"allowed_packages"` RejectedPackages []string `json:"rejected_packages"` diff --git a/internal/ingress/min_package_age_handler.go b/internal/ingress/min_package_age_handler.go new file mode 100644 index 00000000..a46b2c14 --- /dev/null +++ b/internal/ingress/min_package_age_handler.go @@ -0,0 +1,54 @@ +package ingress + +import ( + "encoding/json" + "log" + "net/http" +) + +func (s *Server) handleMinPackageAge(w http.ResponseWriter, r *http.Request) { + var event MinPackageAgeEvent + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + log.Println("Got min-package-age event:", event) + + stored := s.minAgeStore.Add(event) + + // These events are informational only: they should show up in the Logs tab, + // but must not create a native popup notification. + go s.ui.NotifyMinPackageAge(stored) + + w.WriteHeader(http.StatusOK) +} + +func (s *Server) handleMinPackageAgeEvents(w http.ResponseWriter, r *http.Request) { + if !s.validateUIToken(w, r) { + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(s.minAgeStore.List()); err != nil { + log.Printf("failed to encode min-package-age events: %v", err) + } +} + +func (s *Server) handleGetMinPackageAgeEventByID(w http.ResponseWriter, r *http.Request) { + if !s.validateUIToken(w, r) { + return + } + id := r.PathValue("id") + if event, ok := s.minAgeStore.Get(id); ok { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(event); err != nil { + log.Printf("failed to encode min-package-age event %s: %v", id, err) + } + return + } + w.WriteHeader(http.StatusNotFound) + if _, err := w.Write([]byte("Event not found")); err != nil { + log.Printf("failed to write 404 response: %v", err) + } +} diff --git a/internal/ingress/min_package_age_store.go b/internal/ingress/min_package_age_store.go new file mode 100644 index 00000000..3c21b84f --- /dev/null +++ b/internal/ingress/min_package_age_store.go @@ -0,0 +1,67 @@ +package ingress + +import ( + "fmt" + "sync" +) + +type minPackageAgeEventStore struct { + mu sync.RWMutex + events []MinPackageAgeEvent +} + +const minPackageAgeEventID = "min-package-age-suppressed" + +func (e *minPackageAgeEventStore) Add(ev MinPackageAgeEvent) MinPackageAgeEvent { + e.mu.Lock() + defer e.mu.Unlock() + + ecosystem := ev.Ecosystem + if ecosystem == "" { + ecosystem = ev.Artifact.Product + } + if ecosystem == "" { + ecosystem = "unknown" + } + + stableID := fmt.Sprintf("min-package-age-suppressed-%s", ecosystem) + title := fmt.Sprintf("%s package versions suppressed", ecosystem) + + for i := range e.events { + if e.events[i].ID == stableID { + e.events[i].TsMs = ev.TsMs + return e.events[i] + } + } + + stored := MinPackageAgeEvent{ + ID: stableID, + TsMs: ev.TsMs, + Ecosystem: ecosystem, + Title: title, + Message: "One or more package versions were suppressed because they did not meet the minimum package age policy.", + } + + e.events = append(e.events, stored) + + return stored +} + +func (e *minPackageAgeEventStore) Get(id string) (MinPackageAgeEvent, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + for _, ev := range e.events { + if ev.ID == id { + return ev, true + } + } + return MinPackageAgeEvent{}, false +} + +func (e *minPackageAgeEventStore) List() []MinPackageAgeEvent { + e.mu.RLock() + defer e.mu.RUnlock() + out := make([]MinPackageAgeEvent, len(e.events)) + copy(out, e.events) + return out +} diff --git a/internal/ingress/min_package_age_store_test.go b/internal/ingress/min_package_age_store_test.go new file mode 100644 index 00000000..b0a44b82 --- /dev/null +++ b/internal/ingress/min_package_age_store_test.go @@ -0,0 +1,62 @@ +package ingress + +import "testing" + +func TestMinPackageAgeEventStoreAddAssignsIDAndCopiesVersions(t *testing.T) { + store := &minPackageAgeEventStore{} + + input := MinPackageAgeEvent{ + TsMs: 123, + Ecosystem: "vscode", + } + + stored := store.Add(input) + if stored.ID != "min-package-age-suppressed-vscode" { + t.Fatalf("expected stable id %q, got %q", "min-package-age-suppressed-vscode", stored.ID) + } + if stored.Title != "vscode package versions suppressed" || stored.Message == "" { + t.Fatalf("expected generic title and message to be populated") + } + if stored.Ecosystem != "vscode" { + t.Fatalf("expected ecosystem to be stored, got %q", stored.Ecosystem) + } + + got, ok := store.Get(stored.ID) + if !ok { + t.Fatalf("expected stored event to exist") + } + if got.Title != stored.Title || got.Message != stored.Message { + t.Fatalf("expected stored event to preserve generic copy") + } +} + +func TestMinPackageAgeEventStoreAddUpdatesExistingEntryInsteadOfDuplicating(t *testing.T) { + store := &minPackageAgeEventStore{} + + first := store.Add(MinPackageAgeEvent{TsMs: 123, Ecosystem: "vscode"}) + second := store.Add(MinPackageAgeEvent{TsMs: 456, Ecosystem: "vscode"}) + + if first.ID != second.ID { + t.Fatalf("expected stable aggregate id, got %q and %q", first.ID, second.ID) + } + if len(store.List()) != 1 { + t.Fatalf("expected a single aggregate event, got %d", len(store.List())) + } + if second.TsMs != 456 { + t.Fatalf("expected timestamp to be refreshed, got %d", second.TsMs) + } +} + +func TestMinPackageAgeEventStoreAddCreatesOneEntryPerEcosystem(t *testing.T) { + store := &minPackageAgeEventStore{} + + first := store.Add(MinPackageAgeEvent{TsMs: 123, Ecosystem: "vscode"}) + second := store.Add(MinPackageAgeEvent{TsMs: 456, Ecosystem: "npm"}) + + if first.ID == second.ID { + t.Fatalf("expected different ids per ecosystem, got %q", first.ID) + } + if len(store.List()) != 2 { + t.Fatalf("expected one aggregate event per ecosystem, got %d", len(store.List())) + } +} diff --git a/internal/ingress/server.go b/internal/ingress/server.go index 1915e754..6093b0b6 100644 --- a/internal/ingress/server.go +++ b/internal/ingress/server.go @@ -26,6 +26,7 @@ type UIProvider interface { NotifyBlocked(ev any) NotifyBlockedUpdated(ev any) NotifyTlsTerminationFailed(ev any) + NotifyMinPackageAge(ev any) NotifyPermissionsUpdated(ev any) StartSetupWizard(steps []string) } @@ -40,6 +41,7 @@ type Server struct { eventStore *eventStore tlsEventStore *tlsEventStore + minAgeStore *minPackageAgeEventStore chromeNames *chromeExtensionNameResolver mu sync.RWMutex } @@ -51,6 +53,7 @@ func New(cfg *config.ConfigInfo, ui UIProvider, proxy proxy.ProxyManager) *Serve proxy: proxy, eventStore: &eventStore{}, tlsEventStore: &tlsEventStore{}, + minAgeStore: &minPackageAgeEventStore{}, chromeNames: newChromeExtensionNameResolver(), } } @@ -65,6 +68,7 @@ func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("POST /events/blocks", s.handleBlock) mux.HandleFunc("POST /events/tls-termination-failed", s.handleTlsTerminationFailed) + mux.HandleFunc("POST /events/min-package-age", s.handleMinPackageAge) mux.HandleFunc("POST /events/permissions", s.handlePermissionsUpdated) mux.HandleFunc("GET /ping", s.handlePing) @@ -74,6 +78,8 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /v1/tls-events", s.handleTlsEvents) mux.HandleFunc("GET /v1/tls-events/{id}", s.handleGetTlsEventByID) + mux.HandleFunc("GET /v1/min-package-age-events", s.handleMinPackageAgeEvents) + mux.HandleFunc("GET /v1/min-package-age-events/{id}", s.handleGetMinPackageAgeEventByID) mux.HandleFunc("GET /v1/version", s.handleVersion) diff --git a/internal/uiclient/client.go b/internal/uiclient/client.go index 524f7e02..1ec5711a 100644 --- a/internal/uiclient/client.go +++ b/internal/uiclient/client.go @@ -164,6 +164,15 @@ func (c *Client) NotifyTlsTerminationFailed(ev any) { } } +// NotifyMinPackageAge sends an informational minimum-package-age log entry to +// the UI. This is intentionally logs-only and should not be surfaced as a +// native popup notification. +func (c *Client) NotifyMinPackageAge(ev any) { + if err := c.post("/v1/min-package-age", ev); err != nil { + log.Printf("Failed to notify UI of min-package-age event: %v (UI may not be running)", err) + } +} + // NotifyPermissionsUpdated sends the latest permissions to the UI. func (c *Client) NotifyPermissionsUpdated(perms any) { if err := c.post("/v1/permissions", perms); err != nil { diff --git a/ui/appserver/server.go b/ui/appserver/server.go index 3d17094f..8c134869 100644 --- a/ui/appserver/server.go +++ b/ui/appserver/server.go @@ -40,6 +40,7 @@ type Server struct { onBlocked func(ev daemon.BlockEvent) onBlockedUpdated func(ev daemon.BlockEvent) onTlsTerminationFailed func(ev daemon.TlsTerminationFailedEvent) + onMinPackageAge func(ev daemon.MinPackageAgeEvent) onPermissionsUpdated func(ev daemon.PermissionsResponse) onSetupWizard func(steps []string) } @@ -53,6 +54,7 @@ func (s *Server) SetHandlers( onBlocked func(daemon.BlockEvent), onBlockedUpdated func(daemon.BlockEvent), onTlsTerminationFailed func(daemon.TlsTerminationFailedEvent), + onMinPackageAge func(daemon.MinPackageAgeEvent), onPermissionsUpdated func(daemon.PermissionsResponse), onSetupWizard func(steps []string), ) { @@ -62,6 +64,7 @@ func (s *Server) SetHandlers( s.onBlocked = onBlocked s.onBlockedUpdated = onBlockedUpdated s.onTlsTerminationFailed = onTlsTerminationFailed + s.onMinPackageAge = onMinPackageAge s.onPermissionsUpdated = onPermissionsUpdated s.onSetupWizard = onSetupWizard } @@ -73,6 +76,7 @@ func (s *Server) Start() { mux.HandleFunc("POST /v1/blocked", s.handleBlocked) mux.HandleFunc("POST /v1/blocked-update", s.handleBlockedUpdated) mux.HandleFunc("POST /v1/tls-termination-failed", s.handleTlsTerminationFailed) + mux.HandleFunc("POST /v1/min-package-age", s.handleMinPackageAge) mux.HandleFunc("POST /v1/permissions", s.handlePermissionsUpdated) mux.HandleFunc("POST /v1/setup-wizard", s.handleSetupWizard) @@ -165,6 +169,28 @@ func (s *Server) handlePermissionsUpdated(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusOK) } +func (s *Server) handleMinPackageAge(w http.ResponseWriter, r *http.Request) { + if !validateToken(w, r) { + return + } + var ev daemon.MinPackageAgeEvent + if err := json.NewDecoder(r.Body).Decode(&ev); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if err := ev.Validate(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.mu.Lock() + cb := s.onMinPackageAge + s.mu.Unlock() + if cb != nil { + cb(ev) + } + w.WriteHeader(http.StatusOK) +} + type setupWizardBody struct { Steps []string `json:"steps"` } diff --git a/ui/daemon/client.go b/ui/daemon/client.go index d253dfd4..42e25cb5 100644 --- a/ui/daemon/client.go +++ b/ui/daemon/client.go @@ -195,6 +195,49 @@ func GetTlsEvent(eventID string) (TlsTerminationFailedEvent, error) { return out, nil } +// ListMinPackageAgeEvents fetches GET /v1/min-package-age-events?limit=N. +func ListMinPackageAgeEvents(limit int) ([]MinPackageAgeEvent, error) { + if limit <= 0 { + limit = 50 + } + resp, err := doRequest(http.MethodGet, fmt.Sprintf("/v1/min-package-age-events?limit=%d", limit), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("list min package age events: %s", resp.Status) + } + var out []MinPackageAgeEvent + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { + return out[i].TsMs > out[j].TsMs + }) + return out, nil +} + +// GetMinPackageAgeEvent fetches GET /v1/min-package-age-events/:id. +func GetMinPackageAgeEvent(eventID string) (MinPackageAgeEvent, error) { + var out MinPackageAgeEvent + if err := validateEventID(eventID); err != nil { + return out, err + } + resp, err := doRequest(http.MethodGet, "/v1/min-package-age-events/"+eventID, nil) + if err != nil { + return out, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return out, fmt.Errorf("get min package age event: %s", resp.Status) + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return out, err + } + return out, nil +} + // CertificateStatus is returned by GET /v1/certificate/status. type CertificateStatus struct { NeedsInstall bool `json:"needs_install"` diff --git a/ui/daemon/types.go b/ui/daemon/types.go index 736c70c3..4037d863 100644 --- a/ui/daemon/types.go +++ b/ui/daemon/types.go @@ -47,6 +47,14 @@ type TlsTerminationFailedEvent struct { Error string `json:"error"` } +type MinPackageAgeEvent struct { + ID string `json:"id,omitempty"` + TsMs int64 `json:"ts_ms"` + Ecosystem string `json:"ecosystem,omitempty"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` +} + type EcosystemExceptions struct { AllowedPackages []string `json:"allowed_packages"` RejectedPackages []string `json:"rejected_packages"` @@ -79,3 +87,22 @@ func (e *TlsTerminationFailedEvent) Validate() error { } return nil } + +func (e *MinPackageAgeEvent) Validate() error { + if e.ID == "" { + return fmt.Errorf("missing or empty required field: id") + } + if e.TsMs == 0 { + return fmt.Errorf("missing or empty required field: ts_ms") + } + if e.Ecosystem == "" { + return fmt.Errorf("missing or empty required field: ecosystem") + } + if e.Title == "" { + return fmt.Errorf("missing or empty required field: title") + } + if e.Message == "" { + return fmt.Errorf("missing or empty required field: message") + } + return nil +} diff --git a/ui/daemonservice.go b/ui/daemonservice.go index 4b64999b..d4f9b5b5 100644 --- a/ui/daemonservice.go +++ b/ui/daemonservice.go @@ -43,6 +43,14 @@ func (s *DaemonService) GetTlsEvent(eventId string) (daemon.TlsTerminationFailed return daemon.GetTlsEvent(eventId) } +func (s *DaemonService) ListMinPackageAgeEvents(limit int) ([]daemon.MinPackageAgeEvent, error) { + return daemon.ListMinPackageAgeEvents(limit) +} + +func (s *DaemonService) GetMinPackageAgeEvent(eventId string) (daemon.MinPackageAgeEvent, error) { + return daemon.GetMinPackageAgeEvent(eventId) +} + func (s *DaemonService) GetVersion() (string, error) { return daemon.GetVersion() } diff --git a/ui/frontend/src/App.tsx b/ui/frontend/src/App.tsx index e9f1bffb..a16fb2c9 100644 --- a/ui/frontend/src/App.tsx +++ b/ui/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { EventsList } from "./pages/EventsList"; import { EventDetail } from "./pages/EventDetail"; import { TlsEventsList } from "./pages/TlsEventsList"; import { TlsEventDetail } from "./pages/TlsEventDetail"; +import { MinPackageAgeEventDetail } from "./pages/MinPackageAgeEventDetail"; import { ProtectedEcosystems } from "./pages/ProtectedEcosystems"; import { InstallPage } from "./pages/InstallPage"; import { getVersion, setupCheck, setupStart } from "./api"; @@ -74,6 +75,7 @@ function DashboardLayout() { }; const eventsTabActive = location.pathname === "/" || location.pathname.startsWith("/events"); + const logsTabActive = location.pathname.startsWith("/tls-events") || location.pathname.startsWith("/min-package-age-events"); return (
@@ -97,7 +99,7 @@ function DashboardLayout() { `app-tab${eventsTabActive ? " app-tab--active" : ""}`}> Events - `app-tab${isActive ? " app-tab--active" : ""}`}> + `app-tab${logsTabActive ? " app-tab--active" : ""}`}> Logs } /> } /> } /> + } /> } /> diff --git a/ui/frontend/src/api.ts b/ui/frontend/src/api.ts index 22391db1..f69b2ba4 100644 --- a/ui/frontend/src/api.ts +++ b/ui/frontend/src/api.ts @@ -1,5 +1,5 @@ import * as DaemonService from "../bindings/endpoint-protection-ui/daemonservice.js"; -import type { BlockEvent, TlsTerminationFailedEvent } from "./types"; +import type { BlockEvent, MinPackageAgeEvent, TlsTerminationFailedEvent } from "./types"; export async function listEvents(limit: number): Promise { return DaemonService.ListEvents(limit); @@ -23,6 +23,14 @@ export async function getTlsEvent(eventId: string): Promise { + return DaemonService.ListMinPackageAgeEvents(limit); +} + +export async function getMinPackageAgeEvent(eventId: string): Promise { + return DaemonService.GetMinPackageAgeEvent(eventId); +} + export async function getVersion(): Promise { return DaemonService.GetVersion(); } diff --git a/ui/frontend/src/pages/MinPackageAgeEventDetail.tsx b/ui/frontend/src/pages/MinPackageAgeEventDetail.tsx new file mode 100644 index 00000000..d721d212 --- /dev/null +++ b/ui/frontend/src/pages/MinPackageAgeEventDetail.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState, useCallback } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import type { MinPackageAgeEvent } from "../types"; +import { getMinPackageAgeEvent } from "../api"; +import { Events } from "@wailsio/runtime"; +import { formatEventTime, isConnectionError } from "../utils"; + +export function MinPackageAgeEventDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadEvent = useCallback(() => { + if (!id) return; + setLoading(true); + setError(null); + getMinPackageAgeEvent(id) + .then(setEvent) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + .finally(() => setLoading(false)); + }, [id]); + + useEffect(() => { + loadEvent(); + }, [loadEvent]); + + useEffect(() => { + const unsub = Events.On("min_package_age", (ev: unknown) => { + const payload = (ev as { data?: MinPackageAgeEvent }).data; + if (payload && payload.id === id) { + setEvent(payload); + } + }); + return () => { + unsub(); + }; + }, [id]); + + if (loading && !event) return

Loading event…

; + + if (error && !event) { + const connectionFailed = isConnectionError(error); + return ( +
+
+

+ {connectionFailed ? "Can't connect to Aikido Endpoint Protection" : "Something went wrong"} +

+

+ {connectionFailed + ? "The app couldn't reach the Aikido Endpoint Protection service. Make sure it's running, then try again." + : "We couldn't load this log entry. You can try again or go back to the list."} +

+
+ + +
+
+
+ ); + } + + if (!event) return

Event not found.

; + + return ( +
+
+ + +
+
Minimum Package Age
+

{event.title}

+

{event.message}

+
+ +
+
+
Ecosystem
+
{event.ecosystem}
+
+
+
Occurred at
+
{formatEventTime(event.ts_ms)}
+
+
+
+
+ ); +} diff --git a/ui/frontend/src/pages/TlsEventsList.tsx b/ui/frontend/src/pages/TlsEventsList.tsx index 174275a8..1388b190 100644 --- a/ui/frontend/src/pages/TlsEventsList.tsx +++ b/ui/frontend/src/pages/TlsEventsList.tsx @@ -1,15 +1,24 @@ import { useEffect, useState, useCallback } from "react"; import { useNavigate } from "react-router-dom"; -import type { TlsTerminationFailedEvent } from "../types"; +import type { LogEvent, MinPackageAgeEvent, TlsTerminationFailedEvent } from "../types"; import { Events } from "@wailsio/runtime"; -import { collectLogs, listTlsEvents } from "../api"; +import { listMinPackageAgeEvents, listTlsEvents,collectLogs } from "../api"; import { formatEventTime, isConnectionError } from "../utils"; +function compareDescByTime(a: LogEvent, b: LogEvent) { + return b.ts_ms - a.ts_ms; +} + +function upsertLogEvent(events: LogEvent[], nextEvent: LogEvent): LogEvent[] { + const filtered = events.filter((event) => !(event.type === nextEvent.type && event.id === nextEvent.id)); + return [nextEvent, ...filtered].sort(compareDescByTime); +} + type CollectStatus = "idle" | "success" | "error"; export function TlsEventsList() { const navigate = useNavigate(); - const [events, setEvents] = useState([]); + const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [collecting, setCollecting] = useState(false); @@ -65,8 +74,15 @@ export function TlsEventsList() { setLoading(true); setError(null); try { - const list = await listTlsEvents(50); - setEvents(list ?? []); + const [tlsEvents, minPackageAgeEvents] = await Promise.all([ + listTlsEvents(50), + listMinPackageAgeEvents(50), + ]); + const combined: LogEvent[] = [ + ...(tlsEvents ?? []).map((event) => ({ ...event, type: "tls" as const })), + ...(minPackageAgeEvents ?? []).map((event) => ({ ...event, type: "min_package_age" as const })), + ].sort(compareDescByTime); + setEvents(combined); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -81,7 +97,21 @@ export function TlsEventsList() { useEffect(() => { const unsub = Events.On("tls_termination_failed", (ev: unknown) => { const payload = (ev as { data?: TlsTerminationFailedEvent }).data; - if (payload) setEvents((prev) => [payload, ...prev]); + if (payload) { + setEvents((prev) => upsertLogEvent(prev, { ...payload, type: "tls" })); + } + }); + return () => { + unsub(); + }; + }, []); + + useEffect(() => { + const unsub = Events.On("min_package_age", (ev: unknown) => { + const payload = (ev as { data?: MinPackageAgeEvent }).data; + if (payload) { + setEvents((prev) => upsertLogEvent(prev, { ...payload, type: "min_package_age" })); + } }); return () => { unsub(); @@ -131,20 +161,29 @@ export function TlsEventsList() {
navigate(`/tls-events/${ev.id}`)} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") navigate(`/tls-events/${ev.id}`); }} + onClick={() => navigate(ev.type === "tls" ? `/tls-events/${ev.id}` : `/min-package-age-events/${ev.id}`)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + navigate(ev.type === "tls" ? `/tls-events/${ev.id}` : `/min-package-age-events/${ev.id}`); + } + }} tabIndex={0} role="button" >
- {ev.sni} + + {ev.type === "tls" ? ev.sni : ev.title} +
{formatEventTime(ev.ts_ms)} - {ev.app && ( + {ev.type === "tls" && ev.app && ( <> · {ev.app} @@ -164,7 +203,7 @@ export function TlsEventsList() {

No Logs

- When a TLS MITM handshake fails (e.g. due to certificate pinning) or other network-related issues occur, it will appear here. + When a TLS MITM handshake fails, or when Aikido suppresses versions that are too new under the minimum package age policy, it will appear here.

)} diff --git a/ui/frontend/src/types.ts b/ui/frontend/src/types.ts index aa5cafdc..fcb15fd8 100644 --- a/ui/frontend/src/types.ts +++ b/ui/frontend/src/types.ts @@ -26,3 +26,15 @@ export interface TlsTerminationFailedEvent { app_path?: string; error: string; } + +export interface MinPackageAgeEvent { + id: string; + ts_ms: number; + ecosystem: string; + title: string; + message: string; +} + +export type LogEvent = + | ({ type: "tls" } & TlsTerminationFailedEvent) + | ({ type: "min_package_age" } & MinPackageAgeEvent); diff --git a/ui/main.go b/ui/main.go index 6d04e845..5d393a4f 100644 --- a/ui/main.go +++ b/ui/main.go @@ -41,6 +41,7 @@ func init() { application.RegisterEvent[daemon.BlockEvent]("blocked") application.RegisterEvent[daemon.BlockEvent]("blocked_updated") application.RegisterEvent[daemon.TlsTerminationFailedEvent]("tls_termination_failed") + application.RegisterEvent[daemon.MinPackageAgeEvent]("min_package_age") application.RegisterEvent[daemon.PermissionsResponse]("permissions_updated") application.RegisterEvent[FocusEventPayload]("focus_event") application.RegisterEvent[SetupStatePayload]("setup_state") @@ -379,6 +380,10 @@ func startAppServer(app *application.App, wm *windowManager, statusCh chan<- app log.Println("TLS termination failed event:", ev) app.Event.Emit("tls_termination_failed", ev) }, + func(ev daemon.MinPackageAgeEvent) { + log.Println("Min package age event:", ev) + app.Event.Emit("min_package_age", ev) + }, func(ev daemon.PermissionsResponse) { log.Println("Permissions updated") app.Event.Emit("permissions_updated", ev) diff --git a/ui/mock/main.go b/ui/mock/main.go index 0ba3a172..342f6a9c 100644 --- a/ui/mock/main.go +++ b/ui/mock/main.go @@ -45,9 +45,17 @@ type TlsEvent struct { Error string `json:"error"` } +type MinPackageAgeEvent struct { + ID string `json:"id"` + TsMs int64 `json:"ts_ms"` + Ecosystem string `json:"ecosystem"` + Title string `json:"title"` + Message string `json:"message"` +} + // ── seed data ──────────────────────────────────────────────────────── -func seedData() ([]BlockEvent, []TlsEvent) { +func seedData() ([]BlockEvent, []TlsEvent, []MinPackageAgeEvent) { now := time.Now().UnixMilli() blocks := []BlockEvent{ @@ -141,7 +149,24 @@ func seedData() ([]BlockEvent, []TlsEvent) { }, } - return blocks, tlsEvents + minPackageAgeEvents := []MinPackageAgeEvent{ + { + ID: "min-package-age-suppressed-vscode", + TsMs: now - 240_000, + Ecosystem: "vscode", + Title: "vscode package versions suppressed", + Message: "One or more package versions were suppressed because they did not meet the minimum package age policy.", + }, + { + ID: "min-package-age-suppressed-npm", + TsMs: now - 120_000, + Ecosystem: "npm", + Title: "npm package versions suppressed", + Message: "One or more package versions were suppressed because they did not meet the minimum package age policy.", + }, + } + + return blocks, tlsEvents, minPackageAgeEvents } type EcosystemExceptions struct { @@ -187,14 +212,15 @@ func seedPermissions() PermissionsResponse { // ── server ─────────────────────────────────────────────────────────── type server struct { - mu sync.RWMutex - blocks []BlockEvent - tlsEvents []TlsEvent - permissions PermissionsResponse - extensionInstalled bool - extensionActivated bool - vpnAllowed bool - token string + mu sync.RWMutex + blocks []BlockEvent + tlsEvents []TlsEvent + minPackageAgeEvents []MinPackageAgeEvent + permissions PermissionsResponse + extensionInstalled bool + extensionActivated bool + vpnAllowed bool + token string } func (s *server) writeJSON(w http.ResponseWriter, v any) { @@ -246,6 +272,25 @@ func (s *server) handleGetTlsEvent(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) } +func (s *server) handleListMinPackageAgeEvents(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + s.writeJSON(w, s.minPackageAgeEvents) +} + +func (s *server) handleGetMinPackageAgeEvent(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + s.mu.RLock() + defer s.mu.RUnlock() + for _, e := range s.minPackageAgeEvents { + if e.ID == id { + s.writeJSON(w, e) + return + } + } + http.NotFound(w, r) +} + func (s *server) handlePermissions(w http.ResponseWriter, r *http.Request) { s.mu.RLock() defer s.mu.RUnlock() @@ -390,8 +435,13 @@ func (s *server) handleSetupRestart(w http.ResponseWriter, r *http.Request) { } func main() { - blocks, tlsEvents := seedData() - s := &server{blocks: blocks, tlsEvents: tlsEvents, permissions: seedPermissions()} + blocks, tlsEvents, minPackageAgeEvents := seedData() + s := &server{ + blocks: blocks, + tlsEvents: tlsEvents, + minPackageAgeEvents: minPackageAgeEvents, + permissions: seedPermissions(), + } mux := http.NewServeMux() mux.HandleFunc("GET /v1/version", s.handleVersion) @@ -399,6 +449,8 @@ func main() { mux.HandleFunc("GET /v1/events/{id}", s.handleGetEvent) mux.HandleFunc("GET /v1/tls-events", s.handleListTlsEvents) mux.HandleFunc("GET /v1/tls-events/{id}", s.handleGetTlsEvent) + mux.HandleFunc("GET /v1/min-package-age-events", s.handleListMinPackageAgeEvents) + mux.HandleFunc("GET /v1/min-package-age-events/{id}", s.handleGetMinPackageAgeEvent) mux.HandleFunc("GET /v1/permissions", s.handlePermissions) mux.HandleFunc("POST /v1/events/{id}/request-access", s.handleRequestAccess) mux.HandleFunc("GET /v1/certificate/status", s.handleCertificateStatus)