Compare commits

...

33 Commits

Author SHA1 Message Date
GitHub Actions
3fe592926d chore: update electron-to-chromium version to 1.5.342 in package-lock.json 2026-04-22 00:23:17 +00:00
GitHub Actions
5bcf3069c6 chore: ensure both coverage output directories are created in frontend test coverage script 2026-04-22 00:21:53 +00:00
GitHub Actions
6546130518 chore: update QA report with detailed gate status and revalidation results 2026-04-22 00:13:35 +00:00
GitHub Actions
07108cfa8d chore: refactor frontend test coverage script to improve directory handling and cleanup 2026-04-22 00:13:35 +00:00
GitHub Actions
de945c358b chore: update coverage reports directory configuration in vitest 2026-04-22 00:13:35 +00:00
GitHub Actions
e5c7b85f82 chore: enhance accessibility tests by adding route readiness checks 2026-04-22 00:13:35 +00:00
GitHub Actions
6e06cc3396 chore: update security test paths in Playwright configuration 2026-04-22 00:13:35 +00:00
GitHub Actions
7e3b5b13b4 chore: update @tailwindcss packages to version 4.2.4 and tapable to version 2.3.3 2026-04-22 00:13:35 +00:00
GitHub Actions
91ba53476c chore: update QA/Security DoD Audit Report with latest findings and gate statuses 2026-04-22 00:13:35 +00:00
GitHub Actions
442425a4a5 chore: update version to v0.27.0 2026-04-22 00:13:35 +00:00
GitHub Actions
71fe278e33 chore: update Docker client initialization and container listing logic 2026-04-22 00:13:35 +00:00
GitHub Actions
468af25887 chore: add lefthook and backend test output files to .gitignore 2026-04-22 00:13:35 +00:00
GitHub Actions
d437de1ccf chore: add new output files to .gitignore for scan and coverage results 2026-04-22 00:13:35 +00:00
GitHub Actions
8c56f40131 chore: remove unused libc entries and clean up dependencies in package-lock.json 2026-04-22 00:13:35 +00:00
GitHub Actions
2bf4f869ab chore: update vulnerability suppression and documentation for CVE-2026-34040 in .grype.yaml, .trivyignore, and SECURITY.md 2026-04-22 00:13:35 +00:00
GitHub Actions
dd698afa7e chore: update go.mod and go.sum to remove unused dependencies and add new ones 2026-04-22 00:13:35 +00:00
GitHub Actions
5db3f7046c chore: add accessibility test suite documentation and baseline expiration dates 2026-04-22 00:13:35 +00:00
GitHub Actions
b59a788101 chore: include accessibility scans in non-security CI shards
Add automated accessibility suite execution to the standard non-security
end-to-end browser shards so regressions are caught during routine CI runs.

This change is necessary to enforce accessibility checks consistently across
Chromium, Firefox, and WebKit without creating a separate pipeline path.

Behavior impact:
- Non-security shard jobs now run accessibility tests alongside existing suites
- Security-specific job behavior remains unchanged
- Sharding logic remains unchanged, with only test scope expanded

Operational consideration:
- Monitor shard runtime balance after rollout; if sustained skew appears,
  split accessibility coverage into its own sharded workflow stage.
2026-04-22 00:13:35 +00:00
GitHub Actions
e7460f7e50 chore: update accessibility baseline and enhance loading waits for a11y tests 2026-04-22 00:13:35 +00:00
GitHub Actions
1e1727faa1 chore: add accessibility tests for domains, notifications, setup, and tasks pages 2026-04-22 00:13:35 +00:00
GitHub Actions
0c87c350e5 chore: add accessibility tests for security and uptime pages 2026-04-22 00:13:35 +00:00
GitHub Actions
03101012b9 chore: add accessibility tests for various pages including certificates, dashboard, dns providers, login, proxy hosts, and settings 2026-04-22 00:13:35 +00:00
GitHub Actions
5f855ea779 chore: add accessibility testing support with @axe-core/playwright and related utilities 2026-04-22 00:13:35 +00:00
GitHub Actions
a74d10d138 doc: Integrate @axe-core/playwright for Automated Accessibility Testing
Co-authored-by: Copilot <copilot@github.com>
2026-04-22 00:13:35 +00:00
Jeremy
515a95aaf1 Merge pull request #968 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-04-21 20:08:35 -04:00
renovate[bot]
1bcb4de6f8 fix(deps): update non-major-updates 2026-04-21 22:49:48 +00:00
Jeremy
07764db43e Merge pull request #966 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update non-major-updates (feature/beta-release)
2026-04-21 09:12:51 -04:00
renovate[bot]
54f32c03d0 chore(deps): update non-major-updates 2026-04-21 12:38:30 +00:00
Jeremy
c983250327 Merge pull request #965 from Wikid82/development
Propagate changes from development into feature/beta-release
2026-04-20 20:57:07 -04:00
Jeremy
2308f372d7 Merge pull request #964 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-04-20 17:56:55 -04:00
Jeremy
d68001b949 Merge pull request #963 from Wikid82/main
Propagate changes from main into development
2026-04-20 17:56:25 -04:00
renovate[bot]
96f0be19a4 fix(deps): update non-major-updates 2026-04-20 21:45:50 +00:00
Jeremy
c1470eaac0 Merge pull request #961 from Wikid82/development
Propagate changes from development into feature/beta-release
2026-04-20 12:37:40 -04:00
41 changed files with 2707 additions and 8007 deletions

View File

@@ -541,7 +541,7 @@ jobs:
format: 'table' format: 'table'
severity: 'CRITICAL,HIGH' severity: 'CRITICAL,HIGH'
exit-code: '0' exit-code: '0'
version: 'v0.69.3' version: 'v0.70.0'
continue-on-error: true continue-on-error: true
- name: Run Trivy vulnerability scanner (SARIF) - name: Run Trivy vulnerability scanner (SARIF)
@@ -553,7 +553,7 @@ jobs:
format: 'sarif' format: 'sarif'
output: 'trivy-results.sarif' output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH' severity: 'CRITICAL,HIGH'
version: 'v0.69.3' version: 'v0.70.0'
continue-on-error: true continue-on-error: true
- name: Check Trivy SARIF exists - name: Check Trivy SARIF exists
@@ -701,7 +701,7 @@ jobs:
format: 'table' format: 'table'
severity: 'CRITICAL,HIGH' severity: 'CRITICAL,HIGH'
exit-code: '0' exit-code: '0'
version: 'v0.69.3' version: 'v0.70.0'
- name: Run Trivy scan on PR image (SARIF - blocking) - name: Run Trivy scan on PR image (SARIF - blocking)
id: trivy-scan id: trivy-scan
@@ -712,7 +712,7 @@ jobs:
output: 'trivy-pr-results.sarif' output: 'trivy-pr-results.sarif'
severity: 'CRITICAL,HIGH' severity: 'CRITICAL,HIGH'
exit-code: '1' # Intended to block, but continued on error for now exit-code: '1' # Intended to block, but continued on error for now
version: 'v0.69.3' version: 'v0.70.0'
continue-on-error: true continue-on-error: true
- name: Check Trivy PR SARIF exists - name: Check Trivy PR SARIF exists

View File

@@ -980,6 +980,7 @@ jobs:
--project=chromium \ --project=chromium \
--shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \
--output=playwright-output/chromium-shard-${{ matrix.shard }} \ --output=playwright-output/chromium-shard-${{ matrix.shard }} \
tests/a11y \
tests/core \ tests/core \
tests/dns-provider-crud.spec.ts \ tests/dns-provider-crud.spec.ts \
tests/dns-provider-types.spec.ts \ tests/dns-provider-types.spec.ts \
@@ -1225,6 +1226,7 @@ jobs:
--project=firefox \ --project=firefox \
--shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \
--output=playwright-output/firefox-shard-${{ matrix.shard }} \ --output=playwright-output/firefox-shard-${{ matrix.shard }} \
tests/a11y \
tests/core \ tests/core \
tests/dns-provider-crud.spec.ts \ tests/dns-provider-crud.spec.ts \
tests/dns-provider-types.spec.ts \ tests/dns-provider-types.spec.ts \
@@ -1470,6 +1472,7 @@ jobs:
--project=webkit \ --project=webkit \
--shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \
--output=playwright-output/webkit-shard-${{ matrix.shard }} \ --output=playwright-output/webkit-shard-${{ matrix.shard }} \
tests/a11y \
tests/core \ tests/core \
tests/dns-provider-crud.spec.ts \ tests/dns-provider-crud.spec.ts \
tests/dns-provider-types.spec.ts \ tests/dns-provider-types.spec.ts \

View File

@@ -464,7 +464,7 @@ jobs:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ needs.build-and-push-nightly.outputs.digest }} image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ needs.build-and-push-nightly.outputs.digest }}
format: 'sarif' format: 'sarif'
output: 'trivy-nightly.sarif' output: 'trivy-nightly.sarif'
version: 'v0.69.3' version: 'v0.70.0'
trivyignores: '.trivyignore' trivyignores: '.trivyignore'
- name: Upload Trivy results - name: Upload Trivy results

View File

@@ -102,7 +102,7 @@ jobs:
format: 'table' format: 'table'
severity: 'CRITICAL,HIGH' severity: 'CRITICAL,HIGH'
exit-code: '1' # Fail workflow if vulnerabilities found exit-code: '1' # Fail workflow if vulnerabilities found
version: 'v0.69.3' version: 'v0.70.0'
continue-on-error: true continue-on-error: true
- name: Run Trivy vulnerability scanner (SARIF) - name: Run Trivy vulnerability scanner (SARIF)
@@ -113,7 +113,7 @@ jobs:
format: 'sarif' format: 'sarif'
output: 'trivy-weekly-results.sarif' output: 'trivy-weekly-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM' severity: 'CRITICAL,HIGH,MEDIUM'
version: 'v0.69.3' version: 'v0.70.0'
- name: Upload Trivy results to GitHub Security - name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
@@ -127,7 +127,7 @@ jobs:
format: 'json' format: 'json'
output: 'trivy-weekly-results.json' output: 'trivy-weekly-results.json'
severity: 'CRITICAL,HIGH,MEDIUM,LOW' severity: 'CRITICAL,HIGH,MEDIUM,LOW'
version: 'v0.69.3' version: 'v0.70.0'
- name: Upload Trivy JSON results - name: Upload Trivy JSON results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7

5
.gitignore vendored
View File

@@ -318,3 +318,8 @@ test_output.txt
coverage_results.txt coverage_results.txt
final-results.json final-results.json
new-results.json new-results.json
scan_output.json
coverage_output.txt
frontend/lint_output.txt
lefthook_out.txt
backend/test_out.txt

View File

