Compare commits

...

266 Commits

Author SHA1 Message Date
Jeremy
bfe535d36a Merge pull request #816 from Wikid82/hotfix/docker_build
fix(docker): update CADDY_VERSION to 2.11.2 for improved stability
2026-03-09 13:47:14 -04:00
GitHub Actions
aaf52475ee fix(docker): update Caddy version to 2.11.2 for consistency across documentation and scripts 2026-03-09 16:51:01 +00:00
GitHub Actions
cd35f6d8c7 fix(docker): update CADDY_CANDIDATE_VERSION to 2.11.2 for consistency 2026-03-09 16:47:48 +00:00
GitHub Actions
85b0bb1f5e fix(docker): update CADDY_VERSION to 2.11.2 for improved stability 2026-03-09 16:40:30 +00:00
Jeremy
36d561bbb8 Merge pull request #815 from Wikid82/nightly
Weekly: Promote nightly to main (2026-03-09)
2026-03-09 08:36:28 -04:00
Jeremy
fccb1f06ac Merge pull request #814 from Wikid82/bot/update-geolite2-checksum
chore(docker): update GeoLite2-Country.mmdb checksum
2026-03-09 08:36:09 -04:00
Wikid82
cf46ff0a3b chore(docker): update GeoLite2-Country.mmdb checksum
Automated checksum update for GeoLite2-Country.mmdb database.

Old: d3031e02196523cbb5f74291122033f2be277b2130abedd4b5bee52ba79832be
New: b79afc28a0a52f89c15e8d92b05c173f314dd4f687719f96cf921012d900fcce

Auto-generated by: .github/workflows/update-geolite2.yml
2026-03-09 02:56:06 +00:00
Jeremy
a66659476d Merge pull request #794 from Wikid82/feature/beta-release
Restructure User Management
2026-03-04 13:31:05 -05:00
GitHub Actions
7a8b0343e4 fix: update user record to trigger user_update audit event in E2E workflow 2026-03-04 15:36:02 +00:00
Jeremy
cc3077d709 Merge pull request #798 from Wikid82/renovate/feature/beta-release-docker-login-action-4.x
chore(deps): update docker/login-action action to v4 (feature/beta-release)
2026-03-04 08:36:19 -05:00
renovate[bot]
d1362a7fba chore(deps): update docker/login-action action to v4 2026-03-04 13:35:15 +00:00
GitHub Actions
4e9e1919a8 fix: update UserProfile role type and enhance API response typings for getProfile and updateProfile 2026-03-04 12:43:41 +00:00
GitHub Actions
f19f53ed9a fix(e2e): update user lifecycle audit entry checks to ensure both user_create and user_update events are present 2026-03-04 12:41:56 +00:00
GitHub Actions
f062dc206e fix: restrict email changes for non-admin users to profile settings 2026-03-04 12:38:28 +00:00
GitHub Actions
a97cb334a2 fix(deps): update @exodus/bytes, electron-to-chromium, and node-releases to latest versions 2026-03-04 12:28:05 +00:00
Jeremy
cf52a943b5 Merge pull request #797 from Wikid82/renovate/feature/beta-release-docker-setup-qemu-action-4.x
chore(deps): update docker/setup-qemu-action action to v4 (feature/beta-release)
2026-03-04 07:18:01 -05:00
Jeremy
46d0ecc4fb Merge pull request #796 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-03-04 07:17:31 -05:00
renovate[bot]
348c5e5405 chore(deps): update docker/setup-qemu-action action to v4 2026-03-04 12:16:35 +00:00
renovate[bot]
25dbe82360 fix(deps): update non-major-updates 2026-03-04 12:16:29 +00:00
GitHub Actions
fc404da455 fix(e2e): resolve shard 4 failures from 3-tier role model changes
Three tests broke when the Admin/User/Passthrough privilege model replaced
the old admin/user/guest hierarchy in PR-3.

- user-management: tighten heading locator to name='User Management' to avoid
  strict mode violation; the settings layout now renders a second h1
  ('Settings') alongside the page content heading
- user-lifecycle: update audit trail assertion from 2 to 1; users are now
  created with a role in a single API call so the backend does not emit a
  user_update audit entry when STEP 2 sends the same role value as creation
- auth-fixtures: replace invalid role='guest' with role='passthrough' in the
  guestUser fixture; the 'guest' role was removed in PR-3 and 'passthrough' is
  the equivalent lowest-privilege role in the new model

Verified: all three previously-failing tests now pass locally.
2026-03-03 13:10:44 +00:00
GitHub Actions
ed27fb0da9 fix(e2e): update account navigation locator and skip legacy Account.tsx test sections
The Account.tsx page was removed in PR-2b and replaced by UsersPage.tsx with
a UserDetailModal. Several E2E test sections still referenced UI elements that
only existed in the deleted page, causing CI failures across shards.

- admin-onboarding: update header profile link locator from /settings/account
  to /settings/users to match the new navigation target in Layout.tsx
