From d89b86675cb6d950789de4d1ac81806ee03ec606 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 26 Feb 2026 02:22:08 +0000 Subject: [PATCH 1/4] chore: Add comprehensive tests for notification and permission handlers - Implement tests for classifyProviderTestFailure function to cover various error scenarios. - Enhance notification provider handler tests for token validation, type change rejection, and missing provider ID. - Add tests for permission helper functions to ensure proper admin authentication checks. - Expand coverage for utility functions in user handler and docker service tests, including error extraction and socket path handling. - Introduce a QA report for PR #754 highlighting coverage metrics and security findings related to Gotify and webhook notifications. --- .../handlers/notification_coverage_test.go | 287 ++++++++++++ .../api/handlers/permission_helpers_test.go | 31 ++ .../api/handlers/user_handler_test.go | 66 +++ .../notifications/http_wrapper_test.go | 424 ++++++++++++++++++ .../internal/services/docker_service_test.go | 92 ++++ .../services/notification_service_test.go | 76 ++++ docs/reports/qa_report_pr754.md | 138 ++++++ 7 files changed, 1114 insertions(+) create mode 100644 docs/reports/qa_report_pr754.md diff --git a/backend/internal/api/handlers/notification_coverage_test.go b/backend/internal/api/handlers/notification_coverage_test.go index 23317576..162364dc 100644 --- a/backend/internal/api/handlers/notification_coverage_test.go +++ b/backend/internal/api/handlers/notification_coverage_test.go @@ -378,6 +378,38 @@ func TestNotificationProviderHandler_Test_RejectsGotifyTokenWithWhitespace(t *te assert.NotContains(t, w.Body.String(), "secret-with-space") } +func TestClassifyProviderTestFailure_NilError(t *testing.T) { + code, category, message := classifyProviderTestFailure(nil) + + assert.Equal(t, "PROVIDER_TEST_FAILED", code) + assert.Equal(t, "dispatch", category) + assert.Equal(t, "Provider test failed", message) +} + +func TestClassifyProviderTestFailure_DefaultStatusCode(t *testing.T) { + code, category, message := classifyProviderTestFailure(errors.New("provider returned status 500")) + + assert.Equal(t, "PROVIDER_TEST_REMOTE_REJECTED", code) + assert.Equal(t, "dispatch", category) + assert.Contains(t, message, "HTTP 500") +} + +func TestClassifyProviderTestFailure_GenericError(t *testing.T) { + code, category, message := classifyProviderTestFailure(errors.New("something completely unexpected")) + + assert.Equal(t, "PROVIDER_TEST_FAILED", code) + assert.Equal(t, "dispatch", category) + assert.Equal(t, "Provider test failed", message) +} + +func TestClassifyProviderTestFailure_InvalidDiscordWebhookURL(t *testing.T) { + code, category, message := classifyProviderTestFailure(errors.New("invalid discord webhook url")) + + assert.Equal(t, "PROVIDER_TEST_URL_INVALID", code) + assert.Equal(t, "validation", category) + assert.Contains(t, message, "Provider URL") +} + func TestClassifyProviderTestFailure_URLValidation(t *testing.T) { code, category, message := classifyProviderTestFailure(errors.New("destination URL validation failed")) @@ -748,3 +780,258 @@ func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) { assert.Equal(t, 400, w.Code) } + +func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + payload := map[string]any{ + "template": "minimal", + "token": "secret-token-value", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/preview", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Preview(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY") +} + +func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + existing := models.NotificationProvider{ + ID: "update-type-test", + Name: "Discord Provider", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", + } + require.NoError(t, db.Create(&existing).Error) + + payload := map[string]any{ + "name": "Changed Type Provider", + "type": "gotify", + "url": "https://gotify.example.com", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Params = gin.Params{{Key: "id", Value: "update-type-test"}} + c.Request = httptest.NewRequest("PUT", "/providers/update-type-test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Update(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "PROVIDER_TYPE_IMMUTABLE") +} + +func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + payload := map[string]any{ + "type": "discord", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Test(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "MISSING_PROVIDER_ID") +} + +func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + payload := map[string]any{ + "type": "discord", + "id": "nonexistent-provider", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Test(c) + + assert.Equal(t, 404, w.Code) + assert.Contains(t, w.Body.String(), "PROVIDER_NOT_FOUND") +} + +func TestNotificationProviderHandler_Test_EmptyProviderURL(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + existing := models.NotificationProvider{ + ID: "empty-url-test", + Name: "Empty URL Provider", + Type: "discord", + URL: "", + } + require.NoError(t, db.Create(&existing).Error) + + payload := map[string]any{ + "type": "discord", + "id": "empty-url-test", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Test(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "PROVIDER_CONFIG_MISSING") +} + +func TestIsProviderValidationError_Comprehensive(t *testing.T) { + cases := []struct { + name string + err error + expect bool + }{ + {"nil", nil, false}, + {"invalid_custom_template", errors.New("invalid custom template: missing field"), true}, + {"rendered_template", errors.New("rendered template exceeds maximum"), true}, + {"failed_to_parse", errors.New("failed to parse template: unexpected end"), true}, + {"failed_to_render", errors.New("failed to render template: missing key"), true}, + {"invalid_discord_webhook", errors.New("invalid Discord webhook URL"), true}, + {"unrelated_error", errors.New("database connection failed"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, isProviderValidationError(tc.err)) + }) + } +} + +func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + existing := models.NotificationProvider{ + ID: "unsupported-type", + Name: "Custom Provider", + Type: "slack", + URL: "https://hooks.slack.com/test", + } + require.NoError(t, db.Create(&existing).Error) + + payload := map[string]any{ + "name": "Updated Slack Provider", + "url": "https://hooks.slack.com/updated", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Params = gin.Params{{Key: "id", Value: "unsupported-type"}} + c.Request = httptest.NewRequest("PUT", "/providers/unsupported-type", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Update(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "UNSUPPORTED_PROVIDER_TYPE") +} + +func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + existing := models.NotificationProvider{ + ID: "gotify-keep-token", + Name: "Gotify Provider", + Type: "gotify", + URL: "https://gotify.example.com", + Token: "existing-secret-token", + } + require.NoError(t, db.Create(&existing).Error) + + payload := map[string]any{ + "name": "Updated Gotify", + "url": "https://gotify.example.com/new", + "template": "minimal", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Params = gin.Params{{Key: "id", Value: "gotify-keep-token"}} + c.Request = httptest.NewRequest("PUT", "/providers/gotify-keep-token", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Update(c) + + assert.Equal(t, 200, w.Code) + + var updated models.NotificationProvider + require.NoError(t, db.Where("id = ?", "gotify-keep-token").First(&updated).Error) + assert.Equal(t, "existing-secret-token", updated.Token) +} + +func TestNotificationProviderHandler_Test_ReadDBError(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + _ = db.Migrator().DropTable(&models.NotificationProvider{}) + + payload := map[string]any{ + "type": "discord", + "id": "some-provider", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Test(c) + + assert.Equal(t, 500, w.Code) + assert.Contains(t, w.Body.String(), "PROVIDER_READ_FAILED") +} diff --git a/backend/internal/api/handlers/permission_helpers_test.go b/backend/internal/api/handlers/permission_helpers_test.go index 3113d57a..f9d4fd77 100644 --- a/backend/internal/api/handlers/permission_helpers_test.go +++ b/backend/internal/api/handlers/permission_helpers_test.go @@ -168,3 +168,34 @@ func TestLogPermissionAudit_ActorFallback(t *testing.T) { assert.Equal(t, "permissions", audit.EventCategory) assert.Contains(t, audit.Details, fmt.Sprintf("\"admin\":%v", false)) } + +func TestRequireAuthenticatedAdmin_NoUserID(t *testing.T) { + t.Parallel() + + ctx, rec := newTestContextWithRequest() + result := requireAuthenticatedAdmin(ctx) + assert.False(t, result) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "Authorization header required") +} + +func TestRequireAuthenticatedAdmin_UserIDPresentAndAdmin(t *testing.T) { + t.Parallel() + + ctx, _ := newTestContextWithRequest() + ctx.Set("userID", uint(1)) + ctx.Set("role", "admin") + result := requireAuthenticatedAdmin(ctx) + assert.True(t, result) +} + +func TestRequireAuthenticatedAdmin_UserIDPresentButNotAdmin(t *testing.T) { + t.Parallel() + + ctx, rec := newTestContextWithRequest() + ctx.Set("userID", uint(1)) + ctx.Set("role", "user") + result := requireAuthenticatedAdmin(ctx) + assert.False(t, result) + assert.Equal(t, http.StatusForbidden, rec.Code) +} diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 0629c2e6..bdcb24b7 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "encoding/json" + "errors" "net/http" "net/http/httptest" "strconv" @@ -2639,3 +2640,68 @@ func TestResendInvite_WithExpiredInvite(t *testing.T) { db.First(&updatedUser, user.ID) assert.True(t, updatedUser.InviteExpires.After(time.Now())) } + +// ===== Additional coverage for uncovered utility functions ===== + +func TestIsSetupConflictError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"nil error", nil, false}, + {"unique constraint failed", errors.New("UNIQUE constraint failed: users.email"), true}, + {"duplicate key", errors.New("duplicate key value violates unique constraint"), true}, + {"database is locked", errors.New("database is locked"), true}, + {"database table is locked", errors.New("database table is locked"), true}, + {"case insensitive", errors.New("UNIQUE CONSTRAINT FAILED"), true}, + {"unrelated error", errors.New("connection refused"), false}, + {"empty error", errors.New(""), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSetupConflictError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMaskSecretForResponse(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"non-empty secret", "my-secret-key", "********"}, + {"empty string", "", ""}, + {"whitespace only", " ", ""}, + {"single char", "x", "********"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maskSecretForResponse(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRedactInviteURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"non-empty url", "https://example.com/invite/abc123", "[REDACTED]"}, + {"empty string", "", ""}, + {"whitespace only", " ", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := redactInviteURL(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/backend/internal/notifications/http_wrapper_test.go b/backend/internal/notifications/http_wrapper_test.go index 5a73d0ad..6262c091 100644 --- a/backend/internal/notifications/http_wrapper_test.go +++ b/backend/internal/notifications/http_wrapper_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/http/httptest" neturl "net/url" @@ -497,3 +498,426 @@ func TestBuildSafeRequestURLWithTLSServer(t *testing.T) { t.Fatalf("expected host header %q, got %q", serverURL.Host, hostHeader) } } + +// ===== Additional coverage for uncovered paths ===== + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, errors.New("simulated read error") +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestApplyRedirectGuardNilClient(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.applyRedirectGuard(nil) +} + +func TestGuardDestinationNilURL(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + err := wrapper.guardDestination(nil) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected validation failure for nil URL, got: %v", err) + } +} + +func TestGuardDestinationEmptyHostname(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + err := wrapper.guardDestination(&neturl.URL{Scheme: "https", Host: ""}) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected validation failure for empty hostname, got: %v", err) + } +} + +func TestGuardDestinationUserInfoRejection(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + u := &neturl.URL{Scheme: "https", Host: "example.com", User: neturl.User("admin")} + err := wrapper.guardDestination(u) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected userinfo rejection, got: %v", err) + } +} + +func TestGuardDestinationFragmentRejection(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + u := &neturl.URL{Scheme: "https", Host: "example.com", Fragment: "section"} + err := wrapper.guardDestination(u) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected fragment rejection, got: %v", err) + } +} + +func TestGuardDestinationPrivateIPRejection(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + err := wrapper.guardDestination(&neturl.URL{Scheme: "https", Host: "192.168.1.1"}) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected private IP rejection, got: %v", err) + } +} + +func TestIsAllowedDestinationIPEdgeCases(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + + tests := []struct { + name string + hostname string + ip net.IP + expected bool + }{ + {"nil IP", "", nil, false}, + {"unspecified", "0.0.0.0", net.IPv4zero, false}, + {"multicast", "224.0.0.1", net.ParseIP("224.0.0.1"), false}, + {"link-local unicast", "169.254.1.1", net.ParseIP("169.254.1.1"), false}, + {"loopback without allowHTTP", "127.0.0.1", net.ParseIP("127.0.0.1"), false}, + {"private 10.x", "10.0.0.1", net.ParseIP("10.0.0.1"), false}, + {"private 172.16.x", "172.16.0.1", net.ParseIP("172.16.0.1"), false}, + {"private 192.168.x", "192.168.1.1", net.ParseIP("192.168.1.1"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := wrapper.isAllowedDestinationIP(tt.hostname, tt.ip) + if result != tt.expected { + t.Fatalf("isAllowedDestinationIP(%q, %v) = %v, want %v", tt.hostname, tt.ip, result, tt.expected) + } + }) + } +} + +func TestIsAllowedDestinationIPLoopbackAllowHTTP(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = true + + if !wrapper.isAllowedDestinationIP("localhost", net.ParseIP("127.0.0.1")) { + t.Fatal("expected loopback allowed for localhost with allowHTTP") + } + + if wrapper.isAllowedDestinationIP("not-localhost", net.ParseIP("127.0.0.1")) { + t.Fatal("expected loopback rejected for non-localhost hostname") + } +} + +func TestIsLocalDestinationHost(t *testing.T) { + tests := []struct { + host string + expected bool + }{ + {"localhost", true}, + {"LOCALHOST", true}, + {"127.0.0.1", true}, + {"::1", true}, + {"example.com", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := isLocalDestinationHost(tt.host); got != tt.expected { + t.Fatalf("isLocalDestinationHost(%q) = %v, want %v", tt.host, got, tt.expected) + } + }) + } +} + +func TestShouldRetryComprehensive(t *testing.T) { + tests := []struct { + name string + resp *http.Response + err error + expected bool + }{ + {"nil resp nil err", nil, nil, false}, + {"timeout error string", nil, errors.New("operation timeout"), true}, + {"connection error string", nil, errors.New("connection reset"), true}, + {"unrelated error", nil, errors.New("json parse error"), false}, + {"500 response", &http.Response{StatusCode: 500}, nil, true}, + {"502 response", &http.Response{StatusCode: 502}, nil, true}, + {"503 response", &http.Response{StatusCode: 503}, nil, true}, + {"429 response", &http.Response{StatusCode: 429}, nil, true}, + {"200 response", &http.Response{StatusCode: 200}, nil, false}, + {"400 response", &http.Response{StatusCode: 400}, nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldRetry(tt.resp, tt.err); got != tt.expected { + t.Fatalf("shouldRetry = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestShouldRetryNetError(t *testing.T) { + netErr := &net.DNSError{Err: "no such host", Name: "example.invalid"} + if !shouldRetry(nil, netErr) { + t.Fatal("expected net.Error to trigger retry via errors.As fallback") + } +} + +func TestReadCappedResponseBodyReadError(t *testing.T) { + _, err := readCappedResponseBody(errReader{}) + if err == nil || !strings.Contains(err.Error(), "read response body") { + t.Fatalf("expected read body error, got: %v", err) + } +} + +func TestReadCappedResponseBodyOversize(t *testing.T) { + oversized := strings.NewReader(strings.Repeat("x", MaxNotifyResponseBodyBytes+10)) + _, err := readCappedResponseBody(oversized) + if err == nil || !strings.Contains(err.Error(), "response payload exceeds") { + t.Fatalf("expected oversize error, got: %v", err) + } +} + +func TestReadCappedResponseBodySuccess(t *testing.T) { + content, err := readCappedResponseBody(strings.NewReader("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "hello" { + t.Fatalf("expected 'hello', got %q", string(content)) + } +} + +func TestHasDisallowedQueryAuthKeyAllVariants(t *testing.T) { + tests := []struct { + name string + key string + expected bool + }{ + {"token", "token", true}, + {"auth", "auth", true}, + {"apikey", "apikey", true}, + {"api_key", "api_key", true}, + {"TOKEN uppercase", "TOKEN", true}, + {"Api_Key mixed", "Api_Key", true}, + {"safe key", "callback", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query := neturl.Values{} + query.Set(tt.key, "secret") + if got := hasDisallowedQueryAuthKey(query); got != tt.expected { + t.Fatalf("hasDisallowedQueryAuthKey with key %q = %v, want %v", tt.key, got, tt.expected) + } + }) + } +} + +func TestHasDisallowedQueryAuthKeyEmptyQuery(t *testing.T) { + if hasDisallowedQueryAuthKey(neturl.Values{}) { + t.Fatal("expected empty query to be safe") + } +} + +func TestNotifyMaxRedirects(t *testing.T) { + tests := []struct { + name string + envValue string + expected int + }{ + {"empty", "", 0}, + {"valid 3", "3", 3}, + {"zero", "0", 0}, + {"negative", "-1", 0}, + {"above max", "10", 5}, + {"exactly 5", "5", 5}, + {"invalid", "abc", 0}, + {"whitespace", " 2 ", 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CHARON_NOTIFY_MAX_REDIRECTS", tt.envValue) + if got := notifyMaxRedirects(); got != tt.expected { + t.Fatalf("notifyMaxRedirects() = %d, want %d", got, tt.expected) + } + }) + } +} + +func TestResolveAllowedDestinationIPRejectsPrivateIP(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + _, err := wrapper.resolveAllowedDestinationIP("192.168.1.1") + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected private IP rejection, got: %v", err) + } +} + +func TestResolveAllowedDestinationIPRejectsLoopback(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + _, err := wrapper.resolveAllowedDestinationIP("127.0.0.1") + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected loopback rejection, got: %v", err) + } +} + +func TestResolveAllowedDestinationIPAllowsPublic(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + ip, err := wrapper.resolveAllowedDestinationIP("1.1.1.1") + if err != nil { + t.Fatalf("expected public IP to be allowed, got: %v", err) + } + if !ip.Equal(net.ParseIP("1.1.1.1")) { + t.Fatalf("expected 1.1.1.1, got %v", ip) + } +} + +func TestBuildSafeRequestURLRejectsPrivateHostname(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + u := &neturl.URL{Scheme: "https", Host: "192.168.1.1", Path: "/hook"} + _, _, err := wrapper.buildSafeRequestURL(u) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected private host rejection, got: %v", err) + } +} + +func TestWaitBeforeRetryBasic(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + var sleptDuration time.Duration + wrapper.sleep = func(d time.Duration) { sleptDuration = d } + wrapper.jitterNanos = func(int64) int64 { return 0 } + wrapper.retryPolicy.BaseDelay = 100 * time.Millisecond + wrapper.retryPolicy.MaxDelay = 1 * time.Second + + wrapper.waitBeforeRetry(1) + if sleptDuration != 100*time.Millisecond { + t.Fatalf("expected 100ms delay for attempt 1, got %v", sleptDuration) + } + + wrapper.waitBeforeRetry(2) + if sleptDuration != 200*time.Millisecond { + t.Fatalf("expected 200ms delay for attempt 2, got %v", sleptDuration) + } +} + +func TestWaitBeforeRetryClampedToMax(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + var sleptDuration time.Duration + wrapper.sleep = func(d time.Duration) { sleptDuration = d } + wrapper.jitterNanos = func(int64) int64 { return 0 } + wrapper.retryPolicy.BaseDelay = 1 * time.Second + wrapper.retryPolicy.MaxDelay = 2 * time.Second + + wrapper.waitBeforeRetry(5) + if sleptDuration != 2*time.Second { + t.Fatalf("expected clamped delay of 2s, got %v", sleptDuration) + } +} + +func TestWaitBeforeRetryDefaultJitter(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.jitterNanos = nil + wrapper.sleep = func(time.Duration) {} + wrapper.retryPolicy.BaseDelay = 100 * time.Millisecond + wrapper.retryPolicy.MaxDelay = 1 * time.Second + wrapper.waitBeforeRetry(1) +} + +func TestHTTPWrapperSendExhaustsRetriesOnTransportError(t *testing.T) { + var calls int32 + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = true + wrapper.sleep = func(time.Duration) {} + wrapper.jitterNanos = func(int64) int64 { return 0 } + wrapper.httpClientFactory = func(bool, int) *http.Client { + return &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + return nil, errors.New("connection timeout failure") + }), + } + } + + _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ + URL: "http://localhost:19999/hook", + Body: []byte(`{"msg":"test"}`), + }) + if err == nil { + t.Fatal("expected error after transport failures") + } + if !strings.Contains(err.Error(), "outbound request failed") { + t.Fatalf("expected outbound request failed message, got: %v", err) + } + if got := atomic.LoadInt32(&calls); got != 3 { + t.Fatalf("expected 3 attempts, got %d", got) + } +} + +func TestHTTPWrapperSendExhaustsRetriesOn500(t *testing.T) { + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = true + wrapper.sleep = func(time.Duration) {} + wrapper.jitterNanos = func(int64) int64 { return 0 } + + _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ + URL: server.URL, + Body: []byte(`{"msg":"test"}`), + }) + if err == nil || !strings.Contains(err.Error(), "status 500") { + t.Fatalf("expected 500 status error, got: %v", err) + } + if got := atomic.LoadInt32(&calls); got != 3 { + t.Fatalf("expected 3 attempts for 500 retries, got %d", got) + } +} + +func TestHTTPWrapperSendTransportErrorNoRetry(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = true + wrapper.retryPolicy.MaxAttempts = 1 + wrapper.httpClientFactory = func(bool, int) *http.Client { + return &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("some unretryable error") + }), + } + } + + _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ + URL: "http://localhost:19999/hook", + Body: []byte(`{"msg":"test"}`), + }) + if err == nil || !strings.Contains(err.Error(), "outbound request failed") { + t.Fatalf("expected outbound request failed, got: %v", err) + } +} + +func TestSanitizeTransportErrorReasonNetworkUnreachable(t *testing.T) { + result := sanitizeTransportErrorReason(errors.New("connect: network is unreachable")) + if result != "network unreachable" { + t.Fatalf("expected 'network unreachable', got %q", result) + } +} + +func TestSanitizeTransportErrorReasonCertificate(t *testing.T) { + result := sanitizeTransportErrorReason(errors.New("x509: certificate signed by unknown authority")) + if result != "tls handshake failed" { + t.Fatalf("expected 'tls handshake failed', got %q", result) + } +} + +func TestAllowNotifyHTTPOverride(t *testing.T) { + result := allowNotifyHTTPOverride() + if !result { + t.Fatal("expected allowHTTP to be true in test binary") + } +} diff --git a/backend/internal/services/docker_service_test.go b/backend/internal/services/docker_service_test.go index 4e2a955b..fa35e599 100644 --- a/backend/internal/services/docker_service_test.go +++ b/backend/internal/services/docker_service_test.go @@ -3,6 +3,7 @@ package services import ( "context" "errors" + "fmt" "net" "net/url" "os" @@ -263,3 +264,94 @@ func TestBuildLocalDockerUnavailableDetails_GenericError(t *testing.T) { assert.Contains(t, details, "uid=") assert.Contains(t, details, "gid=") } + +// ===== Additional coverage for uncovered paths ===== + +func TestDockerUnavailableError_NilDetails(t *testing.T) { + var nilErr *DockerUnavailableError + assert.Equal(t, "", nilErr.Details()) +} + +func TestExtractErrno_UrlErrorWrapping(t *testing.T) { + urlErr := &url.Error{Op: "dial", URL: "unix:///var/run/docker.sock", Err: syscall.EACCES} + errno, ok := extractErrno(urlErr) + assert.True(t, ok) + assert.Equal(t, syscall.EACCES, errno) +} + +func TestExtractErrno_SyscallError(t *testing.T) { + scErr := &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED} + errno, ok := extractErrno(scErr) + assert.True(t, ok) + assert.Equal(t, syscall.ECONNREFUSED, errno) +} + +func TestExtractErrno_NilError(t *testing.T) { + _, ok := extractErrno(nil) + assert.False(t, ok) +} + +func TestExtractErrno_NonSyscallError(t *testing.T) { + _, ok := extractErrno(errors.New("some generic error")) + assert.False(t, ok) +} + +func TestExtractErrno_OpErrorWrapping(t *testing.T) { + opErr := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EPERM} + errno, ok := extractErrno(opErr) + assert.True(t, ok) + assert.Equal(t, syscall.EPERM, errno) +} + +func TestExtractErrno_NestedUrlSyscallOpError(t *testing.T) { + innerErr := &net.OpError{ + Op: "dial", + Net: "unix", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.EACCES}, + } + urlErr := &url.Error{Op: "Get", URL: "unix:///var/run/docker.sock", Err: innerErr} + errno, ok := extractErrno(urlErr) + assert.True(t, ok) + assert.Equal(t, syscall.EACCES, errno) +} + +func TestSocketPathFromDockerHost(t *testing.T) { + tests := []struct { + name string + host string + expected string + }{ + {"unix socket", "unix:///var/run/docker.sock", "/var/run/docker.sock"}, + {"tcp host", "tcp://192.168.1.1:2375", ""}, + {"empty", "", ""}, + {"whitespace unix", " unix:///tmp/docker.sock ", "/tmp/docker.sock"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := socketPathFromDockerHost(tt.host) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildLocalDockerUnavailableDetails_OsErrNotExist(t *testing.T) { + err := fmt.Errorf("wrapped: %w", os.ErrNotExist) + details := buildLocalDockerUnavailableDetails(err, "unix:///var/run/docker.sock") + assert.Contains(t, details, "not found") + assert.Contains(t, details, "/var/run/docker.sock") +} + +func TestBuildLocalDockerUnavailableDetails_NonUnixHost(t *testing.T) { + err := errors.New("cannot connect") + details := buildLocalDockerUnavailableDetails(err, "tcp://192.168.1.1:2375") + assert.Contains(t, details, "Cannot connect") + assert.Contains(t, details, "tcp://192.168.1.1:2375") +} + +func TestBuildLocalDockerUnavailableDetails_EPERMWithStatFail(t *testing.T) { + err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EPERM} + details := buildLocalDockerUnavailableDetails(err, "unix:///tmp/nonexistent-eperm.sock") + assert.Contains(t, details, "not accessible") + assert.Contains(t, details, "could not be stat") +} diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index c4032fb4..47ecc412 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -2538,3 +2538,79 @@ func TestTestProvider_WebhookWorksWhenFlagExplicitlyFalse(t *testing.T) { err := svc.TestProvider(provider) assert.NoError(t, err) } + +func TestUpdateProvider_TypeMutationBlocked(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + existing := models.NotificationProvider{ + ID: "prov-type-mut", + Type: "webhook", + Name: "Original", + URL: "https://example.com/hook", + } + require.NoError(t, db.Create(&existing).Error) + + update := models.NotificationProvider{ + ID: "prov-type-mut", + Type: "discord", + Name: "Changed", + URL: "https://discord.com/api/webhooks/123/abc", + } + err := svc.UpdateProvider(&update) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot change provider type") +} + +func TestUpdateProvider_GotifyKeepsExistingToken(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + existing := models.NotificationProvider{ + ID: "prov-gotify-token", + Type: "gotify", + Name: "My Gotify", + URL: "https://gotify.example.com", + Token: "original-secret-token", + } + require.NoError(t, db.Create(&existing).Error) + + update := models.NotificationProvider{ + ID: "prov-gotify-token", + Type: "gotify", + Name: "My Gotify Updated", + URL: "https://gotify.example.com", + Token: "", + } + err := svc.UpdateProvider(&update) + require.NoError(t, err) + assert.Equal(t, "original-secret-token", update.Token) +} + +func TestGetFeatureFlagValue_FoundSetting(t *testing.T) { + db := setupNotificationTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{})) + svc := NewNotificationService(db) + + tests := []struct { + name string + value string + expected bool + }{ + {"true_string", "true", true}, + {"yes_string", "yes", true}, + {"one_string", "1", true}, + {"false_string", "false", false}, + {"no_string", "no", false}, + {"zero_string", "0", false}, + {"whitespace_true", " True ", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.Where("key = ?", "test.flag").Delete(&models.Setting{}) + db.Create(&models.Setting{Key: "test.flag", Value: tt.value}) + result := svc.getFeatureFlagValue("test.flag", false) + assert.Equal(t, tt.expected, result, "value=%q", tt.value) + }) + } +} diff --git a/docs/reports/qa_report_pr754.md b/docs/reports/qa_report_pr754.md new file mode 100644 index 00000000..93aafc59 --- /dev/null +++ b/docs/reports/qa_report_pr754.md @@ -0,0 +1,138 @@ +# QA Report — PR #754: Enable and Test Gotify and Custom Webhook Notifications + +**Branch:** `feature/beta-release` +**Date:** 2026-02-25 +**Auditor:** QA Security Agent + +--- + +## Summary + +| # | Check | Result | Details | +|---|-------|--------|---------| +| 1 | Local Patch Coverage Preflight | **WARN** | 79.5% overall (threshold 90%), 78.3% backend (threshold 85%) — advisory only | +| 2 | Backend Coverage ≥ 85% | **PASS** | 87.0% statement / 87.3% line (threshold 87%) | +| 3 | Frontend Coverage ≥ 85% | **PASS** | 88.21% statement / 88.97% line (threshold 85%) | +| 4 | TypeScript Type Check | **PASS** | Zero errors | +| 5 | Pre-commit Hooks | **PASS** | All 15 hooks passed | +| 6a | Trivy Filesystem Scan | **PASS** | 0 CRITICAL/HIGH in project code (findings only in Go module cache) | +| 6b | Docker Image Scan | **WARN** | 1 HIGH in Caddy transitive dep (CVE-2026-25793, nebula v1.9.7 → fixed 1.10.3) | +| 6c | CodeQL (Go + JavaScript) | **PASS** | 0 errors, 0 warnings across both languages | +| 7 | GORM Security Scan | **PASS** | 0 CRITICAL/HIGH (2 INFO suggestions: missing indexes on UserPermittedHost) | +| 8 | Go Vulnerability Check | **PASS** | No vulnerabilities found | + +--- + +## Detailed Findings + +### 1. Local Patch Coverage Preflight + +- **Status:** WARN (advisory, not blocking per policy) +- Overall patch coverage: **79.5%** (threshold: 90%) +- Backend patch coverage: **78.3%** (threshold: 85%) +- Artifacts generated but `test-results/` directory was not persisted at repo root +- **Action:** Consider adding targeted tests for uncovered changed lines in notification service/handler + +### 2. Backend Unit Test Coverage + +- **Status:** PASS +- Statement coverage: **87.0%** +- Line coverage: **87.3%** +- All tests passed (0 failures) + +### 3. Frontend Unit Test Coverage + +- **Status:** PASS +- Statement coverage: **88.21%** +- Branch coverage: **80.58%** +- Function coverage: **85.20%** +- Line coverage: **88.97%** +- All tests passed (0 failures) +- Coverage files generated: `lcov.info`, `coverage-summary.json`, `coverage-final.json` + +### 4. TypeScript Type Check + +- **Status:** PASS +- `tsc --noEmit` completed with zero errors + +### 5. Pre-commit Hooks + +- **Status:** PASS +- All hooks passed: + - fix end of files + - trim trailing whitespace + - check yaml + - check for added large files + - shellcheck + - actionlint (GitHub Actions) + - dockerfile validation + - Go Vet + - golangci-lint (Fast Linters - BLOCKING) + - Check .version matches latest Git tag + - Prevent large files not tracked by LFS + - Prevent committing CodeQL DB artifacts + - Prevent committing data/backups files + - Frontend TypeScript Check + - Frontend Lint (Fix) + +### 6a. Trivy Filesystem Scan + +- **Status:** PASS +- Scanned `backend/` and `frontend/` directories: **0 CRITICAL, 0 HIGH** +- Full workspace scan found 3 CRITICAL + 14 HIGH across Go module cache dependencies (not project code) +- Trivy misconfig scanner crashed (known Trivy bug in ansible parser — nil pointer dereference in `discovery.go:82`). Vuln scanner completed successfully. + +### 6b. Docker Image Scan + +- **Status:** WARN (not blocking — upstream dependency) +- Image: `charon:local` +- **1 HIGH finding:** + - **CVE-2026-25793** — `github.com/slackhq/nebula` v1.9.7 (in `usr/bin/caddy` binary) + - Description: Blocklist evasion via ECDSA Signature Malleability + - Fixed in: v1.10.3 + - Impact: Caddy transitive dependency, not Charon code +- **Remediation:** Upgrade Caddy to a version that pulls nebula ≥ 1.10.3 when available + +### 6c. CodeQL Scans + +- **Status:** PASS +- **Go:** 0 errors, 0 warnings +- **JavaScript:** 0 errors, 0 warnings (347/347 files scanned) +- SARIF outputs: `codeql-results-go.sarif`, `codeql-results-javascript.sarif` + +### 7. GORM Security Scan + +- **Status:** PASS +- Scanned: 41 Go files (2207 lines), 2 seconds +- **0 CRITICAL, 0 HIGH, 0 MEDIUM** +- 2 INFO suggestions: + - `backend/internal/models/user.go:109` — `UserPermittedHost.UserID` missing index + - `backend/internal/models/user.go:110` — `UserPermittedHost.ProxyHostID` missing index + +### 8. Go Vulnerability Check + +- **Status:** PASS +- `govulncheck ./...` — No vulnerabilities found + +--- + +## Gotify Token Security Review + +- No Gotify tokens found in logs, test artifacts, or API examples +- No tokenized URL query parameters exposed in diagnostics or output +- Token handling follows `json:"-"` pattern (verified via `HasToken` computed field approach in PR) + +--- + +## Recommendation + +### GO / NO-GO: **GO** (conditional) + +All blocking gates pass. Two advisory warnings exist: + +1. **Patch coverage** (79.5% overall, 78.3% backend) is below advisory thresholds but not a blocking gate per current policy +2. **Docker image** has 1 HIGH CVE in Caddy's transitive dependency (nebula) — upstream fix required, not actionable in Charon code + +**Conditions:** +- Track nebula CVE-2026-25793 remediation as a follow-up issue when a Caddy update incorporates the fix +- Consider adding targeted tests for uncovered changed lines in notification service/handler to improve patch coverage From 1913e9d7393b737316feee2b50b9dd44612a05f3 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 26 Feb 2026 03:07:26 +0000 Subject: [PATCH 2/4] fix: remove obsolete GHCR downloads badge script --- scripts/update-ghcr-downloads-badge.mjs | 107 ------------------------ 1 file changed, 107 deletions(-) delete mode 100644 scripts/update-ghcr-downloads-badge.mjs diff --git a/scripts/update-ghcr-downloads-badge.mjs b/scripts/update-ghcr-downloads-badge.mjs deleted file mode 100644 index edab7f2a..00000000 --- a/scripts/update-ghcr-downloads-badge.mjs +++ /dev/null @@ -1,107 +0,0 @@ -const DEFAULT_OUTPUT = ".github/badges/ghcr-downloads.json"; -const GH_API_BASE = "https://api.github.com"; - -const owner = process.env.GHCR_OWNER || process.env.GITHUB_REPOSITORY_OWNER; -const packageName = process.env.GHCR_PACKAGE || "charon"; -const outputPath = process.env.BADGE_OUTPUT || DEFAULT_OUTPUT; -const token = process.env.GITHUB_TOKEN || ""; - -if (!owner) { - throw new Error("GHCR owner is required. Set GHCR_OWNER or GITHUB_REPOSITORY_OWNER."); -} - -const headers = { - Accept: "application/vnd.github+json", -}; - -if (token) { - headers.Authorization = `Bearer ${token}`; -} - -const formatCount = (value) => { - if (value >= 1_000_000_000) { - return `${(value / 1_000_000_000).toFixed(1).replace(/\.0$/, "")}B`; - } - if (value >= 1_000_000) { - return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; - } - if (value >= 1_000) { - return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`; - } - return String(value); -}; - -const getNextLink = (linkHeader) => { - if (!linkHeader) { - return null; - } - const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/); - return match ? match[1] : null; -}; - -const fetchPage = async (url) => { - const response = await fetch(url, { headers }); - if (!response.ok) { - const detail = await response.text(); - const error = new Error(`Request failed: ${response.status} ${response.statusText}`); - error.status = response.status; - error.detail = detail; - throw error; - } - const data = await response.json(); - const link = response.headers.get("link"); - return { data, next: getNextLink(link) }; -}; - -const fetchAllVersions = async (baseUrl) => { - let url = `${baseUrl}?per_page=100`; - const versions = []; - - while (url) { - const { data, next } = await fetchPage(url); - versions.push(...data); - url = next; - } - - return versions; -}; - -const fetchVersionsWithFallback = async () => { - const userUrl = `${GH_API_BASE}/users/${owner}/packages/container/${packageName}/versions`; - try { - return await fetchAllVersions(userUrl); - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - - const orgUrl = `${GH_API_BASE}/orgs/${owner}/packages/container/${packageName}/versions`; - return fetchAllVersions(orgUrl); -}; - -const run = async () => { - const versions = await fetchVersionsWithFallback(); - const totalDownloads = versions.reduce( - (sum, version) => sum + (version.download_count || 0), - 0 - ); - - const badge = { - schemaVersion: 1, - label: "GHCR pulls", - message: formatCount(totalDownloads), - color: "blue", - cacheSeconds: 3600, - }; - - const output = `${JSON.stringify(badge, null, 2)}\n`; - await import("node:fs/promises").then((fs) => fs.writeFile(outputPath, output)); - - console.log(`GHCR downloads: ${totalDownloads} -> ${outputPath}`); -}; - -run().catch((error) => { - console.error(error); - process.exit(1); -}); From ac720f95df26cdd2d2d6ace5b0bcbee222a7780e Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 26 Feb 2026 03:30:02 +0000 Subject: [PATCH 3/4] fix: implement GHCR and Docker Hub prune scripts with summary reporting --- .github/workflows/container-prune.yml | 192 ++++++++++++---- .../WORKFLOW_REVIEW_2026-01-26.md | 3 +- scripts/prune-dockerhub.sh | 174 +++++++++++++++ ...rune-container-images.sh => prune-ghcr.sh} | 211 ++++-------------- 4 files changed, 367 insertions(+), 213 deletions(-) create mode 100755 scripts/prune-dockerhub.sh rename scripts/{prune-container-images.sh => prune-ghcr.sh} (50%) diff --git a/.github/workflows/container-prune.yml b/.github/workflows/container-prune.yml index 861774da..64fa4a28 100644 --- a/.github/workflows/container-prune.yml +++ b/.github/workflows/container-prune.yml @@ -6,10 +6,6 @@ on: - cron: '0 3 * * 0' # Weekly: Sundays at 03:00 UTC workflow_dispatch: inputs: - registries: - description: 'Comma-separated registries to prune (ghcr,dockerhub)' - required: false - default: 'ghcr,dockerhub' keep_days: description: 'Number of days to retain images (unprotected)' required: false @@ -28,47 +24,38 @@ permissions: contents: read jobs: - prune: + prune-ghcr: runs-on: ubuntu-latest env: OWNER: ${{ github.repository_owner }} IMAGE_NAME: charon - REGISTRIES: ${{ github.event.inputs.registries || 'ghcr,dockerhub' }} KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }} KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }} - DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }} PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]' + PRUNE_UNTAGGED: 'true' + PRUNE_SBOM_TAGS: 'true' steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install tools run: | - sudo apt-get update && sudo apt-get install -y jq curl gh + sudo apt-get update && sudo apt-get install -y jq curl - - name: Show prune script being executed - run: | - echo "===== SCRIPT PATH =====" - pwd - ls -la scripts - echo "===== FIRST 20 LINES =====" - head -n 20 scripts/prune-container-images.sh - - - name: Run container prune + - name: Run GHCR prune env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} run: | - chmod +x scripts/prune-container-images.sh - ./scripts/prune-container-images.sh 2>&1 | tee prune-${{ github.run_id }}.log + chmod +x scripts/prune-ghcr.sh + ./scripts/prune-ghcr.sh 2>&1 | tee prune-ghcr-${{ github.run_id }}.log - - name: Summarize prune results (space reclaimed) - if: ${{ always() }} + - name: Summarize GHCR results + if: always() run: | set -euo pipefail - SUMMARY_FILE=prune-summary.env - LOG_FILE=prune-${{ github.run_id }}.log + SUMMARY_FILE=prune-summary-ghcr.env + LOG_FILE=prune-ghcr-${{ github.run_id }}.log human() { local bytes=${1:-0} @@ -76,7 +63,7 @@ jobs: echo "0 B" return fi - awk -v b="$bytes" 'function human(x){ split("B KiB MiB GiB TiB",u," "); i=0; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1]} END{human(b)}' + awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }' } if [ -f "$SUMMARY_FILE" ]; then @@ -86,34 +73,155 @@ jobs: TOTAL_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0) { - echo "## Container prune summary" + echo "## GHCR prune summary" echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))" echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))" } >> "$GITHUB_STEP_SUMMARY" - - printf 'PRUNE_SUMMARY: candidates=%s candidates_bytes=%s deleted=%s deleted_bytes=%s\n' \ - "${TOTAL_CANDIDATES}" "${TOTAL_CANDIDATES_BYTES}" "${TOTAL_DELETED}" "${TOTAL_DELETED_BYTES}" - echo "Deleted approximately: $(human "${TOTAL_DELETED_BYTES}")" - echo "space_saved=$(human "${TOTAL_DELETED_BYTES}")" >> "$GITHUB_OUTPUT" else deleted_bytes=$(grep -oE '\( *approx +[0-9]+ bytes\)' "$LOG_FILE" | sed -E 's/.*approx +([0-9]+) bytes.*/\1/' | awk '{s+=$1} END {print s+0}' || true) deleted_count=$(grep -cE 'deleting |DRY RUN: would delete' "$LOG_FILE" || true) { - echo "## Container prune summary" + echo "## GHCR prune summary" echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))" } >> "$GITHUB_STEP_SUMMARY" - - printf 'PRUNE_SUMMARY: deleted_approx=%s deleted_bytes=%s\n' "${deleted_count}" "${deleted_bytes}" - echo "Deleted approximately: $(human "${deleted_bytes}")" - echo "space_saved=$(human "${deleted_bytes}")" >> "$GITHUB_OUTPUT" fi - - name: Upload prune artifacts - if: ${{ always() }} + - name: Upload GHCR prune artifacts + if: always() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: prune-log-${{ github.run_id }} + name: prune-ghcr-log-${{ github.run_id }} path: | - prune-${{ github.run_id }}.log - prune-summary.env + prune-ghcr-${{ github.run_id }}.log + prune-summary-ghcr.env + + prune-dockerhub: + runs-on: ubuntu-latest + env: + OWNER: ${{ github.repository_owner }} + IMAGE_NAME: charon + KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }} + KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }} + DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }} + PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]' + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install tools + run: | + sudo apt-get update && sudo apt-get install -y jq curl + + - name: Run Docker Hub prune + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + chmod +x scripts/prune-dockerhub.sh + ./scripts/prune-dockerhub.sh 2>&1 | tee prune-dockerhub-${{ github.run_id }}.log + + - name: Summarize Docker Hub results + if: always() + run: | + set -euo pipefail + SUMMARY_FILE=prune-summary-dockerhub.env + LOG_FILE=prune-dockerhub-${{ github.run_id }}.log + + human() { + local bytes=${1:-0} + if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then + echo "0 B" + return + fi + awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }' + } + + if [ -f "$SUMMARY_FILE" ]; then + TOTAL_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0) + TOTAL_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0) + TOTAL_DELETED=$(grep -E '^TOTAL_DELETED=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0) + TOTAL_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0) + + { + echo "## Docker Hub prune summary" + echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))" + echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))" + } >> "$GITHUB_STEP_SUMMARY" + else + deleted_bytes=$(grep -oE '\( *approx +[0-9]+ bytes\)' "$LOG_FILE" | sed -E 's/.*approx +([0-9]+) bytes.*/\1/' | awk '{s+=$1} END {print s+0}' || true) + deleted_count=$(grep -cE 'deleting |DRY RUN: would delete' "$LOG_FILE" || true) + + { + echo "## Docker Hub prune summary" + echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))" + } >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload Docker Hub prune artifacts + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: prune-dockerhub-log-${{ github.run_id }} + path: | + prune-dockerhub-${{ github.run_id }}.log + prune-summary-dockerhub.env + + summarize: + runs-on: ubuntu-latest + needs: [prune-ghcr, prune-dockerhub] + if: always() + steps: + - name: Download all artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: prune-*-log-${{ github.run_id }} + merge-multiple: true + + - name: Combined summary + run: | + set -euo pipefail + + human() { + local bytes=${1:-0} + if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then + echo "0 B" + return + fi + awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }' + } + + GHCR_CANDIDATES=0 GHCR_CANDIDATES_BYTES=0 GHCR_DELETED=0 GHCR_DELETED_BYTES=0 + if [ -f prune-summary-ghcr.env ]; then + GHCR_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0) + GHCR_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0) + GHCR_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-ghcr.env | cut -d= -f2 || echo 0) + GHCR_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0) + fi + + HUB_CANDIDATES=0 HUB_CANDIDATES_BYTES=0 HUB_DELETED=0 HUB_DELETED_BYTES=0 + if [ -f prune-summary-dockerhub.env ]; then + HUB_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0) + HUB_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0) + HUB_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0) + HUB_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0) + fi + + TOTAL_CANDIDATES=$((GHCR_CANDIDATES + HUB_CANDIDATES)) + TOTAL_CANDIDATES_BYTES=$((GHCR_CANDIDATES_BYTES + HUB_CANDIDATES_BYTES)) + TOTAL_DELETED=$((GHCR_DELETED + HUB_DELETED)) + TOTAL_DELETED_BYTES=$((GHCR_DELETED_BYTES + HUB_DELETED_BYTES)) + + { + echo "## Combined container prune summary" + echo "" + echo "| Registry | Candidates | Deleted | Space Reclaimed |" + echo "|----------|------------|---------|-----------------|" + echo "| GHCR | ${GHCR_CANDIDATES} | ${GHCR_DELETED} | $(human "${GHCR_DELETED_BYTES}") |" + echo "| Docker Hub | ${HUB_CANDIDATES} | ${HUB_DELETED} | $(human "${HUB_DELETED_BYTES}") |" + echo "| **Total** | **${TOTAL_CANDIDATES}** | **${TOTAL_DELETED}** | **$(human "${TOTAL_DELETED_BYTES}")** |" + } >> "$GITHUB_STEP_SUMMARY" + + printf 'PRUNE_SUMMARY: candidates=%s candidates_bytes=%s deleted=%s deleted_bytes=%s\n' \ + "${TOTAL_CANDIDATES}" "${TOTAL_CANDIDATES_BYTES}" "${TOTAL_DELETED}" "${TOTAL_DELETED_BYTES}" + echo "Total space reclaimed: $(human "${TOTAL_DELETED_BYTES}")" diff --git a/docs/implementation/WORKFLOW_REVIEW_2026-01-26.md b/docs/implementation/WORKFLOW_REVIEW_2026-01-26.md index c82ca778..e9099914 100644 --- a/docs/implementation/WORKFLOW_REVIEW_2026-01-26.md +++ b/docs/implementation/WORKFLOW_REVIEW_2026-01-26.md @@ -159,7 +159,8 @@ A new scheduled workflow and helper script were added to safely prune old contai - **Files added**: - `.github/workflows/container-prune.yml` (weekly schedule, manual dispatch) - - `scripts/prune-container-images.sh` (dry-run by default; supports GHCR and Docker Hub) + - `scripts/prune-ghcr.sh` (GHCR cleanup) + - `scripts/prune-dockerhub.sh` (Docker Hub cleanup) - **Behavior**: - Default: **dry-run=true** (no destructive changes). diff --git a/scripts/prune-dockerhub.sh b/scripts/prune-dockerhub.sh new file mode 100755 index 00000000..f59fe341 --- /dev/null +++ b/scripts/prune-dockerhub.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -euo pipefail +# prune-dockerhub.sh +# Deletes old container images from Docker Hub according to retention and protection rules. + +OWNER=${OWNER:-${GITHUB_REPOSITORY_OWNER:-Wikid82}} +IMAGE_NAME=${IMAGE_NAME:-charon} + +KEEP_DAYS=${KEEP_DAYS:-30} +KEEP_LAST_N=${KEEP_LAST_N:-30} + +DRY_RUN=${DRY_RUN:-false} +PROTECTED_REGEX=${PROTECTED_REGEX:-'["^v","^latest$","^main$","^develop$"]'} + +DOCKERHUB_USERNAME=${DOCKERHUB_USERNAME:-} +DOCKERHUB_TOKEN=${DOCKERHUB_TOKEN:-} + +LOG_PREFIX="[prune-dockerhub]" + +cutoff_ts=$(date -d "$KEEP_DAYS days ago" +%s 2>/dev/null || date -d "-$KEEP_DAYS days" +%s) + +dry_run=false +case "${DRY_RUN,,}" in + true|1|yes|y|on) dry_run=true ;; + *) dry_run=false ;; +esac + +TOTAL_CANDIDATES=0 +TOTAL_CANDIDATES_BYTES=0 +TOTAL_DELETED=0 +TOTAL_DELETED_BYTES=0 + +echo "$LOG_PREFIX starting with OWNER=$OWNER IMAGE_NAME=$IMAGE_NAME KEEP_DAYS=$KEEP_DAYS KEEP_LAST_N=$KEEP_LAST_N DRY_RUN=$dry_run" +echo "$LOG_PREFIX PROTECTED_REGEX=$PROTECTED_REGEX" + +require() { + command -v "$1" >/dev/null 2>&1 || { echo "$LOG_PREFIX missing required command: $1" >&2; exit 1; } +} +require curl +require jq + +is_protected_tag() { + local tag="$1" + local rgx + while IFS= read -r rgx; do + [[ -z "$rgx" ]] && continue + if [[ "$tag" =~ $rgx ]]; then + return 0 + fi + done < <(echo "$PROTECTED_REGEX" | jq -r '.[]') + return 1 +} + +human_readable() { + local bytes=${1:-0} + if [[ -z "$bytes" ]] || (( bytes <= 0 )); then + echo "0 B" + return + fi + local unit=(B KiB MiB GiB TiB) + local i=0 + local value=$bytes + while (( value > 1024 )) && (( i < 4 )); do + value=$((value / 1024)) + i=$((i + 1)) + done + printf "%s %s" "${value}" "${unit[$i]}" +} + +action_delete_dockerhub() { + echo "$LOG_PREFIX -> Docker Hub cleanup for ${DOCKERHUB_USERNAME:-}/$IMAGE_NAME (dry-run=$dry_run)" + + if [[ -z "${DOCKERHUB_USERNAME:-}" || -z "${DOCKERHUB_TOKEN:-}" ]]; then + echo "$LOG_PREFIX Docker Hub credentials not set; skipping Docker Hub cleanup" + return + fi + + local hub_token page page_size all resp results_count total + local keep_tags tag tag_name last_updated last_ts protected bytes + + hub_token=$(printf '{"username":"%s","password":"%s"}' "$DOCKERHUB_USERNAME" "$DOCKERHUB_TOKEN" | \ + curl -sS -X POST -H "Content-Type: application/json" --data-binary @- \ + https://hub.docker.com/v2/users/login/ | jq -r '.token') + + if [[ -z "$hub_token" || "$hub_token" == "null" ]]; then + echo "$LOG_PREFIX Failed to obtain Docker Hub token; aborting Docker Hub cleanup" + return + fi + + page=1 + page_size=100 + all='[]' + while :; do + resp=$(curl -sS -H "Authorization: JWT $hub_token" \ + "https://hub.docker.com/v2/repositories/${DOCKERHUB_USERNAME}/${IMAGE_NAME}/tags?page_size=$page_size&page=$page") + + results_count=$(echo "$resp" | jq -r '.results | length') + if [[ -z "$results_count" || "$results_count" == "0" ]]; then + break + fi + + all=$(jq -s '.[0] + .[1].results' <(echo "$all") <(echo "$resp")) + ((page++)) + done + + total=$(echo "$all" | jq -r 'length') + if [[ -z "$total" || "$total" == "0" ]]; then + echo "$LOG_PREFIX Docker Hub: no tags found" + return + fi + + echo "$LOG_PREFIX Docker Hub: fetched $total tags total" + + keep_tags=$(echo "$all" | jq -r --argjson n "${KEEP_LAST_N:-0}" ' + (sort_by(.last_updated) | reverse) as $s + | ($s[0:$n] | map(.name)) | join(" ") + ') + + while IFS= read -r tag; do + tag_name=$(echo "$tag" | jq -r '.name') + last_updated=$(echo "$tag" | jq -r '.last_updated') + last_ts=$(date -d "$last_updated" +%s 2>/dev/null || echo 0) + + if [[ -n "$keep_tags" && " $keep_tags " == *" $tag_name "* ]]; then + echo "$LOG_PREFIX keep (last_n): tag=$tag_name last_updated=$last_updated" + continue + fi + + protected=false + if is_protected_tag "$tag_name"; then + protected=true + fi + if $protected; then + echo "$LOG_PREFIX keep (protected): tag=$tag_name last_updated=$last_updated" + continue + fi + + if (( last_ts >= cutoff_ts )); then + echo "$LOG_PREFIX keep (recent): tag=$tag_name last_updated=$last_updated" + continue + fi + + echo "$LOG_PREFIX candidate: tag=$tag_name last_updated=$last_updated" + + bytes=$(echo "$tag" | jq -r '.images | map(.size) | add // 0' 2>/dev/null || echo 0) + TOTAL_CANDIDATES=$((TOTAL_CANDIDATES + 1)) + TOTAL_CANDIDATES_BYTES=$((TOTAL_CANDIDATES_BYTES + bytes)) + + if $dry_run; then + echo "$LOG_PREFIX DRY RUN: would delete Docker Hub tag=$tag_name (approx ${bytes} bytes)" + else + echo "$LOG_PREFIX deleting Docker Hub tag=$tag_name (approx ${bytes} bytes)" + curl -sS -X DELETE -H "Authorization: JWT $hub_token" \ + "https://hub.docker.com/v2/repositories/${DOCKERHUB_USERNAME}/${IMAGE_NAME}/tags/${tag_name}/" >/dev/null || true + TOTAL_DELETED=$((TOTAL_DELETED + 1)) + TOTAL_DELETED_BYTES=$((TOTAL_DELETED_BYTES + bytes)) + fi + + done < <(echo "$all" | jq -c 'sort_by(.last_updated) | .[]') +} + +# Main +action_delete_dockerhub + +echo "$LOG_PREFIX SUMMARY: total_candidates=${TOTAL_CANDIDATES} total_candidates_bytes=${TOTAL_CANDIDATES_BYTES} total_deleted=${TOTAL_DELETED} total_deleted_bytes=${TOTAL_DELETED_BYTES}" +echo "$LOG_PREFIX SUMMARY_HUMAN: candidates=${TOTAL_CANDIDATES} candidates_size=$(human_readable "${TOTAL_CANDIDATES_BYTES}") deleted=${TOTAL_DELETED} deleted_size=$(human_readable "${TOTAL_DELETED_BYTES}")" + +: > prune-summary-dockerhub.env +echo "TOTAL_CANDIDATES=${TOTAL_CANDIDATES}" >> prune-summary-dockerhub.env +echo "TOTAL_CANDIDATES_BYTES=${TOTAL_CANDIDATES_BYTES}" >> prune-summary-dockerhub.env +echo "TOTAL_DELETED=${TOTAL_DELETED}" >> prune-summary-dockerhub.env +echo "TOTAL_DELETED_BYTES=${TOTAL_DELETED_BYTES}" >> prune-summary-dockerhub.env + +echo "$LOG_PREFIX done" diff --git a/scripts/prune-container-images.sh b/scripts/prune-ghcr.sh similarity index 50% rename from scripts/prune-container-images.sh rename to scripts/prune-ghcr.sh index 18edf625..8900fbd8 100755 --- a/scripts/prune-container-images.sh +++ b/scripts/prune-ghcr.sh @@ -1,10 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -echo "[prune] SCRIPT VERSION: GH_API_VARIANT" -# prune-container-images.sh -# Deletes old images from GHCR and Docker Hub according to retention and protection rules. +# prune-ghcr.sh +# Deletes old container images from GitHub Container Registry (GHCR) +# according to retention and protection rules. -REGISTRIES=${REGISTRIES:-ghcr} OWNER=${OWNER:-${GITHUB_REPOSITORY_OWNER:-Wikid82}} IMAGE_NAME=${IMAGE_NAME:-charon} @@ -14,33 +13,29 @@ KEEP_LAST_N=${KEEP_LAST_N:-30} DRY_RUN=${DRY_RUN:-false} PROTECTED_REGEX=${PROTECTED_REGEX:-'["^v","^latest$","^main$","^develop$"]'} -# Extra knobs (optional) PRUNE_UNTAGGED=${PRUNE_UNTAGGED:-true} PRUNE_SBOM_TAGS=${PRUNE_SBOM_TAGS:-true} -LOG_PREFIX="[prune]" +LOG_PREFIX="[prune-ghcr]" -now_ts=$(date +%s) cutoff_ts=$(date -d "$KEEP_DAYS days ago" +%s 2>/dev/null || date -d "-$KEEP_DAYS days" +%s) -# Normalize DRY_RUN to true/false reliably dry_run=false case "${DRY_RUN,,}" in true|1|yes|y|on) dry_run=true ;; *) dry_run=false ;; esac -# Totals TOTAL_CANDIDATES=0 TOTAL_CANDIDATES_BYTES=0 TOTAL_DELETED=0 TOTAL_DELETED_BYTES=0 -echo "$LOG_PREFIX starting with REGISTRIES=$REGISTRIES OWNER=$OWNER IMAGE_NAME=$IMAGE_NAME KEEP_DAYS=$KEEP_DAYS KEEP_LAST_N=$KEEP_LAST_N DRY_RUN=$dry_run" +echo "$LOG_PREFIX starting with OWNER=$OWNER IMAGE_NAME=$IMAGE_NAME KEEP_DAYS=$KEEP_DAYS KEEP_LAST_N=$KEEP_LAST_N DRY_RUN=$dry_run" echo "$LOG_PREFIX PROTECTED_REGEX=$PROTECTED_REGEX PRUNE_UNTAGGED=$PRUNE_UNTAGGED PRUNE_SBOM_TAGS=$PRUNE_SBOM_TAGS" require() { - command -v "$1" >/dev/null 2>&1 || { echo "$LOG_PREFIX missing required command: $1"; exit 1; } + command -v "$1" >/dev/null 2>&1 || { echo "$LOG_PREFIX missing required command: $1" >&2; exit 1; } } require curl require jq @@ -57,8 +52,6 @@ is_protected_tag() { return 1 } -# Some repos generate tons of tags like sha-xxxx, pr-123-xxxx, *.sbom. -# We treat SBOM-only tags as deletable (optional). tag_is_sbom() { local tag="$1" [[ "$tag" == *.sbom ]] @@ -80,9 +73,9 @@ human_readable() { printf "%s %s" "${value}" "${unit[$i]}" } -# --- GHCR --- +# All echo/log statements go to stderr so stdout remains pure JSON ghcr_list_all_versions_json() { - local namespace_type="$1" # orgs or users + local namespace_type="$1" local page=1 local per_page=100 local all='[]' @@ -90,7 +83,6 @@ ghcr_list_all_versions_json() { while :; do local url="https://api.github.com/${namespace_type}/${OWNER}/packages/container/${IMAGE_NAME}/versions?per_page=$per_page&page=$page" - # Use GitHub’s recommended headers local resp resp=$(curl -sS \ -H "Authorization: Bearer $GITHUB_TOKEN" \ @@ -98,29 +90,26 @@ ghcr_list_all_versions_json() { -H "X-GitHub-Api-Version: 2022-11-28" \ "$url" || true) - # ✅ NEW: ensure we got JSON if ! echo "$resp" | jq -e . >/dev/null 2>&1; then - echo "$LOG_PREFIX GHCR returned non-JSON for url=$url" - echo "$LOG_PREFIX GHCR response (first 200 chars): $(echo "$resp" | head -c 200 | tr '\n' ' ')" + echo "$LOG_PREFIX GHCR returned non-JSON for url=$url" >&2 + echo "$LOG_PREFIX GHCR response (first 200 chars): $(echo "$resp" | head -c 200 | tr '\n' ' ')" >&2 echo "[]" return 0 fi - # Handle JSON error messages if echo "$resp" | jq -e 'has("message")' >/dev/null 2>&1; then local msg msg=$(echo "$resp" | jq -r '.message') if [[ "$msg" == "Not Found" ]]; then - echo "$LOG_PREFIX GHCR ${namespace_type} endpoint returned Not Found" + echo "$LOG_PREFIX GHCR ${namespace_type} endpoint returned Not Found" >&2 echo "[]" return 0 fi - echo "$LOG_PREFIX GHCR API error: $msg" - # also print documentation_url if present (helpful) + echo "$LOG_PREFIX GHCR API error: $msg" >&2 doc=$(echo "$resp" | jq -r '.documentation_url // empty') - [[ -n "$doc" ]] && echo "$LOG_PREFIX GHCR docs: $doc" + [[ -n "$doc" ]] && echo "$LOG_PREFIX GHCR docs: $doc" >&2 echo "[]" return 0 fi @@ -146,7 +135,6 @@ action_delete_ghcr() { return fi - # Try orgs first, then users local all local namespace_type="orgs" all=$(ghcr_list_all_versions_json "$namespace_type") @@ -164,12 +152,6 @@ action_delete_ghcr() { echo "$LOG_PREFIX GHCR: fetched $total versions total" - # Normalize a working list: - # - id - # - created_at - # - created_ts - # - tags array - # - tags_csv local normalized normalized=$(echo "$all" | jq -c ' map({ @@ -181,8 +163,6 @@ action_delete_ghcr() { }) ') - # Compute the globally newest KEEP_LAST_N ids to always keep - # (If KEEP_LAST_N is 0 or empty, keep none by this rule) local keep_ids keep_ids=$(echo "$normalized" | jq -r --argjson n "${KEEP_LAST_N:-0}" ' (sort_by(.created_ts) | reverse) as $s @@ -193,21 +173,20 @@ action_delete_ghcr() { echo "$LOG_PREFIX GHCR: keeping newest KEEP_LAST_N ids: $KEEP_LAST_N" fi - # Iterate versions sorted oldest->newest so deletions are predictable + local ver protected all_sbom candidate_bytes while IFS= read -r ver; do local id created created_ts tags_csv + all_sbom=false id=$(echo "$ver" | jq -r '.id') created=$(echo "$ver" | jq -r '.created_at') created_ts=$(echo "$ver" | jq -r '.created_ts') tags_csv=$(echo "$ver" | jq -r '.tags_csv') - # KEEP_LAST_N rule (global) if [[ -n "$keep_ids" && " $keep_ids " == *" $id "* ]]; then echo "$LOG_PREFIX keep (last_n): id=$id tags=$tags_csv created=$created" continue fi - # Protected tags rule protected=false if [[ -n "$tags_csv" ]]; then while IFS= read -r t; do @@ -223,8 +202,6 @@ action_delete_ghcr() { continue fi - # Optional: treat SBOM-only versions/tags as deletable - # If every tag is *.sbom and PRUNE_SBOM_TAGS=true, we allow pruning regardless of “tag protected” rules. if [[ "${PRUNE_SBOM_TAGS,,}" == "true" && -n "$tags_csv" ]]; then all_sbom=true while IFS= read -r t; do @@ -234,46 +211,40 @@ action_delete_ghcr() { break fi done < <(echo "$tags_csv" | tr ',' '\n') - if $all_sbom; then - # allow fallthrough; do not "keep" just because tags are recent - : - fi fi - # Age rule - if (( created_ts >= cutoff_ts )); then - echo "$LOG_PREFIX keep (recent): id=$id tags=$tags_csv created=$created" - continue - fi - - # Optional: prune untagged versions (common GHCR bloat) - if [[ "${PRUNE_UNTAGGED,,}" == "true" ]]; then - # tags_csv can be empty for untagged - if [[ -z "$tags_csv" ]]; then - echo "$LOG_PREFIX candidate (untagged): id=$id tags= created=$created" - else - echo "$LOG_PREFIX candidate: id=$id tags=$tags_csv created=$created" - fi + # If all tags are SBOM tags and PRUNE_SBOM_TAGS is enabled, skip the age check + if [[ "${all_sbom:-false}" == "true" ]]; then + echo "$LOG_PREFIX candidate (sbom-only): id=$id tags=$tags_csv created=$created" else - # If not pruning untagged, skip them - if [[ -z "$tags_csv" ]]; then - echo "$LOG_PREFIX keep (untagged disabled): id=$id created=$created" + if (( created_ts >= cutoff_ts )); then + echo "$LOG_PREFIX keep (recent): id=$id tags=$tags_csv created=$created" continue fi - echo "$LOG_PREFIX candidate: id=$id tags=$tags_csv created=$created" + + if [[ "${PRUNE_UNTAGGED,,}" == "true" ]]; then + if [[ -z "$tags_csv" ]]; then + echo "$LOG_PREFIX candidate (untagged): id=$id tags= created=$created" + else + echo "$LOG_PREFIX candidate: id=$id tags=$tags_csv created=$created" + fi + else + if [[ -z "$tags_csv" ]]; then + echo "$LOG_PREFIX keep (untagged disabled): id=$id created=$created" + continue + fi + echo "$LOG_PREFIX candidate: id=$id tags=$tags_csv created=$created" + fi fi - # Candidate bookkeeping TOTAL_CANDIDATES=$((TOTAL_CANDIDATES + 1)) - # Best-effort size estimation: GHCR registry auth is messy; don’t block prune on it. candidate_bytes=0 if $dry_run; then echo "$LOG_PREFIX DRY RUN: would delete GHCR version id=$id (approx ${candidate_bytes} bytes)" else echo "$LOG_PREFIX deleting GHCR version id=$id" - # Use GitHub API delete curl -sS -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \ "https://api.github.com/${namespace_type}/${OWNER}/packages/container/${IMAGE_NAME}/versions/$id" >/dev/null || true TOTAL_DELETED=$((TOTAL_DELETED + 1)) @@ -282,116 +253,16 @@ action_delete_ghcr() { done < <(echo "$normalized" | jq -c 'sort_by(.created_ts) | .[]') } -# --- Docker Hub --- -action_delete_dockerhub() { - echo "$LOG_PREFIX -> Docker Hub cleanup for ${DOCKERHUB_USERNAME:-}/$IMAGE_NAME (dry-run=$dry_run)" +# Main +action_delete_ghcr - if [[ -z "${DOCKERHUB_USERNAME:-}" || -z "${DOCKERHUB_TOKEN:-}" ]]; then - echo "$LOG_PREFIX Docker Hub credentials not set; skipping Docker Hub cleanup" - return - fi - - hub_token=$(curl -sS -X POST -H "Content-Type: application/json" \ - -d "{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_TOKEN}\"}" \ - https://hub.docker.com/v2/users/login/ | jq -r '.token') - - if [[ -z "$hub_token" || "$hub_token" == "null" ]]; then - echo "$LOG_PREFIX Failed to obtain Docker Hub token; aborting Docker Hub cleanup" - return - fi - - # Fetch all pages first so KEEP_LAST_N can be global - page=1 - page_size=100 - all='[]' - while :; do - resp=$(curl -sS -H "Authorization: JWT $hub_token" \ - "https://hub.docker.com/v2/repositories/${DOCKERHUB_USERNAME}/${IMAGE_NAME}/tags?page_size=$page_size&page=$page") - - results_count=$(echo "$resp" | jq -r '.results | length') - if [[ -z "$results_count" || "$results_count" == "0" ]]; then - break - fi - - all=$(jq -s '.[0] + .[1].results' <(echo "$all") <(echo "$resp")) - ((page++)) - done - - total=$(echo "$all" | jq -r 'length') - if [[ -z "$total" || "$total" == "0" ]]; then - echo "$LOG_PREFIX Docker Hub: no tags found" - return - fi - - echo "$LOG_PREFIX Docker Hub: fetched $total tags total" - - keep_tags=$(echo "$all" | jq -r --argjson n "${KEEP_LAST_N:-0}" ' - (sort_by(.last_updated) | reverse) as $s - | ($s[0:$n] | map(.name)) | join(" ") - ') - - while IFS= read -r tag; do - tag_name=$(echo "$tag" | jq -r '.name') - last_updated=$(echo "$tag" | jq -r '.last_updated') - last_ts=$(date -d "$last_updated" +%s 2>/dev/null || 0) - - if [[ -n "$keep_tags" && " $keep_tags " == *" $tag_name "* ]]; then - echo "$LOG_PREFIX keep (last_n): tag=$tag_name last_updated=$last_updated" - continue - fi - - protected=false - if is_protected_tag "$tag_name"; then - protected=true - fi - if $protected; then - echo "$LOG_PREFIX keep (protected): tag=$tag_name last_updated=$last_updated" - continue - fi - - if (( last_ts >= cutoff_ts )); then - echo "$LOG_PREFIX keep (recent): tag=$tag_name last_updated=$last_updated" - continue - fi - - echo "$LOG_PREFIX candidate: tag=$tag_name last_updated=$last_updated" - - bytes=$(echo "$tag" | jq -r '.images | map(.size) | add // 0' 2>/dev/null || echo 0) - TOTAL_CANDIDATES=$((TOTAL_CANDIDATES + 1)) - TOTAL_CANDIDATES_BYTES=$((TOTAL_CANDIDATES_BYTES + bytes)) - - if $dry_run; then - echo "$LOG_PREFIX DRY RUN: would delete Docker Hub tag=$tag_name (approx ${bytes} bytes)" - else - echo "$LOG_PREFIX deleting Docker Hub tag=$tag_name (approx ${bytes} bytes)" - curl -sS -X DELETE -H "Authorization: JWT $hub_token" \ - "https://hub.docker.com/v2/repositories/${DOCKERHUB_USERNAME}/${IMAGE_NAME}/tags/${tag_name}/" >/dev/null || true - TOTAL_DELETED=$((TOTAL_DELETED + 1)) - TOTAL_DELETED_BYTES=$((TOTAL_DELETED_BYTES + bytes)) - fi - - done < <(echo "$all" | jq -c 'sort_by(.last_updated) | .[]') -} - -# Main: iterate requested registries -IFS=',' read -ra regs <<< "$REGISTRIES" -for r in "${regs[@]}"; do - case "$r" in - ghcr) action_delete_ghcr ;; - dockerhub) action_delete_dockerhub ;; - *) echo "$LOG_PREFIX unknown registry: $r" ;; - esac -done - -# Summary echo "$LOG_PREFIX SUMMARY: total_candidates=${TOTAL_CANDIDATES} total_candidates_bytes=${TOTAL_CANDIDATES_BYTES} total_deleted=${TOTAL_DELETED} total_deleted_bytes=${TOTAL_DELETED_BYTES}" echo "$LOG_PREFIX SUMMARY_HUMAN: candidates=${TOTAL_CANDIDATES} candidates_size=$(human_readable "${TOTAL_CANDIDATES_BYTES}") deleted=${TOTAL_DELETED} deleted_size=$(human_readable "${TOTAL_DELETED_BYTES}")" -# Export summary for workflow parsing -: > prune-summary.env -echo "TOTAL_CANDIDATES=${TOTAL_CANDIDATES}" >> prune-summary.env -echo "TOTAL_CANDIDATES_BYTES=${TOTAL_CANDIDATES_BYTES}" >> prune-summary.env -echo "TOTAL_DELETED=${TOTAL_DELETED}" >> prune-summary.env -echo "TOTAL_DELETED_BYTES=${TOTAL_DELETED_BYTES}" >> prune-summary.env +: > prune-summary-ghcr.env +echo "TOTAL_CANDIDATES=${TOTAL_CANDIDATES}" >> prune-summary-ghcr.env +echo "TOTAL_CANDIDATES_BYTES=${TOTAL_CANDIDATES_BYTES}" >> prune-summary-ghcr.env +echo "TOTAL_DELETED=${TOTAL_DELETED}" >> prune-summary-ghcr.env +echo "TOTAL_DELETED_BYTES=${TOTAL_DELETED_BYTES}" >> prune-summary-ghcr.env echo "$LOG_PREFIX done" From ccdc71950153cff53d6eee71cbd3555afbb4009d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:31:33 +0000 Subject: [PATCH 4/4] fix(deps): update non-major-updates --- .github/workflows/docker-build.yml | 2 +- .github/workflows/nightly-build.yml | 2 +- .github/workflows/quality-checks.yml | 4 ++-- .github/workflows/security-pr.yml | 2 +- .github/workflows/supply-chain-pr.yml | 4 ++-- .github/workflows/supply-chain-verify.yml | 2 +- backend/go.mod | 2 +- backend/go.sum | 2 ++ frontend/package-lock.json | 18 +++++++++--------- frontend/package.json | 4 ++-- package-lock.json | 8 ++++---- package.json | 2 +- 12 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 2484fa17..5741f5dc 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -570,7 +570,7 @@ jobs: # Generate SBOM (Software Bill of Materials) for supply chain security # Only for production builds (main/development) - feature branches use downstream supply-chain-pr.yml - name: Generate SBOM - uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0 if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' with: image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }} diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 9230e796..90d59050 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -220,7 +220,7 @@ jobs: echo "- ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY" - name: Generate SBOM - uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0 with: image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }} format: cyclonedx-json diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index cef355c1..19065708 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -28,7 +28,7 @@ jobs: ref: ${{ github.sha }} - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: ${{ env.GO_VERSION }} cache-dependency-path: backend/go.sum @@ -134,7 +134,7 @@ jobs: } >> "$GITHUB_ENV" - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: ${{ env.GO_VERSION }} cache-dependency-path: backend/go.sum diff --git a/.github/workflows/security-pr.yml b/.github/workflows/security-pr.yml index 965b652a..bd93f198 100644 --- a/.github/workflows/security-pr.yml +++ b/.github/workflows/security-pr.yml @@ -306,7 +306,7 @@ jobs: - name: Upload scan artifacts if: always() && steps.trivy-sarif-check.outputs.exists == 'true' # actions/upload-artifact v4.4.3 - uses: actions/upload-artifact@47309c993abb98030a35d55ef7ff34b7fa1074b5 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }} path: | diff --git a/.github/workflows/supply-chain-pr.yml b/.github/workflows/supply-chain-pr.yml index 41eb6950..2dd63c17 100644 --- a/.github/workflows/supply-chain-pr.yml +++ b/.github/workflows/supply-chain-pr.yml @@ -264,7 +264,7 @@ jobs: # Generate SBOM using official Anchore action (auto-updated by Renovate) - name: Generate SBOM if: steps.set-target.outputs.image_name != '' - uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0 id: sbom with: image: ${{ steps.set-target.outputs.image_name }} @@ -369,7 +369,7 @@ jobs: - name: Upload supply chain artifacts if: steps.set-target.outputs.image_name != '' # actions/upload-artifact v4.6.0 - uses: actions/upload-artifact@47309c993abb98030a35d55ef7ff34b7fa1074b5 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: ${{ steps.pr-number.outputs.is_push == 'true' && format('supply-chain-{0}', steps.sanitize.outputs.branch) || format('supply-chain-pr-{0}', steps.pr-number.outputs.pr_number) }} path: | diff --git a/.github/workflows/supply-chain-verify.yml b/.github/workflows/supply-chain-verify.yml index aacab9b6..37f81d47 100644 --- a/.github/workflows/supply-chain-verify.yml +++ b/.github/workflows/supply-chain-verify.yml @@ -119,7 +119,7 @@ jobs: # Generate SBOM using official Anchore action (auto-updated by Renovate) - name: Generate and Verify SBOM if: steps.image-check.outputs.exists == 'true' - uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0 with: image: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }} format: cyclonedx-json diff --git a/backend/go.mod b/backend/go.mod index 42e48b09..9a6a848b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -17,7 +17,7 @@ require ( github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.48.0 - golang.org/x/net v0.50.0 + golang.org/x/net v0.51.0 golang.org/x/text v0.34.0 golang.org/x/time v0.14.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 diff --git a/backend/go.sum b/backend/go.sum index abe43414..2f3b4cab 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -200,6 +200,8 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6c23ec3c..e6942107 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,7 +41,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.56.1", @@ -50,7 +50,7 @@ "@vitest/coverage-istanbul": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", - "autoprefixer": "^10.4.24", + "autoprefixer": "^10.4.27", "eslint": "^9.39.3 <10.0.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -3565,9 +3565,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz", + "integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==", "dev": true, "license": "MIT", "dependencies": { @@ -4186,9 +4186,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.24", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", - "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", "dev": true, "funding": [ { @@ -4207,7 +4207,7 @@ "license": "MIT", "dependencies": { "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001766", + "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" diff --git a/frontend/package.json b/frontend/package.json index 8ef7c0bd..d7832275 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,7 +60,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.56.1", @@ -69,7 +69,7 @@ "@vitest/coverage-istanbul": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", - "autoprefixer": "^10.4.24", + "autoprefixer": "^10.4.27", "eslint": "^9.39.3 <10.0.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/package-lock.json b/package-lock.json index 7cd3fd4f..045dcf49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "devDependencies": { "@bgotink/playwright-coverage": "^0.3.2", "@playwright/test": "^1.58.2", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "dotenv": "^17.3.1", "markdownlint-cli2": "^0.21.0", "prettier": "^3.8.1", @@ -937,9 +937,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz", + "integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==", "devOptional": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8f302a5c..10208608 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "devDependencies": { "@bgotink/playwright-coverage": "^0.3.2", "@playwright/test": "^1.58.2", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "dotenv": "^17.3.1", "markdownlint-cli2": "^0.21.0", "prettier": "^3.8.1",