@@ -483,73 +483,6 @@ ignore:
# 4. If not yet migrated: Extend expiry by 30 days and update the review comment above # 4. If not yet migrated: Extend expiry by 30 days and update the review comment above
# 5. If extended 3+ times: Open an upstream issue on crowdsecurity/crowdsec requesting pgx/v5 migration # 5. If extended 3+ times: Open an upstream issue on crowdsecurity/crowdsec requesting pgx/v5 migration
# GHSA-x744-4wpc-v9h2 / CVE-2026-34040: Docker AuthZ plugin bypass via oversized request body
# Severity: HIGH (CVSS 8.8)
# CVSS Vector: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
# CWE: CWE-863 (Incorrect Authorization)
# Package: github.com/docker/docker v28.5.2+incompatible (go-module)
# Status: Fixed in moby/moby v29.3.1 — NO fix available for docker/docker import path
#
# Vulnerability Details:
# - Incomplete fix for Docker AuthZ plugin bypass (CVE-2024-41110). An attacker can send an
# oversized request body to the Docker daemon, causing it to forward the request to the AuthZ
# plugin without the body, allowing unauthorized approvals.
#
# Root Cause (No Fix Available for Import Path):
# - The fix exists in moby/moby v29.3.1, but not for the docker/docker import path that Charon uses.
# - Migration to moby/moby/v2 is not practical: currently beta with breaking changes.
# - Fix path: once docker/docker publishes a patched version or moby/moby/v2 stabilizes,
# update the dependency and remove this suppression.
#
# Risk Assessment: ACCEPTED (Not exploitable in Charon context)
# - Charon uses the Docker client SDK only (list containers). The vulnerability is server-side
# in the Docker daemon's AuthZ plugin handler.
# - Charon does not run a Docker daemon or use AuthZ plugins.
# - The attack vector requires local access to the Docker daemon socket with AuthZ plugins enabled.
#
# Mitigation (active while suppression is in effect):
# - Monitor docker/docker releases: https://github.com/moby/moby/releases
# - Monitor moby/moby/v2 stabilization: https://github.com/moby/moby
# - Weekly CI security rebuild flags the moment a fixed version ships.
#
# Review:
# - Reviewed 2026-03-30 (initial suppression): no fix for docker/docker import path. Set 30-day review.
# - Next review: 2026-04-30. Remove suppression once a fix is available for the docker/docker import path.
#
# Removal Criteria:
# - docker/docker publishes a patched version OR moby/moby/v2 stabilizes and migration is feasible
# - Update dependency, rebuild, run security-scan-docker-image, confirm finding is resolved
# - Remove this entry, the GHSA-pxq6-2prw-chj9 entry, and the corresponding .trivyignore entries simultaneously
#
# References:
# - GHSA-x744-4wpc-v9h2: https://github.com/advisories/GHSA-x744-4wpc-v9h2
# - CVE-2026-34040: https://nvd.nist.gov/vuln/detail/CVE-2026-34040
# - CVE-2024-41110 (original): https://nvd.nist.gov/vuln/detail/CVE-2024-41110
# - moby/moby releases: https://github.com/moby/moby/releases
- vulnerability: GHSA-x744-4wpc-v9h2
package:
name: github.com/docker/docker
version: "v28.5.2+incompatible"
type: go-module
reason: |
HIGH — Docker AuthZ plugin bypass via oversized request body in docker/docker v28.5.2+incompatible.
Incomplete fix for CVE-2024-41110. Fixed in moby/moby v29.3.1 but no fix for docker/docker import path.
Charon uses Docker client SDK only (list containers); the vulnerability is server-side in the Docker
daemon's AuthZ plugin handler. Charon does not run a Docker daemon or use AuthZ plugins.
Risk accepted; no remediation path until docker/docker publishes a fix or moby/moby/v2 stabilizes.
Reviewed 2026-03-30: no patched release available for docker/docker import path.
expiry: "2026-04-30" # 30-day review: no fix for docker/docker import path. Extend in 30-day increments with documented justification.
# Action items when this suppression expires:
# 1. Check docker/docker and moby/moby releases: https://github.com/moby/moby/releases
# 2. Check if moby/moby/v2 has stabilized: https://github.com/moby/moby
# 3. If a fix has shipped for docker/docker import path OR moby/moby/v2 is stable:
# a. Update the dependency and rebuild Docker image
# b. Run local security-scan-docker-image and confirm finding is resolved
# c. Remove this entry, GHSA-pxq6-2prw-chj9 entry, and all corresponding .trivyignore entries
# 4. If no fix yet: Extend expiry by 30 days and update the review comment above
# 5. If extended 3+ times: Open an issue to track moby/moby/v2 migration feasibility
# GHSA-pxq6-2prw-chj9 / CVE-2026-33997: Moby off-by-one error in plugin privilege validation # GHSA-pxq6-2prw-chj9 / CVE-2026-33997: Moby off-by-one error in plugin privilege validation
# Severity: MEDIUM (CVSS 6.8) # Severity: MEDIUM (CVSS 6.8)
# Package: github.com/docker/docker v28.5.2+incompatible (go-module) # Package: github.com/docker/docker v28.5.2+incompatible (go-module)
@@ -560,9 +493,9 @@ ignore:
# via crafted plugin configurations. # via crafted plugin configurations.
# #
# Root Cause (No Fix Available for Import Path): # Root Cause (No Fix Available for Import Path):
# - Same import path issue as GHSA-x744-4wpc-v9h2. The fix exists in moby/moby v29.3.1 but not # - Same import path issue as CVE-2026-34040. The fix exists in moby/moby v29.3.1 but not
# for the docker/docker import path that Charon uses. # for the docker/docker import path that Charon uses.
# - Fix path: same as GHSA-x744-4wpc-v9h2 — wait for docker/docker patch or moby/moby/v2 stabilization. # - Fix path: same dependency migration pattern as CVE-2026-34040 (if needed) or upstream fix.
# #
# Risk Assessment: ACCEPTED (Not exploitable in Charon context) # Risk Assessment: ACCEPTED (Not exploitable in Charon context)
# - Charon uses the Docker client SDK only (list containers). The vulnerability is in Docker's # - Charon uses the Docker client SDK only (list containers). The vulnerability is in Docker's
@@ -578,9 +511,9 @@ ignore:
# - Next review: 2026-04-30. Remove suppression once a fix is available for the docker/docker import path. # - Next review: 2026-04-30. Remove suppression once a fix is available for the docker/docker import path.
# #
# Removal Criteria: # Removal Criteria:
# - Same as GHSA-x744-4wpc-v9h2: docker/docker publishes a patched version OR moby/moby/v2 stabilizes # - docker/docker publishes a patched version OR moby/moby/v2 stabilizes
# - Update dependency, rebuild, run security-scan-docker-image, confirm finding is resolved # - Update dependency, rebuild, run security-scan-docker-image, confirm finding is resolved
# - Remove this entry, GHSA-x744-4wpc-v9h2 entry, and all corresponding .trivyignore entries simultaneously # - Remove this entry and all corresponding .trivyignore entries simultaneously
# #
# References: # References:
# - GHSA-pxq6-2prw-chj9: https://github.com/advisories/GHSA-pxq6-2prw-chj9 # - GHSA-pxq6-2prw-chj9: https://github.com/advisories/GHSA-pxq6-2prw-chj9
@@ -606,7 +539,7 @@ ignore:
# 3. If a fix has shipped for docker/docker import path OR moby/moby/v2 is stable: # 3. If a fix has shipped for docker/docker import path OR moby/moby/v2 is stable:
# a. Update the dependency and rebuild Docker image # a. Update the dependency and rebuild Docker image
# b. Run local security-scan-docker-image and confirm finding is resolved # b. Run local security-scan-docker-image and confirm finding is resolved
# c. Remove this entry, GHSA-x744-4wpc-v9h2 entry, and all corresponding .trivyignore entries # c. Remove this entry and all corresponding .trivyignore entries
# 4. If no fix yet: Extend expiry by 30 days and update the review comment above # 4. If no fix yet: Extend expiry by 30 days and update the review comment above
# 5. If extended 3+ times: Open an issue to track moby/moby/v2 migration feasibility # 5. If extended 3+ times: Open an issue to track moby/moby/v2 migration feasibility

View File

@@ -87,23 +87,6 @@ GHSA-x6gf-mpr2-68h6
# exp: 2026-07-09 # exp: 2026-07-09
CVE-2026-32286 CVE-2026-32286
# CVE-2026-34040 / GHSA-x744-4wpc-v9h2: Docker AuthZ plugin bypass via oversized request body
# Severity: HIGH (CVSS 8.8) — Package: github.com/docker/docker v28.5.2+incompatible
# Incomplete fix for CVE-2024-41110. Fixed in moby/moby v29.3.1 but no fix for docker/docker import path.
# Charon uses Docker client SDK only (list containers); the vulnerability is server-side in the Docker daemon.
# Review by: 2026-04-30
# See also: .grype.yaml for full justification
# exp: 2026-04-30
CVE-2026-34040
# GHSA-x744-4wpc-v9h2: Docker AuthZ plugin bypass via oversized request body (GHSA alias)
# Severity: HIGH (CVSS 8.8) — Package: github.com/docker/docker v28.5.2+incompatible
# GHSA alias for CVE-2026-34040. See CVE-2026-34040 entry above for full details.
# Review by: 2026-04-30
# See also: .grype.yaml for full justification
# exp: 2026-04-30
GHSA-x744-4wpc-v9h2
# CVE-2026-33997 / GHSA-pxq6-2prw-chj9: Moby off-by-one error in plugin privilege validation # CVE-2026-33997 / GHSA-pxq6-2prw-chj9: Moby off-by-one error in plugin privilege validation
# Severity: MEDIUM (CVSS 6.8) — Package: github.com/docker/docker v28.5.2+incompatible # Severity: MEDIUM (CVSS 6.8) — Package: github.com/docker/docker v28.5.2+incompatible
# Fixed in moby/moby v29.3.1 but no fix for docker/docker import path. # Fixed in moby/moby v29.3.1 but no fix for docker/docker import path.

View File

@@ -1 +1 @@
v0.21.0 v0.27.0

View File

@@ -160,7 +160,7 @@ RUN set -eux; \
# Note: xx-go install puts binaries in /go/bin/TARGETOS_TARGETARCH/dlv if cross-compiling. # 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. # 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 # renovate: datasource=go depName=github.com/go-delve/delve
ARG DLV_VERSION=1.26.1 ARG DLV_VERSION=1.26.2
# hadolint ignore=DL3059,DL4006 # hadolint ignore=DL3059,DL4006
RUN CGO_ENABLED=0 xx-go install github.com/go-delve/delve/cmd/dlv@v${DLV_VERSION} && \ 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) && \ DLV_PATH=$(find /go/bin -name dlv -type f | head -n 1) && \

View File

@@ -27,7 +27,7 @@ public disclosure.
## Known Vulnerabilities ## Known Vulnerabilities
Last reviewed: 2026-04-09 Last reviewed: 2026-04-21
### [HIGH] CVE-2026-31790 · OpenSSL Vulnerability in Alpine Base Image ### [HIGH] CVE-2026-31790 · OpenSSL Vulnerability in Alpine Base Image
@@ -71,48 +71,6 @@ Dockerfile.
--- ---
### [HIGH] CVE-2026-34040 · Docker AuthZ Plugin Bypass via Oversized Request Body
| Field | Value |
|--------------|-------|
| **ID** | CVE-2026-34040 (GHSA-x744-4wpc-v9h2) |
| **Severity** | High · 8.8 |
| **Status** | Awaiting Upstream |
**What**
Docker Engine AuthZ plugins can be bypassed when an API request body exceeds a
certain size threshold. Charon uses the Docker client SDK only; this is a
server-side vulnerability in the Docker daemon's authorization plugin handler.
**Who**
- Discovered by: Automated scan (govulncheck, Grype)
- Reported: 2026-04-04
- Affects: Docker Engine daemon operators; Charon application is not directly vulnerable
**Where**
- Component: `github.com/docker/docker` v28.5.2+incompatible (Docker client SDK)
- Versions affected: Docker Engine < 29.3.1
**When**
- Discovered: 2026-04-04
- Disclosed (if public): Public
- Target fix: When moby/moby/v2 stabilizes or docker/docker import path is updated
**How**
The vulnerability requires an attacker to send oversized API request bodies to the
Docker daemon. Charon uses the Docker client SDK for container management operations
only and does not expose the Docker socket externally. The attack vector is limited
to the Docker daemon host, not the Charon application.
**Planned Remediation**
Monitor moby/moby/v2 module stabilization. The `docker/docker` import path has no
fix available. When a compatible module path exists, migrate the Docker SDK import.
---
### [HIGH] CVE-2026-2673 · OpenSSL TLS 1.3 Key Exchange Group Downgrade ### [HIGH] CVE-2026-2673 · OpenSSL TLS 1.3 Key Exchange Group Downgrade
| Field | Value | | Field | Value |
@@ -194,8 +152,8 @@ via the Docker client SDK. The attack requires a malicious Docker plugin to be
installed on the host, which is outside Charon's operational scope. installed on the host, which is outside Charon's operational scope.
**Planned Remediation** **Planned Remediation**
Same as CVE-2026-34040: monitor moby/moby/v2 module stabilization. No fix Monitor Moby advisory updates and verify scanner results against current modular
available for the current `docker/docker` import path. Moby dependency paths.
--- ---
@@ -239,6 +197,49 @@ Charon users is negligible since the vulnerable code path is not exercised.
## Patched Vulnerabilities ## Patched Vulnerabilities
### ✅ [HIGH] CVE-2026-34040 · Docker AuthZ Plugin Bypass via Oversized Request Body
| Field | Value |
|--------------|-------|
| **ID** | CVE-2026-34040 (GHSA-x744-4wpc-v9h2) |
| **Severity** | High · 8.8 |
| **Patched** | 2026-04-21 |
**What**
Docker Engine AuthZ plugins can be bypassed when an API request body exceeds a
certain size threshold. The previous Charon backend dependency path was
`github.com/docker/docker`.
**Who**
- Discovered by: Automated scan (govulncheck, Grype)
- Reported: 2026-04-04
**Where**
- Previous component: `github.com/docker/docker` v28.5.2+incompatible (Docker client SDK)
- Remediated component path: `github.com/moby/moby/client` with `github.com/moby/moby/api`
**When**
- Discovered: 2026-04-04
- Patched: 2026-04-21
- Time to patch: 17 days
**How**
The backend Docker service imports and module dependencies were migrated away from
the vulnerable monolith package path to modular Moby dependencies.
**Resolution**
Validation evidence after remediation:
- Backend: `go mod tidy`, `go test ./...`, and `go build ./cmd/api` passed.
- Trivy gate output did not include `CVE-2026-34040` or `GHSA-x744-4wpc-v9h2`.
- Docker image scan gate reported `0 Critical` and `0 High`, and did not include
`CVE-2026-34040` or `GHSA-x744-4wpc-v9h2`.
---
### ✅ [LOW] CVE-2026-26958 · edwards25519 MultiScalarMult Invalid Results ### ✅ [LOW] CVE-2026-26958 · edwards25519 MultiScalarMult Invalid Results
| Field | Value | | Field | Value |

View File