- account-settings: skip five legacy test sections (Profile Management,
  Certificate Email, Password Change, API Key Management, Accessibility) that
  reference deleted Account.tsx elements (#profile-name, #profile-email,
  #useUserEmail, #cert-email) or assume these fields are directly on the page
  rather than inside the UserDetailModal
- Each skipped section includes an explanatory comment pointing to the PR-3
  'Self-Service Profile via Users Page (F10)' suite as the equivalent coverage

Verified: admin-onboarding 8/8 pass; account-settings 8 pass / 20 skipped
2026-03-03 10:27:13 +00:00
GitHub Actions
afbd50b43f fix: update @floating-ui and caniuse-lite packages to latest versions for improved functionality 2026-03-03 09:17:54 +00:00
GitHub Actions
ad2d30b525 fix: update postcss to version 8.5.8 for improved stability 2026-03-03 09:17:25 +00:00
GitHub Actions
a570a3327f fix: update opentelemetry http instrumentation to v0.66.0 2026-03-03 09:16:34 +00:00
GitHub Actions
0fd00575a2 feat: Add passthrough role support and related tests
- Implemented middleware to restrict access for passthrough users in management routes.
- Added unit tests for management access requirements based on user roles.
- Updated user model tests to include passthrough role validation.
- Enhanced frontend user management to support passthrough role in invite modal.
- Created end-to-end tests for passthrough user access restrictions and navigation visibility.
- Verified self-service profile management for admins and regular users.
2026-03-03 09:14:33 +00:00
GitHub Actions
a3d1ae3742 fix: update checkout ref to use full GitHub ref path for accurate branch handling 2026-03-03 04:31:42 +00:00
GitHub Actions
6f408f62ba fix: prevent stale-SHA checkout in scheduled CodeQL security scan
The scheduled CodeQL analysis explicitly passed ref: github.sha, which
is frozen when a cron job is queued, not when it runs. Under load or
during a long queue, the analysis could scan code that is days old,
missing vulnerabilities introduced since the last scheduling window.

Replace with ref: github.ref_name so all trigger types — scheduled,
push, and pull_request — consistently scan the current HEAD of the
branch being processed.
2026-03-03 04:24:47 +00:00
GitHub Actions
e92e7edd70 fix: prevent stale-SHA checkout and pin caddy-security in weekly security rebuild
The scheduled weekly rebuild was failing because GitHub Actions froze
github.sha at job-queue time. When the Sunday cron queued a job on
March 1 with Feb 23 code (CADDY_VERSION=2.11.0-beta.2), that job ran
two days later on March 3 still using the old code, missing the caddy
version fix that had since landed on main.

Additionally, caddy-security was unpinned, so xcaddy auto-resolved it
to v1.1.36 which requires caddy/v2@v2.11.1 — conflicting with xcaddy's
internally bundled v2.11.0-beta.2 reference.

- Add ref: github.ref_name to checkout step so the rebuild always
  fetches current branch HEAD at run time, not the SHA frozen at queue
  time
- Add CADDY_SECURITY_VERSION=1.1.36 ARG to pin the caddy-security
  plugin to a known-compatible version; pass it via --with so xcaddy
  picks up the pinned release
- Add --with github.com/caddyserver/caddy/v2@v${CADDY_TARGET_VERSION}
  to force xcaddy to use the declared Caddy version, overriding its own
  internal go.sum pin for caddy
- Add Renovate custom manager for CADDY_SECURITY_VERSION so future
  caddy-security releases trigger an automated PR instead of silently
  breaking the build

Fixes weekly security rebuild CI failures introduced ~Feb 22 when
caddy-security v1.1.36 was published.
2026-03-03 04:22:39 +00:00
GitHub Actions
4e4c4581ea fix: update Caddy Server version to 2.11.1 in architecture documentation 2026-03-03 03:52:57 +00:00
GitHub Actions
3f12ca05a3 feat: implement role-based access for settings route and add focus trap hook
- Wrapped the Settings component in RequireRole to enforce access control for admin and user roles.
- Introduced a new custom hook `useFocusTrap` to manage focus within modal dialogs, enhancing accessibility.
- Applied the focus trap in InviteModal, PermissionsModal, and UserDetailModal to prevent focus from leaving the dialog.
- Updated PassthroughLanding to focus on the heading when the component mounts.
2026-03-03 03:10:02 +00:00
GitHub Actions
a681d6aa30 feat: remove Account page and add PassthroughLanding page
- Deleted the Account page and its associated logic.
- Introduced a new PassthroughLanding page for users without management access.
- Updated Settings page to conditionally display the Users link for admin users.
- Enhanced UsersPage to support passthrough user role, including invite functionality and user detail modal.
- Updated tests to reflect changes in user roles and navigation.
2026-03-03 03:10:02 +00:00
GitHub Actions
3632d0d88c fix: user roles to use UserRole type and update related tests
- Changed user role representation from string to UserRole type in User model.
- Updated role assignments in various services and handlers to use the new UserRole constants.
- Modified middleware to handle UserRole type for role checks.
- Refactored tests to align with the new UserRole type.
- Added migration function to convert legacy "viewer" roles to "passthrough".
- Ensured all role checks and assignments are consistent across the application.
2026-03-03 03:10:02 +00:00
GitHub Actions
a1a9ab2ece chore(docs): archive uptime monitoring regression investigation plan to address false DOWN states 2026-03-03 03:10:02 +00:00
Jeremy
9c203914dd Merge pull request #795 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update dependency postcss to ^8.5.8 (feature/beta-release)
2026-03-02 19:25:08 -05:00
renovate[bot]
6cfe8ca9f2 chore(deps): update dependency postcss to ^8.5.8 2026-03-03 00:22:16 +00:00
Jeremy
938b170d98 Merge branch 'development' into feature/beta-release 2026-03-02 17:41:57 -05:00
Jeremy
9d6d2cbe53 Merge pull request #793 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update dependency postcss to ^8.5.7 (feature/beta-release)
2026-03-02 17:33:09 -05:00
renovate[bot]
136dd7ef62 chore(deps): update dependency postcss to ^8.5.7 2026-03-02 22:31:09 +00:00
Jeremy
f0c754cc52 Merge pull request #785 from Wikid82/feature/beta-release
Save and Import Functions Hotfix
2026-03-02 17:28:03 -05:00
GitHub Actions
28be62dee0 fix(tests): update cancel endpoint mock to match DELETE requests with session UUID 2026-03-02 22:09:53 +00:00
Jeremy
49bfbf3f76 Merge branch 'development' into feature/beta-release 2026-03-02 16:04:39 -05:00
GitHub Actions
2f90d936bf fix(tests): simplify back/cancel button handling in cross-browser import tests 2026-03-02 21:02:34 +00:00
GitHub Actions
4a60400af9 chore(deps): add tracking for Syft and Grype versions in workflows and scripts 2026-03-02 21:01:42 +00:00
GitHub Actions
18d0c235fa fix(deps): update OpenTelemetry dependencies to v1.41.0 2026-03-02 20:31:45 +00:00
GitHub Actions
fe8225753b fix(tests): remove visibility check for banner in cancel session flow 2026-03-02 20:28:40 +00:00
GitHub Actions
273fb3cf21 fix(tests): improve cancel session flow in cross-browser import tests 2026-03-02 20:04:34 +00:00
GitHub Actions
e3b6693402 fix: correct version-check hook to use global latest tag
The pre-commit version check hook was incorrectly using `git describe`
to find the latest tag, which only traverses the current branch's
ancestry. On feature branches that predate release tags applied to
main/nightly, this caused false failures — reporting v0.19.1 as latest
even though v0.20.0 and v0.21.0 existed globally.

Replaced with `git tag --sort=-v:refname | grep semver | head -1` so
the check always compares .version against the true latest release tag
in the repository, independent of which branch is checked out.
2026-03-02 19:52:47 +00:00
Jeremy
ac915f14c7 Merge pull request #792 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update aquasecurity/trivy-action action to v0.34.2 (feature/beta-release)
2026-03-02 14:08:07 -05:00
renovate[bot]
5ee52dd4d6 chore(deps): update aquasecurity/trivy-action action to v0.34.2 2026-03-02 19:02:20 +00:00
GitHub Actions
b5fd5d5774 fix(tests): update import handler test to use temporary directory for Caddyfile path 2026-03-02 15:29:49 +00:00
Jeremy
ae4f5936b3 Merge pull request #787 from Wikid82/main
Propagate changes from main into development
2026-03-02 10:29:25 -05:00
GitHub Actions
5017fdf4c1 fix: correct spelling of 'linting' in Management agent instructions 2026-03-02 15:25:36 +00:00
GitHub Actions
f0eda7c93c chore: remove workflow_dispatch trigger from quality checks workflow 2026-03-02 15:14:25 +00:00
GitHub Actions
f60a99d0bd fix(tests): update route validation functions to ensure canonical success responses in import/save regression tests 2026-03-02 15:05:05 +00:00
Jeremy
1440b2722e Merge pull request #786 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-03-02 10:02:56 -05:00
Jeremy
f58c96d29f Merge pull request #784 from Wikid82/nightly
Weekly Nightly Promotion
2026-03-02 10:00:05 -05:00
renovate[bot]
3b92700b5b fix(deps): update non-major-updates 2026-03-02 14:58:14 +00:00
GitHub Actions
5c0a543669 chore: update flatted, tldts, and tldts-core to version 7.0.24 in package-lock.json 2026-03-02 14:55:30 +00:00
GitHub Actions
317b695efb chore: update tldts and tldts-core to version 7.0.24 in package-lock.json 2026-03-02 14:54:51 +00:00
GitHub Actions
077e3c1d2b chore: add integration tests for import/save route regression coverage 2026-03-02 14:53:59 +00:00
GitHub Actions
b5c5ab0bc3 chore: add workflow_dispatch trigger to quality checks workflow 2026-03-02 14:53:59 +00:00
Jeremy
a6188bf2f1 Merge branch 'development' into feature/beta-release 2026-03-02 09:48:21 -05:00
Jeremy
2ecd6dd9d4 Merge branch 'main' into nightly 2026-03-02 09:38:57 -05:00
GitHub Actions
16752f4bb1 fixt(import): update cancel functions to accept session UUID and modify related tests 2026-03-02 14:30:24 +00:00
GitHub Actions
a75dd2dcdd chore: refactor agent tools and improve documentation
- Consolidated tools for Management, Planning, Playwright Dev, QA Security, and Supervisor agents to streamline functionality and reduce redundancy.
- Updated terminology from "Proper" fix to "Long Term" fix in Management agent for clarity on implementation choices.
- Added mandatory lintr and type checks before declaring slices "DONE" in Management agent to enhance code quality.
- Enhanced argument hints and descriptions across agents for better guidance on usage.
2026-03-02 14:24:31 +00:00
GitHub Actions
63e79664cc test(routes): add strict route matrix tests for import and save workflows 2026-03-02 14:11:54 +00:00
GitHub Actions
005b7bdf5b fix(handler): enforce session UUID requirement in Cancel method and add related tests 2026-03-02 14:11:20 +00:00
GitHub Actions
0f143af5bc fix(handler): validate session UUID in Cancel method of JSONImportHandler 2026-03-02 14:10:45 +00:00
GitHub Actions
76fb800922 fix(deps): update @csstools/css-syntax-patches-for-csstree and cssstyle to latest versions 2026-03-02 08:39:22 +00:00
Jeremy
58f5295652 Merge pull request #782 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-03-02 03:32:42 -05:00
renovate[bot]
0917a1ae95 fix(deps): update non-major-updates 2026-03-02 08:19:58 +00: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
288 changed files with 23839 additions and 4747 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

View File

@@ -3,9 +3,9 @@ name: 'Management'
description: 'Engineering Director. Delegates ALL research and execution. DO NOT ask it to debug code directly.'
argument-hint: 'The high-level goal (e.g., "Build the new Proxy Host Dashboard widget")'
tools: vscode/extensions, vscode/getProjectSetupInfo, vscode/installExtension, vscode/memory, vscode/openIntegratedBrowser, vscode/runCommand, vscode/askQuestions, vscode/vscodeAPI, execute, read, agent, 'github/*', 'github/*', 'io.github.goreleaser/mcp/*', edit, search, web, 'github/*', '', 'playwright/*', todo, vscode.mermaid-chat-features/renderMermaidDiagram, github.vscode-pull-request-github/issue_fetch, github.vscode-pull-request-github/labels_fetch, github.vscode-pull-request-github/notification_fetch, github.vscode-pull-request-github/doSearch, github.vscode-pull-request-github/activePullRequest, github.vscode-pull-request-github/openPullRequest, ms-azuretools.vscode-containers/containerToolsConfig, ms-python.python/getPythonEnvironmentInfo, ms-python.python/getPythonExecutableCommand, ms-python.python/installPythonPackage, ms-python.python/configurePythonEnvironment
tools: vscode/extensions, vscode/getProjectSetupInfo, vscode/installExtension, vscode/memory, vscode/runCommand, vscode/vscodeAPI, vscode/askQuestions, execute, read, agent, edit, search, web, 'github/*', 'playwright/*', 'github/*', 'github/*', 'io.github.goreleaser/mcp/*', 'mcp-refactor-typescript/*', 'microsoftdocs/mcp/*', browser, vscode.mermaid-chat-features/renderMermaidDiagram, github.vscode-pull-request-github/issue_fetch, github.vscode-pull-request-github/labels_fetch, github.vscode-pull-request-github/notification_fetch, github.vscode-pull-request-github/doSearch, github.vscode-pull-request-github/activePullRequest, github.vscode-pull-request-github/pullRequestStatusChecks, github.vscode-pull-request-github/openPullRequest, ms-azuretools.vscode-containers/containerToolsConfig, ms-python.python/getPythonEnvironmentInfo, ms-python.python/getPythonExecutableCommand, ms-python.python/installPythonPackage, ms-python.python/configurePythonEnvironment, todo
model: GPT-5.3-Codex (copilot)
target: vscode
user-invocable: true
disable-model-invocation: false
@@ -33,7 +33,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
5. **Parallel Execution**:
- You may delegate to `runSubagent` multiple times in parallel if tasks are independent. The only exception is `QA_Security`, which must run last as this validates the entire codebase after all changes.
6. **Implementation Choices**:
- When faced with multiple implementation options, ALWAYS choose the "Prroper" fix over a "Quick" fix. This ensures long-term maintainability and saves double work. The "Quick" fix will only cause more work later when the "Proper" fix is eventually needed.
- When faced with multiple implementation options, ALWAYS choose the "Long Term" fix over a "Quick" fix. This ensures long-term maintainability and saves double work. The "Quick" fix will only cause more work later when the "Long Term" fix is eventually needed.
</global_context>
<workflow>
@@ -67,6 +67,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
- Execute in PR slices, one slice at a time, in dependency order.
- Require each slice to pass review + QA gates before starting the next slice.
- Keep every slice deployable and independently testable.
- **MANDATORY**: Implementation agents must perform linting and type checks locally before declaring their slice "DONE". This is a critical step that must not be skipped to avoid broken commits and security issues.
5. **Phase 5: Review**:
- **Supervisor**: Call `Supervisor` to review the implementation against the plan. Provide feedback and ensure alignment with best practices.

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

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.2 | 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 |

47
.github/renovate.json vendored
View File

@@ -36,6 +36,19 @@
"platformAutomerge": true,
"customManagers": [
{
"customType": "regex",
"description": "Track caddy-security plugin version in Dockerfile",
"managerFilePatterns": [
"/^Dockerfile$/"
],
"matchStrings": [
"ARG CADDY_SECURITY_VERSION=(?<currentValue>[^\\s]+)"
],
"depNameTemplate": "github.com/greenpau/caddy-security",
"datasourceTemplate": "go",
"versioningTemplate": "semver"
},
{
"customType": "regex",
"description": "Track Go dependencies patched in Dockerfile for Caddy CVE fixes",
@@ -117,13 +130,45 @@
{
"customType": "regex",
"description": "Track GO_VERSION in Actions workflows",
"fileMatch": ["^\\.github/workflows/.*\\.yml$"],
"managerFilePatterns": ["/^\\.github/workflows/.*\\.yml$/"],
"matchStrings": [
"GO_VERSION: ['\"]?(?<currentValue>[\\d\\.]+)['\"]?"
],
"depNameTemplate": "golang/go",
"datasourceTemplate": "golang-version",
"versioningTemplate": "semver"
},
{
"customType": "regex",
"description": "Track Syft version in workflows and scripts",
"managerFilePatterns": [
"/^\\.github/workflows/nightly-build\\.yml$/",
"/^\\.github/skills/security-scan-docker-image-scripts/run\\.sh$/"
],
"matchStrings": [
"SYFT_VERSION=\\\"v(?<currentValue>[^\\\"\\s]+)\\\"",
"set_default_env \\\"SYFT_VERSION\\\" \\\"v(?<currentValue>[^\\\"]+)\\\""
],
"depNameTemplate": "anchore/syft",
"datasourceTemplate": "github-releases",
"versioningTemplate": "semver",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"description": "Track Grype version in workflows and scripts",
"managerFilePatterns": [
"/^\\.github/workflows/supply-chain-pr\\.yml$/",
"/^\\.github/skills/security-scan-docker-image-scripts/run\\.sh$/"
],
"matchStrings": [
"anchore/grype/main/install\\.sh \\| sh -s -- -b /usr/local/bin v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)",
"set_default_env \\\"GRYPE_VERSION\\\" \\\"v(?<currentValue>[^\\\"]+)\\\""
],
"depNameTemplate": "anchore/grype",
"datasourceTemplate": "github-releases",
"versioningTemplate": "semver",
"extractVersionTemplate": "^v(?<version>.*)$"
}
],

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
@@ -152,7 +154,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

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
@@ -39,14 +39,19 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.sha }}
# Use github.ref (full ref path) instead of github.ref_name:
# - push/schedule: resolves to refs/heads/<branch>, checking out latest HEAD
# - pull_request: resolves to refs/pull/<n>/merge, the correct PR merge ref
# github.ref_name fails for PRs because it yields "<n>/merge" which checkout
# interprets as a branch name (refs/heads/<n>/merge) that does not exist.
ref: ${{ github.ref }}
- name: Verify CodeQL parity guard
if: matrix.language == 'go'
run: bash scripts/ci/check-codeql-parity.sh
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4
with:
languages: ${{ matrix.language }}
queries: security-and-quality
@@ -57,7 +62,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
@@ -86,10 +91,10 @@ jobs:
run: mkdir -p sarif-results
- name: Autobuild
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
uses: github/codeql-action/autobuild@c793b717bc78562f491db7b0e93a3a178b099162 # v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4
with:
category: "/language:${{ matrix.language }}"
output: sarif-results/${{ matrix.language }}
@@ -122,10 +127,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 +158,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 +210,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:
@@ -111,7 +115,7 @@ jobs:
- name: Set up QEMU
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
@@ -125,7 +129,7 @@ jobs:
- name: Log in to GitHub Container Registry
if: steps.skip.outputs.skip_build != 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
@@ -133,7 +137,7 @@ jobs:
- name: Log in to Docker Hub
if: steps.skip.outputs.skip_build != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -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
@@ -527,7 +531,7 @@ jobs:
- name: Run Trivy scan (table output)
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'table'
@@ -538,7 +542,7 @@ jobs:
- name: Run Trivy vulnerability scanner (SARIF)
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
id: trivy
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'sarif'
@@ -558,15 +562,16 @@ jobs:
- name: Upload Trivy results
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
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 }}
@@ -652,7 +657,7 @@ jobs:
echo "image_ref=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${PR_TAG}" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
@@ -684,7 +689,7 @@ jobs:
echo "✅ Image freshness validated"
- name: Run Trivy scan on PR image (table output)
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ steps.pr-image.outputs.image_ref }}
format: 'table'
@@ -693,7 +698,7 @@ jobs:
- name: Run Trivy scan on PR image (SARIF - blocking)
id: trivy-scan
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ steps.pr-image.outputs.image_ref }}
format: 'sarif'
@@ -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()
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
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@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
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@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
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@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
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@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
sarif_file: 'trivy-pr-results.sarif'
category: 'trivy-nightly'
continue-on-error: true
- name: Create scan summary
if: always()
run: |

