Compare commits

...

197 Commits

Author SHA1 Message Date
Jeremy
f58c96d29f Merge pull request #784 from Wikid82/nightly
Weekly Nightly Promotion
2026-03-02 10:00:05 -05:00
Jeremy
2ecd6dd9d4 Merge branch 'main' into nightly 2026-03-02 09:38:57 -05:00
Jeremy
409dc0526f Merge pull request #779 from Wikid82/feature/beta-release
Uptime Monitoring Hotfix
2026-03-01 23:10:57 -05:00
GitHub Actions
10259146df fix(uptime): implement initial uptime bootstrap logic and related tests 2026-03-02 03:40:37 +00:00
Jeremy
8cbd907d82 Merge pull request #781 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update non-major-updates (feature/beta-release)
2026-03-01 22:16:52 -05:00
Jeremy
ff5ef35a0f Merge pull request #780 from Wikid82/bot/update-geolite2-checksum
chore(docker): update GeoLite2-Country.mmdb checksum
2026-03-01 22:16:18 -05:00
renovate[bot]
fbb86b1cc3 chore(deps): update non-major-updates 2026-03-02 03:15:19 +00:00
Wikid82
0f995edbd1 chore(docker): update GeoLite2-Country.mmdb checksum
Automated checksum update for GeoLite2-Country.mmdb database.

Old: 86fe00e0272865b8bec79defca2e9fb19ad0cf4458697992e1a37ba89077c13a
New: d3031e02196523cbb5f74291122033f2be277b2130abedd4b5bee52ba79832be

Auto-generated by: .github/workflows/update-geolite2.yml
2026-03-02 02:53:18 +00:00
GitHub Actions
aaddb88488 fix(uptime): refine host monitor checks to short-circuit TCP monitors while allowing HTTP/HTTPS checks 2026-03-02 00:24:03 +00:00
GitHub Actions
f79f0218c5 fix(tests): update mock heartbeat generation to align with monitor's latest status 2026-03-01 17:38:01 +00:00
GitHub Actions
d94c9ba623 fix(tests): enhance overwrite resolution flow test to handle browser-specific authentication 2026-03-01 17:17:49 +00:00
GitHub Actions
0241de69f4 fix(uptime): enhance monitor status handling and display logic in MonitorCard 2026-03-01 16:33:09 +00:00
GitHub Actions
f20e789a16 fix(tests): increase timeout for ProxyHostForm tests to improve reliability 2026-03-01 16:30:51 +00:00
GitHub Actions
6f5c8873f9 fix(tests): refactor proxy host creation to use dynamic server URLs in uptime tests 2026-03-01 16:30:21 +00:00
GitHub Actions
7a12ab7928 fix(uptime): remove redundant host failure count reset logic 2026-03-01 16:26:24 +00:00
GitHub Actions
871adca270 fix(deps): update modernc.org/libc to v1.69.0 for improved compatibility 2026-03-01 14:08:13 +00:00
GitHub Actions
dbff270d22 fix(tests): update input handling in ProxyHostForm tests for improved reliability 2026-03-01 14:04:40 +00:00
GitHub Actions
8e1b9d91e2 fix(tests): enhance session handling and cleanup in Caddy import tests 2026-03-01 13:43:50 +00:00
GitHub Actions
67bcef32e4 fix(tests): improve header verification and response handling in Firefox import tests 2026-03-01 13:43:42 +00:00
GitHub Actions
739104e029 fix(workflows): update cron schedule for weekly security rebuild and nightly promotion 2026-03-01 13:14:25 +00:00
GitHub Actions
2204b7bd35 fix(tests): implement retry logic for session reset and navigation stability in Caddy import tests 2026-03-01 13:06:47 +00:00
GitHub Actions
fdbba5b838 fix(tests): remove redundant caddy-import spec exclusions for improved test coverage 2026-03-01 13:06:36 +00:00
GitHub Actions
4ff65c83be fix(tests): refactor CORS handling in Firefox import tests for improved clarity and reliability 2026-03-01 05:31:37 +00:00
GitHub Actions
3409e204eb fix(tests): enhance timeout handling for UI preconditions in import page navigation 2026-03-01 05:18:44 +00:00
GitHub Actions
61bb19e6f3 fix(tests): enhance session resume handling in import tests for improved reliability 2026-03-01 05:18:33 +00:00
GitHub Actions
3cc979f5b8 fix(tests): remove webkit-only test skipping logic for improved test execution 2026-03-01 05:16:38 +00:00
GitHub Actions
ef8f237233 fix(tests): remove redundant Firefox-only test skipping logic 2026-03-01 05:16:27 +00:00
GitHub Actions
43a63007a7 fix(tests): update testIgnore patterns to exclude specific caddy-import tests 2026-03-01 05:14:59 +00:00
GitHub Actions
404aa92ea0 fix(tests): improve response handling and session management in import tests 2026-03-01 05:11:18 +00:00
GitHub Actions
94356e7d4e fix(logging): convert hostID to string for improved logging in SyncAndCheckForHost 2026-03-01 03:56:41 +00:00
GitHub Actions
63c9976e5f fix(tests): improve login handling in navigation tests to manage transient 401 errors 2026-03-01 03:54:45 +00:00
GitHub Actions
09ef4f579e fix(tests): optimize response handling in Firefox import tests 2026-03-01 03:50:50 +00:00
GitHub Actions
fbd94a031e fix(import): handle cancellation of stale import sessions in various states 2026-03-01 03:50:43 +00:00
GitHub Actions
6483a25555 chore(tests): remove deprecated proxy host dropdown tests 2026-03-01 03:49:20 +00:00
GitHub Actions
61b73bc57b fix(tests): increase dashboard load time threshold to 8 seconds 2026-03-01 03:49:12 +00:00
GitHub Actions
d77d618de0 feat(uptime): add pending state handling for monitors; update translations and tests 2026-03-01 02:51:18 +00:00
GitHub Actions
2cd19d8964 fix(uptime): implement SyncAndCheckForHost and cleanup stale failure counts; add tests for concurrency and feature flag handling 2026-03-01 02:46:49 +00:00
GitHub Actions
61d4e12c56 fix(deps): update go.mod entries for various dependencies 2026-03-01 02:46:49 +00:00
Jeremy
5c5c1eabfc Merge branch 'development' into feature/beta-release 2026-02-28 21:02:54 -05:00
GitHub Actions
d9cc0ead71 chore: move ACL and Security Headers hotfix plan documentation to archive 2026-03-01 01:43:10 +00:00
GitHub Actions
b78798b877 chore: Update dependencies in go.sum
- Bump github.com/bytedance/sonic from v1.14.1 to v1.15.0
- Bump github.com/gabriel-vasile/mimetype from v1.4.12 to v1.4.13
- Bump github.com/glebarez/go-sqlite from v1.21.2 to v1.22.0
- Bump github.com/gin-gonic/gin from v1.11.0 to v1.12.0
- Bump github.com/google/pprof to v0.0.0-20250317173921-a4b03ec1a45e
- Bump go.opentelemetry.io/auto/sdk to v1.2.1
- Bump go.opentelemetry.io/otel to v1.40.0
- Update various other dependencies to their latest versions
2026-03-01 01:34:37 +00:00
GitHub Actions
e90ad34c28 chore: add script to update Go module dependencies 2026-03-01 01:33:26 +00:00
GitHub Actions
1a559e3c64 fix(deps): update caniuse-lite to version 1.0.30001775 2026-03-01 01:31:48 +00:00
GitHub Actions
a83967daa3 fix(deps): add new dependencies for pbkdf2, scram, stringprep, and pkcs8 2026-03-01 01:28:24 +00:00
Jeremy
e374d6f7d2 Merge pull request #778 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update dependency @types/node to ^25.3.3 (feature/beta-release)
2026-02-28 20:27:51 -05:00
renovate[bot]
7723d291ce chore(deps): update dependency @types/node to ^25.3.3 2026-03-01 01:14:16 +00:00
Jeremy
386fcd8276 Merge pull request #776 from Wikid82/feature/beta-release
Proxy Host ACL and Security Headers drop down hotfix
2026-02-28 17:33:38 -05:00
GitHub Actions
10f5e5dd1d chore: enhance coverage for AccessListSelector and ProxyHostForm components
- Added new test suite for AccessListSelector to cover token normalization and emitted values.
- Updated existing tests for AccessListSelector to handle prefixed and numeric-string form values.
- Introduced tests for ProxyHostForm to validate DNS detection, including error handling and success scenarios.
- Enhanced ProxyHostForm tests to cover token normalization for security headers and ensure proper handling of existing host values.
- Implemented additional tests for ProxyHostForm to verify domain updates based on selected containers and prompt for new base domains.
2026-02-28 21:08:16 +00:00
GitHub Actions
89281c4255 fix: add UUID validation in resolveSecurityHeaderProfileReference method 2026-02-28 21:08:16 +00:00
Jeremy
de7861abea Merge pull request #777 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update module github.com/gin-gonic/gin to v1.12.0 (feature/beta-release)
2026-02-28 09:02:53 -05:00
renovate[bot]
25443d3319 fix(deps): update module github.com/gin-gonic/gin to v1.12.0 2026-02-28 13:42:23 +00:00
GitHub Actions
be279ba864 fix: update oxc-resolver package versions to 11.19.1 in package-lock.json 2026-02-28 13:06:55 +00:00
GitHub Actions
5fe1cf9265 fix: enhance security header profile handling in ProxyHost to support UUIDs and improve form data normalization 2026-02-28 12:58:59 +00:00
GitHub Actions
cdf7948575 fix: update access list handling in ProxyHostService and forms to support access_list structure 2026-02-28 05:11:33 +00:00
GitHub Actions
b04b94e429 fix: enhance access list handling in ProxyHostHandler and forms to support string IDs 2026-02-28 05:07:24 +00:00
GitHub Actions
0ff19f66b6 fix: update resolveAccessListToken to handle accessLists and improve UUID resolution in AccessListSelector 2026-02-28 05:00:32 +00:00
GitHub Actions
bf583927c1 fix: improve ID parsing logic in AccessListSelector and ProxyHostForm to ensure valid numeric IDs 2026-02-28 04:45:26 +00:00
GitHub Actions
6ed8d8054f fix: update getOptionToken to handle string IDs correctly 2026-02-28 04:41:59 +00:00
GitHub Actions
5c4a558486 chore: enhance ACL handling in dropdowns and add emergency token flows
- Add tests to normalize string numeric ACL IDs in AccessListSelector.
- Implement regression tests for ProxyHostForm to ensure numeric ACL values are submitted correctly.
- Introduce a recovery function for ACL lockout scenarios in auth setup.
- Create new tests for ACL creation and security header profiles to ensure dropdown coverage.
- Add regression tests for ACL and Security Headers dropdown behavior in ProxyHostForm.
- Establish a security shard setup to validate emergency token configurations and reset security states.
- Enhance emergency operations tests to ensure ACL selections persist across create/edit flows.
2026-02-28 04:41:00 +00:00
GitHub Actions
2024ad1373 fix: enhance AccessListSelector and ProxyHostForm to support UUID-only options and improve token resolution 2026-02-28 03:34:54 +00:00
Jeremy
5c0185d5eb Merge branch 'development' into feature/beta-release 2026-02-27 17:13:19 -05:00
GitHub Actions
c9e4916d43 fix: update SelectContent styles to improve z-index and pointer events handling 2026-02-27 22:07:26 +00:00
GitHub Actions
75d945f706 fix: ensure ACL and Security Headers dropdown selections persist correctly in Proxy Host form 2026-02-27 21:57:05 +00:00
Jeremy
99ab2202a2 Merge pull request #774 from Wikid82/feature/beta-release
Caddy version to 2.11.1
2026-02-27 16:18:30 -05:00
GitHub Actions
feaae052ac fix: enhance SQLite error handling in global setup and TestDataManager for better diagnostics 2026-02-27 20:28:43 +00:00
GitHub Actions
476e65e7dd fix: enhance navigation error handling in Caddy import tests with retry logic 2026-02-27 18:44:43 +00:00
GitHub Actions
24a5773637 fix: implement session resume feature in Caddy import tests with mock status handling 2026-02-27 18:38:25 +00:00
Jeremy
0eb0e43d60 Merge branch 'development' into feature/beta-release 2026-02-27 13:37:55 -05:00
Jeremy
6f98962981 Merge pull request #775 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-02-27 13:37:25 -05:00
renovate[bot]
2b3b5c3ff2 fix(deps): update non-major-updates 2026-02-27 18:37:12 +00:00
GitHub Actions
eb5518092f fix: update brace-expansion package to version 5.0.4 2026-02-27 13:44:24 +00:00
GitHub Actions
1b10198d50 fix: improve import session management with enhanced cleanup and status handling 2026-02-27 13:41:26 +00:00
GitHub Actions
449d316174 fix: update fallback Caddy version to 2.11.1 in Dockerfile 2026-02-27 11:04:36 +00:00
Jeremy
9356756065 Merge pull request #772 from Wikid82/feature/beta-release
Hotfix Nightly Build
2026-02-27 05:53:23 -05:00
GitHub Actions
5b3e005f2b fix: enhance nightly build workflow with SBOM generation and fallback mechanism 2026-02-27 10:16:09 +00:00
Jeremy
7654acc710 Merge pull request #770 from Wikid82/renovate/feature/beta-release-major-7-github-artifact-actions
chore(deps): update github artifact actions to v7 (feature/beta-release) (major)
2026-02-27 05:06:32 -05:00
renovate[bot]
afb2901618 chore(deps): update github artifact actions to v7 2026-02-27 10:04:19 +00:00
Jeremy
117fd51082 Merge pull request #754 from Wikid82/feature/beta-release
Enable and test Gotify and Custom Webhook notifications
2026-02-26 22:31:53 -05:00
GitHub Actions
b66ba3ad4d fix: enhance admin onboarding tests with deterministic login navigation and improve accessibility checks in authentication flows 2026-02-27 03:05:41 +00:00
GitHub Actions
cbe238b27d fix: enforce required PR number input for manual dispatch and improve event handling in security scan workflow 2026-02-27 02:48:17 +00:00
Jeremy
f814706fe2 Merge pull request #767 from Wikid82/renovate/feature/beta-release-major-8-github-artifact-actions
chore(deps): update github artifact actions to v8 (feature/beta-release) (major)
2026-02-26 20:50:56 -05:00
renovate[bot]
fc508d01d7 chore(deps): update github artifact actions to v8 2026-02-27 01:50:32 +00:00
GitHub Actions
ba880083be fix: enhance admin onboarding tests to verify redirection and storage state after login 2026-02-27 01:23:53 +00:00
GitHub Actions
b657235870 fix: refactor Caddy import tests to use helper functions for textarea filling and upload handling 2026-02-27 00:41:54 +00:00
GitHub Actions
132b78b317 fix: remove unused readStoredAuthToken function to clean up code 2026-02-26 22:53:48 +00:00
Jeremy
25cb0528e2 Merge pull request #766 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update non-major-updates (feature/beta-release)
2026-02-26 17:52:57 -05:00
Jeremy
e9acaa61cc Merge branch 'feature/beta-release' into renovate/feature/beta-release-non-major-updates 2026-02-26 17:52:45 -05:00
GitHub Actions
218ce5658e fix: enhance Caddy import tests with improved session management and response handling 2026-02-26 22:24:48 +00:00
GitHub Actions
08a17d7716 fix: enhance admin onboarding tests with improved authentication flow and assertions 2026-02-26 21:45:21 +00:00
GitHub Actions
f9c43d50c6 fix: enhance Caddy import tests with improved authentication handling and diagnostics 2026-02-26 21:45:10 +00:00
GitHub Actions
e348b5b2a3 fix: update setSecureCookie logic for local requests and add corresponding test 2026-02-26 21:44:45 +00:00
GitHub Actions
678b442f5e fix: agent tools for improved functionality and consistency across documentation
- Updated tools for Doc_Writer, Frontend_Dev, Management, Planning, Playwright_Dev, QA_Security, and Supervisor agents to enhance terminal command execution capabilities and streamline operations.
- Removed redundant tools and ensured uniformity in tool listings across agents.
2026-02-26 21:42:37 +00:00
GitHub Actions
2470861c4a fix: update @types/node and ast-v8-to-istanbul to latest versions for improved compatibility 2026-02-26 21:33:03 +00:00
GitHub Actions
9e201126a9 fix: update @types/node to version 25.3.2 for improved type definitions 2026-02-26 21:32:32 +00:00
renovate[bot]
5b67808d13 chore(deps): update non-major-updates 2026-02-26 21:31:35 +00:00
GitHub Actions
68e3bee684 fix: enhance import tests with user authentication handling and precondition checks 2026-02-26 20:32:31 +00:00
GitHub Actions
4081003051 fix: remove adminUser parameter from cross-browser import tests for cleaner execution 2026-02-26 15:01:52 +00:00
GitHub Actions
bd2b1bd8b7 fix: enhance error handling in loginUser function for API login failures 2026-02-26 15:01:31 +00:00
GitHub Actions
5e033e4bef chore: add E2E Playwright security suite tests for Chromium, Firefox, and WebKit 2026-02-26 14:05:28 +00:00
GitHub Actions
06ba9bc438 chore: add E2E Playwright tests for Chromium and WebKit non-security shards 2026-02-26 14:02:16 +00:00
GitHub Actions
3339208e53 fix: update minimatch to versions 3.1.5 and 10.2.4 in package-lock.json 2026-02-26 14:01:51 +00:00
GitHub Actions
4fad52aef5 fix: update strip-ansi to version 7.2.0 and its dependencies 2026-02-26 14:01:33 +00:00
GitHub Actions
9664e379ea fix: update import path for TestDataManager in Caddy Import gap coverage tests 2026-02-26 07:51:30 +00:00
GitHub Actions
1e126996cb fix: Add comprehensive E2E tests for Caddy Import functionality
- Introduced `caddy-import-gaps.spec.ts` to cover identified gaps in import E2E tests, including success modal navigation, conflict details expansion, overwrite resolution flow, session resume via banner, and name editing in review.
- Added `caddy-import-webkit.spec.ts` to test WebKit-specific behaviors and edge cases, focusing on event listener attachment, async state management, form submission behavior, cookie/session storage handling, touch event handling, and large file performance.
2026-02-26 07:40:27 +00:00
GitHub Actions
f4115a2977 fix: simplify visibility checks in various test cases 2026-02-26 06:25:53 +00:00
GitHub Actions
c6fd201f90 fix: streamline setup of API mocks in cross-browser E2E tests for Caddy Import 2026-02-26 06:10:53 +00:00
GitHub Actions
6ed988dc5b fix: improve error handling and assertions in E2E tests for notifications and user management 2026-02-26 05:25:02 +00:00
Jeremy
f34a9c4f37 Merge pull request #765 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update actions/setup-go digest to 4b73464 (feature/beta-release)
2026-02-26 00:03:41 -05:00
GitHub Actions
940c42f341 fix: update workflow concurrency groups to enable run cancellation
- Refactor concurrency settings in `e2e-tests-split.yml` and `codecov-upload.yml` to remove SHA and run_id from group strings, allowing for proper cancellation of in-progress runs.
- Ensure that new pushes to the same branch cancel any ongoing workflow runs, improving CI efficiency and reducing queue times.
2026-02-26 04:53:21 +00:00
GitHub Actions
759cff5e7f fix: remove pull request trigger from container prune workflow 2026-02-26 04:47:00 +00:00
renovate[bot]
5a626715d6 chore(deps): update actions/setup-go digest to 4b73464 2026-02-26 04:46:40 +00:00
GitHub Actions
82d18f11a5 fix: restrict push branches in workflows to only main 2026-02-26 04:31:52 +00:00
GitHub Actions
fb5fdb8c4e fix: update branch triggers for CodeQL workflow to restrict pull requests and allow pushes 2026-02-26 04:20:10 +00:00
GitHub Actions
8ff3f305db fix: restrict workflows to trigger only on pushes to the main branch 2026-02-26 04:11:38 +00:00
GitHub Actions
06ceb9ef6f fix: enhance GHCR prune script to include size reporting for candidates and deleted images 2026-02-26 04:05:31 +00:00
GitHub Actions
5a3b143127 fix: remove push trigger from E2E tests workflow 2026-02-26 04:05:31 +00:00
Jeremy
d28add1a73 Merge pull request #764 from Wikid82/renovate/feature/beta-release-major-7-github-artifact-actions
chore(deps): update actions/download-artifact action to v7 (feature/beta-release)
2026-02-25 22:41:39 -05:00
renovate[bot]
70d2465429 chore(deps): update actions/download-artifact action to v7 2026-02-26 03:35:00 +00:00
Jeremy
3cc5126267 Merge pull request #763 from Wikid82/renovate/feature/beta-release-actions-attest-sbom-4.x
chore(deps): update actions/attest-sbom action to v4 (feature/beta-release)
2026-02-25 22:33:17 -05:00
Jeremy
26fde2d649 Merge branch 'feature/beta-release' into renovate/feature/beta-release-actions-attest-sbom-4.x 2026-02-25 22:33:07 -05:00
Jeremy
da2db85bfc Merge pull request #762 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-02-25 22:32:41 -05:00
renovate[bot]
ccdc719501 fix(deps): update non-major-updates 2026-02-26 03:31:33 +00:00
GitHub Actions
ac720f95df fix: implement GHCR and Docker Hub prune scripts with summary reporting 2026-02-26 03:30:02 +00:00
GitHub Actions
1913e9d739 fix: remove obsolete GHCR downloads badge script 2026-02-26 03:07:26 +00:00
renovate[bot]
a7be6c304d chore(deps): update actions/attest-sbom action to v4 2026-02-26 02:32:55 +00:00
GitHub Actions
d89b86675c 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.
2026-02-26 02:22:08 +00:00
GitHub Actions
fb69f3da12 fix: add debug output for prune script execution in container prune workflow 2026-02-25 19:50:28 +00:00
GitHub Actions
e1c0173e3d fix: update script version echo statement in prune-container-images.sh 2026-02-25 19:31:16 +00:00
GitHub Actions
46fe59cf0a fix: add GitHub CLI to tools installation in container prune workflow 2026-02-25 19:21:27 +00:00
GitHub Actions
4a398185c2 fix: remove EthicalCheck workflow due to deprecation and lack of support 2026-02-25 19:13:15 +00:00
GitHub Actions
122030269e fix: enhance API interactions by adding authorization headers and improving page reload handling 2026-02-25 19:12:43 +00:00
Jeremy
5b436a883d Merge pull request #761 from Wikid82/renovate/feature/beta-release-pin-dependencies
chore(deps): pin github/codeql-action action to 4558047 (feature/beta-release)
2026-02-25 14:07:59 -05:00
GitHub Actions
a1c88de3c4 fix: enhance GHCR API interaction by adding recommended headers and improved JSON error handling 2026-02-25 18:59:27 +00:00
GitHub Actions
a6c6ce550e fix: improve destination URL handling in HTTP wrapper to enhance security and maintain original hostname 2026-02-25 17:39:36 +00:00
GitHub Actions
1af04987e0 fix: update protected regex pattern for container pruning scripts and enhance logging details 2026-02-25 17:35:47 +00:00
GitHub Actions
ad31bacc1c fix: enhance error classification for notification provider tests and improve error messages in HTTP wrapper 2026-02-25 17:19:23 +00:00
renovate[bot]
bab8414666 chore(deps): pin github/codeql-action action to 4558047 2026-02-25 16:47:54 +00:00
GitHub Actions
0deffd37e7 fix: change default DRY_RUN value to false in prune-container-images script 2026-02-25 16:40:52 +00:00
GitHub Actions
a98c9ed311 chore: add EthicalCheck workflow for automated API security testing 2026-02-25 16:14:43 +00:00
GitHub Actions
12a04b4744 chore: update devDependencies to include ESLint plugins for CSS, JSON, and Markdown 2026-02-25 16:04:07 +00:00
Jeremy
d97c08bada Merge pull request #760 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update non-major-updates (feature/beta-release)
2026-02-25 11:03:14 -05:00
renovate[bot]
ce335ff342 chore(deps): update non-major-updates 2026-02-25 15:50:29 +00:00
GitHub Actions
cb16ac05a2 fix: implement security severity policy and enhance CodeQL checks for blocking findings 2026-02-25 15:05:41 +00:00
GitHub Actions
0917edb863 fix: enhance notification provider handling by adding token visibility logic and updating related tests 2026-02-25 12:46:11 +00:00
GitHub Actions
4d0df36e5e fix: streamline group management functions and enhance directory checks in entrypoint script 2026-02-25 12:36:19 +00:00
GitHub Actions
7b1861f5a9 fix: enhance security in account settings and notifications payload tests with API key masking and authorization headers 2026-02-25 12:15:34 +00:00
GitHub Actions
29f6664ab0 fix: enforce admin role requirement for SMTP configuration access 2026-02-25 06:29:52 +00:00
GitHub Actions
690480e181 fix: Implement user API enhancements with masked API keys and updated invite link handling 2026-02-25 06:14:03 +00:00
GitHub Actions
c156183666 fix: Enhance security handler tests and implement role-based access control
- Added role-based middleware to various security handler tests to ensure only admin users can access certain endpoints.
- Created a new test file for authorization checks on security mutators, verifying that non-admin users receive forbidden responses.
- Updated existing tests to include role setting for admin users, ensuring consistent access control during testing.
- Introduced sensitive data masking in settings handler responses, ensuring sensitive values are not exposed in API responses.
- Enhanced user handler responses to mask API keys and invite tokens, providing additional security for user-related endpoints.
- Refactored routes to group security admin endpoints under a dedicated route with role-based access control.
- Added tests for import handler routes to verify authorization guards, ensuring only admin users can access import functionalities.
2026-02-25 05:41:35 +00:00
GitHub Actions
d8e6d8d9a9 fix: update vulnerability reporting methods in SECURITY.md 2026-02-25 05:41:00 +00:00
GitHub Actions
7591d2cda8 fix: update minimum coverage threshold to 87 for frontend and backend test scripts 2026-02-25 05:39:06 +00:00
GitHub Actions
aa2e7a1685 choredocker): enhance local Docker socket access and error handling
- Added guidance for Docker socket group access in docker-compose files.
- Introduced docker-compose.override.example.yml for supplemental group configuration.
- Improved entrypoint diagnostics to include socket GID and group guidance.
- Updated README with instructions for setting up Docker socket access.
- Enhanced backend error handling to provide actionable messages for permission issues.
- Updated frontend components to display troubleshooting information regarding Docker socket access.
- Added tests to ensure proper error messages and guidance are rendered in UI.
- Revised code coverage settings to include Docker service files for better regression tracking.
2026-02-25 03:42:01 +00:00
GitHub Actions
9a683c3231 fix: enhance authentication token retrieval and header building across multiple test files 2026-02-25 02:53:10 +00:00
GitHub Actions
e5cebc091d fix: remove model references from agent markdown files 2026-02-25 02:52:28 +00:00
Jeremy
15cdaa8294 Merge pull request #759 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update non-major-updates (feature/beta-release)
2026-02-24 19:44:12 -05:00
renovate[bot]
32f2d25d58 chore(deps): update non-major-updates 2026-02-25 00:43:29 +00:00
GitHub Actions
a9dcc007e5 fix: enhance DockerUnavailableError to include detailed error messages and improve handling in ListContainers 2026-02-24 22:24:38 +00:00
GitHub Actions
bf53712b7c fix: implement bearer token handling in TestDataManager and add API helper authorization tests 2026-02-24 21:07:10 +00:00
GitHub Actions
2b4f60615f fix: add Docker socket volume for container discovery in E2E tests 2026-02-24 20:34:35 +00:00
GitHub Actions
bbaad17e97 fix: enhance notification provider validation and error handling in Test method 2026-02-24 19:56:57 +00:00
Jeremy
bc4c7c1406 Merge pull request #758 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update github/codeql-action digest to 28737ec (feature/beta-release)
2026-02-24 14:55:39 -05:00
renovate[bot]
e13b49cfd2 chore(deps): update github/codeql-action digest to 28737ec 2026-02-24 19:45:29 +00:00
GitHub Actions
4d4a5d3adb fix: update trustTestCertificate function to remove unnecessary parameter 2026-02-24 13:02:44 +00:00
GitHub Actions
7983de9f2a fix: enhance workflow triggers and context handling for security scans 2026-02-24 12:45:25 +00:00
GitHub Actions
0034968919 fix: enforce secure cookie settings and enhance URL validation in HTTP wrapper 2026-02-24 12:41:20 +00:00
GitHub Actions
6cec0a67eb fix: add exception handling for specific SSRF rule in CodeQL SARIF checks 2026-02-24 12:41:20 +00:00
GitHub Actions
f56fa41301 fix: ensure delete confirmation dialog is always open when triggered 2026-02-24 12:41:20 +00:00
GitHub Actions
b1a1a7a238 fix: enhance CodeQL SARIF parsing for improved severity level detection 2026-02-24 12:41:20 +00:00
GitHub Actions
8381790b0b fix: improve CodeQL SARIF parsing for accurate high/critical findings detection 2026-02-24 12:41:20 +00:00
GitHub Actions
65228c5ee8 fix: enhance Docker image loading and tagging in security scan workflow 2026-02-24 12:41:20 +00:00
GitHub Actions
b531a840e8 fix: refactor logout function to use useCallback for improved performance 2026-02-24 12:41:20 +00:00
GitHub Actions
5a2e11878b fix: correct configuration key from 'linters-settings' to 'settings' in golangci-lint files 2026-02-24 12:41:20 +00:00
Jeremy
fcc60a0aa3 Merge branch 'development' into feature/beta-release 2026-02-24 01:46:39 -05:00
GitHub Actions
fdbf1a66cd fix: implement outbound request URL validation and redirect guard in HTTPWrapper 2026-02-24 06:45:14 +00:00
GitHub Actions
e8a513541f fix: enhance Trivy scan result uploads with conditional checks and category tagging 2026-02-24 06:22:03 +00:00
GitHub Actions
bc9f2cf882 chore: enable Gotify and Custom Webhhok notifications and improve payload validation
- Enhanced Notifications component tests to include support for Discord, Gotify, and Webhook provider types.
- Updated test cases to validate the correct handling of provider type options and ensure proper payload structure during creation, preview, and testing.
- Introduced new tests for Gotify token handling and ensured sensitive information is not exposed in the UI.
- Refactored existing tests for clarity and maintainability, including improved assertions and error handling.
- Added comprehensive coverage for payload validation scenarios, including malformed requests and security checks against SSRF and oversized payloads.
2026-02-24 05:34:25 +00:00
Jeremy
1329b00ed5 Merge pull request #750 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update actions/download-artifact digest to 70fc10c (feature/beta-release)
2026-02-23 17:13:46 -05:00
renovate[bot]
a9c5b5b2d8 chore(deps): update actions/download-artifact digest to 70fc10c 2026-02-23 21:17:50 +00:00
Jeremy
4b9508a9be Merge pull request #741 from Wikid82/feature/beta-release
Caddy Version bump to 2.11.1
2026-02-23 16:14:36 -05:00
Jeremy
dc1426ae31 Merge pull request #749 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-02-23 15:16:07 -05:00
renovate[bot]
72bfca2dc3 fix(deps): update non-major-updates 2026-02-23 20:15:18 +00:00
GitHub Actions
09f9f7eb3d chore: remove Caddy Compatibility Gate workflow 2026-02-23 20:15:12 +00:00
GitHub Actions
9e71dd218b chore: update katex to version 0.16.33 in package-lock.json 2026-02-23 19:37:57 +00:00
GitHub Actions
ee5350d675 feat: add keepalive controls to System Settings
- Introduced optional keepalive settings: `keepalive_idle` and `keepalive_count` in the Server struct.
- Implemented UI controls for keepalive settings in System Settings, including validation and persistence.
- Added localization support for new keepalive fields in multiple languages.
- Created a manual test tracking plan for verifying keepalive controls and their behavior.
- Updated existing tests to cover new functionality and ensure proper validation of keepalive inputs.
- Ensured safe defaults and fallback behavior for missing or invalid keepalive values.
2026-02-23 19:33:56 +00:00
Jeremy
9424aca5e2 Merge pull request #748 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update github/codeql-action digest to a754a57 (feature/beta-release)
2026-02-23 09:54:55 -05:00
renovate[bot]
8fa0950138 chore(deps): update github/codeql-action digest to a754a57 2026-02-23 14:48:33 +00:00
GitHub Actions
1315d7a3ef chore: Add cache dependency path for Go setup in workflows 2026-02-23 14:41:55 +00:00
GitHub Actions
63d7c5c0c4 chore: Update Caddy patch scenario and enhance CaddyAdminAPI validation in config 2026-02-23 14:41:55 +00:00
GitHub Actions
79c8e660f5 chore: Update minimum coverage requirements to 87% for backend and frontend tests 2026-02-23 14:41:55 +00:00
GitHub Actions
7b640cc0af chore: Add Prettier and Tailwind CSS plugin to devDependencies 2026-02-23 14:41:55 +00:00
GitHub Actions
1f2b4c7d5e chore: Add Caddy compatibility gate workflow and related scripts; update documentation and test cases 2026-02-23 14:41:55 +00:00
Jeremy
441c3dc947 Merge pull request #747 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update non-major-updates (feature/beta-release)
2026-02-23 09:18:31 -05:00
renovate[bot]
735b9fdd0e chore(deps): update non-major-updates 2026-02-23 14:15:17 +00:00
GitHub Actions
45458df1bf chore: Add Caddy compatibility gate workflow and related scripts; enhance SMTP settings tests 2026-02-23 13:38:02 +00:00
Jeremy
427babd3c1 Merge pull request #742 from Wikid82/development
Propagate changes from development into feature/beta-release
2026-02-23 08:07:28 -05:00
Jeremy
3fa1074ea9 Merge branch 'development' into feature/beta-release 2026-02-23 02:36:39 -05:00
GitHub Actions
51d997c6fb chore: Update current spec to outline Caddy 2.11.1 compatibility, security, and UX impact plan 2026-02-23 07:31:36 +00:00
234 changed files with 18610 additions and 3658 deletions