@@ -3,7 +3,6 @@ module github.com/Wikid82/charon/backend
go 1.26.2 go 1.26.2
require ( require (
github.com/docker/docker v28.5.2+incompatible
github.com/gin-contrib/gzip v1.2.6 github.com/gin-contrib/gzip v1.2.6
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
@@ -11,6 +10,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.42 github.com/mattn/go-sqlite3 v1.14.42
github.com/moby/moby/client v0.4.1
github.com/oschwald/geoip2-golang/v2 v2.1.0 github.com/oschwald/geoip2-golang/v2 v2.1.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
@@ -36,7 +36,6 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-connections v0.7.0 // indirect
@@ -61,18 +60,15 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.67.5 // indirect
@@ -87,7 +83,6 @@ require (
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect
@@ -95,7 +90,6 @@ require (
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
modernc.org/libc v1.72.0 // indirect modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

View File

@@ -1,5 +1,3 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -10,8 +8,6 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.1/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= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@@ -20,15 +16,11 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -77,8 +69,6 @@ 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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 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/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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -105,19 +95,15 @@ github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -132,8 +118,6 @@ github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEn
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8= github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -181,10 +165,6 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8V
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
@@ -193,8 +173,6 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 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/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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -219,12 +197,6 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -269,5 +241,7 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8= software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8=
software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

View File

@@ -13,8 +13,7 @@ import (
"syscall" "syscall"
"github.com/Wikid82/charon/backend/internal/logger" "github.com/Wikid82/charon/backend/internal/logger"
"github.com/docker/docker/api/types/container" "github.com/moby/moby/client"
"github.com/docker/docker/client"
) )
type DockerUnavailableError struct { type DockerUnavailableError struct {
@@ -86,7 +85,7 @@ func NewDockerService() *DockerService {
logger.Log().WithFields(map[string]any{"docker_host_env": envHost, "local_host": localHost}).Info("ignoring non-unix DOCKER_HOST for local docker mode") logger.Log().WithFields(map[string]any{"docker_host_env": envHost, "local_host": localHost}).Info("ignoring non-unix DOCKER_HOST for local docker mode")
} }
cli, err := client.NewClientWithOpts(client.WithHost(localHost), client.WithAPIVersionNegotiation()) cli, err := client.New(client.WithHost(localHost))
if err != nil { if err != nil {
logger.Log().WithError(err).Warn("Failed to initialize Docker client - Docker features will be unavailable") logger.Log().WithError(err).Warn("Failed to initialize Docker client - Docker features will be unavailable")
unavailableErr := NewDockerUnavailableError(err, buildLocalDockerUnavailableDetails(err, localHost)) unavailableErr := NewDockerUnavailableError(err, buildLocalDockerUnavailableDetails(err, localHost))
@@ -115,7 +114,7 @@ func (s *DockerService) ListContainers(ctx context.Context, host string) ([]Dock
if host == "" || host == "local" { if host == "" || host == "local" {
cli = s.client cli = s.client
} else { } else {
cli, err = client.NewClientWithOpts(client.WithHost(host), client.WithAPIVersionNegotiation()) cli, err = client.New(client.WithHost(host))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create remote client: %w", err) return nil, fmt.Errorf("failed to create remote client: %w", err)
} }
@@ -126,7 +125,7 @@ func (s *DockerService) ListContainers(ctx context.Context, host string) ([]Dock
}() }()
} }
containers, err := cli.ContainerList(ctx, container.ListOptions{All: false}) containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: false})
if err != nil { if err != nil {
if isDockerConnectivityError(err) { if isDockerConnectivityError(err) {
if host == "" || host == "local" { if host == "" || host == "local" {
@@ -138,14 +137,16 @@ func (s *DockerService) ListContainers(ctx context.Context, host string) ([]Dock
} }
var result []DockerContainer var result []DockerContainer
for _, c := range containers { for _, c := range containers.Items {
// Get the first network's IP address if available // Get the first network's IP address if available
networkName := "" networkName := ""
ipAddress := "" ipAddress := ""
if c.NetworkSettings != nil && len(c.NetworkSettings.Networks) > 0 { if c.NetworkSettings != nil && len(c.NetworkSettings.Networks) > 0 {
for name, net := range c.NetworkSettings.Networks { for name, net := range c.NetworkSettings.Networks {
networkName = name networkName = name
ipAddress = net.IPAddress if net != nil && net.IPAddress.IsValid() {
ipAddress = net.IPAddress.String()
}
break // Just take the first one for now break // Just take the first one for now
} }
} }
@@ -166,11 +167,16 @@ func (s *DockerService) ListContainers(ctx context.Context, host string) ([]Dock
}) })
} }
shortID := c.ID
if len(shortID) > 12 {
shortID = shortID[:12]
}
result = append(result, DockerContainer{ result = append(result, DockerContainer{
ID: c.ID[:12], // Short ID ID: shortID,
Names: names, Names: names,
Image: c.Image, Image: c.Image,
State: c.State, State: string(c.State),
Status: c.Status, Status: c.Status,
Network: networkName, Network: networkName,
IP: ipAddress, IP: ipAddress,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,897 @@
# CrowdSec IP Whitelist Management — Implementation Plan
**Issue**: [#939 — CrowdSec IP Whitelist Management](https://github.com/owner/Charon/issues/939)
**Date**: 2026-05-20
**Status**: Draft — Awaiting Approval
**Priority**: High
**Archived Previous Plan**: Coverage Improvement Plan (patch coverage ≥ 90%) → `docs/plans/archive/patch-coverage-improvement-plan-2026-05-02.md`
---
## 1. Introduction
### 1.1 Overview
CrowdSec enforces IP ban decisions by default. Operators need a way to permanently exempt known-good IPs (uptime monitors, internal subnets, VPN exits, partners) from ever being banned. CrowdSec handles this through its `whitelists` parser, which intercepts alert evaluation and suppresses bans for matching IPs/CIDRs before decisions are even written.
This feature gives Charon operators a first-class UI for managing those whitelist entries: add an IP or CIDR, give it a reason, and have Charon persist it in the database, render the required YAML parser file into the CrowdSec config tree, and signal CrowdSec to reload—all without manual file editing.
### 1.2 Objectives
- Allow operators to add, view, and remove CrowdSec whitelist entries (IPs and CIDRs) through the Charon management UI.
- Persist entries in SQLite so they survive container restarts.
- Generate a `crowdsecurity/whitelists`-compatible YAML parser file on every mutating operation and on startup.
- Automatically install the `crowdsecurity/whitelists` hub parser so CrowdSec can process the file.
- Show the Whitelist tab only when CrowdSec is in `local` mode, consistent with other CrowdSec-only tabs.
---
## 2. Research Findings
### 2.1 Existing CrowdSec Architecture
| Component | Location | Notes |
|---|---|---|
| Hub parser installer | `configs/crowdsec/install_hub_items.sh` | Run at container start; uses `cscli parsers install --force` |
| CrowdSec handler | `backend/internal/api/handlers/crowdsec_handler.go` | ~2750 LOC; `RegisterRoutes` at L2704 |
| Route registration | `backend/internal/api/routes/routes.go` | `crowdsecHandler.RegisterRoutes(management)` at ~L620 |
| CrowdSec startup | `backend/internal/services/crowdsec_startup.go` | `ReconcileCrowdSecOnStartup()` runs before process start |
| Security config | `backend/internal/models/security_config.go` | `CrowdSecMode`, `CrowdSecConfigDir` (via `cfg.Security.CrowdSecConfigDir`) |
| IP/CIDR helper | `backend/internal/security/whitelist.go` | `IsIPInCIDRList()` using `net.ParseIP` / `net.ParseCIDR` |
| AutoMigrate | `routes.go` ~L95125 | `&models.ManualChallenge{}` is currently the last entry |
### 2.2 Gap Analysis
- `crowdsecurity/whitelists` hub parser is **not** installed by `install_hub_items.sh` — the YAML file would be ignored by CrowdSec without it.
- No `CrowdSecWhitelist` model exists in `backend/internal/models/`.
- No whitelist service, handler methods, or API routes exist.
- No frontend tab, API client functions, or TanStack Query hooks exist.
- No E2E test spec covers whitelist management.
### 2.3 Relevant Patterns
**Model pattern** (from `access_list.go` + `security_config.go`):
```go
type Model struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
// domain fields
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
**Service pattern** (from `access_list_service.go`):
```go
var ErrXxxNotFound = errors.New("xxx not found")
type XxxService struct { db *gorm.DB }
func NewXxxService(db *gorm.DB) *XxxService { return &XxxService{db: db} }
```
**Handler error response pattern** (from `crowdsec_handler.go`):
```go
c.JSON(http.StatusBadRequest, gin.H{"error": "..."})
c.JSON(http.StatusNotFound, gin.H{"error": "..."})
c.JSON(http.StatusInternalServerError, gin.H{"error": "..."})
```
**Frontend API client pattern** (from `frontend/src/api/crowdsec.ts`):
```typescript
export const listXxx = async (): Promise<XxxEntry[]> => {
const resp = await client.get<XxxEntry[]>('/admin/crowdsec/xxx')
return resp.data
}
```
**Frontend mutation pattern** (from `CrowdSecConfig.tsx`):
```typescript
const mutation = useMutation({
mutationFn: (data) => apiCall(data),
onSuccess: () => {
toast.success('...')
queryClient.invalidateQueries({ queryKey: ['crowdsec-whitelist'] })
},
onError: (err) => toast.error(err instanceof Error ? err.message : '...'),
})
```
### 2.4 CrowdSec Whitelist YAML Format
CrowdSec's `crowdsecurity/whitelists` parser expects the following YAML structure at a path under the `parsers/s02-enrich/` directory:
```yaml
name: charon-whitelist
description: "Charon-managed IP/CIDR whitelist"
filter: "evt.Meta.service == 'http'"
whitelist:
reason: "Charon managed whitelist"
ip:
- "1.2.3.4"
cidr:
- "10.0.0.0/8"
- "192.168.0.0/16"
```
For an empty whitelist, both `ip` and `cidr` must be present as empty lists (not omitted) to produce valid YAML that CrowdSec can parse without error.
---
## 3. Technical Specifications
### 3.1 Database Schema
**New model**: `backend/internal/models/crowdsec_whitelist.go`
```go
package models
import "time"
// CrowdSecWhitelist represents a single IP or CIDR exempted from CrowdSec banning.
type CrowdSecWhitelist struct {
ID uint `json:"-" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
IPOrCIDR string `json:"ip_or_cidr" gorm:"not null;uniqueIndex"`
Reason string `json:"reason" gorm:"not null;default:''"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
**AutoMigrate registration** (`backend/internal/api/routes/routes.go`, append after `&models.ManualChallenge{}`):
```go
&models.CrowdSecWhitelist{},
```
### 3.2 API Design
All new endpoints live under the existing `/api/v1` prefix and are registered inside `CrowdsecHandler.RegisterRoutes(rg *gin.RouterGroup)`, following the same `rg.METHOD("/admin/crowdsec/...")` naming pattern as every other CrowdSec endpoint.
#### Endpoint Table
| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/api/v1/admin/crowdsec/whitelist` | Management | List all whitelist entries |
| `POST` | `/api/v1/admin/crowdsec/whitelist` | Management | Add a new entry |
| `DELETE` | `/api/v1/admin/crowdsec/whitelist/:uuid` | Management | Remove an entry by UUID |
#### `GET /admin/crowdsec/whitelist`
**Response 200**:
```json
{
"whitelist": [
{
"uuid": "a1b2c3d4-...",
"ip_or_cidr": "10.0.0.0/8",
"reason": "Internal subnet",
"created_at": "2026-05-20T12:00:00Z",
"updated_at": "2026-05-20T12:00:00Z"
}
]
}
```
#### `POST /admin/crowdsec/whitelist`
**Request body**:
```json
{ "ip_or_cidr": "10.0.0.0/8", "reason": "Internal subnet" }
```
**Response 201**:
```json
{
"uuid": "a1b2c3d4-...",
"ip_or_cidr": "10.0.0.0/8",
"reason": "Internal subnet",
"created_at": "...",
"updated_at": "..."
}
```
**Error responses**:
- `400` — missing/invalid `ip_or_cidr` field, unparseable IP/CIDR
- `409` — duplicate entry (same `ip_or_cidr` already exists)
- `500` — database or YAML write failure
#### `DELETE /admin/crowdsec/whitelist/:uuid`
**Response 204** — no body
**Error responses**:
- `404` — entry not found
- `500` — database or YAML write failure
### 3.3 Service Design
**New file**: `backend/internal/services/crowdsec_whitelist_service.go`
```go
package services
import (
"context"
"errors"
"net"
"os"
"path/filepath"
"text/template"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/yourusername/charon/backend/internal/models"
"github.com/yourusername/charon/backend/internal/logger"
)
var (
ErrWhitelistNotFound = errors.New("whitelist entry not found")
ErrInvalidIPOrCIDR = errors.New("invalid IP address or CIDR notation")
ErrDuplicateEntry = errors.New("whitelist entry already exists")
)
type CrowdSecWhitelistService struct {
db *gorm.DB
dataDir string
}
func NewCrowdSecWhitelistService(db *gorm.DB, dataDir string) *CrowdSecWhitelistService {
return &CrowdSecWhitelistService{db: db, dataDir: dataDir}
}
// List returns all whitelist entries ordered by creation time.
func (s *CrowdSecWhitelistService) List(ctx context.Context) ([]models.CrowdSecWhitelist, error) { ... }
// Add validates, persists, and regenerates the YAML file.
func (s *CrowdSecWhitelistService) Add(ctx context.Context, ipOrCIDR, reason string) (*models.CrowdSecWhitelist, error) { ... }
// Delete removes an entry by UUID and regenerates the YAML file.
func (s *CrowdSecWhitelistService) Delete(ctx context.Context, uuid string) error { ... }
// WriteYAML renders all current entries to <dataDir>/parsers/s02-enrich/charon-whitelist.yaml
func (s *CrowdSecWhitelistService) WriteYAML(ctx context.Context) error { ... }
```
**Validation logic** in `Add()`:
1. Trim whitespace from `ipOrCIDR`.
2. Attempt `net.ParseIP(ipOrCIDR)` — if non-nil, it's a bare IP ✓
3. Attempt `net.ParseCIDR(ipOrCIDR)` — if `err == nil`, it's a valid CIDR ✓; normalize host bits immediately: `ipOrCIDR = network.String()` (e.g., `"10.0.0.1/8"` → `"10.0.0.0/8"`).
4. If both fail → return `ErrInvalidIPOrCIDR`
5. Attempt DB insert; if GORM unique constraint error → return `ErrDuplicateEntry`
6. On success → call `WriteYAML(ctx)` (non-fatal on YAML error: log + return original entry)
> **Note**: `Add()` and `Delete()` do **not** call `cscli hub reload`. Reload is the caller's responsibility (handled in `CrowdsecHandler.AddWhitelist` and `DeleteWhitelist` via `h.CmdExec`).
**CIDR normalization snippet** (step 3):
```go
if ip, network, err := net.ParseCIDR(ipOrCIDR); err == nil {
_ = ip
ipOrCIDR = network.String() // normalizes "10.0.0.1/8" → "10.0.0.0/8"
}
```
**YAML generation** in `WriteYAML()`:
Guard: if `s.dataDir == ""`, return `nil` immediately (no-op — used in unit tests that don't need file I/O).
```go
const whitelistTmpl = `name: charon-whitelist
description: "Charon-managed IP/CIDR whitelist"
filter: "evt.Meta.service == 'http'"
whitelist:
reason: "Charon managed whitelist"
ip:
{{- range .IPs}}
- "{{.}}"
{{- end}}
{{- if not .IPs}}
[]
{{- end}}
cidr:
{{- range .CIDRs}}
- "{{.}}"
{{- end}}
{{- if not .CIDRs}}
[]
{{- end}}
`
```
Target file path: `<dataDir>/config/parsers/s02-enrich/charon-whitelist.yaml`
Directory created with `os.MkdirAll(..., 0o750)` if absent.
File written atomically: render to `<path>.tmp` → `os.Rename(tmp, path)`.
### 3.4 Handler Design
**Additions to `CrowdsecHandler` struct**:
```go
type CrowdsecHandler struct {
// ... existing fields ...
WhitelistSvc *services.CrowdSecWhitelistService // NEW
}
```
**`NewCrowdsecHandler` constructor** — initialize `WhitelistSvc`:
```go
h := &CrowdsecHandler{
// ... existing assignments ...
}
if db != nil {
h.WhitelistSvc = services.NewCrowdSecWhitelistService(db, dataDir)
}
return h
```
**Three new methods on `CrowdsecHandler`**:
```go
// ListWhitelists handles GET /admin/crowdsec/whitelist
func (h *CrowdsecHandler) ListWhitelists(c *gin.Context) {
entries, err := h.WhitelistSvc.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list whitelist entries"})
return
}
c.JSON(http.StatusOK, gin.H{"whitelist": entries})
}
// AddWhitelist handles POST /admin/crowdsec/whitelist
func (h *CrowdsecHandler) AddWhitelist(c *gin.Context) {
var req struct {
IPOrCIDR string `json:"ip_or_cidr" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ip_or_cidr is required"})
return
}
entry, err := h.WhitelistSvc.Add(c.Request.Context(), req.IPOrCIDR, req.Reason)
if errors.Is(err, services.ErrInvalidIPOrCIDR) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errors.Is(err, services.ErrDuplicateEntry) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add whitelist entry"})
return
}
// Reload CrowdSec so the new entry takes effect immediately (non-fatal).
if reloadErr := h.CmdExec.Execute("cscli", "hub", "reload"); reloadErr != nil {
logger.Log().WithError(reloadErr).Warn("failed to reload CrowdSec after whitelist add (non-fatal)")
}
c.JSON(http.StatusCreated, entry)
}
// DeleteWhitelist handles DELETE /admin/crowdsec/whitelist/:uuid
func (h *CrowdsecHandler) DeleteWhitelist(c *gin.Context) {
id := strings.TrimSpace(c.Param("uuid"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "uuid required"})
return
}
err := h.WhitelistSvc.Delete(c.Request.Context(), id)
if errors.Is(err, services.ErrWhitelistNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "whitelist entry not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete whitelist entry"})
return
}
// Reload CrowdSec so the removed entry is no longer exempt (non-fatal).
if reloadErr := h.CmdExec.Execute("cscli", "hub", "reload"); reloadErr != nil {
logger.Log().WithError(reloadErr).Warn("failed to reload CrowdSec after whitelist delete (non-fatal)")
}
c.Status(http.StatusNoContent)
}
```
**Route registration** (append inside `RegisterRoutes`, after existing decision/bouncer routes):
```go
// Whitelist management
rg.GET("/admin/crowdsec/whitelist", h.ListWhitelists)
rg.POST("/admin/crowdsec/whitelist", h.AddWhitelist)
rg.DELETE("/admin/crowdsec/whitelist/:uuid", h.DeleteWhitelist)
```
### 3.5 Startup Integration
**File**: `backend/internal/services/crowdsec_startup.go`
In `ReconcileCrowdSecOnStartup()`, before the CrowdSec process is started:
```go
// Regenerate whitelist YAML to ensure it reflects the current DB state.
whitelistSvc := NewCrowdSecWhitelistService(db, dataDir)
if err := whitelistSvc.WriteYAML(ctx); err != nil {
logger.Log().WithError(err).Warn("failed to write CrowdSec whitelist YAML on startup (non-fatal)")
}
```
This is **non-fatal**: if the DB has no entries, WriteYAML still writes an empty whitelist file, which is valid.
### 3.6 Hub Parser Installation
**File**: `configs/crowdsec/install_hub_items.sh`
Add after the existing `cscli parsers install` lines:
```bash
cscli parsers install crowdsecurity/whitelists --force || echo "⚠️ Failed to install crowdsecurity/whitelists"
```
### 3.7 Frontend Design
#### API Client (`frontend/src/api/crowdsec.ts`)
Append the following types and functions:
```typescript
export interface CrowdSecWhitelistEntry {
uuid: string
ip_or_cidr: string
reason: string
created_at: string
updated_at: string
}
export interface AddWhitelistPayload {
ip_or_cidr: string
reason: string
}
export const listWhitelists = async (): Promise<CrowdSecWhitelistEntry[]> => {
const resp = await client.get<{ whitelist: CrowdSecWhitelistEntry[] }>('/admin/crowdsec/whitelist')
return resp.data.whitelist
}
export const addWhitelist = async (data: AddWhitelistPayload): Promise<CrowdSecWhitelistEntry> => {
const resp = await client.post<CrowdSecWhitelistEntry>('/admin/crowdsec/whitelist', data)
return resp.data
}
export const deleteWhitelist = async (uuid: string): Promise<void> => {
await client.delete(`/admin/crowdsec/whitelist/${uuid}`)
}
```
#### TanStack Query Hooks (`frontend/src/hooks/useCrowdSecWhitelist.ts`)
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { listWhitelists, addWhitelist, deleteWhitelist, AddWhitelistPayload } from '../api/crowdsec'
import { toast } from 'sonner'
export const useWhitelistEntries = () =>
useQuery({
queryKey: ['crowdsec-whitelist'],
queryFn: listWhitelists,
})
export const useAddWhitelist = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: AddWhitelistPayload) => addWhitelist(data),
onSuccess: () => {
toast.success('Whitelist entry added')
queryClient.invalidateQueries({ queryKey: ['crowdsec-whitelist'] })
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Failed to add whitelist entry')
},
})
}
export const useDeleteWhitelist = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (uuid: string) => deleteWhitelist(uuid),
onSuccess: () => {
toast.success('Whitelist entry removed')
queryClient.invalidateQueries({ queryKey: ['crowdsec-whitelist'] })
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : 'Failed to remove whitelist entry')
},
})
}
```
#### CrowdSecConfig.tsx Changes
The `CrowdSecConfig.tsx` page uses a tab navigation pattern. The new "Whitelist" tab:
1. **Visibility**: Only render the tab when `isLocalMode === true` (same guard as Decisions tab).
2. **Tab value**: `"whitelist"` — append to the existing tab list.
3. **Tab panel content** (isolated component or inline JSX):
- **Add entry form**: `ip_or_cidr` text input + `reason` text input + "Add" button (disabled while `addMutation.isPending`). Validation error shown inline when backend returns 400/409.
- **Quick-add current IP**: A secondary "Add My IP" button that calls `GET /api/v1/system/my-ip` (existing endpoint) and pre-fills the `ip_or_cidr` field with the returned IP.
- **Entries table**: Columns — IP/CIDR, Reason, Added, Actions. Each row has a delete button with a confirmation dialog (matching the ban/unban modal pattern used for Decisions).
- **Empty state**: "No whitelist entries" message when the list is empty.
- **Loading state**: Skeleton rows while `useWhitelistEntries` is fetching.
**Imports added to `CrowdSecConfig.tsx`**:
```typescript
import { useWhitelistEntries, useAddWhitelist, useDeleteWhitelist } from '../hooks/useCrowdSecWhitelist'
```
### 3.8 Data Flow Diagram
```
Operator adds IP in UI
POST /api/v1/admin/crowdsec/whitelist
CrowdsecHandler.AddWhitelist()
CrowdSecWhitelistService.Add()
├── Validate IP/CIDR (net.ParseIP / net.ParseCIDR)
├── Normalize CIDR host bits (network.String())
├── Insert into SQLite (models.CrowdSecWhitelist)
└── WriteYAML() → <dataDir>/config/parsers/s02-enrich/charon-whitelist.yaml
h.CmdExec.Execute("cscli", "hub", "reload") [non-fatal on error]
Return 201 to frontend
invalidateQueries(['crowdsec-whitelist'])
Table re-fetches and shows new entry
```
```
Container restart
ReconcileCrowdSecOnStartup()
CrowdSecWhitelistService.WriteYAML()
└── Reads all DB entries → renders YAML
CrowdSec process starts
CrowdSec loads parsers/s02-enrich/charon-whitelist.yaml
└── crowdsecurity/whitelists parser activates
IPs/CIDRs in file are exempt from all ban decisions
```
### 3.9 Error Handling Matrix
| Scenario | Service Error | HTTP Status | Frontend Behavior |
|---|---|---|---|
| Blank `ip_or_cidr` | — | 400 | Inline validation (required field) |
| Malformed IP/CIDR | `ErrInvalidIPOrCIDR` | 400 | Toast: "Invalid IP address or CIDR notation" |
| Duplicate entry | `ErrDuplicateEntry` | 409 | Toast: "This IP/CIDR is already whitelisted" |
| DB unavailable | generic error | 500 | Toast: "Failed to add whitelist entry" |
| UUID not found on DELETE | `ErrWhitelistNotFound` | 404 | Toast: "Whitelist entry not found" |
| YAML write failure | logged, non-fatal | 201 (Add still succeeds) | No user-facing error; log warning |
| CrowdSec reload failure | logged, non-fatal | 201/204 (operation still succeeds) | No user-facing error; log warning |
### 3.10 Security Considerations
- **Input validation**: All `ip_or_cidr` values are validated server-side with `net.ParseIP` / `net.ParseCIDR` before persisting. Arbitrary strings are rejected.
- **Path traversal**: `WriteYAML` constructs the output path via `filepath.Join(s.dataDir, "config", "parsers", "s02-enrich", "charon-whitelist.yaml")`. `dataDir` is set at startup—not user-supplied at request time.
- **Privilege**: All three endpoints require management-level access (same as all other CrowdSec endpoints).
- **YAML injection**: Values are rendered through Go's `text/template` with explicit quoting of each entry; no raw string concatenation.
- **Log safety**: IPs are logged using the same structured field pattern used in existing CrowdSec handler methods (e.g., `logger.Log().WithField("ip", entry.IPOrCIDR).Info(...)`).
---
## 4. Implementation Plan
### Phase 1 — Hub Parser Installation (Groundwork)
**Files Changed**:
- `configs/crowdsec/install_hub_items.sh`
**Task 1.1**: Add `cscli parsers install crowdsecurity/whitelists --force` after the last parser install line (currently `crowdsecurity/syslog-logs`).
**Acceptance**: File change is syntactically valid bash; `shellcheck` passes.
---
### Phase 2 — Database Model
**Files Changed**:
- `backend/internal/models/crowdsec_whitelist.go` _(new file)_
- `backend/internal/api/routes/routes.go` _(append to AutoMigrate call)_
**Task 2.1**: Create `crowdsec_whitelist.go` with the `CrowdSecWhitelist` struct per §3.1.
**Task 2.2**: Append `&models.CrowdSecWhitelist{}` to the `db.AutoMigrate(...)` call in `routes.go`.
**Validation Gate**: `go build ./backend/...` passes; GORM generates `crowdsec_whitelists` table on next startup.
---
### Phase 3 — Whitelist Service
**Files Changed**:
- `backend/internal/services/crowdsec_whitelist_service.go` _(new file)_
**Task 3.1**: Implement `CrowdSecWhitelistService` with `List`, `Add`, `Delete`, `WriteYAML` per §3.3.
**Task 3.2**: Implement IP/CIDR validation in `Add()`:
- `net.ParseIP(ipOrCIDR) != nil` → valid bare IP
- `net.ParseCIDR(ipOrCIDR)` returns no error → valid CIDR
- Both fail → `ErrInvalidIPOrCIDR`
**Task 3.3**: Implement `WriteYAML()`:
- Query all entries from DB.
- Partition into `ips` (bare IPs) and `cidrs` (CIDR notation) slices.
- Render template per §2.4.
- Atomic write: temp file → `os.Rename`.
- Create directory (`os.MkdirAll`) if not present.
**Validation Gate**: `go test ./backend/internal/services/... -run TestCrowdSecWhitelist` passes.
---
### Phase 4 — API Endpoints
**Files Changed**:
- `backend/internal/api/handlers/crowdsec_handler.go`
**Task 4.1**: Add `WhitelistSvc *services.CrowdSecWhitelistService` field to `CrowdsecHandler` struct.
**Task 4.2**: Initialize `WhitelistSvc` in `NewCrowdsecHandler()` when `db != nil`.
**Task 4.3**: Implement `ListWhitelists`, `AddWhitelist`, `DeleteWhitelist` methods per §3.4.
**Task 4.4**: Register three routes in `RegisterRoutes()` per §3.4.
**Task 4.5**: In `AddWhitelist` and `DeleteWhitelist`, after the service call returns without error, call `h.CmdExec.Execute("cscli", "hub", "reload")`. Log a warning on failure; do not change the HTTP response status (reload failure is non-fatal).
**Validation Gate**: `go test ./backend/internal/api/handlers/... -run TestWhitelist` passes; `make lint-fast` clean.
---
### Phase 5 — Startup Integration
**Files Changed**:
- `backend/internal/services/crowdsec_startup.go`
**Task 5.1**: In `ReconcileCrowdSecOnStartup()`, after the DB and config are loaded but before calling `h.Executor.Start()`, instantiate `CrowdSecWhitelistService` and call `WriteYAML(ctx)`. Log warning on error; do not abort startup.
**Validation Gate**: `go test ./backend/internal/services/... -run TestReconcile` passes; existing reconcile tests still pass.
---
### Phase 6 — Frontend API + Hooks
**Files Changed**:
- `frontend/src/api/crowdsec.ts`
- `frontend/src/hooks/useCrowdSecWhitelist.ts` _(new file)_
**Task 6.1**: Add `CrowdSecWhitelistEntry`, `AddWhitelistPayload` types and `listWhitelists`, `addWhitelist`, `deleteWhitelist` functions to `crowdsec.ts` per §3.7.
**Task 6.2**: Create `useCrowdSecWhitelist.ts` with `useWhitelistEntries`, `useAddWhitelist`, `useDeleteWhitelist` hooks per §3.7.
**Validation Gate**: `pnpm test` (Vitest) passes; TypeScript compilation clean.
---
### Phase 7 — Frontend UI
**Files Changed**:
- `frontend/src/pages/CrowdSecConfig.tsx`
**Task 7.1**: Import the three hooks from `useCrowdSecWhitelist.ts`.
**Task 7.2**: Add `"whitelist"` to the tab list (visible only when `isLocalMode === true`).
**Task 7.3**: Implement the Whitelist tab panel:
- Add-entry form with IP/CIDR + Reason inputs.
- "Add My IP" button: `GET /api/v1/system/my-ip` → pre-fill `ip_or_cidr`.
- Entries table with UUID key, IP/CIDR, Reason, created date, delete button.
- Delete confirmation dialog (reuse existing modal pattern).
**Task 7.4**: Wire mutation errors to inline form validation messages (400/409 responses).
**Validation Gate**: `pnpm test` passes; TypeScript clean; `make lint-fast` clean.
---
### Phase 8 — Tests
**Files Changed**:
- `backend/internal/services/crowdsec_whitelist_service_test.go` _(new file)_
- `backend/internal/api/handlers/crowdsec_whitelist_handler_test.go` _(new file)_
- `tests/crowdsec-whitelist.spec.ts` _(new file)_
**Task 8.1 — Service unit tests**:
| Test | Scenario |
|---|---|
| `TestAdd_ValidIP_Success` | Bare IPv4 inserted; YAML file created |
| `TestAdd_ValidIPv6_Success` | Bare IPv6 inserted |
| `TestAdd_ValidCIDR_Success` | CIDR range inserted |
| `TestAdd_CIDRNormalization` | `"10.0.0.1/8"` stored as `"10.0.0.0/8"` |
| `TestAdd_InvalidIPOrCIDR_Error` | Returns `ErrInvalidIPOrCIDR` |
| `TestAdd_DuplicateEntry_Error` | Second identical insert returns `ErrDuplicateEntry` |
| `TestDelete_Success` | Entry removed; YAML regenerated |
| `TestDelete_NotFound_Error` | Returns `ErrWhitelistNotFound` |
| `TestList_Empty` | Returns empty slice |
| `TestList_Populated` | Returns all entries ordered by `created_at` |
| `TestWriteYAML_EmptyList` | Writes valid YAML with empty `ip: []` and `cidr: []` |
| `TestWriteYAML_MixedEntries` | IPs in `ip:` block; CIDRs in `cidr:` block |
| `TestWriteYAML_EmptyDataDir_NoOp` | `dataDir == ""` → returns `nil`, no file written |
**Task 8.2 — Handler unit tests** (using in-memory SQLite + `mockAuthMiddleware`):
| Test | Scenario |
|---|---|
| `TestListWhitelists_200` | Returns 200 with entries array |
| `TestAddWhitelist_201` | Valid payload → 201 |
| `TestAddWhitelist_400_MissingField` | Empty body → 400 |
| `TestAddWhitelist_400_InvalidIP` | Malformed IP → 400 |
| `TestAddWhitelist_409_Duplicate` | Duplicate → 409 |
| `TestDeleteWhitelist_204` | Valid UUID → 204 |
| `TestDeleteWhitelist_404` | Unknown UUID → 404 |
**Task 8.3 — E2E Playwright tests** (`tests/crowdsec-whitelist.spec.ts`):
```typescript
import { test, expect } from '@playwright/test'
test.describe('CrowdSec Whitelist Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:8080')
await page.getByRole('link', { name: 'Security' }).click()
await page.getByRole('tab', { name: 'CrowdSec' }).click()
await page.getByRole('tab', { name: 'Whitelist' }).click()
})
test('Whitelist tab only visible in local mode', async ({ page }) => {
await page.goto('http://localhost:8080')
await page.getByRole('link', { name: 'Security' }).click()
await page.getByRole('tab', { name: 'CrowdSec' }).click()
// When CrowdSec is not in local mode, the Whitelist tab must not exist
await expect(page.getByRole('tab', { name: 'Whitelist' })).toBeHidden()
})
test('displays empty state when no entries exist', async ({ page }) => {
await expect(page.getByText('No whitelist entries')).toBeVisible()
})
test('adds a valid IP address', async ({ page }) => {
await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('203.0.113.5')
await page.getByRole('textbox', { name: 'Reason' }).fill('Uptime monitor')
await page.getByRole('button', { name: 'Add' }).click()
await expect(page.getByText('Whitelist entry added')).toBeVisible()
await expect(page.getByRole('cell', { name: '203.0.113.5' })).toBeVisible()
})
test('adds a valid CIDR range', async ({ page }) => {
await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('10.0.0.0/8')
await page.getByRole('textbox', { name: 'Reason' }).fill('Internal subnet')
await page.getByRole('button', { name: 'Add' }).click()
await expect(page.getByText('Whitelist entry added')).toBeVisible()
await expect(page.getByRole('cell', { name: '10.0.0.0/8' })).toBeVisible()
})
test('"Add My IP" button pre-fills the detected client IP', async ({ page }) => {
await page.getByRole('button', { name: 'Add My IP' }).click()
const ipField = page.getByRole('textbox', { name: 'IP or CIDR' })
const value = await ipField.inputValue()
// Value must be a non-empty valid IP
expect(value).toMatch(/^[\d.]+$|^[0-9a-fA-F:]+$/)
})
test('shows validation error for invalid input', async ({ page }) => {
await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('not-an-ip')
await page.getByRole('button', { name: 'Add' }).click()
await expect(page.getByText('Invalid IP address or CIDR notation')).toBeVisible()
})
test('removes an entry via delete confirmation', async ({ page }) => {
// Seed an entry first
await page.getByRole('textbox', { name: 'IP or CIDR' }).fill('198.51.100.1')
await page.getByRole('button', { name: 'Add' }).click()
await expect(page.getByRole('cell', { name: '198.51.100.1' })).toBeVisible()
// Delete it
await page.getByRole('row', { name: /198\.51\.100\.1/ }).getByRole('button', { name: 'Delete' }).click()
await page.getByRole('button', { name: 'Confirm' }).click()
await expect(page.getByText('Whitelist entry removed')).toBeVisible()
await expect(page.getByRole('cell', { name: '198.51.100.1' })).toBeHidden()
})
})
```
---
### Phase 9 — Documentation
**Files Changed**:
- `ARCHITECTURE.md`
- `docs/features/crowdsec-whitelist.md` _(new file, optional for this PR)_
**Task 9.1**: Update the CrowdSec row in the Cerberus security components table in `ARCHITECTURE.md` to mention whitelist management.
---
## 5. Acceptance Criteria
### Functional
- [ ] Operator can add a bare IPv4 address (e.g., `203.0.113.5`) to the whitelist.
- [ ] Operator can add a bare IPv6 address (e.g., `2001:db8::1`) to the whitelist.
- [ ] Operator can add a CIDR range (e.g., `10.0.0.0/8`) to the whitelist.
- [ ] Adding an invalid IP/CIDR (e.g., `not-an-ip`) returns a 400 error with a clear message.
- [ ] Adding a duplicate entry returns a 409 conflict error.
- [ ] Operator can delete an entry; it disappears from the list.
- [ ] The Whitelist tab is only visible when CrowdSec is in `local` mode.
- [ ] After adding or deleting an entry, the whitelist YAML file is regenerated in `<dataDir>/config/parsers/s02-enrich/charon-whitelist.yaml`.
- [ ] Adding or removing a whitelist entry triggers `cscli hub reload` via `h.CmdExec` so changes take effect immediately without a container restart.
- [ ] On container restart, the YAML file is regenerated from DB entries before CrowdSec starts.
- [ ] **Admin IP protection**: The "Add My IP" button pre-fills the operator's current IP in the `ip_or_cidr` field; a Playwright E2E test verifies the button correctly pre-fills the detected client IP.
### Technical
- [ ] `go test ./backend/...` passes — no regressions.
- [ ] `pnpm test` (Vitest) passes.
- [ ] `make lint-fast` clean — no new lint findings.
- [ ] GORM Security Scanner returns zero CRITICAL/HIGH findings.
- [ ] Playwright E2E suite passes (Firefox, `--project=firefox`).
- [ ] `crowdsecurity/whitelists` parser is installed by `install_hub_items.sh`.
---
## 6. Commit Slicing Strategy
**Decision**: Single PR with ordered logical commits. No scope overlap between commits; each commit leaves the codebase in a compilable state.
**Trigger reasons**: Cross-domain change (infra script + model + service + handler + startup + frontend) benefits from ordered commits for surgical rollback and focused review.
| # | Type | Commit Message | Files | Depends On | Validation Gate |
|---|---|---|---|---|---|
| 1 | `chore` | `install crowdsecurity/whitelists parser by default` | `configs/crowdsec/install_hub_items.sh` | — | `shellcheck` |
| 2 | `feat` | `add CrowdSecWhitelist model and automigrate registration` | `backend/internal/models/crowdsec_whitelist.go`, `backend/internal/api/routes/routes.go` | #1 | `go build ./backend/...` |
| 3 | `feat` | `add CrowdSecWhitelistService with YAML generation` | `backend/internal/services/crowdsec_whitelist_service.go` | #2 | `go test ./backend/internal/services/...` |
| 4 | `feat` | `add whitelist API endpoints to CrowdsecHandler` | `backend/internal/api/handlers/crowdsec_handler.go` | #3 | `go test ./backend/...` + `make lint-fast` |
| 5 | `feat` | `regenerate whitelist YAML on CrowdSec startup reconcile` | `backend/internal/services/crowdsec_startup.go` | #3 | `go test ./backend/internal/services/...` |
| 6 | `feat` | `add whitelist API client functions and TanStack hooks` | `frontend/src/api/crowdsec.ts`, `frontend/src/hooks/useCrowdSecWhitelist.ts` | #4 | `pnpm test` |
| 7 | `feat` | `add Whitelist tab to CrowdSecConfig UI` | `frontend/src/pages/CrowdSecConfig.tsx` | #6 | `pnpm test` + `make lint-fast` |
| 8 | `test` | `add whitelist service and handler unit tests` | `*_test.go` files | #4 | `go test ./backend/...` |
| 9 | `test` | `add E2E tests for CrowdSec whitelist management` | `tests/crowdsec-whitelist.spec.ts` | #7 | Playwright Firefox |
| 10 | `docs` | `update architecture docs for CrowdSec whitelist feature` | `ARCHITECTURE.md` | #7 | `make lint-fast` |
**Rollback notes**:
- Commits 13 are pure additions (no existing code modified except the `AutoMigrate` list append in commit 2 and `install_hub_items.sh` in commit 1). Reverting them is safe.
- Commit 4 modifies `crowdsec_handler.go` by adding fields and methods without altering existing ones; reverting is mechanical.
- Commit 5 modifies `crowdsec_startup.go` — the added block is isolated in a clearly marked section; revert is a 5-line removal.
- Commits 67 are frontend-only; reverting has no backend impact.
---
## 7. Open Questions / Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| CrowdSec does not hot-reload parser files — requires `cscli reload` or process restart | Resolved | `cscli hub reload` is called via `h.CmdExec.Execute(...)` in `AddWhitelist` and `DeleteWhitelist` after each successful `WriteYAML()`. Failure is non-fatal; logged as a warning. |
| `crowdsecurity/whitelists` parser path may differ across CrowdSec versions | Low | Use `<dataDir>/config/parsers/s02-enrich/` which is the canonical path; add a note to verify on version upgrades |
| Large whitelist files could cause CrowdSec performance issues | Very Low | Reasonable for typical use; document a soft limit recommendation (< 500 entries) in the UI |
| `dataDir` empty string in tests | Resolved | Guard added to `WriteYAML`: `if s.dataDir == "" { return nil }` — no-op when `dataDir` is unset |
| `CROWDSEC_TRUSTED_IPS` env var seeding | — | **Follow-up / future enhancement** (not in scope for this PR): if `CROWDSEC_TRUSTED_IPS` is set at runtime, parse comma-separated IPs and include them as read-only seed entries in the generated YAML (separate from DB-managed entries). Document in a follow-up issue. |

View File

@@ -1,447 +1,131 @@
# QA Audit Report — Nightly Build Vulnerability Remediation # QA/Security DoD Audit Report — Issue #929
**Date**: 2026-04-09 Date: 2026-04-21
**Scope**: Dependency-only update — no feature or UI changes Repository: /projects/Charon
**Image Under Test**: `charon:vuln-fix` (built 2026-04-09 14:53 UTC, 632MB) Branch: feature/beta-release
**Branch**: Current working tree (pre-PR) Scope assessed: DoD revalidation after recent fixes (E2E-first, frontend coverage, pre-commit/version gate, SA1019, Trivy CVE check)
--- ## Final Recommendation
## Gate Results Summary FAIL
| # | Gate | Status | Details | Reason: Two mandatory gates are still failing in current rerun evidence:
|---|------|--------|---------| - Playwright E2E-first gate
| 1 | E2E Playwright (Firefox 4/4 shards + Chromium spot check) | PASS | 19 passed, 20 skipped (security suite), 0 failed | - Frontend coverage gate
| 2 | Backend Tests + Coverage | PASS | All tests pass, 88.2% statements / 88.4% lines (gate: 87%) |
| 3 | Frontend Tests + Coverage | PASS | 791 passed, 41 skipped, 89.38% stmts / 90.13% lines (gate: 87%) |
| 4 | Local Patch Coverage Report | PASS | 0 changed lines (dependency-only), 100% patch coverage |
| 5 | Frontend Type Check (tsc --noEmit) | PASS | Zero TypeScript errors |
| 6 | Pre-commit Hooks (lefthook) | PASS | All hooks passed (shellcheck, actionlint, dockerfile-check, YAML, EOF/whitespace) |
| 7a | Trivy Filesystem Scan (CRITICAL/HIGH) | PASS | 0 vulnerabilities in source |
| 7b | govulncheck (backend) | INFO | 2 findings — both `docker/docker` v28.5.2 with no upstream fix (pre-existing, documented in SECURITY.md) |
| 7c | Docker Image Scan (Grype) | PASS | 0 CRITICAL, 2 HIGH (both unfixed Alpine OpenSSL), all target CVEs resolved |
| 8 | Linting (make lint-fast) | PASS | 0 issues |
| 9 | GORM Security Scan (--check) | PASS | 0 CRITICAL, 0 HIGH, 2 INFO suggestions |
**Overall Status: PASS** Pre-commit/version-check is now passing.
--- ## Gate Summary
## Vulnerability Remediation Verification | # | DoD Gate | Status | Notes |
|---|---|---|---|
| 1 | Playwright E2E first | FAIL | Healthy container path confirmed (`charon-e2e Up ... (healthy)`), auth setup passes, but accessibility suite still has 1 failing test (security headers page axe timeout) |
| 2 | Frontend coverage | FAIL | `scripts/frontend-test-coverage.sh` still ends with unhandled `ENOENT` on `frontend/coverage/.tmp/coverage-132.json` |
| 3 | Pre-commit hooks + version check | PASS | `lefthook run pre-commit --all-files` passes; `check-version-match` passes (`.version` matches latest tag `v0.27.0`) |
| 4 | SA1019 reconfirmation | PASS | `golangci-lint run ./... --enable-only staticcheck` reports `0 issues`; no `SA1019` occurrences |
| 5 | Trivy FS status (CVE-2026-34040) | PASS (not detected) | Current FS scan (`trivy fs --scanners vuln .`) exits 0 with no CVE hit; `CVE-2026-34040` not present in available Trivy artifacts |
### Target CVEs — All Resolved ## Detailed Evidence
All CVEs identified in the spec (`docs/plans/current_spec.md`) were verified as absent from the `charon:vuln-fix` image: ### 1) Playwright E2E-first gate (revalidated)
| CVE / GHSA | Package | Was | Now | Status | Execution evidence:
|-----------|---------|-----|-----|--------| - Container health:
| CVE-2026-39883 | otel/sdk | v1.40.0 | v1.43.0 | Resolved | - `docker ps --filter name=charon-e2e --format '{{.Names}} {{.Status}}'`
| CVE-2026-34986 | go-jose/v3 | v3.0.4 | v3.0.5 | Resolved | - Output: `charon-e2e Up 35 minutes (healthy)`
| CVE-2026-34986 | go-jose/v4 | v4.1.3 | v4.1.4 | Resolved | - Auth setup:
| CVE-2026-32286 | pgproto3/v2 | v2.3.3 | Not detected | Resolved | - `PLAYWRIGHT_HTML_OPEN=never npx playwright test --project=firefox tests/auth.setup.ts -g "authenticate"`
| GHSA-xmrv-pmrh-hhx2 | AWS SDK v2 (multiple) | various | Patched | Resolved | - Result: `1 passed`
| CVE-2026-39882 | OTel HTTP exporters | v1.40.0v1.42.0 | v1.43.0 | Resolved | - Evidence: `Login successful`
| CVE-2026-32281/32288/32289 | Go stdlib | 1.26.1 | 1.26.2 | Resolved (via Dockerfile ARG) | - Accessibility rerun:
- `PLAYWRIGHT_HTML_OPEN=never npx playwright test --project=firefox -g "accessibility"`
- Result: `1 failed, 2 skipped, 64 passed`
- Failing test:
- `tests/a11y/security.a11y.spec.ts:21:5`
- `Accessibility: Security security headers page has no critical a11y violations`
- Failure detail: `Test timeout of 90000ms exceeded` during axe analyze step.
### Remaining Vulnerabilities in Docker Image (Pre-existing, Unfixed Upstream) Gate disposition: FAIL.
| Severity | CVE | Package | Version | Status | ### 2) Frontend coverage gate (revalidated)
|----------|-----|---------|---------|--------|
| HIGH | CVE-2026-31790 | libcrypto3, libssl3 | 3.5.5-r0 | Awaiting Alpine patch |
| Medium | CVE-2025-60876 | busybox | 1.37.0-r30 | Awaiting Alpine patch |
| Medium | GHSA-6jwv-w5xf-7j27 | go.etcd.io/bbolt | v1.4.3 | CrowdSec transitive dep |
| Unknown | CVE-2026-28387/28388/28389/28390/31789 | libcrypto3, libssl3 | 3.5.5-r0 | Awaiting Alpine NVD scoring + patch |
**Note**: CVE-2026-31790 (HIGH, OpenSSL) is a **new finding** not previously documented in SECURITY.md. It affects the Alpine 3.23.3 base image and has no fix available. It is **not introduced by this PR** — it would be present in any image built on Alpine 3.23.3. Recommend adding to SECURITY.md known vulnerabilities section. Execution:
- `bash scripts/frontend-test-coverage.sh`
### govulncheck Findings (Backend Source — Pre-existing) Result:
- Coverage run still fails with unhandled rejection.
- Blocking error remains present:
- `Error: ENOENT: no such file or directory, open '/projects/Charon/frontend/coverage/.tmp/coverage-132.json'`
- Run summary before abort:
- `Test Files 128 passed | 5 skipped (187)`
- `Tests 1918 passed | 90 skipped (2008)`
| ID | Module | Fixed In | Notes | Additional state:
|----|--------|----------|-------| - `frontend/coverage/lcov.info` and `frontend/coverage/coverage-summary.json` can exist despite gate failure, but command-level DoD gate remains FAIL due non-zero termination path from unhandled ENOENT.
| GO-2026-4887 (CVE-2026-34040) | docker/docker v28.5.2 | N/A | Already in SECURITY.md |
| GO-2026-4883 (CVE-2026-33997) | docker/docker v28.5.2 | N/A | Already in SECURITY.md |
--- Gate disposition: FAIL.
## Coverage Details ### 3) Pre-commit hooks + version-check gate (revalidated)
### Backend (Go) Execution:
- `lefthook run pre-commit --all-files`
- `bash ./scripts/check-version-match-tag.sh`
- Statement coverage: **88.2%** Result:
- Line coverage: **88.4%** - Pre-commit summary shows all required hooks completed successfully, including:
- Gate threshold: 87% — **PASSED** - `check-version-match`
- `golangci-lint-fast`
- `frontend-type-check`
- `frontend-lint`
- `semgrep`
- Version check output:
- `OK: .version matches latest Git tag v0.27.0`
### Frontend (React/TypeScript) Gate disposition: PASS.
- Statements: **89.38%** ### 4) SA1019 reconfirmation
- Branches: **81.86%**
- Functions: **86.71%**
- Lines: **90.13%**
- Gate threshold: 87% — **PASSED**
### Patch Coverage Execution:
- `cd backend && golangci-lint run ./... --enable-only staticcheck`
- Changed source lines: **0** (dependency-only update) Result:
- Patch coverage: **100%** - Output: `0 issues.`
- Additional grep for `SA1019`: no matches.
--- Conclusion: SA1019 remains resolved.
## E2E Test Details ### 5) Trivy FS reconfirmation for CVE-2026-34040
Tests executed against `charon:vuln-fix` container on `http://127.0.0.1:8080`: Execution:
- `trivy fs --scanners vuln .`
| Browser | Shards | Passed | Skipped | Failed | Result:
|---------|--------|--------|---------|--------| - Exit status: `0`
| Firefox | 4/4 | 11 | 20 | 0 | - Output indicates scan completed with:
| Chromium | 1/4 (spot) | 8 | 0 | 0 | - `Number of language-specific files num=0`
- CVE lookup:
- No `CVE-2026-34040` match found in available Trivy JSON artifacts (`vuln-results.json`, `trivy-image-report.json`).
Skipped tests are from the security suite (separate project configuration). No test failures observed. The full 3-browser suite will run in CI. Conclusion: CVE-2026-34040 not detected in current FS scan context.
--- ## Local Patch Report Artifact Check
## GORM Scanner Details Execution:
- `bash /projects/Charon/scripts/local-patch-report.sh`
- Scanned: 43 Go files (2401 lines) Result:
- CRITICAL: 0 - Generated successfully in warn mode.
- HIGH: 0 - Artifacts verified:
- MEDIUM: 0 - `/projects/Charon/test-results/local-patch-report.md`
- INFO: 2 (missing indexes on `UserPermittedHost` foreign keys — pre-existing, non-blocking) - `/projects/Charon/test-results/local-patch-report.json`
--- ## Blocking Issues
## Recommendations 1. Playwright E2E accessibility suite has one failing security headers test (axe timeout).
2. Frontend coverage command still fails with ENOENT under `frontend/coverage/.tmp`.
1. **Add CVE-2026-31790 to SECURITY.md** — New HIGH OpenSSL vulnerability in Alpine base image. No fix available. Monitor Alpine security advisories. ## Decision
2. **Monitor docker/docker module migration** — 2 govulncheck findings with no upstream fix. Track moby/moby/v2 stabilization.
3. **Monitor bbolt GHSA-6jwv-w5xf-7j27** — Medium severity in CrowdSec transitive dependency. Track CrowdSec updates.
4. **Full CI E2E suite** — Local validation passed on Firefox + Chromium spot check. The complete 3-browser suite should run in CI pipeline.
--- Overall DoD decision for Issue #929: FAIL
## Conclusion Promotion recommendation: keep blocked until both failing mandatory gates are green on rerun.
All audit gates **PASS**. The dependency-only changes successfully remediate all 5 HIGH and 3 MEDIUM vulnerability groups identified in the spec. No regressions detected in tests, type safety, linting, or security scans. The remaining HIGH finding (CVE-2026-31790) is a pre-existing Alpine base image issue unrelated to this PR.
**Verdict: Clear to merge.**
# QA Security Audit Report
| Field | Value |
|-------------|--------------------------------|
| **Date** | 2026-03-24 |
| **Image** | `charon:local` (Alpine 3.23.3) |
| **Go** | 1.26.1 |
| **Grype** | 0.110.0 |
| **Trivy** | 0.69.1 |
| **CodeQL** | Latest (SARIF v2.1.0) |
---
## Executive Summary
The current `charon:local` image built on 2026-03-24 shows a significantly improved
security posture compared to the CI baseline. Three previously tracked SECURITY.md
vulnerabilities are now **resolved** due to Go 1.26.1 compilation and Alpine package
updates. Two new medium/low findings emerged. No CRITICAL or HIGH active
vulnerabilities remain in the unignored scan results.
| Category | Critical | High | Medium | Low | Total |
|------------------------|----------|------|--------|-----|-------|
| **Active (unignored)** | 0 | 0 | 4 | 2 | 6 |
| **Ignored (documented)**| 0 | 4 | 0 | 0 | 4 |
| **Resolved since last audit** | 1 | 4 | 1 | 0 | 6 |
---
## Scans Executed
| # | Scan | Tool | Result |
|---|-------------------------------|-----------|----------------------|
| 1 | Trivy Filesystem | Trivy | 0 findings (no lang-specific files detected) |
| 2 | Docker Image (SBOM + Grype) | Syft/Grype| 6 active, 8 ignored |
| 3 | Trivy Image Report | Trivy | 1 HIGH (stale Feb 25 report; resolved in current build) |
| 4 | CodeQL Go | CodeQL | 1 finding (false positive — see below) |
| 5 | CodeQL JavaScript | CodeQL | 0 findings |
| 6 | GORM Security Scanner | Custom | PASSED (0 issues, 2 info) |
| 7 | Lefthook / Pre-commit | Lefthook | Configured (project uses `lefthook.yml`, not `.pre-commit-config.yaml`) |
---
## Active Findings (Unignored)
### CVE-2025-60876 — BusyBox wget HTTP Request Smuggling
| Field | Value |
|------------------|-------|
| **Severity** | Medium (CVSS 6.5) |
| **Package** | `busybox` 1.37.0-r30 (Alpine APK) |
| **Affected** | `busybox`, `busybox-binsh`, `busybox-extras`, `ssl_client` (4 matches) |
| **Fix Available** | No |
| **Classification** | AWAITING UPSTREAM |
| **EPSS** | 0.00064 (0.20 percentile) |
**Description**: BusyBox wget through 1.37 accepts raw CR/LF and other C0 control bytes
in the HTTP request-target, allowing request line splitting and header injection (CWE-284).
**Risk Assessment**: Low practical risk. Charon does not invoke `busybox wget` in its
application logic. The vulnerable `wget` applet would need to be manually invoked inside
the container with attacker-controlled URLs.
**Remediation**: Monitor Alpine 3.23 for a patched `busybox` APK. No action required
until upstream ships a fix.
---
### CVE-2026-26958 / GHSA-fw7p-63qq-7hpr — edwards25519 MultiScalarMult Invalid Results
| Field | Value |
|------------------|-------|
| **Severity** | Low (CVSS 1.7) |
| **Package** | `filippo.io/edwards25519` v1.1.0 |
| **Location** | CrowdSec binaries (`/usr/local/bin/crowdsec`, `/usr/local/bin/cscli`) |
| **Fix Available** | v1.1.1 |
| **Classification** | AWAITING UPSTREAM |
| **EPSS** | 0.00018 (0.04 percentile) |
**Description**: `MultiScalarMult` produces invalid results or undefined behavior if
the receiver is not the identity point. This is a rarely used, advanced API.
**Risk Assessment**: Minimal. CrowdSec does not directly expose edwards25519
`MultiScalarMult` to external input. The fix exists at v1.1.1 but requires CrowdSec
to rebuild with the updated dependency.
**Remediation**: Awaiting CrowdSec upstream release with updated dependency. No
action available for Charon maintainers.
---
## Ignored Findings (Documented with Justification)
These findings are suppressed in the Grype configuration with documented risk
acceptance rationale. All are in third-party binaries bundled in the container;
none are in Charon's own code.
### CVE-2026-2673 — OpenSSL TLS 1.3 Key Exchange Group Downgrade
| Field | Value |
|------------------|-------|
| **Severity** | High (CVSS 7.5) |
| **Package** | `libcrypto3` / `libssl3` 3.5.5-r0 |
| **Matches** | 2 (libcrypto3, libssl3) |
| **Classification** | ALREADY DOCUMENTED · AWAITING UPSTREAM |
Charon terminates TLS at the Caddy layer; the Go backend does not act as a raw
TLS 1.3 server. Alpine 3.23 still ships 3.5.5-r0. Risk accepted pending Alpine patch.
---
### GHSA-6g7g-w4f8-9c9x — DoS in buger/jsonparser (CrowdSec)
| Field | Value |
|------------------|-------|
| **Severity** | High (CVSS 7.5) |
| **Package** | `github.com/buger/jsonparser` v1.1.1 |
| **Matches** | 2 (crowdsec, cscli binaries) |
| **Fix Available** | v1.1.2 |
| **Classification** | ALREADY DOCUMENTED · AWAITING UPSTREAM |
Charon does not use this package directly. The vector requires reaching CrowdSec's
internal JSON processing pipeline. Risk accepted pending CrowdSec upstream fix.
---
### GHSA-jqcq-xjh3-6g23 / GHSA-x6gf-mpr2-68h6 / CVE-2026-4427 — DoS in pgproto3/v2 (CrowdSec)
| Field | Value |
|------------------|-------|
| **Severity** | High (CVSS 7.5) |
| **Package** | `github.com/jackc/pgproto3/v2` v2.3.3 |
| **Matches** | 4 (2 GHSAs × 2 binaries) |
| **Fix Available** | No (v2 is archived/EOL) |
| **Classification** | ALREADY DOCUMENTED · AWAITING UPSTREAM |
pgproto3/v2 is archived with no fix planned. CrowdSec must migrate to pgx/v5.
Charon uses SQLite, not PostgreSQL; this code path is unreachable in standard
deployment.
---
## Resolved Findings (Since Last SECURITY.md Update)
The following vulnerabilities documented in SECURITY.md are no longer detected in the
current image build. **SECURITY.md should be updated to move these to "Patched
Vulnerabilities".**
### CVE-2025-68121 — Go Stdlib Critical in CrowdSec (RESOLVED)
| Field | Value |
|------------------|-------|
| **Previous Severity** | Critical |
| **Resolution** | CrowdSec binaries now compiled with Go 1.26.1 (was Go 1.25.6) |
| **Verified** | Not detected in Grype scan of current image |
---
### CHARON-2025-001 — CrowdSec Go Stdlib CVE Cluster (RESOLVED)
| Field | Value |
|------------------|-------|
| **Previous Severity** | High |
| **Aliases** | CVE-2025-58183, CVE-2025-58186, CVE-2025-58187, CVE-2025-61729, CVE-2026-25679, CVE-2025-61732, CVE-2026-27142, CVE-2026-27139 |
| **Resolution** | CrowdSec binaries now compiled with Go 1.26.1 |
| **Verified** | None of the aliased CVEs detected in Grype scan |
---
### CVE-2026-27171 — zlib CPU Exhaustion (RESOLVED)
| Field | Value |
|------------------|-------|
| **Previous Severity** | Medium |
| **Resolution** | Alpine now ships `zlib` 1.3.2-r0 (fix threshold: 1.3.2) |
| **Verified** | Not detected in Grype scan; zlib 1.3.2-r0 confirmed in SBOM |
---
### CVE-2026-33186 — gRPC-Go Authorization Bypass (RESOLVED)
| Field | Value |
|------------------|-------|
| **Previous Severity** | Critical |
| **Packages** | `google.golang.org/grpc` v1.74.2 (CrowdSec), v1.79.1 (Caddy) |
| **Resolution** | Upstream releases now include patched gRPC (>= v1.79.3) |
| **Verified** | Not detected in Grype scan; ignore rule present but no match |
---
### GHSA-69x3-g4r3-p962 / CVE-2026-25793 — Nebula ECDSA Malleability (RESOLVED)
| Field | Value |
|------------------|-------|
| **Previous Severity** | High |
| **Package** | `github.com/slackhq/nebula` v1.9.7 in Caddy |
| **Resolution** | Caddy now ships with nebula >= v1.10.3 |
| **Verified** | Not detected in Grype scan; Trivy image report from Feb 25 had this but current build does not |
> **Note**: The stale Trivy image report (`trivy-image-report.json`, dated 2026-02-25) still
> shows CVE-2026-25793. This report predates the current build and should be regenerated.
---
### GHSA-479m-364c-43vc — goxmldsig XML Signature Bypass (RESOLVED)
| Field | Value |
|------------------|-------|
| **Previous Severity** | High |
| **Package** | `github.com/russellhaering/goxmldsig` v1.5.0 in Caddy |
| **Resolution** | Caddy now ships with goxmldsig >= v1.6.0 |
| **Verified** | Not detected in Grype scan; ignore rule present but no match |
---
## CodeQL Analysis
### go/cookie-secure-not-set — FALSE POSITIVE
| Field | Value |
|------------------|-------|
| **Severity** | Medium (CodeQL) |
| **File** | `backend/internal/api/handlers/auth_handler.go:152` |
| **Classification** | FALSE POSITIVE (stale SARIF) |
**Finding**: CodeQL reports "Cookie does not set Secure attribute to true" at line 152.
**Verification**: The `setSecureCookie` function at line 148-156 calls `c.SetCookie()`
with `secure: true` (6th positional argument). The Secure attribute IS set correctly.
This SARIF was generated from a previous code version and does not reflect the current
source. **The CodeQL SARIF files should be regenerated.**
### JavaScript / JS
No findings. Both `codeql-results-javascript.sarif` and `codeql-results-js.sarif` contain
0 results.
---
## GORM Security Scanner
| Metric | Value |
|------------|-------|
| **Result** | PASSED |
| **Files** | 43 Go files (2,396 lines) |
| **Critical** | 0 |
| **High** | 0 |
| **Medium** | 0 |
| **Info** | 2 (missing indexes on foreign keys in `UserPermittedHost`) |
The 2 informational suggestions (`UserID` and `ProxyHostID` missing `gorm:"index"` in
`backend/internal/models/user.go:130-131`) are performance recommendations, not security
issues. They do not block this audit.
---
## CI vs Local Scan Discrepancy
The CI reported **3 Critical, 5 High, 1 Medium**. The local scan on the freshly built
image reports **0 Critical, 0 High, 4 Medium, 2 Low** (active) plus **4 High** (ignored).
**Root causes for the discrepancy:**
1. **Resolved vulnerabilities**: 3 Critical and 4 High findings were resolved by Go 1.26.1
compilation and upstream Caddy/CrowdSec dependency updates since the CI image was built.
2. **Grype ignore rules**: The local scan applies documented risk acceptance rules that
suppress 4 High findings in third-party binaries. CI (Trivy) does not use these rules.
3. **Stale CI artifacts**: The `trivy-image-report.json` dates from 2026-02-25 and does
not reflect the current image state. The `codeql-results-go.sarif` references code that
has since been fixed.
---
## Recommended Actions
### Immediate (This Sprint)
1. **Update SECURITY.md**: Move CVE-2025-68121, CHARON-2025-001, and CVE-2026-27171 to
a "Patched Vulnerabilities" section. Add CVE-2025-60876 and CVE-2026-26958 as new
known vulnerabilities.
2. **Regenerate stale scan artifacts**: Re-run Trivy image scan and CodeQL analysis to
produce current SARIF/JSON files. The existing files predate fixes and produce
misleading CI results.
3. **Clean up Grype ignore rules**: Remove ignore entries for vulnerabilities that are
no longer detected (CVE-2026-33186, GHSA-69x3-g4r3-p962, GHSA-479m-364c-43vc).
Stale ignore rules obscure the actual security posture.
### Next Release
4. **Monitor Alpine APK updates**: Watch for patched `busybox` (CVE-2025-60876) and
`openssl` (CVE-2026-2673) packages in Alpine 3.23.
5. **Monitor CrowdSec releases**: Watch for CrowdSec builds with updated
`filippo.io/edwards25519` >= v1.1.1, `buger/jsonparser` >= v1.1.2, and
`pgx/v5` migration (replacing pgproto3/v2).
6. **Monitor Go 1.26.2-alpine**: When available, bump `GO_VERSION` to pick up any
remaining stdlib patches.
### Informational (Non-Blocking)
7. **GORM indexes**: Consider adding `gorm:"index"` to `UserID` and `ProxyHostID` in
`UserPermittedHost` for query performance.
---
## Gotify Token Review
Verified: No Gotify application tokens appear in scan output, log artifacts, test results,
API examples, or URL query parameters. All diagnostic output is clean.
---
## Conclusion
The Charon container image security posture has materially improved. Six previously known
vulnerabilities are now resolved through Go toolchain and dependency updates. The remaining
active findings are medium/low severity, reside in Alpine base packages and CrowdSec
third-party binaries, and have no available fixes. No vulnerabilities exist in Charon's
own application code. GORM and CodeQL scans confirm the backend code is clean.

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.99.2", "@tanstack/react-query": "^5.99.2",
"axios": "1.15.1", "axios": "1.15.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -43,10 +43,10 @@
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-hook-form": "^7.72.1", "react-hook-form": "^7.73.1",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.4", "react-i18next": "^17.0.4",
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.2",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tldts": "^7.0.28" "tldts": "^7.0.28"
@@ -57,7 +57,7 @@
"@eslint/json": "^1.2.0", "@eslint/json": "^1.2.0",
"@eslint/markdown": "^8.0.1", "@eslint/markdown": "^8.0.1",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.4",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -65,14 +65,14 @@
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.58.2", "@typescript-eslint/parser": "^8.59.0",
"@typescript-eslint/utils": "^8.58.2", "@typescript-eslint/utils": "^8.59.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-istanbul": "^4.1.4", "@vitest/coverage-istanbul": "^4.1.5",
"@vitest/coverage-v8": "^4.1.4", "@vitest/coverage-v8": "^4.1.5",
"@vitest/eslint-plugin": "^1.6.16", "@vitest/eslint-plugin": "^1.6.16",
"@vitest/ui": "^4.1.4", "@vitest/ui": "^4.1.5",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-import-resolver-typescript": "^4.4.4", "eslint-import-resolver-typescript": "^4.4.4",
@@ -89,13 +89,13 @@
"eslint-plugin-unicorn": "^64.0.0", "eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"jsdom": "29.0.2", "jsdom": "29.0.2",
"knip": "^6.5.0", "knip": "^6.6.0",
"postcss": "^8.5.10", "postcss": "^8.5.10",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.4",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.59.0",
"vite": "^8.0.9", "vite": "^8.0.9",
"vitest": "^4.1.4", "vitest": "^4.1.5",
"zod-validation-error": "^5.0.0" "zod-validation-error": "^5.0.0"
}, },
"overrides": { "overrides": {

View File

@@ -6,6 +6,7 @@ const coverageThresholdValue =
process.env.CHARON_MIN_COVERAGE ?? process.env.CPM_MIN_COVERAGE ?? '87.0' process.env.CHARON_MIN_COVERAGE ?? process.env.CPM_MIN_COVERAGE ?? '87.0'
const coverageThreshold = Number.parseFloat(coverageThresholdValue) const coverageThreshold = Number.parseFloat(coverageThresholdValue)
const resolvedCoverageThreshold = Number.isNaN(coverageThreshold) ? 87.0 : coverageThreshold const resolvedCoverageThreshold = Number.isNaN(coverageThreshold) ? 87.0 : coverageThreshold
const coverageReportsDirectory = process.env.VITEST_COVERAGE_REPORTS_DIR ?? './coverage'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
@@ -34,6 +35,7 @@ export default defineConfig({
coverage: { coverage: {
provider: 'v8', provider: 'v8',
clean: false, clean: false,
reportsDirectory: coverageReportsDirectory,
reporter: ['text', 'json', 'html', 'lcov', 'json-summary'], reporter: ['text', 'json', 'html', 'lcov', 'json-summary'],
exclude: [ exclude: [
'node_modules/', 'node_modules/',

View File

@@ -4,6 +4,7 @@ cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvj
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
@@ -18,6 +19,7 @@ github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfed
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
@@ -53,6 +55,7 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/oschwald/geoip2-golang/v2 v2.0.1 h1:YcYoG/L+gmSfk7AlToTmoL0JvblNyhGC8NyVhwDzzi8= github.com/oschwald/geoip2-golang/v2 v2.0.1 h1:YcYoG/L+gmSfk7AlToTmoL0JvblNyhGC8NyVhwDzzi8=
@@ -134,6 +137,8 @@ golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6f
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=

114
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"type-check": "^0.4.0" "type-check": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.2",
"@bgotink/playwright-coverage": "^0.3.2", "@bgotink/playwright-coverage": "^0.3.2",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/eslint-plugin-jsx-a11y": "^6.10.1",
@@ -21,7 +22,20 @@
"tar": "^7.5.13", "tar": "^7.5.13",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.9", "vite": "^8.0.9",
"vitest": "^4.1.4" "vitest": "^4.1.5"
}
},
"node_modules/@axe-core/playwright": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.2.tgz",
"integrity": "sha512-iP6hfNl9G0j/SEUSo8M7D80RbcDo9KRAAfDP4IT5OHB+Wm6zUHIrm8Y51BKI+Oyqduvipf9u1hcRy57zCBKzWQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.11.3"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
} }
}, },
"node_modules/@bcoe/v8-coverage": { "node_modules/@bcoe/v8-coverage": {
@@ -827,16 +841,16 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.4", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "4.1.4", "@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.4", "@vitest/utils": "4.1.5",
"chai": "^6.2.2", "chai": "^6.2.2",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@@ -845,13 +859,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "4.1.4", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "4.1.4", "@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.21" "magic-string": "^0.30.21"
}, },
@@ -872,9 +886,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "4.1.4", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -885,13 +899,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "4.1.4", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "4.1.4", "@vitest/utils": "4.1.5",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@@ -899,14 +913,14 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "4.1.4", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.4", "@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.4", "@vitest/utils": "4.1.5",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -915,9 +929,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "4.1.4", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -925,13 +939,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "4.1.4", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "4.1.4", "@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0" "tinyrainbow": "^3.1.0"
}, },
@@ -1024,6 +1038,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/axe-core": {
"version": "4.11.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz",
"integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -4206,19 +4230,19 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "4.1.4", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "4.1.4", "@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.4", "@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.4", "@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.4", "@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.4", "@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.4", "@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.4", "@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0", "es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0", "expect-type": "^1.3.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -4246,12 +4270,12 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.4", "@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.4", "@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.4", "@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.4", "@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.4", "@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.4", "@vitest/ui": "4.1.5",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*", "jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View File