View File

@@ -44,7 +44,7 @@ jobs:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ env.NODE_VERSION }}

View File

@@ -38,7 +38,7 @@ jobs:
# Step 2: Set up Node.js (for building any JS-based doc tools)
- name: 🔧 Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ env.NODE_VERSION }}

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
@@ -151,7 +150,7 @@ jobs:
- name: Set up Node.js
if: steps.resolve-image.outputs.image_source == 'build'
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
@@ -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
@@ -225,14 +224,15 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
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
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -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
@@ -426,14 +426,15 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
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
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -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
@@ -635,14 +636,15 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
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
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -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
@@ -856,14 +858,47 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
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
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -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
@@ -1060,14 +1095,47 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
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
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -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
@@ -1272,14 +1340,47 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
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
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -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,
@@ -161,13 +162,13 @@ jobs:
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
@@ -175,7 +176,7 @@ jobs:
- name: Log in to Docker Hub
if: env.HAS_DOCKERHUB_TOKEN == 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -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
@@ -277,7 +330,7 @@ jobs:
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
@@ -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
@@ -343,22 +396,128 @@ jobs:
severity-cutoff: high
- name: Scan with Trivy
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-nightly.outputs.digest }}
format: 'sarif'
output: 'trivy-nightly.sarif'
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
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