View File

@@ -94,7 +94,7 @@ Configure the application via `docker-compose.yml`:
| `CHARON_ENV` | `production` | Set to `development` for verbose logging (`CPM_ENV` supported for backward compatibility). |
| `CHARON_HTTP_PORT` | `8080` | Port for the Web UI (`CPM_HTTP_PORT` supported for backward compatibility). |
| `CHARON_DB_PATH` | `/app/data/charon.db` | Path to the SQLite database (`CPM_DB_PATH` supported for backward compatibility). |
| `CHARON_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API (`CPM_CADDY_ADMIN_API` supported for backward compatibility). |
| `CHARON_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API (`CPM_CADDY_ADMIN_API` supported for backward compatibility). Must resolve to an internal allowlisted host on port `2019`. |
| `CHARON_CADDY_CONFIG_ROOT` | `/config` | Path to Caddy autosave configuration directory. |
| `CHARON_CADDY_LOG_DIR` | `/var/log/caddy` | Directory for Caddy access logs. |
| `CHARON_CROWDSEC_LOG_DIR` | `/var/log/crowdsec` | Directory for CrowdSec logs. |
@@ -218,6 +218,8 @@ environment:
- CPM_CADDY_ADMIN_API=http://your-caddy-host:2019
```
If using a non-localhost internal hostname, add it to `CHARON_SSRF_INTERNAL_HOST_ALLOWLIST`.
**Warning**: Charon will replace Caddy's entire configuration. Backup first!
## Performance Tuning

View File

@@ -32,6 +32,8 @@ services:
#- CPM_SECURITY_RATELIMIT_ENABLED=false
#- CPM_SECURITY_ACL_ENABLED=false
- FEATURE_CERBERUS_ENABLED=true
# Docker socket group access: copy docker-compose.override.example.yml
# to docker-compose.override.yml and set your host's docker GID.
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery
- crowdsec_data:/app/data/crowdsec

View File

@@ -27,6 +27,8 @@ services:
- FEATURE_CERBERUS_ENABLED=true
# Emergency "break-glass" token for security reset when ACL blocks access
- CHARON_EMERGENCY_TOKEN=03e4682c1164f0c1cb8e17c99bd1a2d9156b59824dde41af3bb67c513e5c5e92
# Docker socket group access: copy docker-compose.override.example.yml
# to docker-compose.override.yml and set your host's docker GID.
extra_hosts:
- "host.docker.internal:host-gateway"
cap_add:

View File

@@ -0,0 +1,26 @@
# Docker Compose override — copy to docker-compose.override.yml to activate.
#
# Use case: grant the container access to the host Docker socket so that
# Charon can discover running containers.
#
# 1. cp docker-compose.override.example.yml docker-compose.override.yml
# 2. Uncomment the service that matches your compose file:
# - "charon" for docker-compose.local.yml
# - "app" for docker-compose.dev.yml
# 3. Replace <GID> with the output of: stat -c '%g' /var/run/docker.sock
# 4. docker compose up -d
services:
# Uncomment for docker-compose.local.yml
charon:
group_add:
- "<GID>" # e.g. "988" — run: stat -c '%g' /var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# Uncomment for docker-compose.dev.yml
app:
group_add:
- "<GID>" # e.g. "988" — run: stat -c '%g' /var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro

View File

@@ -85,6 +85,7 @@ services:
- playwright_data:/app/data
- playwright_caddy_data:/data
- playwright_caddy_config:/config
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8080/api/v1/health"]
interval: 5s
@@ -111,6 +112,7 @@ services:
volumes:
- playwright_crowdsec_data:/var/lib/crowdsec/data
- playwright_crowdsec_config:/etc/crowdsec
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
healthcheck:
test: ["CMD", "cscli", "version"]
interval: 10s

View File

@@ -49,6 +49,8 @@ services:
# True tmpfs for E2E test data - fresh on every run, in-memory only
# mode=1777 allows any user to write (container runs as non-root)
- /app/data:size=100M,mode=1777
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health || exit 1"]
interval: 5s

View File

@@ -27,30 +27,24 @@ get_group_by_gid() {
}
create_group_with_gid() {
local gid="$1"
local name="$2"
if command -v addgroup >/dev/null 2>&1; then
addgroup -g "$gid" "$name" 2>/dev/null || true
addgroup -g "$1" "$2" 2>/dev/null || true
return
fi
if command -v groupadd >/dev/null 2>&1; then
groupadd -g "$gid" "$name" 2>/dev/null || true
groupadd -g "$1" "$2" 2>/dev/null || true
fi
}
add_user_to_group() {
local user="$1"
local group="$2"
if command -v addgroup >/dev/null 2>&1; then
addgroup "$user" "$group" 2>/dev/null || true
addgroup "$1" "$2" 2>/dev/null || true
return
fi
if command -v usermod >/dev/null 2>&1; then
usermod -aG "$group" "$user" 2>/dev/null || true
usermod -aG "$2" "$1" 2>/dev/null || true
fi
}
@@ -142,8 +136,15 @@ if [ -S "/var/run/docker.sock" ] && is_root; then
fi
fi
elif [ -S "/var/run/docker.sock" ]; then
echo "Note: Docker socket mounted but container is running non-root; skipping docker.sock group setup."
echo " If Docker discovery is needed, run with matching group permissions (e.g., --group-add)"
DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo "unknown")
echo "Note: Docker socket mounted (GID=$DOCKER_SOCK_GID) but container is running non-root; skipping docker.sock group setup."
echo " If Docker discovery is needed, add 'group_add: [\"$DOCKER_SOCK_GID\"]' to your compose service."
if [ "$DOCKER_SOCK_GID" = "0" ]; then
if [ "${ALLOW_DOCKER_SOCK_GID_0:-false}" != "true" ]; then
echo "⚠️ WARNING: Docker socket GID is 0 (root group). group_add: [\"0\"] grants root-group access."
echo " Set ALLOW_DOCKER_SOCK_GID_0=true to acknowledge this risk."
fi
fi
else
echo "Note: Docker socket not found. Docker container discovery will be unavailable."
fi
@@ -191,7 +192,7 @@ if command -v cscli >/dev/null; then
echo "Initializing persistent CrowdSec configuration..."
# Check if .dist has content
if [ -d "/etc/crowdsec.dist" ] && [ -n "$(ls -A /etc/crowdsec.dist 2>/dev/null)" ]; then
if [ -d "/etc/crowdsec.dist" ] && find /etc/crowdsec.dist -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then
echo "Copying config from /etc/crowdsec.dist..."
if ! cp -r /etc/crowdsec.dist/* "$CS_CONFIG_DIR/"; then
echo "ERROR: Failed to copy config from /etc/crowdsec.dist"
@@ -208,7 +209,7 @@ if command -v cscli >/dev/null; then
exit 1
fi
echo "✓ Successfully initialized config from .dist directory"
elif [ -d "/etc/crowdsec" ] && [ ! -L "/etc/crowdsec" ] && [ -n "$(ls -A /etc/crowdsec 2>/dev/null)" ]; then
elif [ -d "/etc/crowdsec" ] && [ ! -L "/etc/crowdsec" ] && find /etc/crowdsec -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then
echo "Copying config from /etc/crowdsec (fallback)..."
if ! cp -r /etc/crowdsec/* "$CS_CONFIG_DIR/"; then
echo "ERROR: Failed to copy config from /etc/crowdsec (fallback)"
@@ -248,7 +249,7 @@ if command -v cscli >/dev/null; then
echo "Expected: /etc/crowdsec -> /app/data/crowdsec/config"
echo "This indicates a critical build-time issue. Symlink must be created at build time as root."
echo "DEBUG: Directory check:"
ls -la /etc/ | grep crowdsec || echo " (no crowdsec entry found)"
find /etc -mindepth 1 -maxdepth 1 -name '*crowdsec*' -exec ls -ld {} \; 2>/dev/null || echo " (no crowdsec entry found)"
exit 1
fi

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

55
.github/security-severity-policy.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
version: 1
effective_date: 2026-02-25
scope:
- local pre-commit manual security hooks
- github actions security workflows
defaults:
blocking:
- critical
- high
medium:
mode: risk-based
default_action: report
require_sla: true
default_sla_days: 14
escalation:
trigger: high-signal class or repeated finding
action: require issue + owner + due date
low:
action: report
codeql:
severity_mapping:
error: high_or_critical
warning: medium_or_lower
note: informational
blocking_levels:
- error
warning_policy:
default_action: report
escalation_high_signal_rule_ids:
- go/request-forgery
- js/missing-rate-limiting
- js/insecure-randomness
trivy:
blocking_severities:
- CRITICAL
- HIGH
medium_policy:
action: report
escalation: issue-with-sla
grype:
blocking_severities:
- Critical
- High
medium_policy:
action: report
escalation: issue-with-sla
enforcement_contract:
codeql_local_vs_ci: "local and ci block on codeql error-level findings only"
supply_chain_medium: "medium vulnerabilities are non-blocking by default and require explicit triage"
auth_regression_guard: "state-changing routes must remain protected by auth middleware"

View File

@@ -32,7 +32,7 @@ cd "${PROJECT_ROOT}"
validate_project_structure "backend" "scripts/go-test-coverage.sh" || error_exit "Invalid project structure"
# Set default environment variables
set_default_env "CHARON_MIN_COVERAGE" "85"
set_default_env "CHARON_MIN_COVERAGE" "87"
set_default_env "PERF_MAX_MS_GETSTATUS_P95" "25ms"
set_default_env "PERF_MAX_MS_GETSTATUS_P95_PARALLEL" "50ms"
set_default_env "PERF_MAX_MS_LISTDECISIONS_P95" "75ms"

View File

@@ -32,7 +32,7 @@ cd "${PROJECT_ROOT}"
validate_project_structure "frontend" "scripts/frontend-test-coverage.sh" || error_exit "Invalid project structure"
# Set default environment variables
set_default_env "CHARON_MIN_COVERAGE" "85"
set_default_env "CHARON_MIN_COVERAGE" "87"
# Execute the legacy script
log_step "EXECUTION" "Running frontend tests with coverage"

View File

@@ -3,6 +3,8 @@ name: Go Benchmark
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
concurrency:
@@ -33,7 +35,7 @@ jobs:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: backend/go.sum

View File

@@ -3,6 +3,8 @@ name: Upload Coverage to Codecov
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
inputs:
run_backend:
@@ -17,7 +19,7 @@ on:
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
@@ -43,7 +45,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: backend/go.sum

View File

@@ -4,7 +4,7 @@ on:
pull_request:
branches: [main, nightly, development]
push:
branches: [main, nightly, development, 'feature/**', 'fix/**']
branches: [main]
workflow_dispatch:
schedule:
- cron: '0 3 * * 1' # Mondays 03:00 UTC
@@ -57,7 +57,7 @@ jobs:
- name: Setup Go
if: matrix.language == 'go'
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version: 1.26.0
cache-dependency-path: backend/go.sum
@@ -122,10 +122,28 @@ jobs:
exit 1
fi
# shellcheck disable=SC2016
EFFECTIVE_LEVELS_JQ='[
.runs[] as $run
| $run.results[]
| . as $result
| ($run.tool.driver.rules // []) as $rules
| ((
$result.level
// (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end)
// ([
$rules[]?
| select((.id // "") == ($result.ruleId // ""))
| (.defaultConfiguration.level // empty)
][0] // empty)
// ""
) | ascii_downcase)
]'
echo "Found SARIF file: $SARIF_FILE"
ERROR_COUNT=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$SARIF_FILE")
WARNING_COUNT=$(jq '[.runs[].results[] | select(.level == "warning")] | length' "$SARIF_FILE")
NOTE_COUNT=$(jq '[.runs[].results[] | select(.level == "note")] | length' "$SARIF_FILE")
ERROR_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"error\")) | length" "$SARIF_FILE")
WARNING_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"warning\")) | length" "$SARIF_FILE")
NOTE_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"note\")) | length" "$SARIF_FILE")
{
echo "**Findings:**"
@@ -135,14 +153,32 @@ jobs:
echo ""
if [ "$ERROR_COUNT" -gt 0 ]; then
echo "❌ **CRITICAL:** High-severity security issues found!"
echo "❌ **BLOCKING:** CodeQL error-level security issues found"
echo ""
echo "### Top Issues:"
echo '```'
jq -r '.runs[].results[] | select(.level == "error") | "\(.ruleId): \(.message.text)"' "$SARIF_FILE" | head -5
# shellcheck disable=SC2016
jq -r '
.runs[] as $run
| $run.results[]
| . as $result
| ($run.tool.driver.rules // []) as $rules
| ((
$result.level
// (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end)
// ([
$rules[]?
| select((.id // "") == ($result.ruleId // ""))
| (.defaultConfiguration.level // empty)
][0] // empty)
// ""
) | ascii_downcase) as $effectiveLevel
| select($effectiveLevel == "error")
| "\($effectiveLevel): \($result.ruleId // \"<unknown-rule>\"): \($result.message.text)"
' "$SARIF_FILE" | head -5
echo '```'
else
echo "✅ No high-severity issues found"
echo "✅ No blocking CodeQL issues found"
fi
} >> "$GITHUB_STEP_SUMMARY"
@@ -169,9 +205,26 @@ jobs:
exit 1
fi
ERROR_COUNT=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$SARIF_FILE")
# shellcheck disable=SC2016
ERROR_COUNT=$(jq -r '[
.runs[] as $run
| $run.results[]
| . as $result
| ($run.tool.driver.rules // []) as $rules
| ((
$result.level
// (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end)
// ([
$rules[]?
| select((.id // "") == ($result.ruleId // ""))
| (.defaultConfiguration.level // empty)
][0] // empty)
// ""
) | ascii_downcase) as $effectiveLevel
| select($effectiveLevel == "error")
] | length' "$SARIF_FILE")
if [ "$ERROR_COUNT" -gt 0 ]; then
echo "::error::CodeQL found $ERROR_COUNT high-severity security issues. Fix before merging."
echo "::error::CodeQL found $ERROR_COUNT blocking findings (effective-level=error). Fix before merging. Policy: .github/security-severity-policy.yml"
exit 1
fi

View File

@@ -5,10 +5,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
@@ -27,16 +23,17 @@ 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' }}
PROTECTED_REGEX: '["^v","^latest$","^main$","^develop$"]'
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
@@ -45,21 +42,19 @@ jobs:
run: |
sudo apt-get update && sudo apt-get install -y jq curl
- 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}
@@ -67,7 +62,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
@@ -77,34 +72,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() }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
- name: Upload GHCR prune artifacts
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
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@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
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}")"

View File

@@ -23,7 +23,11 @@ name: Docker Build, Publish & Test
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
workflow_run:
workflows: ["Docker Lint"]
types: [completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.head_ref || github.ref_name }}
@@ -38,7 +42,7 @@ env:
TRIGGER_HEAD_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
TRIGGER_REF: ${{ github.event_name == 'workflow_run' && format('refs/heads/{0}', github.event.workflow_run.head_branch) || github.ref }}
TRIGGER_HEAD_REF: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.head_ref }}
TRIGGER_PR_NUMBER: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || github.event.pull_request.number }}
TRIGGER_PR_NUMBER: ${{ github.event_name == 'workflow_run' && join(github.event.workflow_run.pull_requests.*.number, '') || github.event.pull_request.number }}
TRIGGER_ACTOR: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.actor.login || github.actor }}
jobs:
@@ -339,7 +343,7 @@ jobs:
- name: Upload Image Artifact
if: success() && steps.skip.outputs.skip_build != 'true' && env.TRIGGER_EVENT == 'pull_request'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ env.TRIGGER_EVENT == 'pull_request' && format('pr-image-{0}', env.TRIGGER_PR_NUMBER) || 'push-image' }}
path: /tmp/charon-pr-image.tar
@@ -561,12 +565,13 @@ jobs:
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
sarif_file: 'trivy-results.sarif'
category: '.github/workflows/docker-build.yml:build-and-push'
token: ${{ secrets.GITHUB_TOKEN }}
# 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 }}
@@ -575,7 +580,7 @@ jobs:
# Create verifiable attestation for the SBOM
- name: Attest SBOM
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0
uses: actions/attest-sbom@07e74fc4e78d1aad915e867f9a094073a9f71527 # v4.0.0
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
with:
subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -702,13 +707,47 @@ jobs:
exit-code: '1' # Intended to block, but continued on error for now
continue-on-error: true
- name: Upload Trivy scan results
- name: Check Trivy PR SARIF exists
if: always()
id: trivy-pr-check
run: |
if [ -f trivy-pr-results.sarif ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload Trivy scan results
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
sarif_file: 'trivy-pr-results.sarif'
category: 'docker-pr-image'
- name: Upload Trivy compatibility results (docker-build category)
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
sarif_file: 'trivy-pr-results.sarif'
category: '.github/workflows/docker-build.yml:build-and-push'
continue-on-error: true
- name: Upload Trivy compatibility results (docker-publish alias)
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
sarif_file: 'trivy-pr-results.sarif'
category: '.github/workflows/docker-publish.yml:build-and-push'
continue-on-error: true
- name: Upload Trivy compatibility results (nightly alias)
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
sarif_file: 'trivy-pr-results.sarif'
category: 'trivy-nightly'
continue-on-error: true
- name: Create scan summary
if: always()
run: |

View File

@@ -80,7 +80,6 @@ on:
default: false
type: boolean
pull_request:
push:
env:
NODE_VERSION: '20'
@@ -96,7 +95,7 @@ env:
CI_LOG_LEVEL: 'verbose'
concurrency:
group: e2e-split-${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.head.sha || github.sha }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
@@ -143,7 +142,7 @@ jobs:
- name: Set up Go
if: steps.resolve-image.outputs.image_source == 'build'
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
@@ -191,7 +190,7 @@ jobs:
- name: Upload Docker image artifact
if: steps.resolve-image.outputs.image_source == 'build'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docker-image
path: charon-e2e-image.tar
@@ -230,6 +229,7 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Log in to Docker Hub
if: needs.build.outputs.image_source == 'registry'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
@@ -247,7 +247,7 @@ jobs:
- name: Download Docker image artifact
if: needs.build.outputs.image_source == 'build'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
with:
name: docker-image
@@ -347,7 +347,7 @@ jobs:
- name: Upload HTML report (Chromium Security)
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-report-chromium-security
path: playwright-report/
@@ -355,7 +355,7 @@ jobs:
- name: Upload Chromium Security coverage (if enabled)
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-coverage-chromium-security
path: coverage/e2e/
@@ -363,7 +363,7 @@ jobs:
- name: Upload test traces on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: traces-chromium-security
path: test-results/**/*.zip
@@ -382,7 +382,7 @@ jobs:
- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-diagnostics-chromium-security
path: diagnostics/
@@ -395,7 +395,7 @@ jobs:
- name: Upload Docker logs on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docker-logs-chromium-security
path: docker-logs-chromium-security.txt
@@ -431,6 +431,7 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Log in to Docker Hub
if: needs.build.outputs.image_source == 'registry'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
@@ -448,7 +449,7 @@ jobs:
- name: Download Docker image artifact
if: needs.build.outputs.image_source == 'build'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
with:
name: docker-image
@@ -556,7 +557,7 @@ jobs:
- name: Upload HTML report (Firefox Security)
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-report-firefox-security
path: playwright-report/
@@ -564,7 +565,7 @@ jobs:
- name: Upload Firefox Security coverage (if enabled)
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-coverage-firefox-security
path: coverage/e2e/
@@ -572,7 +573,7 @@ jobs:
- name: Upload test traces on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: traces-firefox-security
path: test-results/**/*.zip
@@ -591,7 +592,7 @@ jobs:
- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-diagnostics-firefox-security
path: diagnostics/
@@ -604,7 +605,7 @@ jobs:
- name: Upload Docker logs on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docker-logs-firefox-security
path: docker-logs-firefox-security.txt
@@ -640,6 +641,7 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Log in to Docker Hub
if: needs.build.outputs.image_source == 'registry'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
@@ -657,7 +659,7 @@ jobs:
- name: Download Docker image artifact
if: needs.build.outputs.image_source == 'build'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
with:
name: docker-image
@@ -765,7 +767,7 @@ jobs:
- name: Upload HTML report (WebKit Security)
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-report-webkit-security
path: playwright-report/
@@ -773,7 +775,7 @@ jobs:
- name: Upload WebKit Security coverage (if enabled)
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-coverage-webkit-security
path: coverage/e2e/
@@ -781,7 +783,7 @@ jobs:
- name: Upload test traces on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: traces-webkit-security
path: test-results/**/*.zip
@@ -800,7 +802,7 @@ jobs:
- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-diagnostics-webkit-security
path: diagnostics/
@@ -813,7 +815,7 @@ jobs:
- name: Upload Docker logs on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docker-logs-webkit-security
path: docker-logs-webkit-security.txt
@@ -861,6 +863,39 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Preflight disk diagnostics (before cleanup)
run: |
echo "Disk usage before cleanup"
df -h
docker system df || true
- name: Preflight cleanup (best effort)
run: |
echo "Best-effort cleanup for CI runner"
docker system prune -af || true
rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true
rm -f docker-logs-*.txt charon-e2e-image.tar || true
- name: Preflight disk diagnostics and threshold gate
run: |
set -euo pipefail
MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024))
echo "Disk usage after cleanup"
df -h
docker system df || true
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}"
FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}')
FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}')
echo "Free bytes on /: $FREE_ROOT_BYTES"
echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES"
if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then
echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B"
exit 42
fi
- name: Log in to Docker Hub
if: needs.build.outputs.image_source == 'registry'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
@@ -878,7 +913,7 @@ jobs:
- name: Download Docker image artifact
if: needs.build.outputs.image_source == 'build'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
with:
name: docker-image
@@ -968,7 +1003,7 @@ jobs:
- name: Upload HTML report (Chromium shard ${{ matrix.shard }})
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-report-chromium-shard-${{ matrix.shard }}
path: playwright-report/
@@ -976,7 +1011,7 @@ jobs:
- name: Upload Playwright output (Chromium shard ${{ matrix.shard }})
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-output-chromium-shard-${{ matrix.shard }}
path: playwright-output/chromium-shard-${{ matrix.shard }}/
@@ -984,7 +1019,7 @@ jobs:
- name: Upload Chromium coverage (if enabled)
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-coverage-chromium-shard-${{ matrix.shard }}
path: coverage/e2e/
@@ -992,7 +1027,7 @@ jobs:
- name: Upload test traces on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: traces-chromium-shard-${{ matrix.shard }}
path: test-results/**/*.zip
@@ -1011,7 +1046,7 @@ jobs:
- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-diagnostics-chromium-shard-${{ matrix.shard }}
path: diagnostics/
@@ -1024,7 +1059,7 @@ jobs:
- name: Upload Docker logs on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docker-logs-chromium-shard-${{ matrix.shard }}
path: docker-logs-chromium-shard-${{ matrix.shard }}.txt
@@ -1065,6 +1100,39 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Preflight disk diagnostics (before cleanup)
run: |
echo "Disk usage before cleanup"
df -h
docker system df || true
- name: Preflight cleanup (best effort)
run: |
echo "Best-effort cleanup for CI runner"
docker system prune -af || true
rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true
rm -f docker-logs-*.txt charon-e2e-image.tar || true
- name: Preflight disk diagnostics and threshold gate
run: |
set -euo pipefail
MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024))
echo "Disk usage after cleanup"
df -h
docker system df || true
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}"
FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}')
FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}')
echo "Free bytes on /: $FREE_ROOT_BYTES"
echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES"
if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then
echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B"
exit 42
fi
- name: Log in to Docker Hub
if: needs.build.outputs.image_source == 'registry'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
@@ -1082,7 +1150,7 @@ jobs:
- name: Download Docker image artifact
if: needs.build.outputs.image_source == 'build'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
with:
name: docker-image
@@ -1180,7 +1248,7 @@ jobs:
- name: Upload HTML report (Firefox shard ${{ matrix.shard }})
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-report-firefox-shard-${{ matrix.shard }}
path: playwright-report/
@@ -1188,7 +1256,7 @@ jobs:
- name: Upload Playwright output (Firefox shard ${{ matrix.shard }})
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-output-firefox-shard-${{ matrix.shard }}
path: playwright-output/firefox-shard-${{ matrix.shard }}/
@@ -1196,7 +1264,7 @@ jobs:
- name: Upload Firefox coverage (if enabled)
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-coverage-firefox-shard-${{ matrix.shard }}
path: coverage/e2e/
@@ -1204,7 +1272,7 @@ jobs:
- name: Upload test traces on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: traces-firefox-shard-${{ matrix.shard }}
path: test-results/**/*.zip
@@ -1223,7 +1291,7 @@ jobs:
- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-diagnostics-firefox-shard-${{ matrix.shard }}
path: diagnostics/
@@ -1236,7 +1304,7 @@ jobs:
- name: Upload Docker logs on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docker-logs-firefox-shard-${{ matrix.shard }}
path: docker-logs-firefox-shard-${{ matrix.shard }}.txt
@@ -1277,6 +1345,39 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Preflight disk diagnostics (before cleanup)
run: |
echo "Disk usage before cleanup"
df -h
docker system df || true
- name: Preflight cleanup (best effort)
run: |
echo "Best-effort cleanup for CI runner"
docker system prune -af || true
rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true
rm -f docker-logs-*.txt charon-e2e-image.tar || true
- name: Preflight disk diagnostics and threshold gate
run: |
set -euo pipefail
MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024))
echo "Disk usage after cleanup"
df -h
docker system df || true
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}"
FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}')
FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}')
echo "Free bytes on /: $FREE_ROOT_BYTES"
echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES"
if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then
echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B"
exit 42
fi
- name: Log in to Docker Hub
if: needs.build.outputs.image_source == 'registry'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
@@ -1294,7 +1395,7 @@ jobs:
- name: Download Docker image artifact
if: needs.build.outputs.image_source == 'build'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
with:
name: docker-image
@@ -1392,7 +1493,7 @@ jobs:
- name: Upload HTML report (WebKit shard ${{ matrix.shard }})
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: playwright-report-webkit-shard-${{ matrix.shard }}
path: playwright-report/
@@ -1400,7 +1501,7 @@ jobs:
- name: Upload Playwright output (WebKit shard ${{ matrix.shard }})
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: playwright-output-webkit-shard-${{ matrix.shard }}
path: playwright-output/webkit-shard-${{ matrix.shard }}/
@@ -1408,7 +1509,7 @@ jobs:
- name: Upload WebKit coverage (if enabled)
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: e2e-coverage-webkit-shard-${{ matrix.shard }}
path: coverage/e2e/
@@ -1416,7 +1517,7 @@ jobs:
- name: Upload test traces on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: traces-webkit-shard-${{ matrix.shard }}
path: test-results/**/*.zip
@@ -1435,7 +1536,7 @@ jobs:
- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: e2e-diagnostics-webkit-shard-${{ matrix.shard }}
path: diagnostics/
@@ -1448,7 +1549,7 @@ jobs:
- name: Upload Docker logs on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: docker-logs-webkit-shard-${{ matrix.shard }}
path: docker-logs-webkit-shard-${{ matrix.shard }}.txt

