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 (
Loading event…
; + + if (error && !event) { + const connectionFailed = isConnectionError(error); + return ( ++ {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."} +
+Event not found.
; + + return ( +{event.message}
+- 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.