@@ -28,7 +28,7 @@ jobs:
(github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'development')
steps:
- name: Set up Node (for github-script)
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ env.NODE_VERSION }}

View File

@@ -3,6 +3,9 @@ name: Quality Checks
on:
pull_request:
push:
branches:
- nightly
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -18,6 +21,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 +137,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
@@ -225,7 +249,7 @@ jobs:
bash "scripts/repo_health_check.sh"
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

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,13 +33,25 @@ 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
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: ${{ env.NODE_VERSION }}

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@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
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@a5b959e10d29aec4f277040b4d27d0f6bea2322a
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@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
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:
@@ -36,13 +36,18 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# Explicitly fetch the current HEAD of the ref at run time, not the
# SHA that was frozen when this scheduled job was queued. Without this,
# a queued job can run days later with stale code.
ref: ${{ github.ref_name }}
- name: Normalize image name
run: |
echo "IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
@@ -56,7 +61,7 @@ jobs:
echo "Base image digest: $DIGEST"
- name: Log in to Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -88,7 +93,7 @@ jobs:
BASE_IMAGE=${{ steps.base-image.outputs.digest }}
- name: Run Trivy vulnerability scanner (CRITICAL+HIGH)
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'table'
@@ -98,7 +103,7 @@ jobs:
- name: Run Trivy vulnerability scanner (SARIF)
id: trivy-sarif
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
@@ -106,12 +111,12 @@ jobs:
severity: 'CRITICAL,HIGH,MEDIUM'
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
with:
sarif_file: 'trivy-weekly-results.sarif'
- name: Run Trivy vulnerability scanner (JSON for artifact)
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'json'
@@ -119,7 +124,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,9 +339,30 @@ 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
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4
continue-on-error: true
with:
sarif_file: grype-results.sarif
@@ -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.21.0

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.2 --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.2 | 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,13 @@ 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.2 build.
ARG CADDY_VERSION=2.11.2
ARG CADDY_CANDIDATE_VERSION=2.11.2
ARG CADDY_USE_CANDIDATE=0
ARG CADDY_PATCH_SCENARIO=B
# renovate: datasource=go depName=github.com/greenpau/caddy-security
ARG CADDY_SECURITY_VERSION=1.1.36
## 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 +70,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
@@ -131,7 +136,7 @@ RUN set -eux; \
# Note: xx-go install puts binaries in /go/bin/TARGETOS_TARGETARCH/dlv if cross-compiling.
# We find it and move it to /go/bin/dlv so it's in a consistent location for the next stage.
# renovate: datasource=go depName=github.com/go-delve/delve
ARG DLV_VERSION=1.26.0
ARG DLV_VERSION=1.26.1
# hadolint ignore=DL3059,DL4006
RUN CGO_ENABLED=0 xx-go install github.com/go-delve/delve/cmd/dlv@v${DLV_VERSION} && \
DLV_PATH=$(find /go/bin -name dlv -type f | head -n 1) && \
@@ -196,6 +201,10 @@ 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
ARG CADDY_SECURITY_VERSION
# renovate: datasource=go depName=github.com/caddyserver/xcaddy
ARG XCADDY_VERSION=0.4.5
@@ -213,11 +222,18 @@ 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} \
--with github.com/greenpau/caddy-security \
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_TARGET_VERSION} \
--with github.com/caddyserver/caddy/v2@v${CADDY_TARGET_VERSION} \
--with github.com/greenpau/caddy-security@v${CADDY_SECURITY_VERSION} \
--with github.com/corazawaf/coraza-caddy/v2 \
--with github.com/hslatman/caddy-crowdsec-bouncer@v0.10.0 \
--with github.com/zhangjiayin/caddy-geoip2 \
@@ -239,12 +255,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 +417,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=b79afc28a0a52f89c15e8d92b05c173f314dd4f687719f96cf921012d900fcce
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