View File

@@ -103,11 +103,12 @@ jobs:
const workflows = [
{ id: 'e2e-tests-split.yml' },
{ id: 'codecov-upload.yml', inputs: { run_backend: 'true', run_frontend: 'true' } },
{ id: 'security-pr.yml' },
{ id: 'supply-chain-verify.yml' },
{ id: 'codeql.yml' },
];
core.info('Skipping security-pr.yml: PR-only workflow intentionally excluded from nightly non-PR dispatch');
for (const workflow of workflows) {
const { data: workflowRuns } = await github.rest.actions.listWorkflowRuns({
owner,
@@ -220,14 +221,66 @@ 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
id: sbom_primary
continue-on-error: true
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
with:
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}
format: cyclonedx-json
output-file: sbom-nightly.json
syft-version: v1.42.1
- name: Generate SBOM fallback with pinned Syft
if: always()
run: |
set -euo pipefail
if [[ "${{ steps.sbom_primary.outcome }}" == "success" ]] && [[ -s sbom-nightly.json ]] && jq -e . sbom-nightly.json >/dev/null 2>&1; then
echo "Primary SBOM generation succeeded with valid JSON; skipping fallback"
exit 0
fi
echo "Primary SBOM generation failed or produced missing/invalid output; using deterministic Syft fallback"
SYFT_VERSION="v1.42.1"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
TARBALL="syft_${SYFT_VERSION#v}_${OS}_${ARCH}.tar.gz"
BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}"
curl -fsSLo "$TARBALL" "${BASE_URL}/${TARBALL}"
curl -fsSLo checksums.txt "${BASE_URL}/syft_${SYFT_VERSION#v}_checksums.txt"
grep " ${TARBALL}$" checksums.txt > checksum_line.txt
sha256sum -c checksum_line.txt
tar -xzf "$TARBALL" syft
chmod +x syft
./syft "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}" -o cyclonedx-json=sbom-nightly.json
- name: Verify SBOM artifact
if: always()
run: |
set -euo pipefail
test -s sbom-nightly.json
jq -e . sbom-nightly.json >/dev/null
jq -e '
.bomFormat == "CycloneDX"
and (.specVersion | type == "string" and length > 0)
and has("version")
and has("metadata")
and (.components | type == "array")
' sbom-nightly.json >/dev/null
- name: Upload SBOM artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: sbom-nightly
path: sbom-nightly.json
@@ -331,7 +384,7 @@ jobs:
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Download SBOM
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: sbom-nightly
@@ -355,10 +408,116 @@ jobs:
sarif_file: 'trivy-nightly.sarif'
category: 'trivy-nightly'
- name: Check for critical CVEs
- name: Security severity policy summary
run: |
if grep -q "CRITICAL" trivy-nightly.sarif; then
echo "❌ Critical vulnerabilities found in nightly build"
{
echo "## 🔐 Nightly Supply Chain Severity Policy"
echo ""
echo "- Blocking: Critical, High"
echo "- Medium: non-blocking by default (report + triage SLA)"
echo "- Policy file: .github/security-severity-policy.yml"
} >> "$GITHUB_STEP_SUMMARY"
- name: Check for Critical/High CVEs
run: |
set -euo pipefail
jq -e . trivy-nightly.sarif >/dev/null
CRITICAL_COUNT=$(jq -r '
[
.runs[] as $run
| ($run.tool.driver.rules // []) as $rules
| $run.results[]?
| . as $result
| (
(
if (($result.ruleIndex | type) == "number") then
($rules[$result.ruleIndex].properties["security-severity"] // empty)
else
empty
end
)
// ([
$rules[]?
| select((.id // "") == ($result.ruleId // ""))
| .properties["security-severity"]
][0] // empty)
// empty
) as $securitySeverity
| (try ($securitySeverity | tonumber) catch empty) as $score
| select($score != null and $score >= 9.0)
] | length
' trivy-nightly.sarif)
HIGH_COUNT=$(jq -r '
[
.runs[] as $run
| ($run.tool.driver.rules // []) as $rules
| $run.results[]?
| . as $result
| (
(
if (($result.ruleIndex | type) == "number") then
($rules[$result.ruleIndex].properties["security-severity"] // empty)
else
empty
end
)
// ([
$rules[]?
| select((.id // "") == ($result.ruleId // ""))
| .properties["security-severity"]
][0] // empty)
// empty
) as $securitySeverity
| (try ($securitySeverity | tonumber) catch empty) as $score
| select($score != null and $score >= 7.0 and $score < 9.0)
] | length
' trivy-nightly.sarif)
MEDIUM_COUNT=$(jq -r '
[
.runs[] as $run
| ($run.tool.driver.rules // []) as $rules
| $run.results[]?
| . as $result
| (
(
if (($result.ruleIndex | type) == "number") then
($rules[$result.ruleIndex].properties["security-severity"] // empty)
else
empty
end
)
// ([
$rules[]?
| select((.id // "") == ($result.ruleId // ""))
| .properties["security-severity"]
][0] // empty)
// empty
) as $securitySeverity
| (try ($securitySeverity | tonumber) catch empty) as $score
| select($score != null and $score >= 4.0 and $score < 7.0)
] | length
' trivy-nightly.sarif)
{
echo "- Structured SARIF counts: CRITICAL=${CRITICAL_COUNT}, HIGH=${HIGH_COUNT}, MEDIUM=${MEDIUM_COUNT}"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$CRITICAL_COUNT" -gt 0 ]; then
echo "❌ Critical vulnerabilities found in nightly build (${CRITICAL_COUNT})"
exit 1
fi
echo "✅ No critical vulnerabilities found"
if [ "$HIGH_COUNT" -gt 0 ]; then
echo "❌ High vulnerabilities found in nightly build (${HIGH_COUNT})"
exit 1
fi
if [ "$MEDIUM_COUNT" -gt 0 ]; then
echo "::warning::Medium vulnerabilities found in nightly build (${MEDIUM_COUNT}). Non-blocking by policy; triage with SLA per .github/security-severity-policy.yml"
fi
echo "✅ No Critical/High vulnerabilities found"

View File

@@ -3,6 +3,8 @@ name: Quality Checks
on:
pull_request:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -18,6 +20,27 @@ env:
GOTOOLCHAIN: auto
jobs:
auth-route-protection-contract:
name: Auth Route Protection Contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Set up Go
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: backend/go.sum
- name: Run auth protection contract tests
run: |
set -euo pipefail
cd backend
go test ./internal/api/routes -run 'TestRegister_StateChangingRoutesRequireAuthentication|TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist|TestRegister_AuthenticatedRoutes' -count=1 -v
codecov-trigger-parity-guard:
name: Codecov Trigger/Comment Parity Guard
runs-on: ubuntu-latest
@@ -113,7 +136,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

View File

@@ -20,6 +20,7 @@ permissions:
jobs:
goreleaser:
if: ${{ !contains(github.ref_name, '-candidate') && !contains(github.ref_name, '-rc') }}
runs-on: ubuntu-latest
env:
# Use the built-in GITHUB_TOKEN by default for GitHub API operations.
@@ -32,10 +33,22 @@ jobs:
with:
fetch-depth: 0
- name: Enforce PR-2 release promotion guard
env:
REPO_VARS_JSON: ${{ toJSON(vars) }}
run: |
PR2_GATE_STATUS="$(printf '%s' "$REPO_VARS_JSON" | jq -r '.CHARON_PR2_GATES_PASSED // "false"')"
if [[ "$PR2_GATE_STATUS" != "true" ]]; then
echo "::error::Releasable tag promotion is blocked until PR-2 security/retirement gates pass."
echo "::error::Set repository variable CHARON_PR2_GATES_PASSED=true only after PR-2 approval."
exit 1
fi
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: backend/go.sum
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6

View File

@@ -25,7 +25,7 @@ jobs:
fetch-depth: 1
- name: Run Renovate
uses: renovatebot/github-action@d65ef9e20512193cc070238b49c3873a361cd50c # v46.1.1
uses: renovatebot/github-action@7b4b65bf31e07d4e3e51708d07700fb41bc03166 # v46.1.3
with:
configurationFile: .github/renovate.json
token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -34,7 +34,7 @@ jobs:
- name: Upload health output
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: repo-health-output
path: |

View File

@@ -4,18 +4,22 @@
name: Security Scan (PR)
on:
workflow_run:
workflows: ["Docker Build, Publish & Test"]
types: [completed]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to scan (optional)'
required: false
description: 'PR number to scan'
required: true
type: string
pull_request:
push:
branches: [main]
concurrency:
group: security-pr-${{ github.event.workflow_run.event || github.event_name }}-${{ github.event.workflow_run.head_branch || github.ref }}
group: security-pr-${{ github.event_name == 'workflow_run' && github.event.workflow_run.event || github.event_name }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
cancel-in-progress: true
jobs:
@@ -23,16 +27,18 @@ jobs:
name: Trivy Binary Scan
runs-on: ubuntu-latest
timeout-minutes: 10
# Run for: manual dispatch, PR builds, or any push builds from docker-build
# Run for manual dispatch, direct PR/push, or successful upstream workflow_run
if: >-
github.event_name == 'workflow_dispatch' ||
github.event_name == 'pull_request' ||
((github.event.workflow_run.event == 'push' || github.event.workflow_run.pull_requests[0].number != null) &&
(github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success'))
github.event_name == 'push' ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.status == 'completed' &&
github.event.workflow_run.conclusion == 'success')
permissions:
contents: read
pull-requests: write
security-events: write
actions: read
@@ -41,27 +47,65 @@ jobs:
# actions/checkout v4.2.2
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
- name: Extract PR number from workflow_run
id: pr-info
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Manual dispatch - use input or fail gracefully
if [[ -n "${{ inputs.pr_number }}" ]]; then
echo "pr_number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
echo "✅ Using manually provided PR number: ${{ inputs.pr_number }}"
else
echo "⚠️ No PR number provided for manual dispatch"
echo "pr_number=" >> "$GITHUB_OUTPUT"
fi
if [[ "${{ github.event_name }}" == "push" ]]; then
echo "pr_number=" >> "$GITHUB_OUTPUT"
echo "is_push=true" >> "$GITHUB_OUTPUT"
echo "✅ Push event detected; using local image path"
exit 0
fi
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "pr_number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
echo "is_push=false" >> "$GITHUB_OUTPUT"
echo "✅ Pull request event detected: PR #${{ github.event.pull_request.number }}"
exit 0
fi
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
INPUT_PR_NUMBER="${{ inputs.pr_number }}"
if [[ -z "${INPUT_PR_NUMBER}" ]]; then
echo "❌ workflow_dispatch requires inputs.pr_number"
exit 1
fi
if [[ ! "${INPUT_PR_NUMBER}" =~ ^[0-9]+$ ]]; then
echo "❌ reason_category=invalid_input"
echo "reason=workflow_dispatch pr_number must be digits-only"
exit 1
fi
PR_NUMBER="${INPUT_PR_NUMBER}"
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "is_push=false" >> "$GITHUB_OUTPUT"
echo "✅ Using manually provided PR number: ${PR_NUMBER}"
exit 0
fi
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then
# Explicit contract validation happens in the dedicated guard step.
echo "pr_number=" >> "$GITHUB_OUTPUT"
echo "is_push=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ -n "${{ github.event.workflow_run.pull_requests[0].number || '' }}" ]]; then
echo "pr_number=${{ github.event.workflow_run.pull_requests[0].number }}" >> "$GITHUB_OUTPUT"
echo "is_push=false" >> "$GITHUB_OUTPUT"
echo "✅ Found PR number from workflow_run payload: ${{ github.event.workflow_run.pull_requests[0].number }}"
exit 0
fi
fi
# Extract PR number from context
HEAD_SHA="${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}"
HEAD_SHA="${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}"
echo "🔍 Looking for PR with head SHA: ${HEAD_SHA}"
# Query GitHub API for PR associated with this commit
@@ -73,21 +117,38 @@ jobs:
if [[ -n "${PR_NUMBER}" ]]; then
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "is_push=false" >> "$GITHUB_OUTPUT"
echo "✅ Found PR number: ${PR_NUMBER}"
else
echo "⚠️ Could not find PR number for SHA: ${HEAD_SHA}"
echo "pr_number=" >> "$GITHUB_OUTPUT"
echo " Could not determine PR number for workflow_run SHA: ${HEAD_SHA}"
exit 1
fi
# Check if this is a push event (not a PR)
if [[ "${{ github.event_name }}" == "push" || "${{ github.event.workflow_run.event }}" == "push" || -z "${PR_NUMBER}" ]]; then
HEAD_BRANCH="${{ github.event.workflow_run.head_branch || github.ref_name }}"
echo "is_push=true" >> "$GITHUB_OUTPUT"
echo "✅ Detected push build from branch: ${HEAD_BRANCH}"
else
echo "is_push=false" >> "$GITHUB_OUTPUT"
- name: Validate workflow_run trust boundary and event contract
if: github.event_name == 'workflow_run'
run: |
if [[ "${{ github.event.workflow_run.name }}" != "Docker Build, Publish & Test" ]]; then
echo "❌ reason_category=unexpected_upstream_workflow"
echo "workflow_name=${{ github.event.workflow_run.name }}"
exit 1
fi
if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then
echo "❌ reason_category=unsupported_upstream_event"
echo "upstream_event=${{ github.event.workflow_run.event }}"
echo "run_id=${{ github.event.workflow_run.id }}"
exit 1
fi
if [[ "${{ github.event.workflow_run.head_repository.full_name }}" != "${{ github.repository }}" ]]; then
echo "❌ reason_category=untrusted_upstream_repository"
echo "upstream_head_repository=${{ github.event.workflow_run.head_repository.full_name }}"
echo "expected_repository=${{ github.repository }}"
exit 1
fi
echo "✅ workflow_run trust boundary and event contract validated"
- name: Build Docker image (Local)
if: github.event_name == 'push' || github.event_name == 'pull_request'
run: |
@@ -97,95 +158,149 @@ jobs:
- name: Check for PR image artifact
id: check-artifact
if: (steps.pr-info.outputs.pr_number != '' || steps.pr-info.outputs.is_push == 'true') && github.event_name != 'push' && github.event_name != 'pull_request'
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Determine artifact name based on event type
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
ARTIFACT_NAME="push-image"
else
PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}"
ARTIFACT_NAME="pr-image-${PR_NUMBER}"
PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}"
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then
echo "❌ reason_category=invalid_input"
echo "reason=Resolved PR number must be digits-only"
exit 1
fi
RUN_ID="${{ github.event.workflow_run.id }}"
ARTIFACT_NAME="pr-image-${PR_NUMBER}"
RUN_ID="${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || '' }}"
echo "🔍 Checking for artifact: ${ARTIFACT_NAME}"
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# For manual dispatch, find the most recent workflow run with this artifact
RUN_ID=$(gh api \
# Manual replay path: find latest successful docker-build pull_request run for this PR.
RUNS_JSON=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?status=success&per_page=10" \
--jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "")
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?event=pull_request&status=success&per_page=100" 2>&1)
RUNS_STATUS=$?
if [[ ${RUNS_STATUS} -ne 0 ]]; then
echo "❌ reason_category=api_error"
echo "reason=Failed to query workflow runs for PR lookup"
echo "upstream_run_id=unknown"
echo "artifact_name=${ARTIFACT_NAME}"
echo "api_output=${RUNS_JSON}"
exit 1
fi
RUN_ID=$(printf '%s' "${RUNS_JSON}" | jq -r --argjson pr "${PR_NUMBER}" '.workflow_runs[] | select((.pull_requests // []) | any(.number == $pr)) | .id' | head -n 1)
if [[ -z "${RUN_ID}" ]]; then
echo "⚠️ No successful workflow runs found"
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
exit 0
echo "❌ reason_category=not_found"
echo "reason=No successful docker-build pull_request run found for PR #${PR_NUMBER}"
echo "upstream_run_id=unknown"
echo "artifact_name=${ARTIFACT_NAME}"
exit 1
fi
elif [[ -z "${RUN_ID}" ]]; then
# If triggered by push/pull_request, RUN_ID is empty. Find recent run for this commit.
HEAD_SHA="${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}"
echo "🔍 Searching for workflow run for SHA: ${HEAD_SHA}"
# Retry a few times as the run might be just starting or finishing
for i in {1..3}; do
RUN_ID=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?head_sha=${HEAD_SHA}&status=success&per_page=1" \
--jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "")
if [[ -n "${RUN_ID}" ]]; then break; fi
echo "⏳ Waiting for workflow run to appear/complete... ($i/3)"
sleep 5
done
fi
echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT"
# Check if the artifact exists in the workflow run
ARTIFACT_ID=$(gh api \
ARTIFACTS_JSON=$(gh api \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
--jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .id" 2>/dev/null || echo "")
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" 2>&1)
ARTIFACTS_STATUS=$?
if [[ -n "${ARTIFACT_ID}" ]]; then
echo "artifact_exists=true" >> "$GITHUB_OUTPUT"
echo "artifact_id=${ARTIFACT_ID}" >> "$GITHUB_OUTPUT"
echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})"
else
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
echo "⚠️ Artifact not found: ${ARTIFACT_NAME}"
echo " This is expected for non-PR builds or if the image was not uploaded"
if [[ ${ARTIFACTS_STATUS} -ne 0 ]]; then
echo "❌ reason_category=api_error"
echo "reason=Failed to query artifacts for upstream run"
echo "upstream_run_id=${RUN_ID}"
echo "artifact_name=${ARTIFACT_NAME}"
echo "api_output=${ARTIFACTS_JSON}"
exit 1
fi
- name: Skip if no artifact
if: ((steps.pr-info.outputs.pr_number == '' && steps.pr-info.outputs.is_push != 'true') || steps.check-artifact.outputs.artifact_exists != 'true') && github.event_name != 'push' && github.event_name != 'pull_request'
run: |
echo " Skipping security scan - no PR image artifact available"
echo "This is expected for:"
echo " - Pushes to main/release branches"
echo " - PRs where Docker build failed"
echo " - Manual dispatch without PR number"
exit 0
ARTIFACT_ID=$(printf '%s' "${ARTIFACTS_JSON}" | jq -r --arg name "${ARTIFACT_NAME}" '.artifacts[] | select(.name == $name) | .id' | head -n 1)
if [[ -z "${ARTIFACT_ID}" ]]; then
echo "❌ reason_category=not_found"
echo "reason=Required artifact was not found"
echo "upstream_run_id=${RUN_ID}"
echo "artifact_name=${ARTIFACT_NAME}"
exit 1
fi
{
echo "artifact_exists=true"
echo "artifact_id=${ARTIFACT_ID}"
echo "artifact_name=${ARTIFACT_NAME}"
} >> "$GITHUB_OUTPUT"
echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})"
- name: Download PR image artifact
if: steps.check-artifact.outputs.artifact_exists == 'true'
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
# actions/download-artifact v4.1.8
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3
with:
name: ${{ steps.pr-info.outputs.is_push == 'true' && 'push-image' || format('pr-image-{0}', steps.pr-info.outputs.pr_number) }}
name: ${{ steps.check-artifact.outputs.artifact_name }}
run-id: ${{ steps.check-artifact.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Load Docker image
if: steps.check-artifact.outputs.artifact_exists == 'true'
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
id: load-image
run: |
echo "📦 Loading Docker image..."
docker load < charon-pr-image.tar
echo "✅ Docker image loaded"
if [[ ! -r "charon-pr-image.tar" ]]; then
echo "❌ ERROR: Artifact image tar is missing or unreadable"
exit 1
fi
MANIFEST_TAGS=""
if tar -tf charon-pr-image.tar | grep -qx "manifest.json"; then
MANIFEST_TAGS=$(tar -xOf charon-pr-image.tar manifest.json 2>/dev/null | jq -r '.[]?.RepoTags[]?' 2>/dev/null | sed '/^$/d' || true)
else
echo "⚠️ manifest.json not found in artifact tar; will try docker-load-image-id fallback"
fi
LOAD_OUTPUT=$(docker load < charon-pr-image.tar 2>&1)
echo "${LOAD_OUTPUT}"
SOURCE_IMAGE_REF=""
SOURCE_RESOLUTION_MODE=""
while IFS= read -r tag; do
[[ -z "${tag}" ]] && continue
if docker image inspect "${tag}" >/dev/null 2>&1; then
SOURCE_IMAGE_REF="${tag}"
SOURCE_RESOLUTION_MODE="manifest_tag"
break
fi
done <<< "${MANIFEST_TAGS}"
if [[ -z "${SOURCE_IMAGE_REF}" ]]; then
LOAD_IMAGE_ID=$(printf '%s\n' "${LOAD_OUTPUT}" | sed -nE 's/^Loaded image ID: (sha256:[0-9a-f]+)$/\1/p' | head -n1)
if [[ -n "${LOAD_IMAGE_ID}" ]] && docker image inspect "${LOAD_IMAGE_ID}" >/dev/null 2>&1; then
SOURCE_IMAGE_REF="${LOAD_IMAGE_ID}"
SOURCE_RESOLUTION_MODE="load_image_id"
fi
fi
if [[ -z "${SOURCE_IMAGE_REF}" ]]; then
echo "❌ ERROR: Could not resolve a valid image reference from manifest tags or docker load image ID"
exit 1
fi
docker tag "${SOURCE_IMAGE_REF}" "charon:artifact"
{
echo "source_image_ref=${SOURCE_IMAGE_REF}"
echo "source_resolution_mode=${SOURCE_RESOLUTION_MODE}"
echo "image_ref=charon:artifact"
} >> "$GITHUB_OUTPUT"
echo "✅ Docker image resolved via ${SOURCE_RESOLUTION_MODE} and tagged as charon:artifact"
docker images | grep charon
- name: Extract charon binary from container
@@ -214,31 +329,10 @@ jobs:
exit 0
fi
# Normalize image name for reference
IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]')
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
BRANCH_NAME="${{ github.event.workflow_run.head_branch }}"
if [[ -z "${BRANCH_NAME}" ]]; then
echo "❌ ERROR: Branch name is empty for push build"
exit 1
fi
# Normalize branch name for Docker tag (replace / and other special chars with -)
# This matches docker/metadata-action behavior: type=ref,event=branch
TAG_SAFE_BRANCH="${BRANCH_NAME//\//-}"
IMAGE_REF="ghcr.io/${IMAGE_NAME}:${TAG_SAFE_BRANCH}"
elif [[ -n "${{ steps.pr-info.outputs.pr_number }}" ]]; then
IMAGE_REF="ghcr.io/${IMAGE_NAME}:pr-${{ steps.pr-info.outputs.pr_number }}"
else
echo "❌ ERROR: Cannot determine image reference"
echo " - is_push: ${{ steps.pr-info.outputs.is_push }}"
echo " - pr_number: ${{ steps.pr-info.outputs.pr_number }}"
echo " - branch: ${{ github.event.workflow_run.head_branch }}"
exit 1
fi
# Validate the image reference format
if [[ ! "${IMAGE_REF}" =~ ^ghcr\.io/[a-z0-9_-]+/[a-z0-9_-]+:[a-zA-Z0-9._-]+$ ]]; then
echo "❌ ERROR: Invalid image reference format: ${IMAGE_REF}"
# For workflow_run artifact path, always use locally tagged image from loaded artifact.
IMAGE_REF="${{ steps.load-image.outputs.image_ref }}"
if [[ -z "${IMAGE_REF}" ]]; then
echo "❌ ERROR: Loaded artifact image reference is empty"
exit 1
fi
@@ -268,7 +362,7 @@ jobs:
- name: Run Trivy filesystem scan (SARIF output)
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
# aquasecurity/trivy-action v0.33.1
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518
uses: aquasecurity/trivy-action@4c61e6329bab9be735ca35291551614bc663dff3
with:
scan-type: 'fs'
scan-ref: ${{ steps.extract.outputs.binary_path }}
@@ -277,19 +371,30 @@ jobs:
severity: 'CRITICAL,HIGH,MEDIUM'
continue-on-error: true
- name: Check Trivy SARIF output exists
if: always() && (steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request')
id: trivy-sarif-check
run: |
if [[ -f trivy-binary-results.sarif ]]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo " No Trivy SARIF output found; skipping SARIF/artifact upload steps"
fi
- name: Upload Trivy SARIF to GitHub Security
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
# github/codeql-action v4
uses: github/codeql-action/upload-sarif@710e2945787622b429f8982cacb154faa182de18
uses: github/codeql-action/upload-sarif@0ec47d036c68ae0cf94c629009b1029407111281
with:
sarif_file: 'trivy-binary-results.sarif'
category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event.workflow_run.head_branch) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
category: ${{ 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) }}
continue-on-error: true
- name: Run Trivy filesystem scan (fail on CRITICAL/HIGH)
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
# aquasecurity/trivy-action v0.33.1
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518
uses: aquasecurity/trivy-action@4c61e6329bab9be735ca35291551614bc663dff3
with:
scan-type: 'fs'
scan-ref: ${{ steps.extract.outputs.binary_path }}
@@ -298,11 +403,11 @@ jobs:
exit-code: '1'
- name: Upload scan artifacts
if: always() && (steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request')
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.workflow_run.head_branch) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
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: |
trivy-binary-results.sarif
retention-days: 14
@@ -312,7 +417,7 @@ jobs:
run: |
{
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
echo "## 🔒 Security Scan Results - Branch: ${{ github.event.workflow_run.head_branch }}"
echo "## 🔒 Security Scan Results - Branch: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }}"
else
echo "## 🔒 Security Scan Results - PR #${{ steps.pr-info.outputs.pr_number }}"
fi

View File

@@ -6,7 +6,7 @@ name: Weekly Security Rebuild
on:
schedule:
- cron: '0 2 * * 0' # Sundays at 02:00 UTC
- cron: '0 12 * * 2' # Tuesdays at 12:00 UTC
workflow_dispatch:
inputs:
force_rebuild:
@@ -119,7 +119,7 @@ jobs:
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
- name: Upload Trivy JSON results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: trivy-weekly-scan-${{ github.run_number }}
path: trivy-weekly-results.json

View File

@@ -11,6 +11,8 @@ on:
type: string
pull_request:
push:
branches:
- main
concurrency:
group: supply-chain-pr-${{ github.event.workflow_run.event || github.event_name }}-${{ github.event.workflow_run.head_branch || github.ref }}
@@ -264,7 +266,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 }}
@@ -337,6 +339,27 @@ jobs:
echo " Low: ${LOW_COUNT}"
echo " Total: ${TOTAL_COUNT}"
- name: Security severity policy summary
if: steps.set-target.outputs.image_name != ''
run: |
CRITICAL_COUNT="${{ steps.vuln-summary.outputs.critical_count }}"
HIGH_COUNT="${{ steps.vuln-summary.outputs.high_count }}"
MEDIUM_COUNT="${{ steps.vuln-summary.outputs.medium_count }}"
{
echo "## 🔐 Supply Chain Severity Policy"
echo ""
echo "- Blocking: Critical, High"
echo "- Medium: non-blocking by default (report + triage SLA)"
echo "- Policy file: .github/security-severity-policy.yml"
echo ""
echo "Current scan counts: Critical=${CRITICAL_COUNT}, High=${HIGH_COUNT}, Medium=${MEDIUM_COUNT}"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "${MEDIUM_COUNT}" -gt 0 ]]; then
echo "::warning::${MEDIUM_COUNT} medium vulnerabilities found. Non-blocking by policy; create/maintain triage issue with SLA per .github/security-severity-policy.yml"
fi
- name: Upload SARIF to GitHub Security
if: steps.check-artifact.outputs.artifact_found == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
@@ -348,7 +371,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: |
@@ -433,10 +456,11 @@ jobs:
echo "✅ PR comment posted"
- name: Fail on critical vulnerabilities
- name: Fail on Critical/High vulnerabilities
if: steps.set-target.outputs.image_name != ''
run: |
CRITICAL_COUNT="${{ steps.vuln-summary.outputs.critical_count }}"
HIGH_COUNT="${{ steps.vuln-summary.outputs.high_count }}"
if [[ "${CRITICAL_COUNT}" -gt 0 ]]; then
echo "🚨 Found ${CRITICAL_COUNT} CRITICAL vulnerabilities!"
@@ -444,4 +468,10 @@ jobs:
exit 1
fi
echo "✅ No critical vulnerabilities found"
if [[ "${HIGH_COUNT}" -gt 0 ]]; then
echo "🚨 Found ${HIGH_COUNT} HIGH vulnerabilities!"
echo "Please review the vulnerability report and address high severity issues before merging."
exit 1
fi
echo "✅ No Critical/High vulnerabilities found"

View File

@@ -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
@@ -144,7 +144,7 @@ jobs:
- name: Upload SBOM Artifact
if: steps.image-check.outputs.exists == 'true' && always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: sbom-${{ steps.tag.outputs.tag }}
path: sbom-verify.cyclonedx.json
@@ -324,7 +324,7 @@ jobs:
- name: Upload Vulnerability Scan Artifact
if: steps.validate-sbom.outputs.valid == 'true' && always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: vulnerability-scan-${{ steps.tag.outputs.tag }}
path: |

View File

@@ -5,9 +5,9 @@ name: Weekly Nightly to Main Promotion
on:
schedule:
# Every Monday at 10:30 UTC (5:30am EST / 6:30am EDT)
# Every Monday at 12:00 UTC (7:00am EST / 8:00am EDT)
# Offset from nightly sync (09:00 UTC) to avoid schedule race and allow validation completion.
- cron: '30 10 * * 1'
- cron: '0 12 * * 1'
workflow_dispatch:
inputs:
reason:

View File

@@ -113,7 +113,7 @@ repos:
stages: [manual] # Only runs when explicitly called
- id: frontend-type-check
name: Frontend TypeScript Check
entry: bash -c 'cd frontend && npm run type-check'
entry: bash -c 'cd frontend && npx tsc --noEmit'
language: system
files: '^frontend/.*\.(ts|tsx)$'
pass_filenames: false

View File

@@ -1 +1 @@
v0.19.0
v0.19.1

163
.vscode/tasks.json vendored
View File

@@ -724,6 +724,13 @@
"group": "test",
"problemMatcher": []
},
{
"label": "Security: Caddy PR-1 Compatibility Matrix",
"type": "shell",
"command": "cd /projects/Charon && bash scripts/caddy-compat-matrix.sh --candidate-version 2.11.1 --patch-scenarios A,B,C --platforms linux/amd64,linux/arm64 --smoke-set boot_caddy,plugin_modules,config_validate,admin_api_health --output-dir test-results/caddy-compat --docs-report docs/reports/caddy-compatibility-matrix.md",
"group": "test",
"problemMatcher": []
},
{
"label": "Test: E2E Playwright (Skill)",
"type": "shell",
@@ -808,6 +815,162 @@
"close": false
}
},
{
"label": "Test: E2E Playwright (Chromium) - Non-Security Shards 1/4-4/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=chromium --shard=1/4 --output=playwright-output/chromium-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=chromium --shard=2/4 --output=playwright-output/chromium-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=chromium --shard=3/4 --output=playwright-output/chromium-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=chromium --shard=4/4 --output=playwright-output/chromium-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 1/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=chromium --shard=1/4 --output=playwright-output/chromium-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 2/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=chromium --shard=2/4 --output=playwright-output/chromium-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 3/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=chromium --shard=3/4 --output=playwright-output/chromium-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 4/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=chromium --shard=4/4 --output=playwright-output/chromium-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (WebKit) - Non-Security Shards 1/4-4/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=webkit --shard=1/4 --output=playwright-output/webkit-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=webkit --shard=2/4 --output=playwright-output/webkit-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=webkit --shard=3/4 --output=playwright-output/webkit-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=webkit --shard=4/4 --output=playwright-output/webkit-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 1/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=webkit --shard=1/4 --output=playwright-output/webkit-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 2/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=webkit --shard=2/4 --output=playwright-output/webkit-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 3/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=webkit --shard=3/4 --output=playwright-output/webkit-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 4/4",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=webkit --shard=4/4 --output=playwright-output/webkit-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (Chromium) - Security Suite",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=true PLAYWRIGHT_SKIP_SECURITY_DEPS=0 npx playwright test --project=security-tests --output=playwright-output/chromium-security tests/security",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (FireFox) - Security Suite",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=true PLAYWRIGHT_SKIP_SECURITY_DEPS=0 npx playwright test --project=firefox --output=playwright-output/firefox-security tests/security",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright (WebKit) - Security Suite",
"type": "shell",
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=true PLAYWRIGHT_SKIP_SECURITY_DEPS=0 npx playwright test --project=webkit --output=playwright-output/webkit-security tests/security",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "dedicated",
"close": false
}
},
{
"label": "Test: E2E Playwright with Coverage",
"type": "shell",

View File

@@ -126,7 +126,7 @@ graph TB
| **HTTP Framework** | Gin | Latest | Routing, middleware, HTTP handling |
| **Database** | SQLite | 3.x | Embedded database |
| **ORM** | GORM | Latest | Database abstraction layer |
| **Reverse Proxy** | Caddy Server | 2.11.0-beta.2 | Embedded HTTP/HTTPS proxy |
| **Reverse Proxy** | Caddy Server | 2.11.1 | Embedded HTTP/HTTPS proxy |
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
| **Metrics** | Prometheus Client | Latest | Application metrics |
@@ -1259,6 +1259,14 @@ go test ./integration/...
9. **Release Notes:** Generate changelog from commits
10. **Notify:** Send release notification (Discord, email)
**Mandatory rollout gates (sign-off block):**
1. Digest freshness and index digest parity across GHCR and Docker Hub
2. Per-arch digest parity across GHCR and Docker Hub
3. SBOM and vulnerability scans against immutable refs (`image@sha256:...`)
4. Artifact freshness timestamps after push
5. Evidence block with required rollout verification fields
### Supply Chain Security
**Components:**
@@ -1292,10 +1300,10 @@ cosign verify \
wikid82/charon:latest
# Inspect SBOM
syft wikid82/charon:latest -o json
syft ghcr.io/wikid82/charon@sha256:<index-digest> -o json
# Scan for vulnerabilities
grype wikid82/charon:latest
grype ghcr.io/wikid82/charon@sha256:<index-digest>
```
### Rollback Strategy

View File

@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed: Added robust validation and debug logging for Docker image tags to prevent invalid reference errors.
- Fixed: Removed log masking for image references and added manifest validation to debug CI failures.
- **Proxy Hosts**: Fixed ACL and Security Headers dropdown selections so create/edit saves now keep the selected values (including clearing to none) after submit and reload.
- **CI**: Fixed Docker image reference output so integration jobs never pull an empty image ref
- **E2E Test Reliability**: Resolved test timeout issues affecting CI/CD pipeline stability
- Fixed config reload overlay blocking test interactions

View File

@@ -14,8 +14,11 @@ ARG BUILD_DEBUG=0
# avoid accidentally pulling a v3 major release. Renovate can still update
# this ARG to a specific v2.x tag when desired.
## Try to build the requested Caddy v2.x tag (Renovate can update this ARG).
## If the requested tag isn't available, fall back to a known-good v2.11.0-beta.2 build.
ARG CADDY_VERSION=2.11.0-beta.2
## If the requested tag isn't available, fall back to a known-good v2.11.1 build.
ARG CADDY_VERSION=2.11.1
ARG CADDY_CANDIDATE_VERSION=2.11.1
ARG CADDY_USE_CANDIDATE=0
ARG CADDY_PATCH_SCENARIO=B
## When an official caddy image tag isn't available on the host, use a
## plain Alpine base image and overwrite its caddy binary with our
## xcaddy-built binary in the later COPY step. This avoids relying on
@@ -65,7 +68,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# ---- Frontend Builder ----
# Build the frontend using the BUILDPLATFORM to avoid arm64 musl Rollup native issues
# renovate: datasource=docker depName=node
FROM --platform=$BUILDPLATFORM node:24.13.1-alpine AS frontend-builder
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine AS frontend-builder
WORKDIR /app/frontend
# Copy frontend package files
@@ -196,6 +199,9 @@ FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS caddy-builder
ARG TARGETOS
ARG TARGETARCH
ARG CADDY_VERSION
ARG CADDY_CANDIDATE_VERSION
ARG CADDY_USE_CANDIDATE
ARG CADDY_PATCH_SCENARIO
# renovate: datasource=go depName=github.com/caddyserver/xcaddy
ARG XCADDY_VERSION=0.4.5
@@ -213,10 +219,16 @@ RUN --mount=type=cache,target=/go/pkg/mod \
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
sh -c 'set -e; \
CADDY_TARGET_VERSION="${CADDY_VERSION}"; \
if [ "${CADDY_USE_CANDIDATE}" = "1" ]; then \
CADDY_TARGET_VERSION="${CADDY_CANDIDATE_VERSION}"; \
fi; \
echo "Using Caddy target version: v${CADDY_TARGET_VERSION}"; \
echo "Using Caddy patch scenario: ${CADDY_PATCH_SCENARIO}"; \
export XCADDY_SKIP_CLEANUP=1; \
echo "Stage 1: Generate go.mod with xcaddy..."; \
# Run xcaddy to generate the build directory and go.mod
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_VERSION} \
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_TARGET_VERSION} \
--with github.com/greenpau/caddy-security \
--with github.com/corazawaf/coraza-caddy/v2 \
--with github.com/hslatman/caddy-crowdsec-bouncer@v0.10.0 \
@@ -239,12 +251,21 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
go get github.com/expr-lang/expr@v1.17.7; \
# renovate: datasource=go depName=github.com/hslatman/ipstore
go get github.com/hslatman/ipstore@v0.4.0; \
# NOTE: smallstep/certificates (pulled by caddy-security stack) currently
# uses legacy nebula APIs removed in nebula v1.10+, which causes compile
# failures in authority/provisioner. Keep this pinned to a known-compatible
# v1.9.x release until upstream stack supports nebula v1.10+.
# renovate: datasource=go depName=github.com/slackhq/nebula
go get github.com/slackhq/nebula@v1.9.7; \
if [ "${CADDY_PATCH_SCENARIO}" = "A" ]; then \
# Rollback scenario: keep explicit nebula pin if upstream compatibility regresses.
# NOTE: smallstep/certificates (pulled by caddy-security stack) currently
# uses legacy nebula APIs removed in nebula v1.10+, which causes compile
# failures in authority/provisioner. Keep this pinned to a known-compatible
# v1.9.x release until upstream stack supports nebula v1.10+.
# renovate: datasource=go depName=github.com/slackhq/nebula
go get github.com/slackhq/nebula@v1.9.7; \
elif [ "${CADDY_PATCH_SCENARIO}" = "B" ] || [ "${CADDY_PATCH_SCENARIO}" = "C" ]; then \
# Default PR-2 posture: retire explicit nebula pin and use upstream resolution.
echo "Skipping nebula pin for scenario ${CADDY_PATCH_SCENARIO}"; \
else \
echo "Unsupported CADDY_PATCH_SCENARIO=${CADDY_PATCH_SCENARIO}"; \
exit 1; \
fi; \
# Clean up go.mod and ensure all dependencies are resolved
go mod tidy; \
echo "Dependencies patched successfully"; \
@@ -392,7 +413,7 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"]
# Note: In production, users should provide their own MaxMind license key
# This uses the publicly available GeoLite2 database
# In CI, timeout quickly rather than retrying to save build time
ARG GEOLITE2_COUNTRY_SHA256=86fe00e0272865b8bec79defca2e9fb19ad0cf4458697992e1a37ba89077c13a
ARG GEOLITE2_COUNTRY_SHA256=d3031e02196523cbb5f74291122033f2be277b2130abedd4b5bee52ba79832be
RUN mkdir -p /app/data/geoip && \
if [ -n "$CI" ]; then \
echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \

View File

@@ -94,6 +94,19 @@ services:
retries: 3
start_period: 40s
```
> **Docker Socket Access:** Charon runs as a non-root user. If you mount the Docker socket for container discovery, the container needs permission to read it. Find your socket's group ID and add it to the compose file:
>
> ```bash
> stat -c '%g' /var/run/docker.sock
> ```
>
> Then add `group_add: ["<gid>"]` under your service (replace `<gid>` with the number from the command above). For example, if the result is `998`:
>
> ```yaml
> group_add:
> - "998"
> ```
### 2⃣ Generate encryption key:
```bash
openssl rand -base64 32

View File

@@ -25,11 +25,10 @@ We take security seriously. If you discover a security vulnerability in Charon,
- Impact assessment
- Suggested fix (if applicable)
**Alternative Method**: Email
**Alternative Method**: GitHub Issues (Public)
- Send to: `security@charon.dev` (if configured)
- Use PGP encryption (key available below, if applicable)
- Include same information as GitHub advisory
1. Go to <https://github.com/Wikid82/Charon/issues>
2. Create a new issue with the same information as above
### What to Include
@@ -125,6 +124,7 @@ For complete technical details, see:
### Infrastructure Security
- **Non-root by default**: Charon runs as an unprivileged user (`charon`, uid 1000) inside the container. Docker socket access is granted via a minimal supplemental group matching the host socket's GID—never by running as root. If the socket GID is `0` (root group), Charon requires explicit opt-in before granting access.
- **Container isolation**: Docker-based deployment
- **Minimal attack surface**: Alpine Linux base image
- **Dependency scanning**: Regular Trivy and govulncheck scans

View File

@@ -19,36 +19,76 @@ Example: `0.1.0-alpha`, `1.0.0-beta.1`, `2.0.0-rc.2`
## Creating a Release
### Automated Release Process
### Canonical Release Process (Tag-Derived CI)
1. **Update version** in `.version` file:
1. **Create and push a release tag**:
```bash
echo "1.0.0" > .version
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
```
2. **Commit version bump**:
2. **GitHub Actions automatically**:
- Runs release workflow from the pushed tag (`.github/workflows/release-goreleaser.yml`)
- Builds and publishes release artifacts/images through CI (`.github/workflows/docker-build.yml`)
- Creates/updates GitHub Release metadata
3. **Container tags are published**:
- `v1.0.0` (exact version)
- `1.0` (minor version)
- `1` (major version)
- `latest` (for non-prerelease on main branch)
### Legacy/Optional `.version` Path
The `.version` file is optional and not the canonical release trigger.
Use it only when you need local/version-file parity checks:
1. **Set `.version` locally (optional)**:
```bash
git add .version
git commit -m "chore: bump version to 1.0.0"
echo "1.0.0" > .version
```
3. **Create and push tag**:
2. **Validate `.version` matches the latest tag**:
```bash
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
bash scripts/check-version-match-tag.sh
```
4. **GitHub Actions automatically**:
- Creates GitHub Release with changelog
- Builds multi-arch Docker images (amd64, arm64)
- Publishes to GitHub Container Registry with tags:
- `v1.0.0` (exact version)
- `1.0` (minor version)
- `1` (major version)
- `latest` (for non-prerelease on main branch)
### Deterministic Rollout Verification Gates (Mandatory)
Release sign-off is blocked until all items below pass in the same validation
run.
Enforcement points:
- Release sign-off checklist/process (mandatory): All gates below remain required for release sign-off.
- CI-supported checks (current): `.github/workflows/docker-build.yml` and `.github/workflows/supply-chain-verify.yml` enforce the subset currently implemented in workflows.
- Manual validation required until CI parity: Validate any not-yet-implemented workflow gates via VS Code tasks `Security: Full Supply Chain Audit`, `Security: Verify SBOM`, `Security: Generate SLSA Provenance`, and `Security: Sign with Cosign`.
- Optional version-file parity check: `Utility: Check Version Match Tag` (script: `scripts/check-version-match-tag.sh`).
- [ ] **Digest freshness/parity:** Capture pre-push and post-push index digests
for the target tag in GHCR and Docker Hub, confirm expected freshness,
and confirm cross-registry index digest parity.
- [ ] **Per-arch parity:** Confirm per-platform (`linux/amd64`, `linux/arm64`,
and any published platform) digest parity between GHCR and Docker Hub.
- [ ] **Immutable digest scanning:** Run SBOM and vulnerability scans against
immutable refs only, using `image@sha256:<index-digest>`.
- [ ] **Artifact freshness:** Confirm scan artifacts are generated after the
push timestamp and in the same validation run.
- [ ] **Evidence block present:** Include the mandatory evidence block fields
listed below.
#### Mandatory Evidence Block Fields
- Tag name
- Index digest (`sha256:...`)
- Per-arch digests (platform -> digest)
- Scan tool versions
- Push timestamp and scan timestamp(s)
- Artifact file names generated in this run
## Container Image Tags

View File

@@ -12,7 +12,7 @@ linters:
- ineffassign # Ineffectual assignments
- unused # Unused code detection
- gosec # Security checks (critical issues only)
linters-settings:
settings:
govet:
enable:
- shadow

View File

@@ -1,5 +1,5 @@
# golangci-lint configuration
version: 2
version: "2"
run:
timeout: 5m
tests: true
@@ -14,7 +14,7 @@ linters:
- staticcheck
- unused
- errcheck
linters-settings:
settings:
gocritic:
enabled-tags:
- diagnostic

View File

@@ -260,7 +260,7 @@ func main() {
}
// Register import handler with config dependencies
routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
routes.RegisterImportHandler(router, db, cfg, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
// Check for mounted Caddyfile on startup
if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil {

View File

@@ -311,7 +311,8 @@ func TestMain_DefaultStartupGracefulShutdown_Subprocess(t *testing.T) {
if err != nil {
t.Fatalf("find free http port: %v", err)
}
if err := os.MkdirAll(filepath.Dir(dbPath), 0o750); err != nil {
err = os.MkdirAll(filepath.Dir(dbPath), 0o750)
if err != nil {
t.Fatalf("mkdir db dir: %v", err)
}

View File

@@ -64,11 +64,13 @@ func main() {
jsonOutPath := resolvePath(repoRoot, *jsonOutFlag)
mdOutPath := resolvePath(repoRoot, *mdOutFlag)
if err := assertFileExists(backendCoveragePath, "backend coverage file"); err != nil {
err = assertFileExists(backendCoveragePath, "backend coverage file")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := assertFileExists(frontendCoveragePath, "frontend coverage file"); err != nil {
err = assertFileExists(frontendCoveragePath, "frontend coverage file")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

View File

@@ -235,7 +235,8 @@ func TestGitDiffAndWriters(t *testing.T) {
t.Fatalf("expected empty diff for HEAD...HEAD, got: %q", diffContent)
}
if _, err := gitDiff(repoRoot, "bad-baseline"); err == nil {
_, err = gitDiff(repoRoot, "bad-baseline")
if err == nil {
t.Fatal("expected gitDiff failure for invalid baseline")
}
@@ -263,7 +264,8 @@ func TestGitDiffAndWriters(t *testing.T) {
}
jsonPath := filepath.Join(t.TempDir(), "report.json")
if err := writeJSON(jsonPath, report); err != nil {
err = writeJSON(jsonPath, report)
if err != nil {
t.Fatalf("writeJSON should succeed: %v", err)
}
// #nosec G304 -- Test reads artifact path created by this test.
@@ -276,7 +278,8 @@ func TestGitDiffAndWriters(t *testing.T) {
}
markdownPath := filepath.Join(t.TempDir(), "report.md")
if err := writeMarkdown(markdownPath, report, "backend/coverage.txt", "frontend/coverage/lcov.info"); err != nil {
err = writeMarkdown(markdownPath, report, "backend/coverage.txt", "frontend/coverage/lcov.info")
if err != nil {
t.Fatalf("writeMarkdown should succeed: %v", err)
}
// #nosec G304 -- Test reads artifact path created by this test.

View File

@@ -5,7 +5,7 @@ go 1.26
require (
github.com/docker/docker v28.5.2+incompatible
github.com/gin-contrib/gzip v1.2.5
github.com/gin-gonic/gin v1.11.0
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
@@ -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
@@ -29,8 +29,8 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
@@ -42,16 +42,16 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -66,6 +66,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
@@ -73,28 +74,29 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.22.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.69.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
)

View File

@@ -6,10 +6,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -37,16 +37,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -64,21 +64,23 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -118,6 +120,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -136,21 +140,20 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -161,45 +164,52 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
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/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
@@ -207,6 +217,8 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
@@ -229,11 +241,31 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,124 @@
//go:build integration
// +build integration
package integration
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/Wikid82/charon/backend/internal/notifications"
)
func TestNotificationHTTPWrapperIntegration_RetriesOn429AndSucceeds(t *testing.T) {
t.Parallel()
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
current := atomic.AddInt32(&calls, 1)
if current == 1 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
wrapper := notifications.NewNotifyHTTPWrapper()
result, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
URL: server.URL,
Body: []byte(`{"message":"hello"}`),
})
if err != nil {
t.Fatalf("expected retry success, got error: %v", err)
}
if result.Attempts != 2 {
t.Fatalf("expected 2 attempts, got %d", result.Attempts)
}
}
func TestNotificationHTTPWrapperIntegration_DoesNotRetryOn400(t *testing.T) {
t.Parallel()
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.WriteHeader(http.StatusBadRequest)
}))
defer server.Close()
wrapper := notifications.NewNotifyHTTPWrapper()
_, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
URL: server.URL,
Body: []byte(`{"message":"hello"}`),
})
if err == nil {
t.Fatalf("expected non-retryable 400 error")
}
if atomic.LoadInt32(&calls) != 1 {
t.Fatalf("expected one request attempt, got %d", calls)
}
}
func TestNotificationHTTPWrapperIntegration_RejectsTokenizedQueryWithoutEcho(t *testing.T) {
t.Parallel()
wrapper := notifications.NewNotifyHTTPWrapper()
secret := "pr1-secret-token-value"
_, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
URL: "http://example.com/hook?token=" + secret,
Body: []byte(`{"message":"hello"}`),
})
if err == nil {
t.Fatalf("expected tokenized query rejection")
}
if !strings.Contains(err.Error(), "query authentication is not allowed") {
t.Fatalf("expected sanitized query-auth rejection, got: %v", err)
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("error must not echo secret token")
}
}
func TestNotificationHTTPWrapperIntegration_HeaderAllowlistSafety(t *testing.T) {
t.Parallel()
var seenAuthHeader string
var seenCookieHeader string
var seenGotifyKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenAuthHeader = r.Header.Get("Authorization")
seenCookieHeader = r.Header.Get("Cookie")
seenGotifyKey = r.Header.Get("X-Gotify-Key")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
wrapper := notifications.NewNotifyHTTPWrapper()
_, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
URL: server.URL,
Headers: map[string]string{
"Authorization": "Bearer should-not-leak",
"Cookie": "session=should-not-leak",
"X-Gotify-Key": "allowed-token",
},
Body: []byte(`{"message":"hello"}`),
})
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
if seenAuthHeader != "" {
t.Fatalf("authorization header must be stripped")
}
if seenCookieHeader != "" {
t.Fatalf("cookie header must be stripped")
}
if seenGotifyKey != "allowed-token" {
t.Fatalf("expected X-Gotify-Key to pass through")
}
}

View File

@@ -170,6 +170,7 @@ func TestSecurityHandler_UpdateConfig_ApplyCaddyError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("PUT", "/security/config", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -190,6 +191,7 @@ func TestSecurityHandler_GenerateBreakGlass_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/security/breakglass", http.NoBody)
h.GenerateBreakGlass(c)
@@ -252,6 +254,7 @@ func TestSecurityHandler_UpsertRuleSet_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/security/rulesets", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -277,6 +280,7 @@ func TestSecurityHandler_CreateDecision_LogError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -297,6 +301,7 @@ func TestSecurityHandler_DeleteRuleSet_Error(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "999"}}
h.DeleteRuleSet(c)

View File

@@ -127,18 +127,20 @@ func isLocalRequest(c *gin.Context) bool {
// setSecureCookie sets an auth cookie with security best practices
// - HttpOnly: prevents JavaScript access (XSS protection)
// - Secure: derived from request scheme to allow HTTP/IP logins when needed
// - Secure: true for HTTPS; false only for local non-HTTPS loopback flows
// - SameSite: Strict for HTTPS, Lax for HTTP/IP to allow forward-auth redirects
func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
scheme := requestScheme(c)
secure := scheme == "https"
secure := true
sameSite := http.SameSiteStrictMode
if scheme != "https" {
sameSite = http.SameSiteLaxMode
if isLocalRequest(c) {
secure = false
}
}
if isLocalRequest(c) {
secure = false
sameSite = http.SameSiteLaxMode
}
@@ -152,7 +154,7 @@ func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
maxAge, // maxAge in seconds
"/", // path
domain, // domain (empty = current host)
secure, // secure (HTTPS only in production)
secure, // secure (always true)
true, // httpOnly (no JS access)
)
}

View File

@@ -94,10 +94,28 @@ func TestSetSecureCookie_HTTP_Lax(t *testing.T) {
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
c := cookies[0]
assert.False(t, c.Secure)
assert.True(t, c.Secure)
assert.Equal(t, http.SameSiteLaxMode, c.SameSite)
}
func TestSetSecureCookie_HTTP_Loopback_Insecure(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest("POST", "http://127.0.0.1:8080/login", http.NoBody)
req.Host = "127.0.0.1:8080"
req.Header.Set("X-Forwarded-Proto", "http")
ctx.Request = req
setSecureCookie(ctx, "auth_token", "abc", 60)
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
assert.False(t, cookie.Secure)
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
}
func TestSetSecureCookie_ForwardedHTTPS_LocalhostForcesInsecure(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
@@ -115,7 +133,7 @@ func TestSetSecureCookie_ForwardedHTTPS_LocalhostForcesInsecure(t *testing.T) {
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
assert.False(t, cookie.Secure)
assert.True(t, cookie.Secure)
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
}
@@ -136,7 +154,7 @@ func TestSetSecureCookie_ForwardedHTTPS_LoopbackForcesInsecure(t *testing.T) {
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
assert.False(t, cookie.Secure)
assert.True(t, cookie.Secure)
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
}
@@ -158,7 +176,7 @@ func TestSetSecureCookie_ForwardedHostLocalhostForcesInsecure(t *testing.T) {
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
assert.False(t, cookie.Secure)
assert.True(t, cookie.Secure)
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
}
@@ -180,7 +198,7 @@ func TestSetSecureCookie_OriginLoopbackForcesInsecure(t *testing.T) {
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
assert.False(t, cookie.Secure)
assert.True(t, cookie.Secure)
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
}

View File

@@ -71,10 +71,14 @@ func (h *DockerHandler) ListContainers(c *gin.Context) {
if err != nil {
var unavailableErr *services.DockerUnavailableError
if errors.As(err, &unavailableErr) {
details := unavailableErr.Details()
if details == "" {
details = "Cannot connect to Docker. Please ensure Docker is running and the socket is accessible (e.g., /var/run/docker.sock is mounted)."
}
log.WithFields(map[string]any{"server_id": util.SanitizeForLog(serverID), "host": util.SanitizeForLog(host), "error": util.SanitizeForLog(err.Error())}).Warn("docker unavailable")
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Docker daemon unavailable",
"details": "Cannot connect to Docker. Please ensure Docker is running and the socket is accessible (e.g., /var/run/docker.sock is mounted).",
"details": details,
})
return
}

View File

@@ -63,7 +63,7 @@ func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T)
gin.SetMode(gin.TestMode)
router := gin.New()
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"))}
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"), "Local Docker socket is mounted but not accessible by current process")}
remoteSvc := &fakeRemoteServerService{}
h := NewDockerHandler(dockerSvc, remoteSvc)
@@ -78,7 +78,7 @@ func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T)
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
// Verify the new details field is included in the response
assert.Contains(t, w.Body.String(), "details")
assert.Contains(t, w.Body.String(), "Docker is running")
assert.Contains(t, w.Body.String(), "not accessible by current process")
}
func TestDockerHandler_ListContainers_ServerIDResolvesToTCPHost(t *testing.T) {
@@ -360,3 +360,47 @@ func TestDockerHandler_ListContainers_GenericError(t *testing.T) {
})
}
}
func TestDockerHandler_ListContainers_503FallbackDetailsWhenEmpty(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("socket error"))}
remoteSvc := &fakeRemoteServerService{}
h := NewDockerHandler(dockerSvc, remoteSvc)
api := router.Group("/api/v1")
h.RegisterRoutes(api)
req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
assert.Contains(t, w.Body.String(), "docker.sock is mounted")
}
func TestDockerHandler_ListContainers_503DetailsWithGroupGuidance(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
groupDetails := `Local Docker socket is mounted but not accessible by current process (uid=1000 gid=1000). Process groups (1000) do not include socket gid 988; run container with matching supplemental group (e.g., --group-add 988 or compose group_add: ["988"]).`
dockerSvc := &fakeDockerService{
err: services.NewDockerUnavailableError(errors.New("EACCES"), groupDetails),
}
remoteSvc := &fakeRemoteServerService{}
h := NewDockerHandler(dockerSvc, remoteSvc)
api := router.Group("/api/v1")
h.RegisterRoutes(api)
req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?host=local", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
assert.Contains(t, w.Body.String(), "--group-add 988")
assert.Contains(t, w.Body.String(), "group_add")
}

View File

@@ -31,6 +31,7 @@ var defaultFlags = []string{
"feature.notifications.engine.notify_v1.enabled",
"feature.notifications.service.discord.enabled",
"feature.notifications.service.gotify.enabled",
"feature.notifications.service.webhook.enabled",
"feature.notifications.legacy.fallback_enabled",
"feature.notifications.security_provider_events.enabled", // Blocker 3: Add security_provider_events gate
}
@@ -42,6 +43,7 @@ var defaultFlagValues = map[string]bool{
"feature.notifications.engine.notify_v1.enabled": false,
"feature.notifications.service.discord.enabled": false,
"feature.notifications.service.gotify.enabled": false,
"feature.notifications.service.webhook.enabled": false,
"feature.notifications.legacy.fallback_enabled": false,
"feature.notifications.security_provider_events.enabled": false, // Blocker 3: Default disabled for this stage
}

View File

@@ -93,6 +93,10 @@ func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
// GetStatus returns current import session status.
func (h *ImportHandler) GetStatus(c *gin.Context) {
if !requireAuthenticatedAdmin(c) {
return
}
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").
@@ -155,6 +159,10 @@ func (h *ImportHandler) GetStatus(c *gin.Context) {
// GetPreview returns parsed hosts and conflicts for review.
func (h *ImportHandler) GetPreview(c *gin.Context) {
if !requireAuthenticatedAdmin(c) {
return
}
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").

View File

@@ -3,6 +3,7 @@ package handlers
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -14,6 +15,7 @@ import (
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/trace"
)
func setupNotificationCoverageDB(t *testing.T) *gorm.DB {
@@ -319,6 +321,159 @@ func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) {
assert.Equal(t, 400, w.Code)
}
func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
"type": "gotify",
"url": "https://gotify.example/message",
"token": "super-secret-client-token",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Set(string(trace.RequestIDKey), "req-token-reject-1")
c.Request = httptest.NewRequest(http.MethodPost, "/providers/test", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Test(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "TOKEN_WRITE_ONLY", resp["code"])
assert.Equal(t, "validation", resp["category"])
assert.Equal(t, "Gotify token is accepted only on provider create/update", resp["error"])
assert.Equal(t, "req-token-reject-1", resp["request_id"])
assert.NotContains(t, w.Body.String(), "super-secret-client-token")
}
func TestNotificationProviderHandler_Test_RejectsGotifyTokenWithWhitespace(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
"type": "gotify",
"token": " secret-with-space ",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest(http.MethodPost, "/providers/test", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Test(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
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"))
assert.Equal(t, "PROVIDER_TEST_URL_INVALID", code)
assert.Equal(t, "validation", category)
assert.Contains(t, message, "Provider URL")
}
func TestClassifyProviderTestFailure_AuthRejected(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: provider returned status 401"))
assert.Equal(t, "PROVIDER_TEST_AUTH_REJECTED", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "rejected authentication")
}
func TestClassifyProviderTestFailure_EndpointNotFound(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: provider returned status 404"))
assert.Equal(t, "PROVIDER_TEST_ENDPOINT_NOT_FOUND", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "endpoint was not found")
}
func TestClassifyProviderTestFailure_UnreachableEndpoint(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed"))
assert.Equal(t, "PROVIDER_TEST_UNREACHABLE", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "Could not reach provider endpoint")
}
func TestClassifyProviderTestFailure_DNSLookupFailed(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: dns lookup failed"))
assert.Equal(t, "PROVIDER_TEST_DNS_FAILED", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "DNS lookup failed")
}
func TestClassifyProviderTestFailure_ConnectionRefused(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: connection refused"))
assert.Equal(t, "PROVIDER_TEST_CONNECTION_REFUSED", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "refused the connection")
}
func TestClassifyProviderTestFailure_Timeout(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: request timed out"))
assert.Equal(t, "PROVIDER_TEST_TIMEOUT", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "timed out")
}
func TestClassifyProviderTestFailure_TLSHandshakeFailed(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: tls handshake failed"))
assert.Equal(t, "PROVIDER_TEST_TLS_FAILED", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "TLS handshake failed")
}
func TestNotificationProviderHandler_Templates(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
@@ -625,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")
}

View File

@@ -15,7 +15,7 @@ import (
"gorm.io/gorm"
)
// TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents tests that create rejects non-Discord providers with security events.
// TestBlocker3_CreateProviderValidationWithSecurityEvents verifies supported/unsupported provider handling with security events enabled.
func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -31,15 +31,16 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
service := services.NewNotificationService(db)
handler := NewNotificationProviderHandler(service)
// Test cases: non-Discord provider types with security events enabled
// Test cases: provider types with security events enabled
testCases := []struct {
name string
providerType string
wantStatus int
}{
{"webhook", "webhook"},
{"slack", "slack"},
{"gotify", "gotify"},
{"email", "email"},
{"webhook", "webhook", http.StatusCreated},
{"gotify", "gotify", http.StatusCreated},
{"slack", "slack", http.StatusBadRequest},
{"email", "email", http.StatusBadRequest},
}
for _, tc := range testCases {
@@ -69,14 +70,15 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
// Call Create
handler.Create(c)
// Blocker 3: Should reject with 400
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider with security events")
assert.Equal(t, tc.wantStatus, w.Code)
// Verify error message
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Contains(t, response["error"], "discord", "Error should mention Discord")
if tc.wantStatus == http.StatusBadRequest {
assert.Contains(t, response["code"], "UNSUPPORTED_PROVIDER_TYPE")
}
})
}
}
@@ -129,8 +131,7 @@ func TestBlocker3_CreateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
assert.Equal(t, http.StatusCreated, w.Code, "Should accept Discord provider with security events")
}
// TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents tests that create NOW REJECTS non-Discord providers even without security events.
// NOTE: This test was updated for Discord-only rollout (current_spec.md) - now globally rejects all non-Discord.
// TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents verifies webhook create without security events remains accepted.
func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -172,17 +173,10 @@ func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testin
// Call Create
handler.Create(c)
// Discord-only rollout: Now REJECTS with 400
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider (Discord-only rollout)")
// Verify error message
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Contains(t, response["error"], "discord", "Error should mention Discord")
assert.Equal(t, http.StatusCreated, w.Code)
}
// TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents tests that update rejects non-Discord providers with security events.
// TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents verifies webhook update with security events is allowed in PR-1 scope.
func TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -235,14 +229,7 @@ func TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
// Call Update
handler.Update(c)
// Blocker 3: Should reject with 400
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider update with security events")
// Verify error message
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Contains(t, response["error"], "discord", "Error should mention Discord")
assert.Equal(t, http.StatusOK, w.Code)
}
// TestBlocker3_UpdateProviderAcceptsDiscordWithSecurityEvents tests that update accepts Discord providers with security events.
@@ -302,7 +289,7 @@ func TestBlocker3_UpdateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code, "Should accept Discord provider update with security events")
}
// TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly tests that having any security event enabled enforces Discord-only.
// TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly tests webhook remains accepted with security flags in PR-1 scope.
func TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -353,9 +340,8 @@ func TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly(t *testing.T) {
// Call Create
handler.Create(c)
// Blocker 3: Should reject with 400
assert.Equal(t, http.StatusBadRequest, w.Code,
"Should reject webhook provider with %s enabled", field)
assert.Equal(t, http.StatusCreated, w.Code,
"Should accept webhook provider with %s enabled", field)
})
}
}
@@ -407,5 +393,5 @@ func TestBlocker3_UpdateProvider_DatabaseError(t *testing.T) {
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "provider not found", response["error"])
assert.Equal(t, "Provider not found", response["error"])
}

View File

@@ -16,7 +16,7 @@ import (
"gorm.io/gorm"
)
// TestDiscordOnly_CreateRejectsNonDiscord tests that create globally rejects non-Discord providers.
// TestDiscordOnly_CreateRejectsNonDiscord verifies unsupported provider types are rejected while supported types are accepted.
func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -30,13 +30,15 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
testCases := []struct {
name string
providerType string
wantStatus int
wantCode string
}{
{"webhook", "webhook"},
{"slack", "slack"},
{"gotify", "gotify"},
{"telegram", "telegram"},
{"generic", "generic"},
{"email", "email"},
{"webhook", "webhook", http.StatusCreated, ""},
{"gotify", "gotify", http.StatusCreated, ""},
{"slack", "slack", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
{"telegram", "telegram", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
{"generic", "generic", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
{"email", "email", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
}
for _, tc := range testCases {
@@ -61,13 +63,14 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
handler.Create(c)
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider")
assert.Equal(t, tc.wantStatus, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "PROVIDER_TYPE_DISCORD_ONLY", response["code"])
assert.Contains(t, response["error"], "discord")
if tc.wantCode != "" {
assert.Equal(t, tc.wantCode, response["code"])
}
})
}
}
@@ -156,8 +159,8 @@ func TestDiscordOnly_UpdateRejectsTypeMutation(t *testing.T) {
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "DEPRECATED_PROVIDER_TYPE_IMMUTABLE", response["code"])
assert.Contains(t, response["error"], "cannot change provider type")
assert.Equal(t, "PROVIDER_TYPE_IMMUTABLE", response["code"])
assert.Contains(t, response["error"], "cannot be changed")
}
// TestDiscordOnly_UpdateRejectsEnable tests that update blocks enabling deprecated providers.
@@ -205,13 +208,7 @@ func TestDiscordOnly_UpdateRejectsEnable(t *testing.T) {
handler.Update(c)
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject enabling deprecated provider")
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "DEPRECATED_PROVIDER_CANNOT_ENABLE", response["code"])
assert.Contains(t, response["error"], "cannot enable deprecated")
assert.Equal(t, http.StatusOK, w.Code)
}
// TestDiscordOnly_UpdateAllowsDisabledDeprecated tests that update allows updating disabled deprecated providers (except type/enable).
@@ -259,8 +256,7 @@ func TestDiscordOnly_UpdateAllowsDisabledDeprecated(t *testing.T) {
handler.Update(c)
// Should still reject because type must be discord
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord type even for read-only fields")
assert.Equal(t, http.StatusOK, w.Code)
}
// TestDiscordOnly_UpdateAcceptsDiscord tests that update accepts Discord provider updates.
@@ -360,21 +356,21 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
expectedCode string
}{
{
name: "create_non_discord",
name: "create_unsupported",
setupFunc: func(db *gorm.DB) string {
return ""
},
requestFunc: func(id string) (*http.Request, gin.Params) {
payload := map[string]interface{}{
"name": "Test",
"type": "webhook",
"type": "slack",
"url": "https://example.com",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body))
return req, nil
},
expectedCode: "PROVIDER_TYPE_DISCORD_ONLY",
expectedCode: "UNSUPPORTED_PROVIDER_TYPE",
},
{
name: "update_type_mutation",
@@ -399,34 +395,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
req, _ := http.NewRequest("PUT", "/api/v1/notifications/providers/"+id, bytes.NewBuffer(body))
return req, []gin.Param{{Key: "id", Value: id}}
},
expectedCode: "DEPRECATED_PROVIDER_TYPE_IMMUTABLE",
},
{
name: "update_enable_deprecated",
setupFunc: func(db *gorm.DB) string {
provider := models.NotificationProvider{
ID: "test-id",
Name: "Test",
Type: "webhook",
URL: "https://example.com",
Enabled: false,
MigrationState: "deprecated",
}
db.Create(&provider)
return "test-id"
},
requestFunc: func(id string) (*http.Request, gin.Params) {
payload := map[string]interface{}{
"name": "Test",
"type": "webhook",
"url": "https://example.com",
"enabled": true,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("PUT", "/api/v1/notifications/providers/"+id, bytes.NewBuffer(body))
return req, []gin.Param{{Key: "id", Value: id}}
},
expectedCode: "DEPRECATED_PROVIDER_CANNOT_ENABLE",
expectedCode: "PROVIDER_TYPE_IMMUTABLE",
},
}

View File

@@ -4,11 +4,13 @@ import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/trace"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -25,6 +27,7 @@ type notificationProviderUpsertRequest struct {
URL string `json:"url"`
Config string `json:"config"`
Template string `json:"template"`
Token string `json:"token,omitempty"`
Enabled bool `json:"enabled"`
NotifyProxyHosts bool `json:"notify_proxy_hosts"`
NotifyRemoteServers bool `json:"notify_remote_servers"`
@@ -37,6 +40,16 @@ type notificationProviderUpsertRequest struct {
NotifySecurityCrowdSecDecisions bool `json:"notify_security_crowdsec_decisions"`
}
type notificationProviderTestRequest struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
Config string `json:"config"`
Template string `json:"template"`
Token string `json:"token,omitempty"`
}
func (r notificationProviderUpsertRequest) toModel() models.NotificationProvider {
return models.NotificationProvider{
Name: r.Name,
@@ -44,6 +57,7 @@ func (r notificationProviderUpsertRequest) toModel() models.NotificationProvider
URL: r.URL,
Config: r.Config,
Template: r.Template,
Token: strings.TrimSpace(r.Token),
Enabled: r.Enabled,
NotifyProxyHosts: r.NotifyProxyHosts,
NotifyRemoteServers: r.NotifyRemoteServers,
@@ -57,6 +71,70 @@ func (r notificationProviderUpsertRequest) toModel() models.NotificationProvider
}
}
func providerRequestID(c *gin.Context) string {
if value, ok := c.Get(string(trace.RequestIDKey)); ok {
if requestID, ok := value.(string); ok {
return requestID
}
}
return ""
}
func respondSanitizedProviderError(c *gin.Context, status int, code, category, message string) {
response := gin.H{
"error": message,
"code": code,
"category": category,
}
if requestID := providerRequestID(c); requestID != "" {
response["request_id"] = requestID
}
c.JSON(status, response)
}
var providerStatusCodePattern = regexp.MustCompile(`provider returned status\s+(\d{3})`)
func classifyProviderTestFailure(err error) (code string, category string, message string) {
if err == nil {
return "PROVIDER_TEST_FAILED", "dispatch", "Provider test failed"
}
errText := strings.ToLower(strings.TrimSpace(err.Error()))
if strings.Contains(errText, "destination url validation failed") ||
strings.Contains(errText, "invalid webhook url") ||
strings.Contains(errText, "invalid discord webhook url") {
return "PROVIDER_TEST_URL_INVALID", "validation", "Provider URL is invalid or blocked. Verify the URL and try again"
}
if statusMatch := providerStatusCodePattern.FindStringSubmatch(errText); len(statusMatch) == 2 {
switch statusMatch[1] {
case "401", "403":
return "PROVIDER_TEST_AUTH_REJECTED", "dispatch", "Provider rejected authentication. Verify your Gotify token"
case "404":
return "PROVIDER_TEST_ENDPOINT_NOT_FOUND", "dispatch", "Provider endpoint was not found. Verify the provider URL path"
default:
return "PROVIDER_TEST_REMOTE_REJECTED", "dispatch", fmt.Sprintf("Provider rejected the test request (HTTP %s)", statusMatch[1])
}
}
if strings.Contains(errText, "outbound request failed") || strings.Contains(errText, "failed to send webhook") {
switch {
case strings.Contains(errText, "dns lookup failed"):
return "PROVIDER_TEST_DNS_FAILED", "dispatch", "DNS lookup failed for provider host. Verify the hostname in the provider URL"
case strings.Contains(errText, "connection refused"):
return "PROVIDER_TEST_CONNECTION_REFUSED", "dispatch", "Provider host refused the connection. Verify port and service availability"
case strings.Contains(errText, "request timed out"):
return "PROVIDER_TEST_TIMEOUT", "dispatch", "Provider request timed out. Verify network route and provider responsiveness"
case strings.Contains(errText, "tls handshake failed"):
return "PROVIDER_TEST_TLS_FAILED", "dispatch", "TLS handshake failed. Verify HTTPS certificate and URL scheme"
}
return "PROVIDER_TEST_UNREACHABLE", "dispatch", "Could not reach provider endpoint. Verify URL, DNS, and network connectivity"
}
return "PROVIDER_TEST_FAILED", "dispatch", "Provider test failed"
}
func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler {
return NewNotificationProviderHandlerWithDeps(service, nil, "")
}
@@ -71,6 +149,10 @@ func (h *NotificationProviderHandler) List(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers"})
return
}
for i := range providers {
providers[i].HasToken = providers[i].Token != ""
providers[i].Token = ""
}
c.JSON(http.StatusOK, providers)
}
@@ -81,16 +163,13 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
var req notificationProviderUpsertRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid notification provider payload")
return
}
// Discord-only enforcement for this rollout
if req.Type != "discord" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "only discord provider type is supported in this release; additional providers will be enabled in future releases after validation",
"code": "PROVIDER_TYPE_DISCORD_ONLY",
})
providerType := strings.ToLower(strings.TrimSpace(req.Type))
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" {
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
return
}
@@ -106,15 +185,17 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
if err := h.service.CreateProvider(&provider); err != nil {
// If it's a validation error from template parsing, return 400
if isProviderValidationError(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_VALIDATION_FAILED", "validation", "Notification provider validation failed")
return
}
if respondPermissionError(c, h.securityService, "notification_provider_save_failed", err, h.dataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_CREATE_FAILED", "internal", "Failed to create provider")
return
}
provider.HasToken = provider.Token != ""
provider.Token = ""
c.JSON(http.StatusCreated, provider)
}
@@ -126,7 +207,7 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
id := c.Param("id")
var req notificationProviderUpsertRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid notification provider payload")
return
}
@@ -134,39 +215,29 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
var existing models.NotificationProvider
if err := h.service.DB.Where("id = ?", id).First(&existing).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
respondSanitizedProviderError(c, http.StatusNotFound, "PROVIDER_NOT_FOUND", "validation", "Provider not found")
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch provider"})
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_READ_FAILED", "internal", "Failed to read provider")
return
}
// Block type mutation for existing non-Discord providers
if existing.Type != "discord" && req.Type != existing.Type {
c.JSON(http.StatusBadRequest, gin.H{
"error": "cannot change provider type for deprecated non-discord providers; delete and recreate as discord provider instead",
"code": "DEPRECATED_PROVIDER_TYPE_IMMUTABLE",
})
if strings.TrimSpace(req.Type) != "" && strings.TrimSpace(req.Type) != existing.Type {
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_TYPE_IMMUTABLE", "validation", "Provider type cannot be changed")
return
}
// Block enable mutation for existing non-Discord providers
if existing.Type != "discord" && req.Enabled && !existing.Enabled {
c.JSON(http.StatusBadRequest, gin.H{
"error": "cannot enable deprecated non-discord providers; only discord providers can be enabled",
"code": "DEPRECATED_PROVIDER_CANNOT_ENABLE",
})
providerType := strings.ToLower(strings.TrimSpace(existing.Type))
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" {
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
return
}
// Discord-only enforcement for this rollout (new providers or type changes)
if req.Type != "discord" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "only discord provider type is supported in this release; additional providers will be enabled in future releases after validation",
"code": "PROVIDER_TYPE_DISCORD_ONLY",
})
return
if providerType == "gotify" && strings.TrimSpace(req.Token) == "" {
// Keep existing token if update payload omits token
req.Token = existing.Token
}
req.Type = existing.Type
provider := req.toModel()
provider.ID = id
@@ -179,15 +250,17 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
if err := h.service.UpdateProvider(&provider); err != nil {
if isProviderValidationError(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_VALIDATION_FAILED", "validation", "Notification provider validation failed")
return
}
if respondPermissionError(c, h.securityService, "notification_provider_save_failed", err, h.dataRoot) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_UPDATE_FAILED", "internal", "Failed to update provider")
return
}
provider.HasToken = provider.Token != ""
provider.Token = ""
c.JSON(http.StatusOK, provider)
}
@@ -221,16 +294,44 @@ func (h *NotificationProviderHandler) Delete(c *gin.Context) {
}
func (h *NotificationProviderHandler) Test(c *gin.Context) {
var req notificationProviderTestRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid test payload")
return
}
providerType := strings.ToLower(strings.TrimSpace(req.Type))
if providerType == "gotify" && strings.TrimSpace(req.Token) != "" {
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Gotify token is accepted only on provider create/update")
return
}
providerID := strings.TrimSpace(req.ID)
if providerID == "" {
respondSanitizedProviderError(c, http.StatusBadRequest, "MISSING_PROVIDER_ID", "validation", "Trusted provider ID is required for test dispatch")
return
}
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
if err := h.service.DB.Where("id = ?", providerID).First(&provider).Error; err != nil {
if err == gorm.ErrRecordNotFound {
respondSanitizedProviderError(c, http.StatusNotFound, "PROVIDER_NOT_FOUND", "validation", "Provider not found")
return
}
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_READ_FAILED", "internal", "Failed to read provider")
return
}
if strings.TrimSpace(provider.URL) == "" {
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_CONFIG_MISSING", "validation", "Trusted provider configuration is incomplete")
return
}
if err := h.service.TestProvider(provider); err != nil {
// Create internal notification for the failure
_, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed: %v", provider.Name, err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
_, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed", provider.Name))
code, category, message := classifyProviderTestFailure(err)
respondSanitizedProviderError(c, http.StatusBadRequest, code, category, message)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
@@ -249,9 +350,15 @@ func (h *NotificationProviderHandler) Templates(c *gin.Context) {
func (h *NotificationProviderHandler) Preview(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid preview payload")
return
}
if tokenValue, ok := raw["token"]; ok {
if tokenText, isString := tokenValue.(string); isString && strings.TrimSpace(tokenText) != "" {
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Gotify token is accepted only on provider create/update")
return
}
}
var provider models.NotificationProvider
// Marshal raw into provider to get proper types
@@ -279,7 +386,8 @@ func (h *NotificationProviderHandler) Preview(c *gin.Context) {
rendered, parsed, err := h.service.RenderTemplate(provider, payload)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
_ = rendered
respondSanitizedProviderError(c, http.StatusBadRequest, "TEMPLATE_PREVIEW_FAILED", "validation", "Template preview failed")
return
}
c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})

View File

@@ -120,25 +120,60 @@ func TestNotificationProviderHandler_Templates(t *testing.T) {
}
func TestNotificationProviderHandler_Test(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
r, db := setupNotificationProviderTest(t)
// Test with invalid provider (should fail validation or service check)
// Since we don't have notification dispatch mocked easily here,
// we expect it might fail or pass depending on service implementation.
// Looking at service code, TestProvider should validate and dispatch.
// If URL is invalid, it should error.
provider := models.NotificationProvider{
Type: "discord",
URL: "invalid-url",
stored := models.NotificationProvider{
ID: "trusted-provider-id",
Name: "Stored Provider",
Type: "discord",
URL: "invalid-url",
Enabled: true,
}
body, _ := json.Marshal(provider)
require.NoError(t, db.Create(&stored).Error)
payload := map[string]any{
"id": stored.ID,
"type": "discord",
"url": "https://discord.com/api/webhooks/123/override",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// It should probably fail with 400
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "PROVIDER_TEST_URL_INVALID")
}
func TestNotificationProviderHandler_Test_RequiresTrustedProviderID(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
payload := map[string]any{
"type": "discord",
"url": "https://discord.com/api/webhooks/123/abc",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "MISSING_PROVIDER_ID")
}
func TestNotificationProviderHandler_Test_ReturnsNotFoundForUnknownProvider(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
payload := map[string]any{
"id": "missing-provider-id",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), "PROVIDER_NOT_FOUND")
}
func TestNotificationProviderHandler_Errors(t *testing.T) {
@@ -248,8 +283,8 @@ func TestNotificationProviderHandler_CreateRejectsDiscordIPHost(t *testing.T) {
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "invalid Discord webhook URL")
assert.Contains(t, w.Body.String(), "IP address hosts are not allowed")
assert.Contains(t, w.Body.String(), "PROVIDER_VALIDATION_FAILED")
assert.Contains(t, w.Body.String(), "validation")
}
func TestNotificationProviderHandler_CreateAcceptsDiscordHostname(t *testing.T) {
@@ -378,3 +413,100 @@ func TestNotificationProviderHandler_UpdatePreservesServerManagedMigrationFields
require.NotNil(t, dbProvider.LastMigratedAt)
assert.Equal(t, now, dbProvider.LastMigratedAt.UTC().Round(time.Second))
}
func TestNotificationProviderHandler_List_ReturnsHasTokenTrue(t *testing.T) {
r, db := setupNotificationProviderTest(t)
p := models.NotificationProvider{
ID: "tok-true",
Name: "Gotify With Token",
Type: "gotify",
URL: "https://gotify.example.com",
Token: "secret-app-token",
}
require.NoError(t, db.Create(&p).Error)
req, _ := http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var raw []map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
require.Len(t, raw, 1)
assert.Equal(t, true, raw[0]["has_token"])
}
func TestNotificationProviderHandler_List_ReturnsHasTokenFalse(t *testing.T) {
r, db := setupNotificationProviderTest(t)
p := models.NotificationProvider{
ID: "tok-false",
Name: "Discord No Token",
Type: "discord",
URL: "https://discord.com/api/webhooks/123/abc",
}
require.NoError(t, db.Create(&p).Error)
req, _ := http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var raw []map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
require.Len(t, raw, 1)
assert.Equal(t, false, raw[0]["has_token"])
}
func TestNotificationProviderHandler_List_NeverExposesRawToken(t *testing.T) {
r, db := setupNotificationProviderTest(t)
p := models.NotificationProvider{
ID: "tok-hidden",
Name: "Secret Gotify",
Type: "gotify",
URL: "https://gotify.example.com",
Token: "super-secret-value",
}
require.NoError(t, db.Create(&p).Error)
req, _ := http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.NotContains(t, w.Body.String(), "super-secret-value")
var raw []map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
require.Len(t, raw, 1)
_, hasTokenField := raw[0]["token"]
assert.False(t, hasTokenField, "raw token field must not appear in JSON response")
}
func TestNotificationProviderHandler_Create_ResponseHasHasToken(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
payload := map[string]interface{}{
"name": "New Gotify",
"type": "gotify",
"url": "https://gotify.example.com",
"token": "app-token-123",
"template": "minimal",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var raw map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
assert.Equal(t, true, raw["has_token"])
assert.NotContains(t, w.Body.String(), "app-token-123")
}

View File

@@ -65,7 +65,7 @@ func TestUpdate_BlockTypeMutationForNonDiscord(t *testing.T) {
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "DEPRECATED_PROVIDER_TYPE_IMMUTABLE", response["code"])
assert.Equal(t, "PROVIDER_TYPE_IMMUTABLE", response["code"])
}
// TestUpdate_AllowTypeMutationForDiscord verifies Discord can be updated

View File

@@ -24,6 +24,17 @@ func requireAdmin(c *gin.Context) bool {
return false
}
func requireAuthenticatedAdmin(c *gin.Context) bool {
if _, exists := c.Get("userID"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authorization header required",
})
return false
}
return requireAdmin(c)
}
func isAdmin(c *gin.Context) bool {
role, _ := c.Get("role")
roleStr, _ := role.(string)

View File

@@ -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)
}

View File

@@ -130,6 +130,7 @@ func generateForwardHostWarnings(forwardHost string) []ProxyHostWarning {
// ProxyHostHandler handles CRUD operations for proxy hosts.
type ProxyHostHandler struct {
service *services.ProxyHostService
db *gorm.DB
caddyManager *caddy.Manager
notificationService *services.NotificationService
uptimeService *services.UptimeService
@@ -183,6 +184,74 @@ func parseNullableUintField(value any, fieldName string) (*uint, bool, error) {
}
}
func (h *ProxyHostHandler) resolveAccessListReference(value any) (*uint, error) {
if value == nil {
return nil, nil
}
parsedID, _, parseErr := parseNullableUintField(value, "access_list_id")
if parseErr == nil {
return parsedID, nil
}
uuidValue, isString := value.(string)
if !isString {
return nil, parseErr
}
trimmed := strings.TrimSpace(uuidValue)
if trimmed == "" {
return nil, nil
}
var acl models.AccessList
if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&acl).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("access list not found")
}
return nil, fmt.Errorf("failed to resolve access list")
}
id := acl.ID
return &id, nil
}
func (h *ProxyHostHandler) resolveSecurityHeaderProfileReference(value any) (*uint, error) {
if value == nil {
return nil, nil
}
parsedID, _, parseErr := parseNullableUintField(value, "security_header_profile_id")
if parseErr == nil {
return parsedID, nil
}
uuidValue, isString := value.(string)
if !isString {
return nil, parseErr
}
trimmed := strings.TrimSpace(uuidValue)
if trimmed == "" {
return nil, nil
}
if _, err := uuid.Parse(trimmed); err != nil {
return nil, parseErr
}
var profile models.SecurityHeaderProfile
if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&profile).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("security header profile not found")
}
return nil, fmt.Errorf("failed to resolve security header profile")
}
id := profile.ID
return &id, nil
}
func parseForwardPortField(value any) (int, error) {
switch v := value.(type) {
case float64:
@@ -221,6 +290,7 @@ func parseForwardPortField(value any) (int, error) {
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService, uptimeService *services.UptimeService) *ProxyHostHandler {
return &ProxyHostHandler{
service: services.NewProxyHostService(db),
db: db,
caddyManager: caddyManager,
notificationService: ns,
uptimeService: uptimeService,
@@ -252,8 +322,38 @@ func (h *ProxyHostHandler) List(c *gin.Context) {
// Create creates a new proxy host.
func (h *ProxyHostHandler) Create(c *gin.Context) {
var payload map[string]any
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if rawAccessListRef, ok := payload["access_list_id"]; ok {
resolvedAccessListID, resolveErr := h.resolveAccessListReference(rawAccessListRef)
if resolveErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
return
}
payload["access_list_id"] = resolvedAccessListID
}
if rawSecurityHeaderRef, ok := payload["security_header_profile_id"]; ok {
resolvedSecurityHeaderID, resolveErr := h.resolveSecurityHeaderProfileReference(rawSecurityHeaderRef)
if resolveErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
return
}
payload["security_header_profile_id"] = resolvedSecurityHeaderID
}
payloadBytes, marshalErr := json.Marshal(payload)
if marshalErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}
var host models.ProxyHost
if err := c.ShouldBindJSON(&host); err != nil {
if err := json.Unmarshal(payloadBytes, &host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -313,6 +413,11 @@ func (h *ProxyHostHandler) Create(c *gin.Context) {
)
}
// Trigger immediate uptime monitor creation + health check (non-blocking)
if h.uptimeService != nil {
go h.uptimeService.SyncAndCheckForHost(host.ID)
}
// Generate advisory warnings for private/Docker IPs
warnings := generateForwardHostWarnings(host.ForwardHost)
@@ -430,12 +535,12 @@ func (h *ProxyHostHandler) Update(c *gin.Context) {
host.CertificateID = parsedID
}
if v, ok := payload["access_list_id"]; ok {
parsedID, _, parseErr := parseNullableUintField(v, "access_list_id")
if parseErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": parseErr.Error()})
resolvedAccessListID, resolveErr := h.resolveAccessListReference(v)
if resolveErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
return
}
host.AccessListID = parsedID
host.AccessListID = resolvedAccessListID
}
if v, ok := payload["dns_provider_id"]; ok {
@@ -453,54 +558,12 @@ func (h *ProxyHostHandler) Update(c *gin.Context) {
// Security Header Profile: update only if provided
if v, ok := payload["security_header_profile_id"]; ok {
logger := middleware.GetRequestLogger(c)
// Sanitize user-provided values for log injection protection (CWE-117)
safeUUID := sanitizeForLog(uuidStr)
logger.WithField("host_uuid", safeUUID).WithField("raw_value", sanitizeForLog(fmt.Sprintf("%v", v))).Debug("Processing security_header_profile_id update")
if v == nil {
logger.WithField("host_uuid", safeUUID).Debug("Setting security_header_profile_id to nil")
host.SecurityHeaderProfileID = nil
} else {
conversionSuccess := false
switch t := v.(type) {
case float64:
logger.Debug("Received security_header_profile_id as float64")
if id, ok := safeFloat64ToUint(t); ok {
host.SecurityHeaderProfileID = &id
conversionSuccess = true
logger.Info("Successfully converted security_header_profile_id from float64")
} else {
logger.Warn("Failed to convert security_header_profile_id from float64: value is negative or not a valid uint")
}
case int:
logger.Debug("Received security_header_profile_id as int")
if id, ok := safeIntToUint(t); ok {
host.SecurityHeaderProfileID = &id
conversionSuccess = true
logger.Info("Successfully converted security_header_profile_id from int")
} else {
logger.Warn("Failed to convert security_header_profile_id from int: value is negative")
}
case string:
logger.Debug("Received security_header_profile_id as string")
if n, err := strconv.ParseUint(t, 10, 32); err == nil {
id := uint(n)
host.SecurityHeaderProfileID = &id
conversionSuccess = true
logger.WithField("host_uuid", safeUUID).WithField("profile_id", id).Info("Successfully converted security_header_profile_id from string")
} else {
logger.Warn("Failed to parse security_header_profile_id from string")
}
default:
logger.Warn("Unsupported type for security_header_profile_id")
}
if !conversionSuccess {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid security_header_profile_id: unable to convert value %v of type %T to uint", v, v)})
return
}
resolvedSecurityHeaderID, resolveErr := h.resolveSecurityHeaderProfileReference(v)
if resolveErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
return
}
host.SecurityHeaderProfileID = resolvedSecurityHeaderID
}
// Locations: replace only if provided
@@ -587,11 +650,10 @@ func (h *ProxyHostHandler) Delete(c *gin.Context) {
return
}
// check if we should also delete associated uptime monitors (query param: delete_uptime=true)
deleteUptime := c.DefaultQuery("delete_uptime", "false") == "true"
if deleteUptime && h.uptimeService != nil {
// Find all monitors referencing this proxy host and delete each
// Always clean up associated uptime monitors when deleting a proxy host.
// The query param delete_uptime=true is kept for backward compatibility but
// cleanup now runs unconditionally to prevent orphaned monitors.
if h.uptimeService != nil {
var monitors []models.UptimeMonitor
if err := h.uptimeService.DB.Where("proxy_host_id = ?", host.ID).Find(&monitors).Error; err == nil {
for _, m := range monitors {

View File

@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -44,6 +45,219 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
return r, db
}
func setupTestRouterWithReferenceTables(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.AccessList{},
&models.SecurityHeaderProfile{},
&models.Notification{},
&models.NotificationProvider{},
))
ns := services.NewNotificationService(db)
h := NewProxyHostHandler(db, nil, ns, nil)
r := gin.New()
api := r.Group("/api/v1")
h.RegisterRoutes(api)
return r, db
}
func setupTestRouterWithUptime(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.Notification{},
&models.NotificationProvider{},
&models.UptimeMonitor{},
&models.UptimeHeartbeat{},
&models.UptimeHost{},
&models.Setting{},
))
ns := services.NewNotificationService(db)
us := services.NewUptimeService(db, ns)
h := NewProxyHostHandler(db, nil, ns, us)
r := gin.New()
api := r.Group("/api/v1")
h.RegisterRoutes(api)
return r, db
}
func TestProxyHostHandler_ResolveAccessListReference_TargetedBranches(t *testing.T) {
t.Parallel()
_, db := setupTestRouterWithReferenceTables(t)
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
resolved, err := h.resolveAccessListReference(true)
require.Error(t, err)
require.Nil(t, resolved)
require.Contains(t, err.Error(), "invalid access_list_id")
resolved, err = h.resolveAccessListReference(" ")
require.NoError(t, err)
require.Nil(t, resolved)
acl := models.AccessList{UUID: uuid.NewString(), Name: "resolve-acl", Type: "ip", Enabled: true}
require.NoError(t, db.Create(&acl).Error)
resolved, err = h.resolveAccessListReference(acl.UUID)
require.NoError(t, err)
require.NotNil(t, resolved)
require.Equal(t, acl.ID, *resolved)
}
func TestProxyHostHandler_ResolveSecurityHeaderReference_TargetedBranches(t *testing.T) {
t.Parallel()
_, db := setupTestRouterWithReferenceTables(t)
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
resolved, err := h.resolveSecurityHeaderProfileReference(" ")
require.NoError(t, err)
require.Nil(t, resolved)
profile := models.SecurityHeaderProfile{
UUID: uuid.NewString(),
Name: "resolve-security-profile",
IsPreset: false,
SecurityScore: 90,
}
require.NoError(t, db.Create(&profile).Error)
resolved, err = h.resolveSecurityHeaderProfileReference(profile.UUID)
require.NoError(t, err)
require.NotNil(t, resolved)
require.Equal(t, profile.ID, *resolved)
resolved, err = h.resolveSecurityHeaderProfileReference(uuid.NewString())
require.Error(t, err)
require.Nil(t, resolved)
require.Contains(t, err.Error(), "security header profile not found")
require.NoError(t, db.Migrator().DropTable(&models.SecurityHeaderProfile{}))
resolved, err = h.resolveSecurityHeaderProfileReference(uuid.NewString())
require.Error(t, err)
require.Nil(t, resolved)
require.Contains(t, err.Error(), "failed to resolve security header profile")
}
func TestProxyHostCreate_ReferenceResolution_TargetedBranches(t *testing.T) {
t.Parallel()
router, db := setupTestRouterWithReferenceTables(t)
acl := models.AccessList{UUID: uuid.NewString(), Name: "create-acl", Type: "ip", Enabled: true}
require.NoError(t, db.Create(&acl).Error)
profile := models.SecurityHeaderProfile{
UUID: uuid.NewString(),
Name: "create-security-profile",
IsPreset: false,
SecurityScore: 85,
}
require.NoError(t, db.Create(&profile).Error)
t.Run("creates host when references are valid UUIDs", func(t *testing.T) {
body := map[string]any{
"name": "Create Ref Success",
"domain_names": "create-ref-success.example.com",
"forward_scheme": "http",
"forward_host": "localhost",
"forward_port": 8080,
"enabled": true,
"access_list_id": acl.UUID,
"security_header_profile_id": profile.UUID,
}
payload, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var created models.ProxyHost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
require.NotNil(t, created.AccessListID)
require.Equal(t, acl.ID, *created.AccessListID)
require.NotNil(t, created.SecurityHeaderProfileID)
require.Equal(t, profile.ID, *created.SecurityHeaderProfileID)
})
t.Run("returns bad request for invalid access list reference type", func(t *testing.T) {
body := `{"name":"Create ACL Type Error","domain_names":"create-acl-type-error.example.com","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true,"access_list_id":true}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
})
t.Run("returns bad request for missing security header profile", func(t *testing.T) {
body := map[string]any{
"name": "Create Security Missing",
"domain_names": "create-security-missing.example.com",
"forward_scheme": "http",
"forward_host": "localhost",
"forward_port": 8080,
"enabled": true,
"security_header_profile_id": uuid.NewString(),
}
payload, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
})
}
func TestProxyHostCreate_TriggersAsyncUptimeSyncWhenServiceConfigured(t *testing.T) {
t.Parallel()
router, db := setupTestRouterWithUptime(t)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(upstream.Close)
domain := strings.TrimPrefix(upstream.URL, "http://")
body := fmt.Sprintf(`{"name":"Uptime Hook","domain_names":"%s","forward_scheme":"http","forward_host":"app-service","forward_port":8080,"enabled":true}`, domain)
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var created models.ProxyHost
require.NoError(t, db.Where("domain_names = ?", domain).First(&created).Error)
var count int64
require.Eventually(t, func() bool {
db.Model(&models.UptimeMonitor{}).Where("proxy_host_id = ?", created.ID).Count(&count)
return count > 0
}, 3*time.Second, 50*time.Millisecond)
}
func TestProxyHostLifecycle(t *testing.T) {
t.Parallel()
router, _ := setupTestRouter(t)

View File

@@ -75,6 +75,203 @@ func createTestSecurityHeaderProfile(t *testing.T, db *gorm.DB, name string) mod
return profile
}
// createTestAccessList creates an access list for testing.
func createTestAccessList(t *testing.T, db *gorm.DB, name string) models.AccessList {
t.Helper()
acl := models.AccessList{
UUID: uuid.NewString(),
Name: name,
Type: "ip",
Enabled: true,
}
require.NoError(t, db.Create(&acl).Error)
return acl
}
func TestProxyHostUpdate_AccessListID_Transitions_NoUnrelatedMutation(t *testing.T) {
t.Parallel()
router, db := setupUpdateTestRouter(t)
aclOne := createTestAccessList(t, db, "ACL One")
aclTwo := createTestAccessList(t, db, "ACL Two")
host := models.ProxyHost{
UUID: uuid.NewString(),
Name: "Access List Transition Host",
DomainNames: "acl-transition.test.com",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 8080,
Enabled: true,
SSLForced: true,
Application: "none",
AccessListID: &aclOne.ID,
}
require.NoError(t, db.Create(&host).Error)
assertUnrelatedFields := func(t *testing.T, current models.ProxyHost) {
t.Helper()
assert.Equal(t, "Access List Transition Host", current.Name)
assert.Equal(t, "acl-transition.test.com", current.DomainNames)
assert.Equal(t, "localhost", current.ForwardHost)
assert.Equal(t, 8080, current.ForwardPort)
assert.True(t, current.SSLForced)
assert.Equal(t, "none", current.Application)
}
runUpdate := func(t *testing.T, update map[string]any) {
t.Helper()
body, _ := json.Marshal(update)
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// value -> value
runUpdate(t, map[string]any{"access_list_id": aclTwo.ID})
var updated models.ProxyHost
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
require.NotNil(t, updated.AccessListID)
assert.Equal(t, aclTwo.ID, *updated.AccessListID)
assertUnrelatedFields(t, updated)
// value -> null
runUpdate(t, map[string]any{"access_list_id": nil})
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
assert.Nil(t, updated.AccessListID)
assertUnrelatedFields(t, updated)
// null -> value
runUpdate(t, map[string]any{"access_list_id": aclOne.ID})
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
require.NotNil(t, updated.AccessListID)
assert.Equal(t, aclOne.ID, *updated.AccessListID)
assertUnrelatedFields(t, updated)
}
func TestProxyHostUpdate_AccessListID_UUIDNotFound_ReturnsBadRequest(t *testing.T) {
t.Parallel()
router, db := setupUpdateTestRouter(t)
host := createTestProxyHost(t, db, "acl-uuid-not-found")
updateBody := map[string]any{
"name": "ACL UUID Not Found",
"domain_names": "acl-uuid-not-found.test.com",
"forward_scheme": "http",
"forward_host": "localhost",
"forward_port": 8080,
"access_list_id": uuid.NewString(),
}
body, _ := json.Marshal(updateBody)
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
var result map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
assert.Contains(t, result["error"], "access list not found")
}
func TestProxyHostUpdate_AccessListID_ResolveQueryFailure_ReturnsBadRequest(t *testing.T) {
t.Parallel()
router, db := setupUpdateTestRouter(t)
host := createTestProxyHost(t, db, "acl-resolve-query-failure")
require.NoError(t, db.Migrator().DropTable(&models.AccessList{}))
updateBody := map[string]any{
"name": "ACL Resolve Query Failure",
"domain_names": "acl-resolve-query-failure.test.com",
"forward_scheme": "http",
"forward_host": "localhost",
"forward_port": 8080,
"access_list_id": uuid.NewString(),
}
body, _ := json.Marshal(updateBody)
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
var result map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
assert.Contains(t, result["error"], "failed to resolve access list")
}
func TestProxyHostUpdate_SecurityHeaderProfileID_Transitions_NoUnrelatedMutation(t *testing.T) {
t.Parallel()
router, db := setupUpdateTestRouter(t)
profileOne := createTestSecurityHeaderProfile(t, db, "Security Profile One")
profileTwo := createTestSecurityHeaderProfile(t, db, "Security Profile Two")
host := models.ProxyHost{
UUID: uuid.NewString(),
Name: "Security Profile Transition Host",
DomainNames: "security-transition.test.com",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 9090,
Enabled: true,
SSLForced: true,
Application: "none",
SecurityHeaderProfileID: &profileOne.ID,
}
require.NoError(t, db.Create(&host).Error)
assertUnrelatedFields := func(t *testing.T, current models.ProxyHost) {
t.Helper()
assert.Equal(t, "Security Profile Transition Host", current.Name)
assert.Equal(t, "security-transition.test.com", current.DomainNames)
assert.Equal(t, "localhost", current.ForwardHost)
assert.Equal(t, 9090, current.ForwardPort)
assert.True(t, current.SSLForced)
assert.Equal(t, "none", current.Application)
}
runUpdate := func(t *testing.T, update map[string]any) {
t.Helper()
body, _ := json.Marshal(update)
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
// value -> value
runUpdate(t, map[string]any{"security_header_profile_id": fmt.Sprintf("%d", profileTwo.ID)})
var updated models.ProxyHost
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
require.NotNil(t, updated.SecurityHeaderProfileID)
assert.Equal(t, profileTwo.ID, *updated.SecurityHeaderProfileID)
assertUnrelatedFields(t, updated)
// value -> null
runUpdate(t, map[string]any{"security_header_profile_id": ""})
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
assert.Nil(t, updated.SecurityHeaderProfileID)
assertUnrelatedFields(t, updated)
// null -> value
runUpdate(t, map[string]any{"security_header_profile_id": fmt.Sprintf("%d", profileOne.ID)})
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
require.NotNil(t, updated.SecurityHeaderProfileID)
assert.Equal(t, profileOne.ID, *updated.SecurityHeaderProfileID)
assertUnrelatedFields(t, updated)
}
// TestProxyHostUpdate_EnableStandardHeaders_Null tests updating enable_standard_headers to null.
func TestProxyHostUpdate_EnableStandardHeaders_Null(t *testing.T) {
t.Parallel()

View File

@@ -59,6 +59,10 @@ func TestSecurityHandler_ReloadGeoIP_NotInitialized(t *testing.T) {
h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
r.POST("/security/geoip/reload", h.ReloadGeoIP)
w := httptest.NewRecorder()
@@ -75,6 +79,10 @@ func TestSecurityHandler_ReloadGeoIP_LoadError(t *testing.T) {
h.SetGeoIPService(&services.GeoIPService{}) // dbPath empty => Load() will error
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
r.POST("/security/geoip/reload", h.ReloadGeoIP)
w := httptest.NewRecorder()
@@ -90,6 +98,10 @@ func TestSecurityHandler_LookupGeoIP_MissingIPAddress(t *testing.T) {
h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
r.POST("/security/geoip/lookup", h.LookupGeoIP)
payload := []byte(`{}`)
@@ -109,6 +121,10 @@ func TestSecurityHandler_LookupGeoIP_ServiceUnavailable(t *testing.T) {
h.SetGeoIPService(&services.GeoIPService{}) // present but not loaded
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
r.POST("/security/geoip/lookup", h.LookupGeoIP)
payload, _ := json.Marshal(map[string]string{"ip_address": "8.8.8.8"})

View File

@@ -261,6 +261,10 @@ func (h *SecurityHandler) GetConfig(c *gin.Context) {
// UpdateConfig creates or updates the SecurityConfig in DB
func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
if !requireAdmin(c) {
return
}
var payload models.SecurityConfig
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
@@ -290,6 +294,10 @@ func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
// GenerateBreakGlass generates a break-glass token and returns the plaintext token once
func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) {
if !requireAdmin(c) {
return
}
token, err := h.svc.GenerateBreakGlassToken("default")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"})
@@ -316,6 +324,10 @@ func (h *SecurityHandler) ListDecisions(c *gin.Context) {
// CreateDecision creates a manual decision (override) - for now no checks besides payload
func (h *SecurityHandler) CreateDecision(c *gin.Context) {
if !requireAdmin(c) {
return
}
var payload models.SecurityDecision
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
@@ -371,6 +383,10 @@ func (h *SecurityHandler) ListRuleSets(c *gin.Context) {
// UpsertRuleSet uploads or updates a ruleset
func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
if !requireAdmin(c) {
return
}
var payload models.SecurityRuleSet
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
@@ -401,6 +417,10 @@ func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
// DeleteRuleSet removes a ruleset by id
func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) {
if !requireAdmin(c) {
return
}
idParam := c.Param("id")
if idParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
@@ -610,6 +630,10 @@ func (h *SecurityHandler) GetGeoIPStatus(c *gin.Context) {
// ReloadGeoIP reloads the GeoIP database from disk.
func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
if !requireAdmin(c) {
return
}
if h.geoipSvc == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "GeoIP service not initialized",
@@ -641,6 +665,10 @@ func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
// LookupGeoIP performs a GeoIP lookup for a given IP address.
func (h *SecurityHandler) LookupGeoIP(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req struct {
IPAddress string `json:"ip_address" binding:"required"`
}
@@ -707,6 +735,10 @@ func (h *SecurityHandler) GetWAFExclusions(c *gin.Context) {
// AddWAFExclusion adds a rule exclusion to the WAF configuration
func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
if !requireAdmin(c) {
return
}
var req WAFExclusionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})
@@ -786,6 +818,10 @@ func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
// DeleteWAFExclusion removes a rule exclusion by rule_id
func (h *SecurityHandler) DeleteWAFExclusion(c *gin.Context) {
if !requireAdmin(c) {
return
}
ruleIDParam := c.Param("rule_id")
if ruleIDParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})

View File

@@ -100,6 +100,10 @@ func TestSecurityHandler_CreateDecision_SQLInjection(t *testing.T) {
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/api/v1/security/decisions", h.CreateDecision)
// Attempt SQL injection via payload fields
@@ -143,6 +147,10 @@ func TestSecurityHandler_UpsertRuleSet_MassivePayload(t *testing.T) {
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
// Try to submit a 3MB payload (should be rejected by service)
@@ -175,6 +183,10 @@ func TestSecurityHandler_UpsertRuleSet_EmptyName(t *testing.T) {
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
payload := map[string]any{
@@ -203,6 +215,10 @@ func TestSecurityHandler_CreateDecision_EmptyFields(t *testing.T) {
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/api/v1/security/decisions", h.CreateDecision)
testCases := []struct {
@@ -347,6 +363,10 @@ func TestSecurityAudit_DeleteRuleSet_InvalidID(t *testing.T) {
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/api/v1/security/rulesets/:id", h.DeleteRuleSet)
testCases := []struct {
@@ -388,6 +408,10 @@ func TestSecurityHandler_UpsertRuleSet_XSSInContent(t *testing.T) {
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
router.GET("/api/v1/security/rulesets", h.ListRuleSets)
@@ -433,6 +457,10 @@ func TestSecurityHandler_UpdateConfig_RateLimitBounds(t *testing.T) {
h := NewSecurityHandler(cfg, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.PUT("/api/v1/security/config", h.UpdateConfig)
testCases := []struct {

View File

@@ -0,0 +1,58 @@
package handlers
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
)
func TestSecurityHandler_MutatorsRequireAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityRuleSet{}, &models.SecurityDecision{}, &models.SecurityAudit{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("userID", uint(123))
c.Set("role", "user")
c.Next()
})
router.POST("/security/config", handler.UpdateConfig)
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
router.POST("/security/decisions", handler.CreateDecision)
router.POST("/security/rulesets", handler.UpsertRuleSet)
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
testCases := []struct {
name string
method string
url string
body string
}{
{name: "update-config", method: http.MethodPost, url: "/security/config", body: `{"name":"default"}`},
{name: "generate-breakglass", method: http.MethodPost, url: "/security/breakglass/generate", body: `{}`},
{name: "create-decision", method: http.MethodPost, url: "/security/decisions", body: `{"ip":"1.2.3.4","action":"block"}`},
{name: "upsert-ruleset", method: http.MethodPost, url: "/security/rulesets", body: `{"name":"owasp-crs","mode":"block","content":"x"}`},
{name: "delete-ruleset", method: http.MethodDelete, url: "/security/rulesets/1", body: ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.url, bytes.NewBufferString(tc.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
})
}
}

View File

@@ -120,6 +120,10 @@ func TestSecurityHandler_GenerateBreakGlass_ReturnsToken(t *testing.T) {
db := setupTestDB(t)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
w := httptest.NewRecorder()
@@ -251,6 +255,10 @@ func TestSecurityHandler_Enable_Disable_WithAdminWhitelistAndToken(t *testing.T)
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
api := router.Group("/api/v1")
api.POST("/security/enable", handler.Enable)
api.POST("/security/disable", handler.Disable)

View File

@@ -27,6 +27,10 @@ func TestSecurityHandler_UpdateConfig_Success(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/config", handler.UpdateConfig)
payload := map[string]any{
@@ -55,6 +59,10 @@ func TestSecurityHandler_UpdateConfig_DefaultName(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/config", handler.UpdateConfig)
// Payload without name - should default to "default"
@@ -78,6 +86,10 @@ func TestSecurityHandler_UpdateConfig_InvalidPayload(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/config", handler.UpdateConfig)
w := httptest.NewRecorder()
@@ -193,6 +205,10 @@ func TestSecurityHandler_CreateDecision_Success(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/decisions", handler.CreateDecision)
payload := map[string]any{
@@ -218,6 +234,10 @@ func TestSecurityHandler_CreateDecision_MissingIP(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/decisions", handler.CreateDecision)
payload := map[string]any{
@@ -240,6 +260,10 @@ func TestSecurityHandler_CreateDecision_MissingAction(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/decisions", handler.CreateDecision)
payload := map[string]any{
@@ -262,6 +286,10 @@ func TestSecurityHandler_CreateDecision_InvalidPayload(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/decisions", handler.CreateDecision)
w := httptest.NewRecorder()
@@ -306,6 +334,10 @@ func TestSecurityHandler_UpsertRuleSet_Success(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/rulesets", handler.UpsertRuleSet)
payload := map[string]any{
@@ -330,6 +362,10 @@ func TestSecurityHandler_UpsertRuleSet_MissingName(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/rulesets", handler.UpsertRuleSet)
payload := map[string]any{
@@ -353,6 +389,10 @@ func TestSecurityHandler_UpsertRuleSet_InvalidPayload(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/rulesets", handler.UpsertRuleSet)
w := httptest.NewRecorder()
@@ -375,6 +415,10 @@ func TestSecurityHandler_DeleteRuleSet_Success(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
w := httptest.NewRecorder()
@@ -395,6 +439,10 @@ func TestSecurityHandler_DeleteRuleSet_NotFound(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
w := httptest.NewRecorder()
@@ -411,6 +459,10 @@ func TestSecurityHandler_DeleteRuleSet_InvalidID(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
w := httptest.NewRecorder()
@@ -427,6 +479,10 @@ func TestSecurityHandler_DeleteRuleSet_EmptyID(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
// Note: This route pattern won't match empty ID, but testing the handler directly
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
@@ -509,6 +565,10 @@ func TestSecurityHandler_Enable_WithValidBreakGlassToken(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
router.POST("/security/enable", handler.Enable)
@@ -600,6 +660,10 @@ func TestSecurityHandler_Disable_FromRemoteWithToken(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
router.POST("/security/disable", func(c *gin.Context) {
c.Request.RemoteAddr = "192.168.1.100:12345" // Remote IP
@@ -689,6 +753,10 @@ func TestSecurityHandler_GenerateBreakGlass_NoConfig(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
w := httptest.NewRecorder()

View File

@@ -30,6 +30,10 @@ func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) {
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}))
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
api := r.Group("/api/v1")
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, nil)
@@ -148,6 +152,10 @@ func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) {
m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
api := r.Group("/api/v1")
cfg := config.SecurityConfig{}
h := NewSecurityHandler(cfg, db, m)

View File

@@ -110,6 +110,10 @@ func TestSecurityHandler_AddWAFExclusion_Success(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
payload := map[string]any{
@@ -140,6 +144,10 @@ func TestSecurityHandler_AddWAFExclusion_WithTarget(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
payload := map[string]any{
@@ -175,6 +183,10 @@ func TestSecurityHandler_AddWAFExclusion_ToExistingConfig(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
@@ -215,6 +227,10 @@ func TestSecurityHandler_AddWAFExclusion_Duplicate(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
// Try to add duplicate
@@ -244,6 +260,10 @@ func TestSecurityHandler_AddWAFExclusion_DuplicateWithDifferentTarget(t *testing
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
// Add same rule_id with different target - should succeed
@@ -268,6 +288,10 @@ func TestSecurityHandler_AddWAFExclusion_MissingRuleID(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
payload := map[string]any{
@@ -290,6 +314,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidRuleID(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
// Zero rule_id
@@ -313,6 +341,10 @@ func TestSecurityHandler_AddWAFExclusion_NegativeRuleID(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
payload := map[string]any{
@@ -335,6 +367,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidPayload(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
w := httptest.NewRecorder()
@@ -358,6 +394,10 @@ func TestSecurityHandler_DeleteWAFExclusion_Success(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
@@ -394,6 +434,10 @@ func TestSecurityHandler_DeleteWAFExclusion_WithTarget(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
@@ -430,6 +474,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NotFound(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
w := httptest.NewRecorder()
@@ -446,6 +494,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NoConfig(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
w := httptest.NewRecorder()
@@ -462,6 +514,10 @@ func TestSecurityHandler_DeleteWAFExclusion_InvalidRuleID(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
w := httptest.NewRecorder()
@@ -478,6 +534,10 @@ func TestSecurityHandler_DeleteWAFExclusion_ZeroRuleID(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
w := httptest.NewRecorder()
@@ -494,6 +554,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NegativeRuleID(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
w := httptest.NewRecorder()
@@ -533,6 +597,10 @@ func TestSecurityHandler_WAFExclusion_FullWorkflow(t *testing.T) {
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
@@ -37,6 +38,15 @@ type SettingsHandler struct {
DataRoot string
}
const (
settingCaddyKeepaliveIdle = "caddy.keepalive_idle"
settingCaddyKeepaliveCount = "caddy.keepalive_count"
minCaddyKeepaliveIdleDuration = time.Second
maxCaddyKeepaliveIdleDuration = 24 * time.Hour
minCaddyKeepaliveCount = 1
maxCaddyKeepaliveCount = 100
)
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
return &SettingsHandler{
DB: db,
@@ -65,14 +75,43 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
}
// Convert to map for easier frontend consumption
settingsMap := make(map[string]string)
settingsMap := make(map[string]any)
for _, s := range settings {
if isSensitiveSettingKey(s.Key) {
hasSecret := strings.TrimSpace(s.Value) != ""
settingsMap[s.Key] = "********"
settingsMap[s.Key+".has_secret"] = hasSecret
settingsMap[s.Key+".last_updated"] = s.UpdatedAt.UTC().Format(time.RFC3339)
continue
}
settingsMap[s.Key] = s.Value
}
c.JSON(http.StatusOK, settingsMap)
}
func isSensitiveSettingKey(key string) bool {
normalizedKey := strings.ToLower(strings.TrimSpace(key))
sensitiveFragments := []string{
"password",
"secret",
"token",
"api_key",
"apikey",
"webhook",
}
for _, fragment := range sensitiveFragments {
if strings.Contains(normalizedKey, fragment) {
return true
}
}
return false
}
type UpdateSettingRequest struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
@@ -109,6 +148,11 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
}
}
if err := validateOptionalKeepaliveSetting(req.Key, req.Value); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
setting := models.Setting{
Key: req.Key,
Value: req.Value,
@@ -247,6 +291,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
}
}
if err := validateOptionalKeepaliveSetting(key, value); err != nil {
return err
}
setting := models.Setting{
Key: key,
Value: value,
@@ -284,6 +332,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"})
return
}
if strings.Contains(err.Error(), "invalid caddy.keepalive_idle") || strings.Contains(err.Error(), "invalid caddy.keepalive_count") {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
return
}
@@ -401,6 +453,53 @@ func validateAdminWhitelist(whitelist string) error {
return nil
}
func validateOptionalKeepaliveSetting(key, value string) error {
switch key {
case settingCaddyKeepaliveIdle:
return validateKeepaliveIdleValue(value)
case settingCaddyKeepaliveCount:
return validateKeepaliveCountValue(value)
default:
return nil
}
}
func validateKeepaliveIdleValue(value string) error {
idle := strings.TrimSpace(value)
if idle == "" {
return nil
}
d, err := time.ParseDuration(idle)
if err != nil {
return fmt.Errorf("invalid caddy.keepalive_idle")
}
if d < minCaddyKeepaliveIdleDuration || d > maxCaddyKeepaliveIdleDuration {
return fmt.Errorf("invalid caddy.keepalive_idle")
}
return nil
}
func validateKeepaliveCountValue(value string) error {
raw := strings.TrimSpace(value)
if raw == "" {
return nil
}
count, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("invalid caddy.keepalive_count")
}
if count < minCaddyKeepaliveCount || count > maxCaddyKeepaliveCount {
return fmt.Errorf("invalid caddy.keepalive_count")
}
return nil
}
func (h *SettingsHandler) syncAdminWhitelist(whitelist string) error {
return h.syncAdminWhitelistWithDB(h.DB, whitelist)
}
@@ -433,6 +532,10 @@ type SMTPConfigRequest struct {
// GetSMTPConfig returns the current SMTP configuration.
func (h *SettingsHandler) GetSMTPConfig(c *gin.Context) {
if !requireAdmin(c) {
return
}
config, err := h.MailService.GetSMTPConfig()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch SMTP configuration"})

View File

@@ -182,6 +182,31 @@ func TestSettingsHandler_GetSettings(t *testing.T) {
assert.Equal(t, "test_value", response["test_key"])
}
func TestSettingsHandler_GetSettings_MasksSensitiveValues(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
db.Create(&models.Setting{Key: "smtp_password", Value: "super-secret-password", Category: "smtp", Type: "string"})
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.GET("/settings", handler.GetSettings)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/settings", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]any
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "********", response["smtp_password"])
assert.Equal(t, true, response["smtp_password.has_secret"])
_, hasRaw := response["super-secret-password"]
assert.False(t, hasRaw)
}
func TestSettingsHandler_GetSettings_DatabaseError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
@@ -413,6 +438,58 @@ func TestSettingsHandler_UpdateSetting_InvalidAdminWhitelist(t *testing.T) {
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
}
func TestSettingsHandler_UpdateSetting_InvalidKeepaliveIdle(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "caddy.keepalive_idle",
"value": "bad-duration",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_idle")
}
func TestSettingsHandler_UpdateSetting_ValidKeepaliveCount(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.POST("/settings", handler.UpdateSetting)
payload := map[string]string{
"key": "caddy.keepalive_count",
"value": "9",
"category": "caddy",
"type": "number",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var setting models.Setting
err := db.Where("key = ?", "caddy.keepalive_count").First(&setting).Error
assert.NoError(t, err)
assert.Equal(t, "9", setting.Value)
}
func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
@@ -538,6 +615,64 @@ func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) {
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
}
func TestSettingsHandler_PatchConfig_InvalidKeepaliveCount(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.PATCH("/config", handler.PatchConfig)
payload := map[string]any{
"caddy": map[string]any{
"keepalive_count": 0,
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_count")
}
func TestSettingsHandler_PatchConfig_ValidKeepaliveSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := newAdminRouter()
router.PATCH("/config", handler.PatchConfig)
payload := map[string]any{
"caddy": map[string]any{
"keepalive_idle": "30s",
"keepalive_count": 12,
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var idle models.Setting
err := db.Where("key = ?", "caddy.keepalive_idle").First(&idle).Error
assert.NoError(t, err)
assert.Equal(t, "30s", idle.Value)
var count models.Setting
err = db.Where("key = ?", "caddy.keepalive_count").First(&count).Error
assert.NoError(t, err)
assert.Equal(t, "12", count.Value)
}
func TestSettingsHandler_PatchConfig_ReloadFailureReturns500(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
@@ -864,6 +999,25 @@ func TestSettingsHandler_GetSMTPConfig_DatabaseError(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestSettingsHandler_GetSMTPConfig_NonAdminForbidden(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Set("userID", uint(2))
c.Next()
})
router.GET("/api/v1/settings/smtp", handler.GetSMTPConfig)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/settings/smtp", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestSettingsHandler_UpdateSMTPConfig_NonAdmin(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, _ := setupSettingsHandlerWithMail(t)

View File

@@ -103,6 +103,18 @@ type SetupRequest struct {
Password string `json:"password" binding:"required,min=8"`
}
func isSetupConflictError(err error) bool {
if err == nil {
return false
}
errText := strings.ToLower(err.Error())
return strings.Contains(errText, "unique constraint failed") ||
strings.Contains(errText, "duplicate key") ||
strings.Contains(errText, "database is locked") ||
strings.Contains(errText, "database table is locked")
}
// Setup creates the initial admin user and configures the ACME email.
func (h *UserHandler) Setup(c *gin.Context) {
// 1. Check if setup is allowed
@@ -160,6 +172,17 @@ func (h *UserHandler) Setup(c *gin.Context) {
})
if err != nil {
var postTxCount int64
if countErr := h.DB.Model(&models.User{}).Count(&postTxCount).Error; countErr == nil && postTxCount > 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
return
}
if isSetupConflictError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "Setup conflict: setup already in progress or completed"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()})
return
}
@@ -189,7 +212,12 @@ func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"api_key": apiKey})
c.JSON(http.StatusOK, gin.H{
"message": "API key regenerated successfully",
"has_api_key": true,
"api_key_masked": maskSecretForResponse(apiKey),
"api_key_updated": time.Now().UTC().Format(time.RFC3339),
})
}
// GetProfile returns the current user's profile including API key.
@@ -207,11 +235,12 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"api_key": user.APIKey,
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"has_api_key": strings.TrimSpace(user.APIKey) != "",
"api_key_masked": maskSecretForResponse(user.APIKey),
})
}
@@ -548,14 +577,14 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
}
c.JSON(http.StatusCreated, gin.H{
"id": user.ID,
"uuid": user.UUID,
"email": user.Email,
"role": user.Role,
"invite_token": inviteToken, // Return token in case email fails
"invite_url": inviteURL,
"email_sent": emailSent,
"expires_at": inviteExpires,
"id": user.ID,
"uuid": user.UUID,
"email": user.Email,
"role": user.Role,
"invite_token_masked": maskSecretForResponse(inviteToken),
"invite_url": redactInviteURL(inviteURL),
"email_sent": emailSent,
"expires_at": inviteExpires,
})
}
@@ -862,16 +891,32 @@ func (h *UserHandler) ResendInvite(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"uuid": user.UUID,
"email": user.Email,
"role": user.Role,
"invite_token": inviteToken,
"email_sent": emailSent,
"expires_at": inviteExpires,
"id": user.ID,
"uuid": user.UUID,
"email": user.Email,
"role": user.Role,
"invite_token_masked": maskSecretForResponse(inviteToken),
"email_sent": emailSent,
"expires_at": inviteExpires,
})
}
func maskSecretForResponse(value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
return "********"
}
func redactInviteURL(inviteURL string) string {
if strings.TrimSpace(inviteURL) == "" {
return ""
}
return "[REDACTED]"
}
// UpdateUserPermissions updates a user's permission mode and host exceptions (admin only).
func (h *UserHandler) UpdateUserPermissions(c *gin.Context) {
role, _ := c.Get("role")

View File

@@ -3,9 +3,11 @@ package handlers
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"time"
@@ -15,15 +17,11 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) {
// Use unique DB for each test to avoid pollution
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
return NewUserHandler(db), db
}
@@ -131,6 +129,224 @@ func TestUserHandler_Setup(t *testing.T) {
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestUserHandler_Setup_OneWayInvariant_ReentryRejectedAndSingleUser(t *testing.T) {
handler, db := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/setup", handler.Setup)
initialBody := map[string]string{
"name": "Admin",
"email": "admin@example.com",
"password": "password123",
}
initialJSON, _ := json.Marshal(initialBody)
firstReq := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(initialJSON))
firstReq.Header.Set("Content-Type", "application/json")
firstResp := httptest.NewRecorder()
r.ServeHTTP(firstResp, firstReq)
require.Equal(t, http.StatusCreated, firstResp.Code)
secondBody := map[string]string{
"name": "Different Admin",
"email": "different@example.com",
"password": "password123",
}
secondJSON, _ := json.Marshal(secondBody)
secondReq := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(secondJSON))
secondReq.Header.Set("Content-Type", "application/json")
secondResp := httptest.NewRecorder()
r.ServeHTTP(secondResp, secondReq)
require.Equal(t, http.StatusForbidden, secondResp.Code)
var userCount int64
require.NoError(t, db.Model(&models.User{}).Count(&userCount).Error)
assert.Equal(t, int64(1), userCount)
}
func TestUserHandler_Setup_ConcurrentAttemptInvariant(t *testing.T) {
handler, db := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/setup", handler.Setup)
concurrency := 6
start := make(chan struct{})
statuses := make(chan int, concurrency)
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
body := map[string]string{
"name": "Admin",
"email": "admin@example.com",
"password": "password123",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
statuses <- resp.Code
}()
}
close(start)
wg.Wait()
close(statuses)
createdCount := 0
forbiddenOrConflictCount := 0
for status := range statuses {
if status == http.StatusCreated {
createdCount++
continue
}
if status == http.StatusForbidden || status == http.StatusConflict {
forbiddenOrConflictCount++
continue
}
t.Fatalf("unexpected setup concurrency status: %d", status)
}
assert.Equal(t, 1, createdCount)
assert.Equal(t, concurrency-1, forbiddenOrConflictCount)
var userCount int64
require.NoError(t, db.Model(&models.User{}).Count(&userCount).Error)
assert.Equal(t, int64(1), userCount)
}
func TestUserHandler_Setup_ResponseSecretEchoContract(t *testing.T) {
handler, _ := setupUserHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/setup", handler.Setup)
body := map[string]string{
"name": "Admin",
"email": "admin@example.com",
"password": "password123",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
userValue, ok := payload["user"]
require.True(t, ok)
userMap, ok := userValue.(map[string]any)
require.True(t, ok)
_, hasAPIKey := userMap["api_key"]
_, hasPassword := userMap["password"]
_, hasPasswordHash := userMap["password_hash"]
_, hasInviteToken := userMap["invite_token"]
assert.False(t, hasAPIKey)
assert.False(t, hasPassword)
assert.False(t, hasPasswordHash)
assert.False(t, hasInviteToken)
}
func TestUserHandler_GetProfile_SecretEchoContract(t *testing.T) {
handler, db := setupUserHandler(t)
user := &models.User{
UUID: uuid.NewString(),
Email: "profile@example.com",
Name: "Profile User",
APIKey: "real-secret-api-key",
InviteToken: "invite-secret-token",
PasswordHash: "hashed-password-value",
}
require.NoError(t, db.Create(user).Error)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Next()
})
r.GET("/profile", handler.GetProfile)
req := httptest.NewRequest(http.MethodGet, "/profile", http.NoBody)
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
_, hasAPIKey := payload["api_key"]
_, hasPassword := payload["password"]
_, hasPasswordHash := payload["password_hash"]
_, hasInviteToken := payload["invite_token"]
assert.False(t, hasAPIKey)
assert.False(t, hasPassword)
assert.False(t, hasPasswordHash)
assert.False(t, hasInviteToken)
assert.Equal(t, "********", payload["api_key_masked"])
}
func TestUserHandler_ListUsers_SecretEchoContract(t *testing.T) {
handler, db := setupUserHandlerWithProxyHosts(t)
user := &models.User{
UUID: uuid.NewString(),
Email: "user@example.com",
Name: "User",
Role: "user",
APIKey: "raw-api-key",
InviteToken: "raw-invite-token",
PasswordHash: "raw-password-hash",
}
require.NoError(t, db.Create(user).Error)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
r.GET("/users", handler.ListUsers)
req := httptest.NewRequest(http.MethodGet, "/users", http.NoBody)
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var users []map[string]any
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &users))
require.Len(t, users, 1)
_, hasAPIKey := users[0]["api_key"]
_, hasPassword := users[0]["password"]
_, hasPasswordHash := users[0]["password_hash"]
_, hasInviteToken := users[0]["invite_token"]
assert.False(t, hasAPIKey)
assert.False(t, hasPassword)
assert.False(t, hasPasswordHash)
assert.False(t, hasInviteToken)
}
func TestUserHandler_Setup_DBError(t *testing.T) {
// Can't easily mock DB error with sqlite memory unless we close it or something.
// But we can try to insert duplicate email if we had a unique constraint and pre-seeded data,
@@ -162,15 +378,16 @@ func TestUserHandler_RegenerateAPIKey(t *testing.T) {
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]string
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
assert.NotEmpty(t, resp["api_key"])
assert.Equal(t, "API key regenerated successfully", resp["message"])
assert.Equal(t, "********", resp["api_key_masked"])
// Verify DB
var updatedUser models.User
db.First(&updatedUser, user.ID)
assert.Equal(t, resp["api_key"], updatedUser.APIKey)
assert.NotEmpty(t, updatedUser.APIKey)
}
func TestUserHandler_GetProfile(t *testing.T) {
@@ -442,9 +659,7 @@ func TestUserHandler_UpdateProfile_Errors(t *testing.T) {
// ============= User Management Tests (Admin functions) =============
func setupUserHandlerWithProxyHosts(t *testing.T) (*UserHandler, *gorm.DB) {
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
db := OpenTestDB(t)
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{}, &models.SecurityAudit{})
return NewUserHandler(db), db
}
@@ -1376,7 +1591,7 @@ func TestUserHandler_InviteUser_Success(t *testing.T) {
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
assert.NotEmpty(t, resp["invite_token"])
assert.Equal(t, "********", resp["invite_token_masked"])
assert.Equal(t, "", resp["invite_url"])
// email_sent is false because no SMTP is configured
assert.Equal(t, false, resp["email_sent"].(bool))
@@ -1500,7 +1715,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured(t *testing.T) {
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
assert.NotEmpty(t, resp["invite_token"])
assert.Equal(t, "********", resp["invite_token_masked"])
assert.Equal(t, "", resp["invite_url"])
assert.Equal(t, false, resp["email_sent"].(bool))
}
@@ -1553,8 +1768,8 @@ func TestUserHandler_InviteUser_WithSMTPAndConfiguredPublicURL_IncludesInviteURL
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
token := resp["invite_token"].(string)
assert.Equal(t, "https://charon.example.com/accept-invite?token="+token, resp["invite_url"])
assert.Equal(t, "********", resp["invite_token_masked"])
assert.Equal(t, "[REDACTED]", resp["invite_url"])
assert.Equal(t, true, resp["email_sent"].(bool))
}
@@ -1606,7 +1821,7 @@ func TestUserHandler_InviteUser_WithSMTPAndMalformedPublicURL_DoesNotExposeInvit
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
assert.NotEmpty(t, resp["invite_token"])
assert.Equal(t, "********", resp["invite_token_masked"])
assert.Equal(t, "", resp["invite_url"])
assert.Equal(t, false, resp["email_sent"].(bool))
}
@@ -1668,7 +1883,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured_DefaultAppName(t *testing.T)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
assert.NotEmpty(t, resp["invite_token"])
assert.Equal(t, "********", resp["invite_token_masked"])
}
// Note: TestGetBaseURL and TestGetAppName have been removed as these internal helper
@@ -2372,8 +2587,7 @@ func TestResendInvite_Success(t *testing.T) {
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
assert.NotEmpty(t, resp["invite_token"])
assert.NotEqual(t, "oldtoken123", resp["invite_token"])
assert.Equal(t, "********", resp["invite_token_masked"])
assert.Equal(t, "pending-user@example.com", resp["email"])
assert.Equal(t, false, resp["email_sent"].(bool)) // No SMTP configured
@@ -2381,7 +2595,7 @@ func TestResendInvite_Success(t *testing.T) {
var updatedUser models.User
db.First(&updatedUser, user.ID)
assert.NotEqual(t, "oldtoken123", updatedUser.InviteToken)
assert.Equal(t, resp["invite_token"], updatedUser.InviteToken)
assert.NotEmpty(t, updatedUser.InviteToken)
}
func TestResendInvite_WithExpiredInvite(t *testing.T) {
@@ -2419,11 +2633,75 @@ func TestResendInvite_WithExpiredInvite(t *testing.T) {
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "Failed to unmarshal response")
assert.NotEmpty(t, resp["invite_token"])
assert.NotEqual(t, "expiredtoken", resp["invite_token"])
assert.Equal(t, "********", resp["invite_token_masked"])
// Verify new expiration is in the future
var updatedUser models.User
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)
})
}
}

View File

@@ -29,6 +29,29 @@ import (
_ "github.com/Wikid82/charon/backend/pkg/dnsprovider/custom"
)
type uptimeBootstrapService interface {
CleanupStaleFailureCounts() error
SyncMonitors() error
CheckAll()
}
func runInitialUptimeBootstrap(enabled bool, uptimeService uptimeBootstrapService, logWarn func(error, string), logError func(error, string)) {
if !enabled {
return
}
if err := uptimeService.CleanupStaleFailureCounts(); err != nil && logWarn != nil {
logWarn(err, "Failed to cleanup stale failure counts")
}
if err := uptimeService.SyncMonitors(); err != nil && logError != nil {
logError(err, "Failed to sync monitors")
}
// Run initial check immediately after sync to avoid the 90s blind window.
uptimeService.CheckAll()
}
// Register wires up API routes and performs automatic migrations.
func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
// Caddy Manager - created early so it can be used by settings handlers for config reload
@@ -277,7 +300,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
protected.PATCH("/config", settingsHandler.PatchConfig) // Bulk configuration update
// SMTP Configuration
protected.GET("/settings/smtp", settingsHandler.GetSMTPConfig)
protected.GET("/settings/smtp", middleware.RequireRole("admin"), settingsHandler.GetSMTPConfig)
protected.POST("/settings/smtp", settingsHandler.UpdateSMTPConfig)
protected.POST("/settings/smtp/test", settingsHandler.TestSMTPConfig)
protected.POST("/settings/smtp/test-email", settingsHandler.SendTestEmail)
@@ -410,9 +433,10 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
dockerHandler := handlers.NewDockerHandler(dockerService, remoteServerService)
dockerHandler.RegisterRoutes(protected)
// Uptime Service
uptimeSvc := services.NewUptimeService(db, notificationService)
uptimeHandler := handlers.NewUptimeHandler(uptimeSvc)
// Uptime Service — reuse the single uptimeService instance (defined above)
// to share in-memory state (mutexes, notification batching) between
// background checker, ProxyHostHandler, and API handlers.
uptimeHandler := handlers.NewUptimeHandler(uptimeService)
protected.GET("/uptime/monitors", uptimeHandler.List)
protected.POST("/uptime/monitors", uptimeHandler.Create)
protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory)
@@ -463,11 +487,12 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
enabled = s.Value == "true"
}
if enabled {
if err := uptimeService.SyncMonitors(); err != nil {
logger.Log().WithError(err).Error("Failed to sync monitors")
}
}
runInitialUptimeBootstrap(
enabled,
uptimeService,
func(err error, msg string) { logger.Log().WithError(err).Warn(msg) },
func(err error, msg string) { logger.Log().WithError(err).Error(msg) },
)
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
@@ -520,40 +545,43 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
protected.GET("/security/status", securityHandler.GetStatus)
// Security Config management
protected.GET("/security/config", securityHandler.GetConfig)
protected.POST("/security/config", securityHandler.UpdateConfig)
protected.POST("/security/enable", securityHandler.Enable)
protected.POST("/security/disable", securityHandler.Disable)
protected.POST("/security/breakglass/generate", securityHandler.GenerateBreakGlass)
protected.GET("/security/decisions", securityHandler.ListDecisions)
protected.POST("/security/decisions", securityHandler.CreateDecision)
protected.GET("/security/rulesets", securityHandler.ListRuleSets)
protected.POST("/security/rulesets", securityHandler.UpsertRuleSet)
protected.DELETE("/security/rulesets/:id", securityHandler.DeleteRuleSet)
protected.GET("/security/rate-limit/presets", securityHandler.GetRateLimitPresets)
// GeoIP endpoints
protected.GET("/security/geoip/status", securityHandler.GetGeoIPStatus)
protected.POST("/security/geoip/reload", securityHandler.ReloadGeoIP)
protected.POST("/security/geoip/lookup", securityHandler.LookupGeoIP)
// WAF exclusion endpoints
protected.GET("/security/waf/exclusions", securityHandler.GetWAFExclusions)
protected.POST("/security/waf/exclusions", securityHandler.AddWAFExclusion)
protected.DELETE("/security/waf/exclusions/:rule_id", securityHandler.DeleteWAFExclusion)
securityAdmin := protected.Group("/security")
securityAdmin.Use(middleware.RequireRole("admin"))
securityAdmin.POST("/config", securityHandler.UpdateConfig)
securityAdmin.POST("/enable", securityHandler.Enable)
securityAdmin.POST("/disable", securityHandler.Disable)
securityAdmin.POST("/breakglass/generate", securityHandler.GenerateBreakGlass)
securityAdmin.POST("/decisions", securityHandler.CreateDecision)
securityAdmin.POST("/rulesets", securityHandler.UpsertRuleSet)
securityAdmin.DELETE("/rulesets/:id", securityHandler.DeleteRuleSet)
securityAdmin.POST("/geoip/reload", securityHandler.ReloadGeoIP)
securityAdmin.POST("/geoip/lookup", securityHandler.LookupGeoIP)
securityAdmin.POST("/waf/exclusions", securityHandler.AddWAFExclusion)
securityAdmin.DELETE("/waf/exclusions/:rule_id", securityHandler.DeleteWAFExclusion)
// Security module enable/disable endpoints (granular control)
protected.POST("/security/acl/enable", securityHandler.EnableACL)
protected.POST("/security/acl/disable", securityHandler.DisableACL)
protected.PATCH("/security/acl", securityHandler.PatchACL) // E2E tests use PATCH
protected.POST("/security/waf/enable", securityHandler.EnableWAF)
protected.POST("/security/waf/disable", securityHandler.DisableWAF)
protected.PATCH("/security/waf", securityHandler.PatchWAF) // E2E tests use PATCH
protected.POST("/security/cerberus/enable", securityHandler.EnableCerberus)
protected.POST("/security/cerberus/disable", securityHandler.DisableCerberus)
protected.POST("/security/crowdsec/enable", securityHandler.EnableCrowdSec)
protected.POST("/security/crowdsec/disable", securityHandler.DisableCrowdSec)
protected.PATCH("/security/crowdsec", securityHandler.PatchCrowdSec) // E2E tests use PATCH
protected.POST("/security/rate-limit/enable", securityHandler.EnableRateLimit)
protected.POST("/security/rate-limit/disable", securityHandler.DisableRateLimit)
protected.PATCH("/security/rate-limit", securityHandler.PatchRateLimit) // E2E tests use PATCH
securityAdmin.POST("/acl/enable", securityHandler.EnableACL)
securityAdmin.POST("/acl/disable", securityHandler.DisableACL)
securityAdmin.PATCH("/acl", securityHandler.PatchACL) // E2E tests use PATCH
securityAdmin.POST("/waf/enable", securityHandler.EnableWAF)
securityAdmin.POST("/waf/disable", securityHandler.DisableWAF)
securityAdmin.PATCH("/waf", securityHandler.PatchWAF) // E2E tests use PATCH
securityAdmin.POST("/cerberus/enable", securityHandler.EnableCerberus)
securityAdmin.POST("/cerberus/disable", securityHandler.DisableCerberus)
securityAdmin.POST("/crowdsec/enable", securityHandler.EnableCrowdSec)
securityAdmin.POST("/crowdsec/disable", securityHandler.DisableCrowdSec)
securityAdmin.PATCH("/crowdsec", securityHandler.PatchCrowdSec) // E2E tests use PATCH
securityAdmin.POST("/rate-limit/enable", securityHandler.EnableRateLimit)
securityAdmin.POST("/rate-limit/disable", securityHandler.DisableRateLimit)
securityAdmin.PATCH("/rate-limit", securityHandler.PatchRateLimit) // E2E tests use PATCH
// CrowdSec process management and import
// Data dir for crowdsec (persisted on host via volumes)
@@ -635,7 +663,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
proxyHostHandler.RegisterRoutes(protected)
remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService)
remoteServerHandler.RegisterRoutes(api)
remoteServerHandler.RegisterRoutes(protected)
// Initial Caddy Config Sync
go func() {
@@ -674,17 +702,20 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
}
// RegisterImportHandler wires up import routes with config dependencies.
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir, mountPath string) {
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyBinary, importDir, mountPath string) {
securityService := services.NewSecurityService(db)
importHandler := handlers.NewImportHandlerWithDeps(db, caddyBinary, importDir, mountPath, securityService)
api := router.Group("/api/v1")
importHandler.RegisterRoutes(api)
authService := services.NewAuthService(db, cfg)
authenticatedAdmin := api.Group("/")
authenticatedAdmin.Use(middleware.AuthMiddleware(authService), middleware.RequireRole("admin"))
importHandler.RegisterRoutes(authenticatedAdmin)
// NPM Import Handler - supports Nginx Proxy Manager export format
npmImportHandler := handlers.NewNPMImportHandler(db)
npmImportHandler.RegisterRoutes(api)
npmImportHandler.RegisterRoutes(authenticatedAdmin)
// JSON Import Handler - supports both Charon and NPM export formats
jsonImportHandler := handlers.NewJSONImportHandler(db)
jsonImportHandler.RegisterRoutes(api)
jsonImportHandler.RegisterRoutes(authenticatedAdmin)
}

View File

@@ -73,3 +73,55 @@ func TestRegister_LegacyMigrationErrorIsNonFatal(t *testing.T) {
}
require.True(t, hasHealth)
}
func TestRegister_UptimeFeatureFlagDefaultErrorIsNonFatal(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_uptime_flag_warn"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
const cbName = "routes:test_force_settings_query_error"
err = db.Callback().Query().Before("gorm:query").Register(cbName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "settings" {
_ = tx.AddError(errors.New("forced settings query failure"))
}
})
require.NoError(t, err)
t.Cleanup(func() {
_ = db.Callback().Query().Remove(cbName)
})
cfg := config.Config{JWTSecret: "test-secret"}
err = Register(router, db, cfg)
require.NoError(t, err)
}
func TestRegister_SecurityHeaderPresetInitErrorIsNonFatal(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_sec_header_presets_warn"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
const cbName = "routes:test_force_security_header_profile_query_error"
err = db.Callback().Query().Before("gorm:query").Register(cbName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "security_header_profiles" {
_ = tx.AddError(errors.New("forced security_header_profiles query failure"))
}
})
require.NoError(t, err)
t.Cleanup(func() {
_ = db.Callback().Query().Remove(cbName)
})
cfg := config.Config{JWTSecret: "test-secret"}
err = Register(router, db, cfg)
require.NoError(t, err)
}

View File

@@ -1,15 +1,20 @@
package routes_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/routes"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
func setupTestImportDB(t *testing.T) *gorm.DB {
@@ -27,7 +32,7 @@ func TestRegisterImportHandler(t *testing.T) {
db := setupTestImportDB(t)
router := gin.New()
routes.RegisterImportHandler(router, db, "echo", "/tmp", "/import/Caddyfile")
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
// Verify routes are registered by checking the routes list
routeInfo := router.Routes()
@@ -53,3 +58,30 @@ func TestRegisterImportHandler(t *testing.T) {
assert.True(t, found, "route %s should be registered", route)
}
}
func TestRegisterImportHandler_AuthzGuards(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestImportDB(t)
require.NoError(t, db.AutoMigrate(&models.User{}))
cfg := config.Config{JWTSecret: "test-secret"}
router := gin.New()
routes.RegisterImportHandler(router, db, cfg, "echo", "/tmp", "/import/Caddyfile")
unauthReq := httptest.NewRequest(http.MethodGet, "/api/v1/import/status", http.NoBody)
unauthW := httptest.NewRecorder()
router.ServeHTTP(unauthW, unauthReq)
assert.Equal(t, http.StatusUnauthorized, unauthW.Code)
nonAdmin := &models.User{Email: "user@example.com", Role: "user", Enabled: true}
require.NoError(t, db.Create(nonAdmin).Error)
authSvc := services.NewAuthService(db, cfg)
token, err := authSvc.GenerateToken(nonAdmin)
require.NoError(t, err)
nonAdminReq := httptest.NewRequest(http.MethodGet, "/api/v1/import/preview", http.NoBody)
nonAdminReq.Header.Set("Authorization", "Bearer "+token)
nonAdminW := httptest.NewRecorder()
router.ServeHTTP(nonAdminW, nonAdminReq)
assert.Equal(t, http.StatusForbidden, nonAdminW.Code)
}

View File

@@ -1,6 +1,7 @@
package routes
import (
"io"
"net/http"
"net/http/httptest"
"os"
@@ -16,6 +17,16 @@ import (
"gorm.io/gorm"
)
func materializeRoutePath(path string) string {
segments := strings.Split(path, "/")
for i, segment := range segments {
if strings.HasPrefix(segment, ":") {
segments[i] = "1"
}
}
return strings.Join(segments, "/")
}
func TestRegister(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -103,11 +114,13 @@ func TestRegisterImportHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
cfg := config.Config{JWTSecret: "test-secret"}
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_import"), &gorm.Config{})
require.NoError(t, err)
// RegisterImportHandler should not panic
RegisterImportHandler(router, db, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
RegisterImportHandler(router, db, cfg, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
// Verify import routes exist
routes := router.Routes()
@@ -177,6 +190,70 @@ func TestRegister_ProxyHostsRequireAuth(t *testing.T) {
assert.Contains(t, w.Body.String(), "Authorization header required")
}
func TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_mutation_auth_guard"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
mutatingMethods := map[string]bool{
http.MethodPost: true,
http.MethodPut: true,
http.MethodPatch: true,
http.MethodDelete: true,
}
publicMutationAllowlist := map[string]bool{
http.MethodPost + " /api/v1/auth/login": true,
http.MethodPost + " /api/v1/auth/register": true,
http.MethodPost + " /api/v1/setup": true,
http.MethodPost + " /api/v1/invite/accept": true,
http.MethodPost + " /api/v1/security/events": true,
http.MethodPost + " /api/v1/emergency/security-reset": true,
}
for _, route := range router.Routes() {
if !strings.HasPrefix(route.Path, "/api/v1/") {
continue
}
if !mutatingMethods[route.Method] {
continue
}
key := route.Method + " " + route.Path
if publicMutationAllowlist[key] {
continue
}
requestPath := materializeRoutePath(route.Path)
var body io.Reader = http.NoBody
if route.Method == http.MethodPost || route.Method == http.MethodPut || route.Method == http.MethodPatch {
body = strings.NewReader("{}")
}
req := httptest.NewRequest(route.Method, requestPath, body)
if route.Method == http.MethodPost || route.Method == http.MethodPut || route.Method == http.MethodPatch {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Contains(
t,
[]int{http.StatusUnauthorized, http.StatusForbidden},
w.Code,
"state-changing endpoint must deny unauthenticated access unless explicitly allowlisted: %s (materialized path: %s)",
key,
requestPath,
)
}
}
func TestRegister_DNSProviders_NotRegisteredWhenEncryptionKeyMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -362,6 +439,42 @@ func TestRegister_AuthenticatedRoutes(t *testing.T) {
}
}
func TestRegister_StateChangingRoutesRequireAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_mutating_auth_routes"), &gorm.Config{})
require.NoError(t, err)
cfg := config.Config{JWTSecret: "test-secret"}
require.NoError(t, Register(router, db, cfg))
stateChangingPaths := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/backups"},
{http.MethodPost, "/api/v1/settings"},
{http.MethodPatch, "/api/v1/settings"},
{http.MethodPatch, "/api/v1/config"},
{http.MethodPost, "/api/v1/user/profile"},
{http.MethodPost, "/api/v1/remote-servers"},
{http.MethodPost, "/api/v1/remote-servers/test"},
{http.MethodPut, "/api/v1/remote-servers/1"},
{http.MethodDelete, "/api/v1/remote-servers/1"},
{http.MethodPost, "/api/v1/remote-servers/1/test"},
}
for _, tc := range stateChangingPaths {
t.Run(tc.method+"_"+tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code, "State-changing route %s %s should require auth", tc.method, tc.path)
})
}
}
func TestRegister_AdminRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -915,10 +1028,12 @@ func TestRegisterImportHandler_RoutesExist(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
cfg := config.Config{JWTSecret: "test-secret"}
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_import_routes"), &gorm.Config{})
require.NoError(t, err)
RegisterImportHandler(router, db, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
RegisterImportHandler(router, db, cfg, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
routes := router.Routes()
routeMap := make(map[string]bool)

View File

@@ -0,0 +1,107 @@
package routes
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
type testUptimeBootstrapService struct {
cleanupErr error
syncErr error
cleanupCalls int
syncCalls int
checkAllCalls int
}
func (s *testUptimeBootstrapService) CleanupStaleFailureCounts() error {
s.cleanupCalls++
return s.cleanupErr
}
func (s *testUptimeBootstrapService) SyncMonitors() error {
s.syncCalls++
return s.syncErr
}
func (s *testUptimeBootstrapService) CheckAll() {
s.checkAllCalls++
}
func TestRunInitialUptimeBootstrap_Disabled_DoesNothing(t *testing.T) {
svc := &testUptimeBootstrapService{}
warnLogs := 0
errorLogs := 0
runInitialUptimeBootstrap(
false,
svc,
func(err error, msg string) { warnLogs++ },
func(err error, msg string) { errorLogs++ },
)
assert.Equal(t, 0, svc.cleanupCalls)
assert.Equal(t, 0, svc.syncCalls)
assert.Equal(t, 0, svc.checkAllCalls)
assert.Equal(t, 0, warnLogs)
assert.Equal(t, 0, errorLogs)
}
func TestRunInitialUptimeBootstrap_Enabled_HappyPath(t *testing.T) {
svc := &testUptimeBootstrapService{}
warnLogs := 0
errorLogs := 0
runInitialUptimeBootstrap(
true,
svc,
func(err error, msg string) { warnLogs++ },
func(err error, msg string) { errorLogs++ },
)
assert.Equal(t, 1, svc.cleanupCalls)
assert.Equal(t, 1, svc.syncCalls)
assert.Equal(t, 1, svc.checkAllCalls)
assert.Equal(t, 0, warnLogs)
assert.Equal(t, 0, errorLogs)
}
func TestRunInitialUptimeBootstrap_Enabled_CleanupError_StillProceeds(t *testing.T) {
svc := &testUptimeBootstrapService{cleanupErr: errors.New("cleanup failed")}
warnLogs := 0
errorLogs := 0
runInitialUptimeBootstrap(
true,
svc,
func(err error, msg string) { warnLogs++ },
func(err error, msg string) { errorLogs++ },
)
assert.Equal(t, 1, svc.cleanupCalls)
assert.Equal(t, 1, svc.syncCalls)
assert.Equal(t, 1, svc.checkAllCalls)
assert.Equal(t, 1, warnLogs)
assert.Equal(t, 0, errorLogs)
}
func TestRunInitialUptimeBootstrap_Enabled_SyncError_StillChecksAll(t *testing.T) {
svc := &testUptimeBootstrapService{syncErr: errors.New("sync failed")}
warnLogs := 0
errorLogs := 0
runInitialUptimeBootstrap(
true,
svc,
func(err error, msg string) { warnLogs++ },
func(err error, msg string) { errorLogs++ },
)
assert.Equal(t, 1, svc.cleanupCalls)
assert.Equal(t, 1, svc.syncCalls)
assert.Equal(t, 1, svc.checkAllCalls)
assert.Equal(t, 0, warnLogs)
assert.Equal(t, 1, errorLogs)
}

View File

@@ -100,7 +100,10 @@ func TestInviteToken_MustBeUnguessable(t *testing.T) {
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
token := resp["invite_token"].(string)
var invitedUser models.User
require.NoError(t, db.Where("email = ?", "user@test.com").First(&invitedUser).Error)
token := invitedUser.InviteToken
require.NotEmpty(t, token)
// Token MUST be at least 32 chars (64 hex = 32 bytes = 256 bits)
assert.GreaterOrEqual(t, len(token), 64, "Invite token must be at least 64 hex chars (256 bits)")

View File

@@ -857,6 +857,27 @@ func normalizeHeaderOps(headerOps map[string]any) {
}
}
func applyOptionalServerKeepalive(conf *Config, keepaliveIdle string, keepaliveCount int) {
if conf == nil || conf.Apps.HTTP == nil || conf.Apps.HTTP.Servers == nil {
return
}
server, ok := conf.Apps.HTTP.Servers["charon_server"]
if !ok || server == nil {
return
}
idle := strings.TrimSpace(keepaliveIdle)
if idle != "" {
server.KeepaliveIdle = &idle
}
if keepaliveCount > 0 {
count := keepaliveCount
server.KeepaliveCount = &count
}
}
// NormalizeAdvancedConfig traverses a parsed JSON advanced config (map or array)
// and normalizes any headers blocks so that header values are arrays of strings.
// It returns the modified config object which can be JSON marshaled again.

View File

@@ -103,3 +103,43 @@ func TestGenerateConfig_EmergencyRoutesBypassSecurity(t *testing.T) {
require.NotEqual(t, "crowdsec", name)
}
}
func TestApplyOptionalServerKeepalive_OmitsWhenUnset(t *testing.T) {
cfg := &Config{
Apps: Apps{
HTTP: &HTTPApp{Servers: map[string]*Server{
"charon_server": {
Listen: []string{":80", ":443"},
Routes: []*Route{},
},
}},
},
}
applyOptionalServerKeepalive(cfg, "", 0)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.Nil(t, server.KeepaliveIdle)
require.Nil(t, server.KeepaliveCount)
}
func TestApplyOptionalServerKeepalive_AppliesValidValues(t *testing.T) {
cfg := &Config{
Apps: Apps{
HTTP: &HTTPApp{Servers: map[string]*Server{
"charon_server": {
Listen: []string{":80", ":443"},
Routes: []*Route{},
},
}},
},
}
applyOptionalServerKeepalive(cfg, "45s", 7)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server.KeepaliveIdle)
require.Equal(t, "45s", *server.KeepaliveIdle)
require.NotNil(t, server.KeepaliveCount)
require.Equal(t, 7, *server.KeepaliveCount)
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -33,6 +34,15 @@ var (
validateConfigFunc = Validate
)
const (
minKeepaliveIdleDuration = time.Second
maxKeepaliveIdleDuration = 24 * time.Hour
minKeepaliveCount = 1
maxKeepaliveCount = 100
settingCaddyKeepaliveIdle = "caddy.keepalive_idle"
settingCaddyKeepaliveCnt = "caddy.keepalive_count"
)
// DNSProviderConfig contains a DNS provider with its decrypted credentials
// for use in Caddy DNS challenge configuration generation
type DNSProviderConfig struct {
@@ -277,6 +287,18 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
// Compute effective security flags (re-read runtime overrides)
_, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled := m.computeEffectiveFlags(ctx)
keepaliveIdle := ""
var keepaliveIdleSetting models.Setting
if err := m.db.Where("key = ?", settingCaddyKeepaliveIdle).First(&keepaliveIdleSetting).Error; err == nil {
keepaliveIdle = sanitizeKeepaliveIdle(keepaliveIdleSetting.Value)
}
keepaliveCount := 0
var keepaliveCountSetting models.Setting
if err := m.db.Where("key = ?", settingCaddyKeepaliveCnt).First(&keepaliveCountSetting).Error; err == nil {
keepaliveCount = sanitizeKeepaliveCount(keepaliveCountSetting.Value)
}
// Safety check: if Cerberus is enabled in DB and no admin whitelist configured,
// warn but allow initial startup to proceed. This prevents total lockout when
// the user has enabled Cerberus but hasn't configured admin_whitelist yet.
@@ -401,6 +423,8 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
return fmt.Errorf("generate config: %w", err)
}
applyOptionalServerKeepalive(generatedConfig, keepaliveIdle, keepaliveCount)
// Debug logging: WAF configuration state for troubleshooting integration issues
logger.Log().WithFields(map[string]any{
"waf_enabled": wafEnabled,
@@ -467,6 +491,42 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
return nil
}
func sanitizeKeepaliveIdle(value string) string {
idle := strings.TrimSpace(value)
if idle == "" {
return ""
}
d, err := time.ParseDuration(idle)
if err != nil {
return ""
}
if d < minKeepaliveIdleDuration || d > maxKeepaliveIdleDuration {
return ""
}
return idle
}
func sanitizeKeepaliveCount(value string) int {
raw := strings.TrimSpace(value)
if raw == "" {
return 0
}
count, err := strconv.Atoi(raw)
if err != nil {
return 0
}
if count < minKeepaliveCount || count > maxKeepaliveCount {
return 0
}
return count
}
// saveSnapshot stores the config to disk with timestamp.
func (m *Manager) saveSnapshot(conf *Config) (string, error) {
timestamp := time.Now().Unix()

View File

@@ -1,8 +1,10 @@
package caddy
import (
"bytes"
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -185,3 +187,93 @@ func TestManagerApplyConfig_DNSProviders_SkipsDecryptOrJSONFailures(t *testing.T
require.Len(t, captured, 1)
require.Equal(t, uint(24), captured[0].ID)
}
func TestManagerApplyConfig_MapsKeepaliveSettingsToGeneratedServer(t *testing.T) {
var loadBody []byte
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/load" && r.Method == http.MethodPost {
payload, _ := io.ReadAll(r.Body)
loadBody = append([]byte(nil), payload...)
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer caddyServer.Close()
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.Setting{},
&models.CaddyConfig{},
&models.SSLCertificate{},
&models.SecurityConfig{},
&models.SecurityRuleSet{},
&models.SecurityDecision{},
&models.DNSProvider{},
))
db.Create(&models.ProxyHost{DomainNames: "keepalive.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true})
db.Create(&models.SecurityConfig{Name: "default", Enabled: true})
db.Create(&models.Setting{Key: settingCaddyKeepaliveIdle, Value: "45s"})
db.Create(&models.Setting{Key: settingCaddyKeepaliveCnt, Value: "8"})
origVal := validateConfigFunc
defer func() { validateConfigFunc = origVal }()
validateConfigFunc = func(_ *Config) error { return nil }
manager := NewManager(newTestClient(t, caddyServer.URL), db, t.TempDir(), "", false, config.SecurityConfig{CerberusEnabled: true})
require.NoError(t, manager.ApplyConfig(context.Background()))
require.NotEmpty(t, loadBody)
require.True(t, bytes.Contains(loadBody, []byte(`"keepalive_idle":"45s"`)))
require.True(t, bytes.Contains(loadBody, []byte(`"keepalive_count":8`)))
}
func TestManagerApplyConfig_InvalidKeepaliveSettingsFallbackToDefaults(t *testing.T) {
var loadBody []byte
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/load" && r.Method == http.MethodPost {
payload, _ := io.ReadAll(r.Body)
loadBody = append([]byte(nil), payload...)
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer caddyServer.Close()
dsn := "file:" + t.Name() + "_invalid?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.Setting{},
&models.CaddyConfig{},
&models.SSLCertificate{},
&models.SecurityConfig{},
&models.SecurityRuleSet{},
&models.SecurityDecision{},
&models.DNSProvider{},
))
db.Create(&models.ProxyHost{DomainNames: "invalid-keepalive.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true})
db.Create(&models.SecurityConfig{Name: "default", Enabled: true})
db.Create(&models.Setting{Key: settingCaddyKeepaliveIdle, Value: "bad"})
db.Create(&models.Setting{Key: settingCaddyKeepaliveCnt, Value: "-1"})
origVal := validateConfigFunc
defer func() { validateConfigFunc = origVal }()
validateConfigFunc = func(_ *Config) error { return nil }
manager := NewManager(newTestClient(t, caddyServer.URL), db, t.TempDir(), "", false, config.SecurityConfig{CerberusEnabled: true})
require.NoError(t, manager.ApplyConfig(context.Background()))
require.NotEmpty(t, loadBody)
require.False(t, bytes.Contains(loadBody, []byte(`"keepalive_idle"`)))
require.False(t, bytes.Contains(loadBody, []byte(`"keepalive_count"`)))
}

View File

@@ -83,6 +83,8 @@ type Server struct {
AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"`
Logs *ServerLogs `json:"logs,omitempty"`
TrustedProxies *TrustedProxies `json:"trusted_proxies,omitempty"`
KeepaliveIdle *string `json:"keepalive_idle,omitempty"`
KeepaliveCount *int `json:"keepalive_count,omitempty"`
}
// TrustedProxies defines the module for configuring trusted proxy IP ranges.

View File

@@ -7,6 +7,8 @@ import (
"path/filepath"
"strconv"
"strings"
"github.com/Wikid82/charon/backend/internal/security"
)
// Config captures runtime configuration sourced from environment variables.
@@ -106,6 +108,17 @@ func Load() (Config, error) {
Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true",
}
allowedInternalHosts := security.InternalServiceHostAllowlist()
normalizedCaddyAdminURL, err := security.ValidateInternalServiceBaseURL(
cfg.CaddyAdminAPI,
2019,
allowedInternalHosts,
)
if err != nil {
return Config{}, fmt.Errorf("validate caddy admin api url: %w", err)
}
cfg.CaddyAdminAPI = normalizedCaddyAdminURL.String()
if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o700); err != nil {
return Config{}, fmt.Errorf("ensure data directory: %w", err)
}

View File

@@ -258,6 +258,32 @@ func TestLoad_EmergencyConfig(t *testing.T) {
assert.Equal(t, "testpass", cfg.Emergency.BasicAuthPassword)
}
func TestLoad_CaddyAdminAPIValidationAndNormalization(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("CHARON_DB_PATH", filepath.Join(tempDir, "test.db"))
t.Setenv("CHARON_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy"))
t.Setenv("CHARON_IMPORT_DIR", filepath.Join(tempDir, "imports"))
t.Setenv("CHARON_SSRF_INTERNAL_HOST_ALLOWLIST", "")
t.Setenv("CHARON_CADDY_ADMIN_API", "http://localhost:2019/config/")
cfg, err := Load()
require.NoError(t, err)
assert.Equal(t, "http://localhost:2019", cfg.CaddyAdminAPI)
}
func TestLoad_CaddyAdminAPIValidationRejectsNonAllowlistedHost(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("CHARON_DB_PATH", filepath.Join(tempDir, "test.db"))
t.Setenv("CHARON_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy"))
t.Setenv("CHARON_IMPORT_DIR", filepath.Join(tempDir, "imports"))
t.Setenv("CHARON_SSRF_INTERNAL_HOST_ALLOWLIST", "")
t.Setenv("CHARON_CADDY_ADMIN_API", "http://example.com:2019")
_, err := Load()
require.Error(t, err)
assert.Contains(t, err.Error(), "validate caddy admin api url")
}
// ============================================
// splitAndTrim Tests
// ============================================

View File

@@ -14,6 +14,7 @@ type NotificationProvider struct {
Type string `json:"type" gorm:"index"` // discord (only supported type in current rollout)
URL string `json:"url"` // Discord webhook URL (HTTPS format required)
Token string `json:"-"` // Auth token for providers (e.g., Gotify) - never exposed in API
HasToken bool `json:"has_token" gorm:"-"` // Computed: indicates whether a token is set (never exposes raw value)
Engine string `json:"engine,omitempty" gorm:"index"` // notify_v1 (notify-only runtime)
Config string `json:"config"` // JSON payload template for custom webhooks
ServiceConfig string `json:"service_config,omitempty" gorm:"type:text"` // JSON blob for typed service config

View File

@@ -4,5 +4,6 @@ const (
FlagNotifyEngineEnabled = "feature.notifications.engine.notify_v1.enabled"
FlagDiscordServiceEnabled = "feature.notifications.service.discord.enabled"
FlagGotifyServiceEnabled = "feature.notifications.service.gotify.enabled"
FlagWebhookServiceEnabled = "feature.notifications.service.webhook.enabled"
FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled"
)

View File

@@ -0,0 +1,7 @@
package notifications
import "net/http"
func executeNotifyRequest(client *http.Client, req *http.Request) (*http.Response, error) {
return client.Do(req)
}

View File

@@ -0,0 +1,507 @@
package notifications
import (
"bytes"
"context"
crand "crypto/rand"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
neturl "net/url"
"os"
"strconv"
"strings"
"time"
"github.com/Wikid82/charon/backend/internal/network"
"github.com/Wikid82/charon/backend/internal/security"
)
const (
MaxNotifyRequestBodyBytes = 256 * 1024
MaxNotifyResponseBodyBytes = 1024 * 1024
)
type RetryPolicy struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
}
type HTTPWrapperRequest struct {
URL string
Headers map[string]string
Body []byte
}
type HTTPWrapperResult struct {
StatusCode int
ResponseBody []byte
Attempts int
}
type HTTPWrapper struct {
retryPolicy RetryPolicy
allowHTTP bool
maxRedirects int
httpClientFactory func(allowHTTP bool, maxRedirects int) *http.Client
sleep func(time.Duration)
jitterNanos func(int64) int64
}
func NewNotifyHTTPWrapper() *HTTPWrapper {
return &HTTPWrapper{
retryPolicy: RetryPolicy{
MaxAttempts: 3,
BaseDelay: 200 * time.Millisecond,
MaxDelay: 2 * time.Second,
},
allowHTTP: allowNotifyHTTPOverride(),
maxRedirects: notifyMaxRedirects(),
httpClientFactory: func(allowHTTP bool, maxRedirects int) *http.Client {
opts := []network.Option{network.WithTimeout(10 * time.Second), network.WithMaxRedirects(maxRedirects)}
if allowHTTP {
opts = append(opts, network.WithAllowLocalhost())
}
return network.NewSafeHTTPClient(opts...)
},
sleep: time.Sleep,
}
}
func (w *HTTPWrapper) Send(ctx context.Context, request HTTPWrapperRequest) (*HTTPWrapperResult, error) {
if len(request.Body) > MaxNotifyRequestBodyBytes {
return nil, fmt.Errorf("request payload exceeds maximum size")
}
validatedURL, err := w.validateURL(request.URL)
if err != nil {
return nil, err
}
parsedValidatedURL, err := neturl.Parse(validatedURL)
if err != nil {
return nil, fmt.Errorf("destination URL validation failed")
}
validationOptions := []security.ValidationOption{}
if w.allowHTTP {
validationOptions = append(validationOptions, security.WithAllowHTTP(), security.WithAllowLocalhost())
}
safeURL, safeURLErr := security.ValidateExternalURL(parsedValidatedURL.String(), validationOptions...)
if safeURLErr != nil {
return nil, fmt.Errorf("destination URL validation failed")
}
safeParsedURL, safeParseErr := neturl.Parse(safeURL)
if safeParseErr != nil {
return nil, fmt.Errorf("destination URL validation failed")
}
if err := w.guardDestination(safeParsedURL); err != nil {
return nil, err
}
safeRequestURL, hostHeader, safeRequestErr := w.buildSafeRequestURL(safeParsedURL)
if safeRequestErr != nil {
return nil, safeRequestErr
}
headers := sanitizeOutboundHeaders(request.Headers)
client := w.httpClientFactory(w.allowHTTP, w.maxRedirects)
w.applyRedirectGuard(client)
var lastErr error
for attempt := 1; attempt <= w.retryPolicy.MaxAttempts; attempt++ {
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, safeRequestURL.String(), bytes.NewReader(request.Body))
if reqErr != nil {
return nil, fmt.Errorf("create outbound request: %w", reqErr)
}
httpReq.Host = hostHeader
for key, value := range headers {
httpReq.Header.Set(key, value)
}
if httpReq.Header.Get("Content-Type") == "" {
httpReq.Header.Set("Content-Type", "application/json")
}
resp, doErr := executeNotifyRequest(client, httpReq)
if doErr != nil {
lastErr = doErr
if attempt < w.retryPolicy.MaxAttempts && shouldRetry(nil, doErr) {
w.waitBeforeRetry(attempt)
continue
}
return nil, fmt.Errorf("outbound request failed: %s", sanitizeTransportErrorReason(doErr))
}
body, bodyErr := readCappedResponseBody(resp.Body)
closeErr := resp.Body.Close()
if bodyErr != nil {
return nil, bodyErr
}
if closeErr != nil {
return nil, fmt.Errorf("close response body: %w", closeErr)
}
if shouldRetry(resp, nil) && attempt < w.retryPolicy.MaxAttempts {
w.waitBeforeRetry(attempt)
continue
}
if resp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("provider returned status %d", resp.StatusCode)
}
return &HTTPWrapperResult{
StatusCode: resp.StatusCode,
ResponseBody: body,
Attempts: attempt,
}, nil
}
if lastErr != nil {
return nil, fmt.Errorf("provider request failed after retries: %s", sanitizeTransportErrorReason(lastErr))
}
return nil, fmt.Errorf("provider request failed")
}
func sanitizeTransportErrorReason(err error) string {
if err == nil {
return "connection failed"
}
errText := strings.ToLower(strings.TrimSpace(err.Error()))
switch {
case strings.Contains(errText, "no such host"):
return "dns lookup failed"
case strings.Contains(errText, "connection refused"):
return "connection refused"
case strings.Contains(errText, "no route to host") || strings.Contains(errText, "network is unreachable"):
return "network unreachable"
case strings.Contains(errText, "timeout") || strings.Contains(errText, "deadline exceeded"):
return "request timed out"
case strings.Contains(errText, "tls") || strings.Contains(errText, "certificate") || strings.Contains(errText, "x509"):
return "tls handshake failed"
default:
return "connection failed"
}
}
func (w *HTTPWrapper) applyRedirectGuard(client *http.Client) {
if client == nil {
return
}
originalCheckRedirect := client.CheckRedirect
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if originalCheckRedirect != nil {
if err := originalCheckRedirect(req, via); err != nil {
return err
}
}
return w.guardOutboundRequestURL(req)
}
}
func (w *HTTPWrapper) validateURL(rawURL string) (string, error) {
parsedURL, err := neturl.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("invalid destination URL")
}
if hasDisallowedQueryAuthKey(parsedURL.Query()) {
return "", fmt.Errorf("destination URL query authentication is not allowed")
}
options := []security.ValidationOption{}
if w.allowHTTP {
options = append(options, security.WithAllowHTTP(), security.WithAllowLocalhost())
}
validatedURL, err := security.ValidateExternalURL(rawURL, options...)
if err != nil {
return "", fmt.Errorf("destination URL validation failed")
}
return validatedURL, nil
}
func hasDisallowedQueryAuthKey(query neturl.Values) bool {
for key := range query {
normalizedKey := strings.ToLower(strings.TrimSpace(key))
switch normalizedKey {
case "token", "auth", "apikey", "api_key":
return true
}
}
return false
}
func (w *HTTPWrapper) guardOutboundRequestURL(httpReq *http.Request) error {
if httpReq == nil || httpReq.URL == nil {
return fmt.Errorf("destination URL validation failed")
}
reqURL := httpReq.URL.String()
validatedURL, err := w.validateURL(reqURL)
if err != nil {
return err
}
parsedValidatedURL, err := neturl.Parse(validatedURL)
if err != nil {
return fmt.Errorf("destination URL validation failed")
}
return w.guardDestination(parsedValidatedURL)
}
func (w *HTTPWrapper) guardDestination(destinationURL *neturl.URL) error {
if destinationURL == nil {
return fmt.Errorf("destination URL validation failed")
}
if destinationURL.User != nil || destinationURL.Fragment != "" {
return fmt.Errorf("destination URL validation failed")
}
hostname := strings.TrimSpace(destinationURL.Hostname())
if hostname == "" {
return fmt.Errorf("destination URL validation failed")
}
if parsedIP := net.ParseIP(hostname); parsedIP != nil {
if !w.isAllowedDestinationIP(hostname, parsedIP) {
return fmt.Errorf("destination URL validation failed")
}
return nil
}
resolvedIPs, err := net.LookupIP(hostname)
if err != nil || len(resolvedIPs) == 0 {
return fmt.Errorf("destination URL validation failed")
}
for _, resolvedIP := range resolvedIPs {
if !w.isAllowedDestinationIP(hostname, resolvedIP) {
return fmt.Errorf("destination URL validation failed")
}
}
return nil
}
func (w *HTTPWrapper) isAllowedDestinationIP(hostname string, ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
if ip.IsLoopback() {
return w.allowHTTP && isLocalDestinationHost(hostname)
}
if network.IsPrivateIP(ip) {
return false
}
return true
}
func (w *HTTPWrapper) buildSafeRequestURL(destinationURL *neturl.URL) (*neturl.URL, string, error) {
if destinationURL == nil {
return nil, "", fmt.Errorf("destination URL validation failed")
}
hostname := strings.TrimSpace(destinationURL.Hostname())
if hostname == "" {
return nil, "", fmt.Errorf("destination URL validation failed")
}
// Validate destination IPs are allowed (defense-in-depth alongside safeDialer).
_, err := w.resolveAllowedDestinationIP(hostname)
if err != nil {
return nil, "", err
}
// Preserve the original hostname in the URL so Go's TLS layer derives the
// correct ServerName for SNI and certificate verification. The safeDialer
// resolves DNS, validates IPs against SSRF rules, and connects to a
// validated IP at dial time, so protection is maintained without
// IP-pinning in the URL.
safeRequestURL := &neturl.URL{
Scheme: destinationURL.Scheme,
Host: destinationURL.Host,
Path: destinationURL.EscapedPath(),
RawQuery: destinationURL.RawQuery,
}
if safeRequestURL.Path == "" {
safeRequestURL.Path = "/"
}
return safeRequestURL, destinationURL.Host, nil
}
func (w *HTTPWrapper) resolveAllowedDestinationIP(hostname string) (net.IP, error) {
if parsedIP := net.ParseIP(hostname); parsedIP != nil {
if !w.isAllowedDestinationIP(hostname, parsedIP) {
return nil, fmt.Errorf("destination URL validation failed")
}
return parsedIP, nil
}
resolvedIPs, err := net.LookupIP(hostname)
if err != nil || len(resolvedIPs) == 0 {
return nil, fmt.Errorf("destination URL validation failed")
}
for _, resolvedIP := range resolvedIPs {
if w.isAllowedDestinationIP(hostname, resolvedIP) {
return resolvedIP, nil
}
}
return nil, fmt.Errorf("destination URL validation failed")
}
func isLocalDestinationHost(host string) bool {
trimmedHost := strings.TrimSpace(host)
if strings.EqualFold(trimmedHost, "localhost") {
return true
}
parsedIP := net.ParseIP(trimmedHost)
return parsedIP != nil && parsedIP.IsLoopback()
}
func shouldRetry(resp *http.Response, err error) bool {
if err != nil {
var netErr net.Error
if isNetErr := strings.Contains(strings.ToLower(err.Error()), "timeout") || strings.Contains(strings.ToLower(err.Error()), "connection"); isNetErr {
return true
}
return errors.As(err, &netErr)
}
if resp == nil {
return false
}
if resp.StatusCode == http.StatusTooManyRequests {
return true
}
return resp.StatusCode >= http.StatusInternalServerError
}
func readCappedResponseBody(body io.Reader) ([]byte, error) {
limited := io.LimitReader(body, MaxNotifyResponseBodyBytes+1)
content, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if len(content) > MaxNotifyResponseBodyBytes {
return nil, fmt.Errorf("response payload exceeds maximum size")
}
return content, nil
}
func sanitizeOutboundHeaders(headers map[string]string) map[string]string {
allowed := map[string]struct{}{
"content-type": {},
"user-agent": {},
"x-request-id": {},
"x-gotify-key": {},
}
sanitized := make(map[string]string)
for key, value := range headers {
normalizedKey := strings.ToLower(strings.TrimSpace(key))
if _, ok := allowed[normalizedKey]; !ok {
continue
}
sanitized[http.CanonicalHeaderKey(normalizedKey)] = strings.TrimSpace(value)
}
return sanitized
}
func (w *HTTPWrapper) waitBeforeRetry(attempt int) {
delay := w.retryPolicy.BaseDelay << (attempt - 1)
if delay > w.retryPolicy.MaxDelay {
delay = w.retryPolicy.MaxDelay
}
jitterFn := w.jitterNanos
if jitterFn == nil {
jitterFn = func(max int64) int64 {
if max <= 0 {
return 0
}
n, err := crand.Int(crand.Reader, big.NewInt(max))
if err != nil {
return 0
}
return n.Int64()
}
}
jitter := time.Duration(jitterFn(int64(delay) / 2))
sleepFn := w.sleep
if sleepFn == nil {
sleepFn = time.Sleep
}
sleepFn(delay + jitter)
}
func allowNotifyHTTPOverride() bool {
if strings.HasSuffix(os.Args[0], ".test") {
return true
}
allowHTTP := strings.EqualFold(strings.TrimSpace(os.Getenv("CHARON_NOTIFY_ALLOW_HTTP")), "true")
if !allowHTTP {
return false
}
environment := strings.ToLower(strings.TrimSpace(os.Getenv("CHARON_ENV")))
return environment == "development" || environment == "test"
}
func notifyMaxRedirects() int {
raw := strings.TrimSpace(os.Getenv("CHARON_NOTIFY_MAX_REDIRECTS"))
if raw == "" {
return 0
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0
}
if value < 0 {
return 0
}
if value > 5 {
return 5
}
return value
}

Some files were not shown because too many files have changed in this diff Show More