@@ -18,6 +18,7 @@
"smol-toml": "^1.6.1" "smol-toml": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.2",
"@bgotink/playwright-coverage": "^0.3.2", "@bgotink/playwright-coverage": "^0.3.2",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/eslint-plugin-jsx-a11y": "^6.10.1",
@@ -29,6 +30,6 @@
"tar": "^7.5.13", "tar": "^7.5.13",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.9", "vite": "^8.0.9",
"vitest": "^4.1.4" "vitest": "^4.1.5"
} }
} }

View File

@@ -240,7 +240,7 @@ export default defineConfig({
testDir: './tests', testDir: './tests',
testMatch: [ testMatch: [
/security-enforcement\/.*\.spec\.(ts|js)/, /security-enforcement\/.*\.spec\.(ts|js)/,
/security\/.*\.spec\.(ts|js)/, /^tests\/security\/.*\.spec\.(ts|js)/,
], ],
dependencies: ['setup', 'security-shard-setup'], dependencies: ['setup', 'security-shard-setup'],
teardown: 'security-teardown', teardown: 'security-teardown',
@@ -275,7 +275,7 @@ export default defineConfig({
'**/node_modules/**', '**/node_modules/**',
'**/backend/**', '**/backend/**',
'**/security-enforcement/**', '**/security-enforcement/**',
'**/security/**', '**/tests/security/**',
], ],
}, },
@@ -292,7 +292,7 @@ export default defineConfig({
'**/node_modules/**', '**/node_modules/**',
'**/backend/**', '**/backend/**',
'**/security-enforcement/**', '**/security-enforcement/**',
'**/security/**', '**/tests/security/**',
], ],
}, },
@@ -309,7 +309,7 @@ export default defineConfig({
'**/node_modules/**', '**/node_modules/**',
'**/backend/**', '**/backend/**',
'**/security-enforcement/**', '**/security-enforcement/**',
'**/security/**', '**/tests/security/**',
], ],
}, },