@@ -40,7 +40,7 @@ func TestResetPasswordCommand_Succeeds(t *testing.T) {
}
email := "user@example.com"
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: "admin", Enabled: true}
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: models.RoleAdmin, Enabled: true}
user.PasswordHash = "$2a$10$example_hashed_password"
if err = db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
@@ -257,7 +257,7 @@ func TestMain_ResetPasswordCommand_InProcess(t *testing.T) {
}
email := "user@example.com"
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: "admin", Enabled: true}
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: models.RoleAdmin, Enabled: true}
user.PasswordHash = "$2a$10$example_hashed_password"
user.FailedLoginAttempts = 3
if err = db.Create(&user).Error; err != nil {
@@ -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

@@ -72,7 +72,7 @@ func TestSeedMain_ForceAdminUpdatesExistingUserPassword(t *testing.T) {
UUID: "existing-user",
Email: "admin@localhost",
Name: "Old Name",
Role: "viewer",
Role: models.RolePassthrough,
Enabled: false,
PasswordHash: "$2a$10$example_hashed_password",
}
@@ -134,7 +134,7 @@ func TestSeedMain_ForceAdminWithoutPasswordUpdatesMetadata(t *testing.T) {
UUID: "existing-user-no-pass",
Email: "admin@localhost",
Name: "Old Name",
Role: "viewer",
Role: models.RolePassthrough,
Enabled: false,
PasswordHash: "$2a$10$example_hashed_password",
}

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.66.0 // indirect
go.opentelemetry.io/otel v1.41.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.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.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.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
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.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
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)
)
}
@@ -379,7 +381,7 @@ func (h *AuthHandler) Verify(c *gin.Context) {
// Set headers for downstream services
c.Header("X-Forwarded-User", user.Email)
c.Header("X-Forwarded-Groups", user.Role)
c.Header("X-Forwarded-Groups", string(user.Role))
c.Header("X-Forwarded-Name", user.Name)
// Return 200 OK - access granted

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)
}
@@ -412,7 +430,7 @@ func TestAuthHandler_Me(t *testing.T) {
UUID: uuid.NewString(),
Email: "me@example.com",
Name: "Me User",
Role: "admin",
Role: models.RoleAdmin,
}
db.Create(user)
@@ -612,7 +630,7 @@ func TestAuthHandler_Verify_ValidToken(t *testing.T) {
UUID: uuid.NewString(),
Email: "test@example.com",
Name: "Test User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
}
_ = user.SetPassword("password123")
@@ -643,7 +661,7 @@ func TestAuthHandler_Verify_BearerToken(t *testing.T) {
UUID: uuid.NewString(),
Email: "bearer@example.com",
Name: "Bearer User",
Role: "admin",
Role: models.RoleAdmin,
Enabled: true,
}
_ = user.SetPassword("password123")
@@ -672,7 +690,7 @@ func TestAuthHandler_Verify_DisabledUser(t *testing.T) {
UUID: uuid.NewString(),
Email: "disabled@example.com",
Name: "Disabled User",
Role: "user",
Role: models.RoleUser,
}
_ = user.SetPassword("password123")
db.Create(user)
@@ -712,7 +730,7 @@ func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) {
UUID: uuid.NewString(),
Email: "denied@example.com",
Name: "Denied User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeDenyAll,
}
@@ -777,7 +795,7 @@ func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) {
UUID: uuid.NewString(),
Email: "status@example.com",
Name: "Status User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
}
_ = user.SetPassword("password123")
@@ -810,7 +828,7 @@ func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) {
UUID: uuid.NewString(),
Email: "disabled2@example.com",
Name: "Disabled User 2",
Role: "user",
Role: models.RoleUser,
}
_ = user.SetPassword("password123")
db.Create(user)
@@ -862,7 +880,7 @@ func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) {
UUID: uuid.NewString(),
Email: "allowall@example.com",
Name: "Allow All User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeAllowAll,
}
@@ -899,7 +917,7 @@ func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) {
UUID: uuid.NewString(),
Email: "denyall@example.com",
Name: "Deny All User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeDenyAll,
}
@@ -938,7 +956,7 @@ func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) {
UUID: uuid.NewString(),
Email: "permitted@example.com",
Name: "Permitted User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeDenyAll,
PermittedHosts: []models.ProxyHost{*host1}, // Only host1
@@ -1093,7 +1111,7 @@ func TestAuthHandler_Logout_InvalidatesBearerSession(t *testing.T) {
UUID: uuid.NewString(),
Email: "logout-session@example.com",
Name: "Logout Session",
Role: "admin",
Role: models.RoleAdmin,
Enabled: true,
}
_ = user.SetPassword("password123")
@@ -1224,7 +1242,7 @@ func TestAuthHandler_Refresh(t *testing.T) {
handler, db := setupAuthHandler(t)
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: "user", Enabled: true}
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: models.RoleUser, Enabled: true}
require.NoError(t, user.SetPassword("password123"))
require.NoError(t, db.Create(user).Error)
@@ -1314,7 +1332,7 @@ func TestAuthHandler_Verify_UsesOriginalHostFallback(t *testing.T) {
UUID: uuid.NewString(),
Email: "originalhost@example.com",
Name: "Original Host User",
Role: "user",
Role: models.RoleUser,
Enabled: true,
PermissionMode: models.PermissionModeAllowAll,
}

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