View File

@@ -13,22 +13,34 @@ sleep 1
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FRONTEND_DIR="$ROOT_DIR/frontend" FRONTEND_DIR="$ROOT_DIR/frontend"
MIN_COVERAGE="${CHARON_MIN_COVERAGE:-${CPM_MIN_COVERAGE:-87}}" MIN_COVERAGE="${CHARON_MIN_COVERAGE:-${CPM_MIN_COVERAGE:-87}}"
CANONICAL_COVERAGE_DIR="coverage"
RUN_COVERAGE_DIR="coverage/.run-${PPID}-$$-$(date +%s)"
cd "$FRONTEND_DIR" cd "$FRONTEND_DIR"
# Ensure dependencies are installed for CI runs # Ensure dependencies are installed for CI runs
npm ci --silent npm ci --silent
# Ensure coverage output directories exist to avoid intermittent ENOENT errors # Ensure coverage output directories exist
mkdir -p coverage/.tmp mkdir -p "$CANONICAL_COVERAGE_DIR"
mkdir -p "$RUN_COVERAGE_DIR"
# Run tests with coverage and json-summary reporter (force istanbul provider) cleanup() {
# Using istanbul ensures json-summary and coverage-summary artifacts are produced rm -rf "$RUN_COVERAGE_DIR"
# so that downstream checks can parse them reliably. }
npm run test:coverage -- --run
SUMMARY_FILE="coverage/coverage-summary.json" trap cleanup EXIT
LCOV_FILE="coverage/lcov.info"
# Run tests with coverage in an isolated per-run reports directory to avoid
# collisions when multiple coverage processes execute against the same workspace.
VITEST_COVERAGE_REPORTS_DIR="$RUN_COVERAGE_DIR" npm run test:coverage -- --run
# Publish stable artifacts to the canonical coverage directory used by DoD checks.
cp "$RUN_COVERAGE_DIR/coverage-summary.json" "$CANONICAL_COVERAGE_DIR/coverage-summary.json"
cp "$RUN_COVERAGE_DIR/lcov.info" "$CANONICAL_COVERAGE_DIR/lcov.info"
SUMMARY_FILE="$CANONICAL_COVERAGE_DIR/coverage-summary.json"
LCOV_FILE="$CANONICAL_COVERAGE_DIR/lcov.info"
if [ ! -f "$SUMMARY_FILE" ]; then if [ ! -f "$SUMMARY_FILE" ]; then
echo "Error: Coverage summary file not found at $SUMMARY_FILE" echo "Error: Coverage summary file not found at $SUMMARY_FILE"