@@ -384,10 +384,7 @@ func (h *EmergencyHandler) syncSecurityState(ctx context.Context) {
// POST /api/v1/emergency/token/generate
// Requires admin authentication
func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -437,10 +434,7 @@ func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
// GET /api/v1/emergency/token/status
// Requires admin authentication
func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -458,10 +452,7 @@ func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
// DELETE /api/v1/emergency/token
// Requires admin authentication
func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -485,10 +476,7 @@ func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
// PATCH /api/v1/emergency/token/expiration
// Requires admin authentication
func (h *EmergencyHandler) UpdateTokenExpiration(c *gin.Context) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}

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

@@ -340,6 +340,11 @@ func TestCommitAndCancel_InvalidSessionUUID(t *testing.T) {
r.ServeHTTP(wCommit, reqCommit)
assert.Equal(t, http.StatusBadRequest, wCommit.Code)
wCancelMissing := httptest.NewRecorder()
reqCancelMissing, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel", http.NoBody)
r.ServeHTTP(wCancelMissing, reqCancelMissing)
assert.Equal(t, http.StatusBadRequest, wCancelMissing.Code)
wCancel := httptest.NewRecorder()
reqCancel, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel?session_uuid=.", http.NoBody)
r.ServeHTTP(wCancel, reqCancel)

View File

@@ -310,6 +310,11 @@ func (h *JSONImportHandler) Cancel(c *gin.Context) {
return
}
if strings.TrimSpace(req.SessionUUID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
return
}
// Clean up session if it exists
jsonImportSessionsMu.Lock()
delete(jsonImportSessions, req.SessionUUID)

View File

@@ -497,6 +497,62 @@ func TestJSONImportHandler_ConflictDetection(t *testing.T) {
assert.Contains(t, conflictDetails, "conflict.com")
}
func TestJSONImportHandler_Cancel_RequiresValidJSONBody(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
t.Run("missing body", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", http.NoBody)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("invalid json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString("{"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("empty object payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString("{}"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "session_uuid required", resp["error"])
})
t.Run("missing session_uuid payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString(`{"foo":"bar"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "session_uuid required", resp["error"])
})
}
func TestJSONImportHandler_IsCharonFormat(t *testing.T) {
db := setupJSONTestDB(t)
handler := NewJSONImportHandler(db)

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

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/gin-gonic/gin"
@@ -293,6 +294,11 @@ func (h *NPMImportHandler) Cancel(c *gin.Context) {
return
}
if strings.TrimSpace(req.SessionUUID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
return
}
// Clean up session if it exists
npmImportSessionsMu.Lock()
delete(npmImportSessions, req.SessionUUID)

View File

@@ -453,6 +453,62 @@ func TestNPMImportHandler_Cancel(t *testing.T) {
assert.Equal(t, http.StatusNotFound, commitW.Code)
}
func TestNPMImportHandler_Cancel_RequiresValidJSONBody(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
t.Run("missing body", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", http.NoBody)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("invalid json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString("{"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("empty object payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString("{}"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "session_uuid required", resp["error"])
})
t.Run("missing session_uuid payload", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString(`{"foo":"bar"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]string
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "session_uuid required", resp["error"])
})
}
func TestNPMImportHandler_ConvertNPMToImportResult(t *testing.T) {
db := setupNPMTestDB(t)
handler := NewNPMImportHandler(db)

View File

@@ -24,10 +24,19 @@ 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)
return roleStr == "admin"
return c.GetString("role") == string(models.RoleAdmin)
}
func respondPermissionError(c *gin.Context, securityService *services.SecurityService, action string, err error, path string) bool {

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

@@ -307,7 +307,7 @@ func TestSecurityEventIntakeR6Intact(t *testing.T) {
Email: "admin@example.com",
Name: "Admin User",
PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz", // Dummy bcrypt hash
Role: "admin",
Role: models.RoleAdmin,
Enabled: true,
}
require.NoError(t, db.Create(adminUser).Error)

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"})
@@ -1039,10 +1075,7 @@ func (h *SecurityHandler) PatchRateLimit(c *gin.Context) {
// toggleSecurityModule is a helper function that handles enabling/disabling security modules
// It updates the setting, invalidates cache, and triggers Caddy config reload
func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string, enabled bool) {
// Check admin role
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}

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

@@ -22,13 +22,15 @@ import (
type UserHandler struct {
DB *gorm.DB
AuthService *services.AuthService
MailService *services.MailService
securitySvc *services.SecurityService
}
func NewUserHandler(db *gorm.DB) *UserHandler {
func NewUserHandler(db *gorm.DB, authService *services.AuthService) *UserHandler {
return &UserHandler{
DB: db,
AuthService: authService,
MailService: services.NewMailService(db),
securitySvc: services.NewSecurityService(db),
}
@@ -103,6 +105,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
@@ -129,7 +143,7 @@ func (h *UserHandler) Setup(c *gin.Context) {
UUID: uuid.New().String(),
Name: req.Name,
Email: strings.ToLower(req.Email),
Role: "admin",
Role: models.RoleAdmin,
Enabled: true,
APIKey: uuid.New().String(),
}
@@ -160,6 +174,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
}
@@ -174,8 +199,21 @@ func (h *UserHandler) Setup(c *gin.Context) {
})
}
// rejectPassthrough aborts with 403 if the caller is a passthrough user.
// Returns true if the request was rejected (caller should return).
func rejectPassthrough(c *gin.Context, action string) bool {
if c.GetString("role") == string(models.RolePassthrough) {
c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot " + action})
return true
}
return false
}
// RegenerateAPIKey generates a new API key for the authenticated user.
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
if rejectPassthrough(c, "manage API keys") {
return
}
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
@@ -189,11 +227,19 @@ 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.
func (h *UserHandler) GetProfile(c *gin.Context) {
if rejectPassthrough(c, "access profile") {
return
}
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
@@ -207,11 +253,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),
})
}
@@ -223,6 +270,9 @@ type UpdateProfileRequest struct {
// UpdateProfile updates the authenticated user's profile.
func (h *UserHandler) UpdateProfile(c *gin.Context) {
if rejectPassthrough(c, "update profile") {
return
}
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
@@ -280,9 +330,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
// ListUsers returns all users (admin only).
func (h *UserHandler) ListUsers(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -326,9 +374,7 @@ type CreateUserRequest struct {
// CreateUser creates a new user with a password (admin only).
func (h *UserHandler) CreateUser(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -340,7 +386,12 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
// Default role to "user"
if req.Role == "" {
req.Role = "user"
req.Role = string(models.RoleUser)
}
if !models.UserRole(req.Role).IsValid() {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
return
}
// Default permission mode to "allow_all"
@@ -363,7 +414,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
UUID: uuid.New().String(),
Email: strings.ToLower(req.Email),
Name: req.Name,
Role: req.Role,
Role: models.UserRole(req.Role),
Enabled: true,
APIKey: uuid.New().String(),
PermissionMode: models.PermissionMode(req.PermissionMode),
@@ -431,9 +482,7 @@ func generateSecureToken(length int) (string, error) {
// InviteUser creates a new user with an invite token and sends an email (admin only).
func (h *UserHandler) InviteUser(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -447,7 +496,12 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
// Default role to "user"
if req.Role == "" {
req.Role = "user"
req.Role = string(models.RoleUser)
}
if !models.UserRole(req.Role).IsValid() {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
return
}
// Default permission mode to "allow_all"
@@ -477,7 +531,7 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
user := models.User{
UUID: uuid.New().String(),
Email: strings.ToLower(req.Email),
Role: req.Role,
Role: models.UserRole(req.Role),
Enabled: false, // Disabled until invite is accepted
APIKey: uuid.New().String(),
PermissionMode: models.PermissionMode(req.PermissionMode),
@@ -548,14 +602,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,
})
}
@@ -566,9 +620,7 @@ type PreviewInviteURLRequest struct {
// PreviewInviteURL returns what the invite URL would look like with current settings.
func (h *UserHandler) PreviewInviteURL(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -612,9 +664,7 @@ func getAppName(db *gorm.DB) string {
// GetUser returns a single user by ID (admin only).
func (h *UserHandler) GetUser(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -663,11 +713,17 @@ type UpdateUserRequest struct {
Enabled *bool `json:"enabled"`
}
// UpdateUser updates an existing user (admin only).
// UpdateUser updates an existing user (admin only for management fields, self-service for name/password).
func (h *UserHandler) UpdateUser(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
currentRole := c.GetString("role")
currentUserIDRaw, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
currentUserID, ok := currentUserIDRaw.(uint)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session"})
return
}
@@ -685,11 +741,31 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
}
var req UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
return
}
isSelf := uint(id) == currentUserID
isCallerAdmin := currentRole == string(models.RoleAdmin)
// Non-admin users can only update their own name and password via this endpoint.
// Email changes require password verification and must go through PUT /user/profile.
if !isCallerAdmin {
if !isSelf {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
if req.Email != "" {
c.JSON(http.StatusForbidden, gin.H{"error": "Email changes must be made via your profile settings"})
return
}
if req.Role != "" || req.Enabled != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot modify role or enabled status"})
return
}
}
updates := make(map[string]any)
if req.Name != "" {
@@ -698,21 +774,37 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
if req.Email != "" {
email := strings.ToLower(req.Email)
// Check if email is taken by another user
var count int64
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; err == nil && count > 0 {
if dbErr := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; dbErr == nil && count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
return
}
updates["email"] = email
}
needsSessionInvalidation := false
if req.Role != "" {
updates["role"] = req.Role
newRole := models.UserRole(req.Role)
if !newRole.IsValid() {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
return
}
if newRole != user.Role {
// Self-demotion prevention
if isSelf {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot change your own role"})
return
}
updates["role"] = string(newRole)
needsSessionInvalidation = true
}
}
if req.Password != nil {
if err := user.SetPassword(*req.Password); err != nil {
if hashErr := user.SetPassword(*req.Password); hashErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
@@ -721,14 +813,82 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
updates["locked_until"] = nil
}
if req.Enabled != nil {
if req.Enabled != nil && *req.Enabled != user.Enabled {
// Prevent self-disable
if isSelf && !*req.Enabled {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot disable your own account"})
return
}
updates["enabled"] = *req.Enabled
if !*req.Enabled {
needsSessionInvalidation = true
}
}
// Wrap the last-admin checks and the actual update in a transaction to prevent
// race conditions: two concurrent requests could both read adminCount==2
// and both proceed, leaving zero admins.
err = h.DB.Transaction(func(tx *gorm.DB) error {
// Re-fetch user inside transaction for consistent state
if txErr := tx.First(&user, id).Error; txErr != nil {
return txErr
}
// Last-admin protection for role demotion
if newRoleStr, ok := updates["role"]; ok {
newRole := models.UserRole(newRoleStr.(string))
if user.Role == models.RoleAdmin && newRole != models.RoleAdmin {
var adminCount int64
// Policy: count only enabled admins. This is stricter than "WHERE role = ?"
// because a disabled admin cannot act; treating them as non-existent
// prevents leaving the system with only disabled admins.
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
if adminCount <= 1 {
return fmt.Errorf("cannot demote the last admin")
}
}
}
// Last-admin protection for disabling
if enabledVal, ok := updates["enabled"]; ok {
if enabled, isBool := enabledVal.(bool); isBool && !enabled {
if user.Role == models.RoleAdmin {
var adminCount int64
// Policy: count only enabled admins (same rationale as above).
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
if adminCount <= 1 {
return fmt.Errorf("cannot disable the last admin")
}
}
}
}
if len(updates) > 0 {
if txErr := tx.Model(&user).Updates(updates).Error; txErr != nil {
return txErr
}
}
return nil
})
if err != nil {
errMsg := err.Error()
if errMsg == "cannot demote the last admin" || errMsg == "cannot disable the last admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot" + errMsg[len("cannot"):]})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}
if len(updates) > 0 {
if err := h.DB.Model(&user).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
if needsSessionInvalidation && h.AuthService != nil {
if invErr := h.AuthService.InvalidateSessions(user.ID); invErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to invalidate sessions"})
return
}
}
h.logUserAudit(c, "user_update", &user, map[string]any{
@@ -751,13 +911,12 @@ func mapsKeys(values map[string]any) []string {
// DeleteUser deletes a user (admin only).
func (h *UserHandler) DeleteUser(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
currentUserID, _ := c.Get("userID")
currentUserIDRaw, _ := c.Get("userID")
currentUserID, _ := currentUserIDRaw.(uint)
idParam := c.Param("id")
id, err := strconv.ParseUint(idParam, 10, 32)
@@ -767,7 +926,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) {
}
// Prevent self-deletion
if uint(id) == currentUserID.(uint) {
if uint(id) == currentUserID {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete your own account"})
return
}
@@ -805,9 +964,7 @@ type UpdateUserPermissionsRequest struct {
// ResendInvite regenerates and resends an invitation to a pending user (admin only).
func (h *UserHandler) ResendInvite(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}
@@ -862,21 +1019,35 @@ 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")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
if !requireAdmin(c) {
return
}

View File

@@ -23,7 +23,7 @@ func setupUserCoverageDB(t *testing.T) *gorm.DB {
func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Drop table to cause error
_ = db.Migrator().DropTable(&models.User{})
@@ -40,7 +40,7 @@ func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Drop table to cause error
_ = db.Migrator().DropTable(&models.User{})
@@ -57,10 +57,10 @@ func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Create a user to mark setup as complete
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: "admin"}
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: models.RoleAdmin}
_ = user.SetPassword("password123")
db.Create(user)
@@ -76,7 +76,7 @@ func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -91,7 +91,7 @@ func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -105,7 +105,7 @@ func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Drop table to cause error
_ = db.Migrator().DropTable(&models.User{})
@@ -123,7 +123,7 @@ func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -137,7 +137,7 @@ func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
func TestUserHandler_GetProfile_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -152,7 +152,7 @@ func TestUserHandler_GetProfile_NotFound(t *testing.T) {
func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -166,7 +166,7 @@ func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -182,7 +182,7 @@ func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
body, _ := json.Marshal(map[string]string{
"name": "Updated",
@@ -203,14 +203,14 @@ func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
// Create two users
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: "admin", APIKey: "key1"}
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: models.RoleAdmin, APIKey: "key1"}
_ = user1.SetPassword("password123")
db.Create(user1)
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: "admin", APIKey: "key2"}
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: models.RoleAdmin, APIKey: "key2"}
_ = user2.SetPassword("password123")
db.Create(user2)
@@ -236,9 +236,9 @@ func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
_ = user.SetPassword("password123")
db.Create(user)
@@ -263,9 +263,9 @@ func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
func TestUserHandler_UpdateProfile_WrongPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupUserCoverageDB(t)
h := NewUserHandler(db)
h := NewUserHandler(db, nil)
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
_ = user.SetPassword("password123")
db.Create(user)

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ func TestUserLoginAfterEmailChange(t *testing.T) {
cfg := config.Config{}
authService := services.NewAuthService(db, cfg)
authHandler := NewAuthHandler(authService)
userHandler := NewUserHandler(db)
userHandler := NewUserHandler(db, nil)
// Setup Router
gin.SetMode(gin.TestMode)

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
@@ -37,7 +38,7 @@ func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
}
c.Set("userID", user.ID)
c.Set("role", user.Role)
c.Set("role", string(user.Role))
c.Next()
}
}
@@ -95,15 +96,15 @@ func extractAuthCookieToken(c *gin.Context) string {
return token
}
func RequireRole(role string) gin.HandlerFunc {
func RequireRole(role models.UserRole) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, exists := c.Get("role")
if !exists {
userRole := c.GetString("role")
if userRole == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if userRole.(string) != role && userRole.(string) != "admin" {
if userRole != string(role) && userRole != string(models.RoleAdmin) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
return
}
@@ -111,3 +112,14 @@ func RequireRole(role string) gin.HandlerFunc {
c.Next()
}
}
func RequireManagementAccess() gin.HandlerFunc {
return func(c *gin.Context) {
role := c.GetString("role")
if role == string(models.RolePassthrough) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Pass-through users cannot access management features"})
return
}
c.Next()
}
}

View File

@@ -427,3 +427,61 @@ func TestExtractAuthCookieToken_IgnoresNonAuthCookies(t *testing.T) {
token := extractAuthCookieToken(ctx)
assert.Equal(t, "", token)
}
func TestRequireManagementAccess_PassthroughBlocked(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", string(models.RolePassthrough))
c.Next()
})
r.Use(RequireManagementAccess())
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "Pass-through users cannot access management features")
}
func TestRequireManagementAccess_UserAllowed(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", string(models.RoleUser))
c.Next()
})
r.Use(RequireManagementAccess())
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestRequireManagementAccess_AdminAllowed(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", string(models.RoleAdmin))
c.Next()
})
r.Use(RequireManagementAccess())
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}

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