99
tests/a11y/README.md Normal file
View File

@@ -0,0 +1,99 @@
## Accessibility Test Suite (`tests/a11y`)
### Purpose and Scope
This suite checks key Charon pages for accessibility issues using Playwright and axe.
It is focused on page-level smoke coverage so we can catch major accessibility regressions early.
### Run Locally
Run a quick single-browser check:
```bash
npx playwright test tests/a11y/ --project=firefox
```
Run the full cross-browser matrix:
```bash
npx playwright test tests/a11y/ --project=chromium --project=firefox --project=webkit
```
### CI Execution
In CI, this suite runs in the non-security shard jobs of the E2E split workflow:
- Workflow: `.github/workflows/e2e-tests-split.yml`
- Jobs: non-security shard jobs for Chromium, Firefox, and WebKit
- Behavior: `tests/a11y` is included in the Playwright test paths and distributed by `--shard`
### Add a New Page Accessibility Test
1. Create or update a spec in `tests/a11y/`.
2. Import the accessibility fixture from `../fixtures/a11y`.
3. Use wait helpers (for example from `../utils/wait-helpers`) before running axe so page state is stable.
4. Attach scan results with `test.info().attach(...)` for report debugging.
5. Filter known accepted baseline items using `getBaselinedRuleIds('<page-path>')`.
6. Assert with `expectNoA11yViolations`.
Minimal pattern:
```ts
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
test('example page has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await page.goto('/example');
await waitForLoadingComplete(page);
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/example'),
});
});
```
### Baseline Policy
Baseline entries are allowed only for known and accepted issues with clear rationale and a tracking ticket.
- Add a clear `reason` and a `ticket` reference.
- Add `expiresAt` so each baseline is reviewed periodically.
- Remove the baseline entry as soon as the underlying issue is fixed.
### Failure Semantics
- `critical` and `serious` violations fail the test.
- `moderate` and `minor` violations are reported in attached output and do not fail by default.
### Troubleshooting Timeout Flakes
Intermittent timeout flakes can happen, especially on Firefox.
Recommended rerun strategy:
1. Rerun the same failed spec once in Firefox.
2. If it passes on rerun, treat it as a transient flake and continue.
3. If it fails again, run the full a11y suite in Firefox.
4. If still failing, run all three browsers and inspect `a11y-results` attachments.
Useful commands:
```bash
# Rerun one spec in Firefox
npx playwright test tests/a11y/<spec-file>.spec.ts --project=firefox
# Rerun full a11y suite in Firefox
npx playwright test tests/a11y/ --project=firefox
# Rerun full a11y suite in all browsers
npx playwright test tests/a11y/ --project=chromium --project=firefox --project=webkit
```

View File

@@ -0,0 +1,51 @@
export interface BaselineEntry {
ruleId: string;
pages: string[];
reason: string;
ticket?: string;
expiresAt?: string;
}
export const A11Y_BASELINE: BaselineEntry[] = [
{
ruleId: 'color-contrast',
pages: ['/'],
reason: 'Tailwind blue-500 buttons (#3b82f6) have 3.67:1 contrast with white text; requires design system update',
ticket: '#929',
expiresAt: '2026-07-31',
},
{
ruleId: 'label',
pages: ['/settings/users', '/security', '/tasks/backups', '/tasks/import/caddyfile', '/tasks/import/crowdsec'],
reason: 'Form inputs missing associated labels; requires frontend component fixes',
ticket: '#929',
expiresAt: '2026-07-31',
},
{
ruleId: 'button-name',
pages: ['/settings', '/security/headers'],
reason: 'Icon-only buttons missing accessible names; requires aria-label additions',
ticket: '#929',
expiresAt: '2026-07-31',
},
{
ruleId: 'select-name',
pages: ['/tasks/logs'],
reason: 'Select element missing associated label',
ticket: '#929',
expiresAt: '2026-07-31',
},
{
ruleId: 'scrollable-region-focusable',
pages: ['/tasks/logs'],
reason: 'Log output container is scrollable but not keyboard-focusable',
ticket: '#929',
expiresAt: '2026-07-31',
},
];
export function getBaselinedRuleIds(currentPage: string): string[] {
return A11Y_BASELINE
.filter((entry) => entry.pages.some((p) => currentPage.startsWith(p)))
.map((entry) => entry.ruleId);
}

View File

@@ -0,0 +1,29 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete, waitForTableLoad } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
test.describe('Accessibility: Certificates', () => {
test.describe.configure({ mode: 'parallel' });
test('certificates page has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await test.step('Navigate to certificates', async () => {
await page.goto('/certificates');
await waitForLoadingComplete(page);
await waitForTableLoad(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/certificates'),
});
});
});
});

View File

@@ -0,0 +1,28 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
test.describe('Accessibility: Dashboard', () => {
test.describe.configure({ mode: 'parallel' });
test('dashboard has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await test.step('Navigate to dashboard', async () => {
await page.goto('/');
await waitForLoadingComplete(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/'),
});
});
});
});

View File

@@ -0,0 +1,36 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
test.describe('Accessibility: DNS Providers', () => {
test.describe.configure({ mode: 'parallel' });
test('DNS providers page has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await test.step('Navigate to DNS providers', async () => {
await page.goto('/dns/providers');
await waitForLoadingComplete(page);
await page.getByRole('heading', { name: 'DNS Management', level: 1 }).waitFor({
state: 'visible',
timeout: 10000,
});
await page.getByRole('button', { name: 'Add DNS Provider' }).waitFor({
state: 'visible',
timeout: 10000,
});
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/dns/providers'),
});
});
});
});

View File

@@ -0,0 +1,35 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
const domainRoutes = [
{ route: '/domains', name: 'domains' },
{ route: '/remote-servers', name: 'remote servers' },
] as const;
test.describe('Accessibility: Domains & Remote Servers', () => {
test.describe.configure({ mode: 'parallel' });
for (const { route, name } of domainRoutes) {
test(`${name} page has no critical a11y violations`, async ({ page, makeAxeBuilder }) => {
await test.step(`Navigate to ${name}`, async () => {
await page.goto(route);
await waitForLoadingComplete(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds(route),
});
});
});
}
});

View File

@@ -0,0 +1,30 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Accessibility: Login', () => {
test.describe.configure({ mode: 'parallel' });
test('login page has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await test.step('Navigate to login page', async () => {
await page.goto('/login');
await waitForLoadingComplete(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/login'),
});
});
});
});

View File

@@ -0,0 +1,35 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
const notificationRoutes = [
{ route: '/settings/notifications', name: 'notifications' },
{ route: '/settings/smtp', name: 'SMTP settings' },
] as const;
test.describe('Accessibility: Notifications', () => {
test.describe.configure({ mode: 'parallel' });
for (const { route, name } of notificationRoutes) {
test(`${name} page has no critical a11y violations`, async ({ page, makeAxeBuilder }) => {
await test.step(`Navigate to ${name}`, async () => {
await page.goto(route);
await waitForLoadingComplete(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds(route),
});
});
});
}
});

View File

@@ -0,0 +1,29 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete, waitForTableLoad } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
test.describe('Accessibility: Proxy Hosts', () => {
test.describe.configure({ mode: 'parallel' });
test('proxy hosts page has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await test.step('Navigate to proxy hosts', async () => {
await page.goto('/proxy-hosts');
await waitForLoadingComplete(page);
await waitForTableLoad(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/proxy-hosts'),
});
});
});
});

View File

@@ -0,0 +1,81 @@
import { test, expect } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
const securityRoutes = [
{ route: '/security', name: 'security dashboard' },
{ route: '/security/access-lists', name: 'access lists' },
{ route: '/security/crowdsec', name: 'CrowdSec' },
{ route: '/security/waf', name: 'WAF' },
{ route: '/security/rate-limiting', name: 'rate limiting' },
{ route: '/security/headers', name: 'security headers' },
{ route: '/security/encryption', name: 'encryption' },
{ route: '/security/audit-logs', name: 'audit logs' },
] as const;
/**
* Wait for route-specific content to be visible before axe analysis
* Ensures all key page elements have been rendered
*/
async function waitForRouteReady(page: any, route: string): Promise<void> {
// Wait for main content area if it exists (most pages have one)
const main = page.locator('main');
try {
await expect(main).toBeVisible({ timeout: 5000 });
} catch {
// If no main element, just continue (some pages may not have it)
}
// Route-specific readiness conditions - all optional
switch (route) {
case '/security/headers':
// Security headers page has a button to create profiles
try {
await expect(page.getByRole('button', { name: /create|add|new/i }).first())
.toBeVisible({ timeout: 5000 });
} catch {
// Button not found, continue anyway
}
break;
case '/security/audit-logs':
// Audit logs page may have a heading or table
try {
await expect(page.locator('h1, h2, table, [role="grid"]').first())
.toBeVisible({ timeout: 5000 });
} catch {
// No expected content elements, continue anyway
}
break;
default:
// For other routes, just ensure main content is visible (already checked above)
break;
}
}
test.describe('Accessibility: Security', () => {
test.describe.configure({ mode: 'parallel' });
for (const { route, name } of securityRoutes) {
test(`${name} page has no critical a11y violations`, async ({ page, makeAxeBuilder }) => {
await test.step(`Navigate to ${name}`, async () => {
await page.goto(route);
await waitForLoadingComplete(page);
await waitForRouteReady(page, route);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds(route),
});
});
});
}
});

View File

@@ -0,0 +1,49 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete, waitForTableLoad } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
test.describe('Accessibility: Settings', () => {
test.describe.configure({ mode: 'parallel' });
test('settings page has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await test.step('Navigate to settings', async () => {
await page.goto('/settings');
await waitForLoadingComplete(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/settings'),
});
});
});
test('users page has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await test.step('Navigate to users', async () => {
await page.goto('/settings/users');
await waitForLoadingComplete(page);
await waitForTableLoad(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/settings/users'),
});
});
});
});

View File

@@ -0,0 +1,34 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Accessibility: Setup', () => {
test.describe.configure({ mode: 'parallel' });
test('setup page has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await test.step('Navigate to setup page', async () => {
await page.goto('/setup');
const url = page.url();
test.skip(!url.includes('/setup'), 'Setup already complete — page redirected');
await waitForLoadingComplete(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/setup'),
});
});
});
});

View File

@@ -0,0 +1,39 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
const taskRoutes = [
{ route: '/tasks/backups', name: 'backups' },
{ route: '/tasks/logs', name: 'logs' },
{ route: '/tasks/import/caddyfile', name: 'Caddyfile import' },
{ route: '/tasks/import/crowdsec', name: 'CrowdSec import' },
{ route: '/tasks/import/npm', name: 'NPM import' },
{ route: '/tasks/import/json', name: 'JSON import' },
] as const;
test.describe('Accessibility: Tasks', () => {
test.describe.configure({ mode: 'parallel' });
for (const { route, name } of taskRoutes) {
test(`${name} page has no critical a11y violations`, async ({ page, makeAxeBuilder }) => {
await test.step(`Navigate to ${name}`, async () => {
await page.goto(route);
await waitForLoadingComplete(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds(route),
});
});
});
}
});

View File

@@ -0,0 +1,28 @@
import { test } from '../fixtures/a11y';
import { waitForLoadingComplete } from '../utils/wait-helpers';
import { expectNoA11yViolations } from '../utils/a11y-helpers';
import { getBaselinedRuleIds } from './a11y-baseline';
test.describe('Accessibility: Uptime', () => {
test.describe.configure({ mode: 'parallel' });
test('uptime page has no critical a11y violations', async ({ page, makeAxeBuilder }) => {
await test.step('Navigate to uptime', async () => {
await page.goto('/uptime');
await waitForLoadingComplete(page);
});
await test.step('Run axe accessibility scan', async () => {
const results = await makeAxeBuilder().analyze();
test.info().attach('a11y-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expectNoA11yViolations(results, {
knownViolations: getBaselinedRuleIds('/uptime'),
});
});
});
});

18
tests/fixtures/a11y.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import { test as base } from './auth-fixtures';
import AxeBuilder from '@axe-core/playwright';
interface A11yFixtures {
makeAxeBuilder: () => AxeBuilder;
}
export const test = base.extend<A11yFixtures>({
makeAxeBuilder: async ({ page }, use) => {
const makeAxeBuilder = () =>
new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.exclude('.chart-container canvas');
await use(makeAxeBuilder);
},
});
export { expect } from './auth-fixtures';

View File

@@ -0,0 +1,58 @@
import { expect } from '../fixtures/test';
import type { AxeResults, Result } from 'axe-core';
export type ViolationImpact = 'critical' | 'serious' | 'moderate' | 'minor';
export interface A11yAssertionOptions {
failOn?: ViolationImpact[];
knownViolations?: string[];
}
const DEFAULT_FAIL_ON: ViolationImpact[] = ['critical', 'serious'];
export function getFailingViolations(
results: AxeResults,
options: A11yAssertionOptions = {},
): Result[] {
const failOn = options.failOn ?? DEFAULT_FAIL_ON;
const knownViolations = new Set(options.knownViolations ?? []);
return results.violations.filter(
(v) =>
failOn.includes(v.impact as ViolationImpact) &&
!knownViolations.has(v.id),
);
}
export function formatViolation(violation: Result): string {
const nodes = violation.nodes
.map((node, i) => {
const selector = node.target.join(' ');
const html = node.html.length > 200
? `${node.html.slice(0, 200)}`
: node.html;
const fix = node.failureSummary ?? '';
return ` Node ${i + 1}: ${selector}\n HTML: ${html}\n Fix: ${fix}`;
})
.join('\n');
return [
`[${violation.impact?.toUpperCase()}] ${violation.id}: ${violation.description}`,
` Help: ${violation.helpUrl}`,
` Affected nodes (${violation.nodes.length}):`,
nodes,
].join('\n');
}
export function expectNoA11yViolations(
results: AxeResults,
options: A11yAssertionOptions = {},
): void {
const failing = getFailingViolations(results, options);
const message = failing.length > 0
? `Found ${failing.length} accessibility violation(s):\n\n${failing.map(formatViolation).join('\n\n')}`
: '';
expect(failing, message).toEqual([]);
}

File diff suppressed because it is too large Load Diff