Compare commits

...

58 Commits

Author SHA1 Message Date
Jeremy
d68001b949 Merge pull request #963 from Wikid82/main
Propagate changes from main into development
2026-04-20 17:56:25 -04:00
Jeremy
a599623ea9 Merge branch 'development' into main 2026-04-20 17:55:51 -04:00
Jeremy
0f0a442d74 Merge pull request #962 from Wikid82/hotfix/ci
fix(ci): shift GeoLite2 update to Sunday targeting development branch
2026-04-20 12:56:13 -04:00
Jeremy
a8cd4bf34c Merge branch 'feature/beta-release' into development 2026-04-20 12:17:15 -04:00
Jeremy
02911109ef Merge pull request #960 from Wikid82/main
Propagate changes from main into development
2026-04-20 08:50:29 -04:00
GitHub Actions
2bad9fec53 fix: make URL preview invite modal test deterministic 2026-04-20 12:48:33 +00:00
Jeremy
54ce6f677c Merge pull request #959 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-04-20 08:34:32 -04:00
Jeremy
ad7704c1df Merge branch 'feature/beta-release' into renovate/feature/beta-release-non-major-updates 2026-04-20 08:02:55 -04:00
GitHub Actions
330ccae82f fix: update vulnerability suppression for buger/jsonparser to reflect upstream fix availability 2026-04-20 11:56:26 +00:00
renovate[bot]
0a5bb296a9 fix(deps): update non-major-updates 2026-04-20 11:56:08 +00:00
GitHub Actions
437a35bd47 fix: replace div with button for close action in whitelist delete modal
Co-authored-by: Copilot <copilot@github.com>
2026-04-20 11:29:10 +00:00
GitHub Actions
612d3655fa fix: improve IP normalization in normalizeIPOrCIDR function
Co-authored-by: Copilot <copilot@github.com>
2026-04-20 11:27:56 +00:00
GitHub Actions
38cdc5d9d0 fix(deps): update @oxc-project/types and @rolldown dependencies to version 0.126.0 and 1.0.0-rc.16 respectively 2026-04-20 11:16:56 +00:00
GitHub Actions
816124634b fix(deps): update @oxc-parser dependencies to version 0.126.0 and remove unused packages 2026-04-20 11:16:20 +00:00
GitHub Actions
2b2f3c876b chore: fix Renovate lookup failure for google/uuid dependency 2026-04-20 11:02:31 +00:00
Jeremy
20f2624653 Merge pull request #957 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-04-20 06:51:03 -04:00
Jeremy
e8724c5edc Merge branch 'feature/beta-release' into renovate/feature/beta-release-non-major-updates 2026-04-19 17:13:04 -04:00
GitHub Actions
2c284bdd49 test: add tests for handling empty UUID in DeleteWhitelist and invalid CIDR in Add method 2026-04-19 21:11:14 +00:00
GitHub Actions
db1e77ceb3 test(coverage): cover all modified lines for 100% patch coverage vs origin/main
- Add domains field to certificate mock to exercise per-domain loop
  in Dashboard component, covering the previously untested branch
- Extend CrowdSec whitelist test suite with backdrop-click close test
  to cover the dialog dismissal handler
- Remove duplicate describe blocks introduced when whitelist API tests
  were appended to crowdsec.test.ts, resolving ESLint vitest/no-identical-title
  errors that were blocking pre-commit hooks
2026-04-19 21:08:26 +00:00
GitHub Actions
df5e69236a fix(deps): update dependencies for improved stability and performance 2026-04-19 21:03:48 +00:00
renovate[bot]
a3259b042d fix(deps): update non-major-updates 2026-04-19 17:10:33 +00:00
GitHub Actions
f5e7c2bdfc fix(test): resolve CrowdSec card title lookup in Security test mock
The Security component renders the CrowdSec card title using the nested
translation key 'security.crowdsec.title', but the test mock only had the
flat key 'security.crowdsec'. The mock fallback returns the key string
itself when a lookup misses, causing getByText('CrowdSec') to find nothing.

Added 'security.crowdsec.title' to the securityTranslations map so the
mock resolves to the expected 'CrowdSec' string, matching the component's
actual t() call and allowing the title assertion to pass.
2026-04-18 01:39:06 +00:00
GitHub Actions
0859ab31ab fix(deps): update modernc.org/sqlite to version 1.49.1 for improved functionality 2026-04-18 01:36:58 +00:00
GitHub Actions
c02219cc92 fix(deps): update @asamuzakjp/dom-selector, @humanfs/core, @humanfs/node, and hasown to latest versions; add @humanfs/types dependency 2026-04-18 01:35:43 +00:00
GitHub Actions
d73b3aee5c fix(deps): update @humanfs/core and @humanfs/node to latest versions and add @humanfs/types dependency 2026-04-18 01:34:43 +00:00
Jeremy
80eb91e9a1 Merge pull request #956 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-04-17 21:33:31 -04:00
renovate[bot]
aa6c751007 fix(deps): update non-major-updates 2026-04-17 20:39:46 +00:00
GitHub Actions
1af786e7c8 fix: update eslint-plugin-react-hooks and typescript to latest versions for improved compatibility 2026-04-16 23:53:11 +00:00
GitHub Actions
c46c1976a2 fix: update typescript to version 6.0.3 for improved functionality and security 2026-04-16 23:52:39 +00:00
GitHub Actions
3b3ea83ecd chore: add database error handling tests for whitelist service and handler 2026-04-16 23:51:01 +00:00
GitHub Actions
5980a8081c fix: improve regex for delete button name matching in CrowdSec IP Whitelist tests 2026-04-16 14:12:07 +00:00
GitHub Actions
55f64f8050 fix: update translation keys for CrowdSec security titles and badges 2026-04-16 14:07:36 +00:00
GitHub Actions
983ae34147 fix(docker): persist CrowdSec LAPI database across container rebuilds 2026-04-16 14:04:15 +00:00
GitHub Actions
4232c0a8ee fix: update benchmark-action/github-action-benchmark to v1.22.0 and mlugg/setup-zig to v2.2.1 for improved security and functionality 2026-04-16 13:34:36 +00:00
GitHub Actions
402a8b3105 fix: update electron-to-chromium, eslint-plugin-sonarjs, minimatch, and ts-api-utils to latest versions 2026-04-16 13:34:36 +00:00
GitHub Actions
f46bb838ca feat: add QA audit report for CrowdSec IP Whitelist Management 2026-04-16 13:34:36 +00:00
GitHub Actions
3d0179a119 fix: update @asamuzakjp/css-color and @asamuzakjp/dom-selector to latest versions and add @asamuzakjp/generational-cache dependency 2026-04-16 13:34:36 +00:00
GitHub Actions
557b33dc73 fix: update docker/go-connections dependency to v0.7.0 2026-04-16 13:34:36 +00:00
GitHub Actions
2a1652d0b1 feat: add IP whitelist management details to architecture documentation 2026-04-16 13:34:36 +00:00
GitHub Actions
f0fdf9b752 test: update response key for whitelist entries and add validation test for missing fields 2026-04-16 13:34:36 +00:00
GitHub Actions
973efd6412 fix: initialize WhitelistSvc only if db is not nil and update error message in AddWhitelist handler 2026-04-16 13:34:36 +00:00
GitHub Actions
028342c63a fix: update JSON response key for whitelist entries in ListWhitelists handler 2026-04-16 13:34:36 +00:00
GitHub Actions
eb9b907ba3 feat: add end-to-end tests for CrowdSec IP whitelist management 2026-04-16 13:34:36 +00:00
GitHub Actions
aee0eeef82 feat: add unit tests for useCrowdSecWhitelist hooks 2026-04-16 13:34:36 +00:00
GitHub Actions
c977cf6190 feat: add whitelist management functionality to CrowdSecConfig 2026-04-16 13:34:36 +00:00
GitHub Actions
28bc73bb1a feat: add whitelist management hooks for querying and mutating whitelist entries 2026-04-16 13:34:36 +00:00
GitHub Actions
19719693b0 feat: add unit tests for CrowdSecWhitelistService and CrowdsecHandler 2026-04-16 13:34:36 +00:00
GitHub Actions
a243066691 feat: regenerate whitelist YAML on CrowdSec startup 2026-04-16 13:34:36 +00:00
GitHub Actions
741a59c333 feat: add whitelist management endpoints to CrowdsecHandler 2026-04-16 13:34:36 +00:00
GitHub Actions
5642a37c44 feat: implement CrowdSecWhitelistService for managing IP/CIDR whitelists 2026-04-16 13:34:36 +00:00
GitHub Actions
1726a19cb6 feat: add CrowdSecWhitelist model and integrate into API route registration 2026-04-16 13:34:36 +00:00
GitHub Actions
40090cda23 feat: add installation of crowdsecurity/whitelists parser 2026-04-16 13:34:36 +00:00
Jeremy
9945fac150 Merge branch 'development' into feature/beta-release 2026-04-16 09:33:49 -04:00
Jeremy
abf88ab4cb Merge pull request #954 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update non-major-updates (feature/beta-release)
2026-04-16 09:33:04 -04:00
renovate[bot]
98c720987d chore(deps): update non-major-updates 2026-04-16 13:26:37 +00:00
Jeremy
1bd7eab223 Merge pull request #953 from Wikid82/development
Propagate changes from development into feature/beta-release
2026-04-16 09:25:43 -04:00
Jeremy
080e17d85a Merge pull request #951 from Wikid82/main
chore(config): migrate config .github/renovate.json
2026-04-15 13:23:05 -04:00
GitHub Actions
0a3b64ba5c fix: correct misplaced env block in propagate-changes workflow 2026-04-15 17:19:19 +00:00
43 changed files with 4224 additions and 979 deletions

View File

@@ -303,6 +303,19 @@ ACQUIS_EOF
# Also handle case where it might be without trailing slash
sed -i 's|log_dir: /var/log$|log_dir: /var/log/crowdsec|g' "$CS_CONFIG_DIR/config.yaml"
# Redirect CrowdSec LAPI database to persistent volume
# Default path /var/lib/crowdsec/data/crowdsec.db is ephemeral (not volume-mounted),
# so it is destroyed on every container rebuild. The bouncer API key (stored on the
# persistent volume at /app/data/crowdsec/) survives rebuilds but the LAPI database
# that validates it does not — causing perpetual key rejection.
# Redirecting db_path to the volume-mounted CS_DATA_DIR fixes this.
sed -i "s|db_path: /var/lib/crowdsec/data/crowdsec.db|db_path: ${CS_DATA_DIR}/crowdsec.db|g" "$CS_CONFIG_DIR/config.yaml"
if grep -q "db_path:.*${CS_DATA_DIR}" "$CS_CONFIG_DIR/config.yaml"; then
echo "✓ CrowdSec LAPI database redirected to persistent volume: ${CS_DATA_DIR}/crowdsec.db"
else
echo "⚠️ WARNING: Could not verify LAPI db_path redirect — bouncer keys may not survive rebuilds"
fi
# Verify LAPI configuration was applied correctly
if grep -q "listen_uri:.*:8085" "$CS_CONFIG_DIR/config.yaml"; then
echo "✓ CrowdSec LAPI configured for port 8085"

View File

@@ -324,6 +324,12 @@
"matchDatasources": ["go"],
"matchPackageNames": ["github.com/oschwald/geoip2-golang/v2"],
"sourceUrl": "https://github.com/oschwald/geoip2-golang"
},
{
"description": "Fix Renovate lookup for google/uuid",
"matchDatasources": ["go"],
"matchPackageNames": ["github.com/google/uuid"],
"sourceUrl": "https://github.com/google/uuid"
}
]
}

View File

@@ -52,7 +52,7 @@ jobs:
# This avoids gh-pages branch errors and permission issues on fork PRs
if: github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main'
# Security: Pinned to full SHA for supply chain security
uses: benchmark-action/github-action-benchmark@4e0b38bc48375986542b13c0d8976b7b80c60c00 # v1
uses: benchmark-action/github-action-benchmark@a60cea5bc7b49e15c1f58f411161f99e0df48372 # v1.22.0
with:
name: Go Benchmark
tool: 'go'

View File

@@ -166,7 +166,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

View File

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

View File

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

View File

@@ -151,7 +151,7 @@ jobs:
- name: Set up Node.js
if: steps.resolve-image.outputs.image_source == 'build'
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
@@ -225,7 +225,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
@@ -427,7 +427,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
@@ -637,7 +637,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
@@ -859,7 +859,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
@@ -1096,7 +1096,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
@@ -1341,7 +1341,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

View File

@@ -28,7 +28,7 @@ jobs:
(github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'development')
steps:
- name: Set up Node (for github-script)
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
@@ -37,6 +37,8 @@ jobs:
env:
CURRENT_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
CURRENT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}
with:
script: |
const currentBranch = process.env.CURRENT_BRANCH || context.ref.replace('refs/heads/', '');
@@ -133,7 +135,9 @@ jobs:
const sensitive = files.some(fn => configPaths.some(sp => fn.startsWith(sp) || fn.includes(sp)));
if (sensitive) {
core.info(`${src} -> ${base} contains sensitive changes (${files.join(', ')}). Skipping automatic propagation.`);
const preview = files.slice(0, 25).join(', ');
const suffix = files.length > 25 ? ` …(+${files.length - 25} more)` : '';
core.info(`${src} -> ${base} contains sensitive changes (${preview}${suffix}). Skipping automatic propagation.`);
return;
}
} catch (error) {
@@ -203,6 +207,3 @@ jobs:
await createPR('development', targetBranch);
}
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}

View File

@@ -262,7 +262,7 @@ jobs:
bash "scripts/repo_health_check.sh"
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

View File

@@ -52,7 +52,7 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.NODE_VERSION }}
@@ -67,7 +67,7 @@ jobs:
- name: Install Cross-Compilation Tools (Zig)
# Security: Pinned to full SHA for supply chain security
uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.13.0
@@ -75,7 +75,7 @@ jobs:
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7
uses: goreleaser/goreleaser-action@e24998b8b67b290c2fa8b7c14fcfa7de2c5c9b8c # v7
with:
distribution: goreleaser
version: '~> v2.5'

View File

@@ -33,7 +33,7 @@ jobs:
go-version: ${{ env.GO_VERSION }}
- name: Run Renovate
uses: renovatebot/github-action@eb932558ad942cccfd8211cf535f17ff183a9f74 # v46.1.9
uses: renovatebot/github-action@83ec54fee49ab67d9cd201084c1ff325b4b462e4 # v46.1.10
with:
configurationFile: .github/renovate.json
token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -316,6 +316,5 @@ docs/reports/codecove_patch_report.md
vuln-results.json
test_output.txt
coverage_results.txt
new-results.json
.gitignore
final-results.json
new-results.json

View File

@@ -203,45 +203,47 @@ ignore:
# GHSA-6g7g-w4f8-9c9x: buger/jsonparser Delete panic on malformed JSON (DoS)
# Severity: HIGH (CVSS 7.5)
# Package: github.com/buger/jsonparser v1.1.1 (embedded in /usr/local/bin/crowdsec and /usr/local/bin/cscli)
# Status: NO upstream fix available — OSV marks "Last affected: v1.1.1" with no Fixed event
# Status: UPSTREAM FIX EXISTS (v1.1.2 released 2026-03-20) — awaiting CrowdSec to update dependency
# NOTE: As of 2026-04-20, grype v0.111.0 with fresh DB no longer flags this finding in the image.
# This suppression is retained as a safety net in case future DB updates re-surface it.
#
# Vulnerability Details:
# - The Delete function fails to validate offsets on malformed JSON input, producing a
# negative slice index and a runtime panic — denial of service (CWE-125).
# - CVSSv3: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
#
# Root Cause (Third-Party Binary + No Upstream Fix):
# Root Cause (Third-Party Binary — Fix Exists Upstream, Not Yet in CrowdSec):
# - Charon does not use buger/jsonparser directly. It is compiled into CrowdSec binaries.
# - The buger/jsonparser repository has no released fix as of 2026-03-19 (GitHub issue #275
# and golang/vulndb #4514 are both open).
# - Fix path: once buger/jsonparser releases a patched version and CrowdSec updates their
# dependency, rebuild the Docker image and remove this suppression.
# - buger/jsonparser released v1.1.2 on 2026-03-20 fixing issue #275.
# - CrowdSec has not yet released a version built with buger/jsonparser v1.1.2.
# - Fix path: once CrowdSec updates their dependency and rebuilds, rebuild the Docker image
# and remove this suppression.
#
# Risk Assessment: ACCEPTED (Limited exploitability + no upstream fix)
# Risk Assessment: ACCEPTED (Limited exploitability; fix exists upstream but not yet in CrowdSec)
# - The DoS vector requires passing malformed JSON to the vulnerable Delete function within
# CrowdSec's internal processing pipeline; this is not a direct attack surface in Charon.
# - CrowdSec's exposed surface is its HTTP API (not raw JSON stream parsing via this path).
#
# Mitigation (active while suppression is in effect):
# - Monitor buger/jsonparser: https://github.com/buger/jsonparser/issues/275
# - Monitor CrowdSec releases: https://github.com/crowdsecurity/crowdsec/releases
# - Monitor CrowdSec releases for a build using buger/jsonparser >= v1.1.2.
# - CrowdSec releases: https://github.com/crowdsecurity/crowdsec/releases
# - Weekly CI security rebuild flags the moment a fixed image ships.
#
# Review:
# - Reviewed 2026-03-19 (initial suppression): no upstream fix exists. Set 30-day review.
# - Extended 2026-04-04: no upstream fix available. buger/jsonparser issue #275 still open.
# - Next review: 2026-05-19. Remove suppression once buger/jsonparser ships a fix and
# CrowdSec updates their dependency.
# - Reviewed 2026-03-19 (initial suppression): no upstream fix. Set 30-day review.
# - Extended 2026-04-04: no upstream fix. buger/jsonparser issue #275 still open.
# - Updated 2026-04-20: buger/jsonparser v1.1.2 released 2026-03-20. CrowdSec not yet updated.
# Grype v0.111.0 with fresh DB (2026-04-20) no longer flags this finding. Suppression retained
# as a safety net. Next review: 2026-05-19 — remove if CrowdSec ships with v1.1.2+.
#
# Removal Criteria:
# - buger/jsonparser releases a patched version (v1.1.2 or higher)
# - CrowdSec releases a version built with the patched jsonparser
# - CrowdSec releases a version built with buger/jsonparser >= v1.1.2
# - Rebuild Docker image, run security-scan-docker-image, confirm finding is resolved
# - Remove this entry and the corresponding .trivyignore entry simultaneously
#
# References:
# - GHSA-6g7g-w4f8-9c9x: https://github.com/advisories/GHSA-6g7g-w4f8-9c9x
# - Upstream issue: https://github.com/buger/jsonparser/issues/275
# - Upstream fix: https://github.com/buger/jsonparser/releases/tag/v1.1.2
# - golang/vulndb: https://github.com/golang/vulndb/issues/4514
# - CrowdSec releases: https://github.com/crowdsecurity/crowdsec/releases
- vulnerability: GHSA-6g7g-w4f8-9c9x
@@ -251,21 +253,20 @@ ignore:
type: go-module
reason: |
HIGH — DoS panic via malformed JSON in buger/jsonparser v1.1.1 embedded in CrowdSec binaries.
No upstream fix: buger/jsonparser has no released patch as of 2026-03-19 (issue #275 open).
Charon does not use this package directly; the vector requires reaching CrowdSec's internal
JSON processing pipeline. Risk accepted; no remediation path until upstream ships a fix.
Reviewed 2026-03-19: no patched release available.
expiry: "2026-05-19" # Extended 2026-04-04: no upstream fix. Next review 2026-05-19.
Upstream fix: buger/jsonparser v1.1.2 released 2026-03-20; CrowdSec has not yet updated their
dependency. Grype no longer flags this as of 2026-04-20 (fresh DB). Suppression retained as
safety net pending CrowdSec update. Charon does not use this package directly.
Updated 2026-04-20: fix v1.1.2 exists upstream; awaiting CrowdSec dependency update.
expiry: "2026-05-19" # Review 2026-05-19: remove if CrowdSec ships with buger/jsonparser >= v1.1.2.
# Action items when this suppression expires:
# 1. Check buger/jsonparser releases: https://github.com/buger/jsonparser/releases
# and issue #275: https://github.com/buger/jsonparser/issues/275
# 2. If a fix has shipped AND CrowdSec has updated their dependency:
# a. Rebuild Docker image and run local security-scan-docker-image
# b. Remove this suppression entry and the corresponding .trivyignore entry
# 3. If no fix yet: Extend expiry by 30 days and update the review comment above
# 4. If extended 3+ times with no progress: Consider opening an issue upstream or
# evaluating whether CrowdSec can replace buger/jsonparser with a safe alternative
# 1. Check if CrowdSec has released a version with buger/jsonparser >= v1.1.2:
# https://github.com/crowdsecurity/crowdsec/releases
# 2. If CrowdSec has updated: rebuild Docker image, run security-scan-docker-image,
# and remove this suppression entry and the corresponding .trivyignore entry
# 3. If grype still does not flag it with fresh DB: consider removing the suppression as
# it may no longer be necessary
# 4. If no CrowdSec update yet: Extend expiry by 30 days
# GHSA-jqcq-xjh3-6g23: pgproto3/v2 DataRow.Decode panic on negative field length (DoS)
# Severity: HIGH (CVSS 7.5)

View File

@@ -577,6 +577,7 @@ graph LR
- Global threat intelligence (crowd-sourced IP reputation)
- Automatic IP banning with configurable duration
- Decision management API (view, create, delete bans)
- IP whitelist management: operators add/remove IPs and CIDRs via the management UI; entries are persisted in SQLite and regenerated into a `crowdsecurity/whitelists` parser YAML on every mutating operation and at startup
**Modes:**

View File

@@ -13,7 +13,7 @@ ARG BUILD_DEBUG=0
ARG GO_VERSION=1.26.2
# renovate: datasource=docker depName=alpine versioning=docker
ARG ALPINE_IMAGE=alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
ARG ALPINE_IMAGE=alpine:3.23.4@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11
# ---- Shared CrowdSec Version ----
# renovate: datasource=github-releases depName=crowdsecurity/crowdsec
@@ -92,7 +92,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# ---- Frontend Builder ----
# Build the frontend using the BUILDPLATFORM to avoid arm64 musl Rollup native issues
# renovate: datasource=docker depName=node
FROM --platform=$BUILDPLATFORM node:24.14.1-alpine@sha256:8510330d3eb72c804231a834b1a8ebb55cb3796c3e4431297a24d246b8add4d5 AS frontend-builder
FROM --platform=$BUILDPLATFORM node:24.15.0-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS frontend-builder
WORKDIR /app/frontend
# Copy frontend package files
@@ -386,13 +386,13 @@ RUN go get github.com/expr-lang/expr@v${EXPR_LANG_VERSION} && \
go get github.com/jackc/pgx/v4@v4.18.3 && \
# GHSA-xmrv-pmrh-hhx2: AWS SDK v2 event stream injection
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.8 && \
go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.9 && \
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.68.0 && \
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.69.1 && \
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/kinesis
go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.5 && \
go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.6 && \
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/s3
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.99.0 && \
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.99.1 && \
go mod tidy
# Fix compatibility issues with expr-lang v1.17.7

View File

@@ -39,7 +39,7 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -99,5 +99,5 @@ require (
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
modernc.org/sqlite v1.49.1 // indirect
)

View File

@@ -29,8 +29,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
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.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
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-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -263,8 +263,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -63,6 +63,7 @@ type CrowdsecHandler struct {
Hub *crowdsec.HubService
Console *crowdsec.ConsoleEnrollmentService
Security *services.SecurityService
WhitelistSvc *services.CrowdSecWhitelistService
CaddyManager *caddy.Manager // For config reload after bouncer registration
LAPIMaxWait time.Duration // For testing; 0 means 60s default
LAPIPollInterval time.Duration // For testing; 0 means 500ms default
@@ -383,7 +384,7 @@ func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir
securitySvc = services.NewSecurityService(db)
consoleSvc = crowdsec.NewConsoleEnrollmentService(db, &crowdsec.SecureCommandExecutor{}, dataDir, consoleSecret)
}
return &CrowdsecHandler{
h := &CrowdsecHandler{
DB: db,
Executor: executor,
CmdExec: &RealCommandExecutor{},
@@ -395,6 +396,10 @@ func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir
dashCache: newDashboardCache(),
validateLAPIURL: validateCrowdsecLAPIBaseURLDefault,
}
if db != nil {
h.WhitelistSvc = services.NewCrowdSecWhitelistService(db, dataDir)
}
return h
}
// isCerberusEnabled returns true when Cerberus is enabled via DB or env flag.
@@ -2700,6 +2705,75 @@ func fileExists(path string) bool {
return err == nil
}
// ListWhitelists returns all CrowdSec IP/CIDR whitelist entries.
func (h *CrowdsecHandler) ListWhitelists(c *gin.Context) {
entries, err := h.WhitelistSvc.List(c.Request.Context())
if err != nil {
logger.Log().WithError(err).Error("failed to list whitelist entries")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list whitelist entries"})
return
}
c.JSON(http.StatusOK, gin.H{"whitelist": entries})
}
// AddWhitelist adds a new IP or CIDR to the 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 err != nil {
switch {
case errors.Is(err, services.ErrInvalidIPOrCIDR):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address or CIDR notation"})
case errors.Is(err, services.ErrDuplicateEntry):
c.JSON(http.StatusConflict, gin.H{"error": "entry already exists in whitelist"})
default:
logger.Log().WithError(err).Error("failed to add whitelist entry")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add whitelist entry"})
}
return
}
if _, execErr := h.CmdExec.Execute(c.Request.Context(), "cscli", "hub", "reload"); execErr != nil {
logger.Log().WithError(execErr).Warn("cscli hub reload failed after whitelist add (non-fatal)")
}
c.JSON(http.StatusCreated, entry)
}
// DeleteWhitelist removes a whitelist entry by UUID.
func (h *CrowdsecHandler) DeleteWhitelist(c *gin.Context) {
id := c.Param("uuid")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"})
return
}
if err := h.WhitelistSvc.Delete(c.Request.Context(), id); err != nil {
switch {
case errors.Is(err, services.ErrWhitelistNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "whitelist entry not found"})
default:
logger.Log().WithError(err).Error("failed to delete whitelist entry")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete whitelist entry"})
}
return
}
if _, execErr := h.CmdExec.Execute(c.Request.Context(), "cscli", "hub", "reload"); execErr != nil {
logger.Log().WithError(execErr).Warn("cscli hub reload failed after whitelist delete (non-fatal)")
}
c.Status(http.StatusNoContent)
}
// RegisterRoutes registers crowdsec admin routes under protected group
func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/admin/crowdsec/start", h.Start)
@@ -2742,4 +2816,8 @@ func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/admin/crowdsec/dashboard/scenarios", h.DashboardScenarios)
rg.GET("/admin/crowdsec/alerts", h.ListAlerts)
rg.GET("/admin/crowdsec/decisions/export", h.ExportDecisions)
// Whitelist management endpoints (Issue #939)
rg.GET("/admin/crowdsec/whitelist", h.ListWhitelists)
rg.POST("/admin/crowdsec/whitelist", h.AddWhitelist)
rg.DELETE("/admin/crowdsec/whitelist/:uuid", h.DeleteWhitelist)
}

View File

@@ -0,0 +1,284 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type mockCmdExecWhitelist struct {
reloadCalled bool
reloadErr error
}
func (m *mockCmdExecWhitelist) Execute(_ context.Context, _ string, _ ...string) ([]byte, error) {
m.reloadCalled = true
return nil, m.reloadErr
}
func setupWhitelistHandler(t *testing.T) (*CrowdsecHandler, *gin.Engine, *gorm.DB) {
t.Helper()
db := OpenTestDB(t)
require.NoError(t, db.AutoMigrate(&models.CrowdSecWhitelist{}))
fe := &fakeExec{}
h := newTestCrowdsecHandler(t, db, fe, "/bin/false", "")
h.WhitelistSvc = services.NewCrowdSecWhitelistService(db, "")
r := gin.New()
g := r.Group("/api/v1")
g.GET("/admin/crowdsec/whitelist", h.ListWhitelists)
g.POST("/admin/crowdsec/whitelist", h.AddWhitelist)
g.DELETE("/admin/crowdsec/whitelist/:uuid", h.DeleteWhitelist)
return h, r, db
}
func TestListWhitelists_Empty(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/whitelist", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
entries, ok := resp["whitelist"].([]interface{})
assert.True(t, ok)
assert.Empty(t, entries)
}
func TestAddWhitelist_ValidIP(t *testing.T) {
t.Parallel()
h, r, _ := setupWhitelistHandler(t)
mock := &mockCmdExecWhitelist{}
h.CmdExec = mock
body := `{"ip_or_cidr":"1.2.3.4","reason":"test"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
assert.True(t, mock.reloadCalled)
var entry models.CrowdSecWhitelist
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &entry))
assert.Equal(t, "1.2.3.4", entry.IPOrCIDR)
assert.NotEmpty(t, entry.UUID)
}
func TestAddWhitelist_InvalidIP(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)
body := `{"ip_or_cidr":"not-valid","reason":""}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestAddWhitelist_Duplicate(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)
body := `{"ip_or_cidr":"9.9.9.9","reason":""}`
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if i == 0 {
assert.Equal(t, http.StatusCreated, w.Code)
} else {
assert.Equal(t, http.StatusConflict, w.Code)
}
}
}
func TestDeleteWhitelist_Existing(t *testing.T) {
t.Parallel()
h, r, db := setupWhitelistHandler(t)
mock := &mockCmdExecWhitelist{}
h.CmdExec = mock
svc := services.NewCrowdSecWhitelistService(db, "")
entry, err := svc.Add(t.Context(), "7.7.7.7", "to delete")
require.NoError(t, err)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/whitelist/"+entry.UUID, nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
assert.True(t, mock.reloadCalled)
}
func TestDeleteWhitelist_NotFound(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/whitelist/00000000-0000-0000-0000-000000000000", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestListWhitelists_AfterAdd(t *testing.T) {
t.Parallel()
_, r, db := setupWhitelistHandler(t)
svc := services.NewCrowdSecWhitelistService(db, "")
_, err := svc.Add(t.Context(), "8.8.8.8", "google dns")
require.NoError(t, err)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/whitelist", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
entries := resp["whitelist"].([]interface{})
assert.Len(t, entries, 1)
}
func TestAddWhitelist_400_MissingField(t *testing.T) {
t.Parallel()
_, r, _ := setupWhitelistHandler(t)
body := `{}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "ip_or_cidr is required", resp["error"])
}
func TestListWhitelists_DBError(t *testing.T) {
t.Parallel()
_, r, db := setupWhitelistHandler(t)
sqlDB, err := db.DB()
require.NoError(t, err)
_ = sqlDB.Close()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/whitelist", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "failed to list whitelist entries", resp["error"])
}
func TestAddWhitelist_DBError(t *testing.T) {
t.Parallel()
_, r, db := setupWhitelistHandler(t)
sqlDB, err := db.DB()
require.NoError(t, err)
_ = sqlDB.Close()
body := `{"ip_or_cidr":"1.2.3.4","reason":"test"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "failed to add whitelist entry", resp["error"])
}
func TestAddWhitelist_ReloadFailure(t *testing.T) {
t.Parallel()
h, r, _ := setupWhitelistHandler(t)
mock := &mockCmdExecWhitelist{reloadErr: errors.New("cscli failed")}
h.CmdExec = mock
body := `{"ip_or_cidr":"3.3.3.3","reason":"reload test"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/whitelist", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
assert.True(t, mock.reloadCalled)
}
func TestDeleteWhitelist_DBError(t *testing.T) {
t.Parallel()
_, r, db := setupWhitelistHandler(t)
svc := services.NewCrowdSecWhitelistService(db, "")
entry, err := svc.Add(t.Context(), "4.4.4.4", "will close db")
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
_ = sqlDB.Close()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/whitelist/"+entry.UUID, nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "failed to delete whitelist entry", resp["error"])
}
func TestDeleteWhitelist_ReloadFailure(t *testing.T) {
t.Parallel()
h, r, db := setupWhitelistHandler(t)
mock := &mockCmdExecWhitelist{reloadErr: errors.New("cscli failed")}
h.CmdExec = mock
svc := services.NewCrowdSecWhitelistService(db, "")
entry, err := svc.Add(t.Context(), "5.5.5.5", "reload test")
require.NoError(t, err)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/whitelist/"+entry.UUID, nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
assert.True(t, mock.reloadCalled)
}
func TestDeleteWhitelist_EmptyUUID(t *testing.T) {
t.Parallel()
h, _, _ := setupWhitelistHandler(t)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/whitelist/", nil)
c.Params = gin.Params{{Key: "uuid", Value: ""}}
h.DeleteWhitelist(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "uuid is required", resp["error"])
}

View File

@@ -122,6 +122,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
&models.DNSProviderCredential{}, // Multi-credential support (Phase 3)
&models.Plugin{}, // Phase 5: DNS provider plugins
&models.ManualChallenge{}, // Phase 1: Manual DNS challenges
&models.CrowdSecWhitelist{}, // Issue #939: CrowdSec IP whitelist management
); err != nil {
return fmt.Errorf("auto migrate: %w", err)
}

View File

@@ -0,0 +1,13 @@
package models
import "time"
// CrowdSecWhitelist represents a single IP or CIDR block that CrowdSec should never ban.
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"`
}

View File

@@ -197,6 +197,12 @@ func ReconcileCrowdSecOnStartup(db *gorm.DB, executor CrowdsecProcessManager, bi
"data_dir": dataDir,
}).Info("CrowdSec reconciliation: starting CrowdSec (mode=local, not currently running)")
// Regenerate whitelist YAML before starting so CrowdSec loads the current entries.
whitelistSvc := NewCrowdSecWhitelistService(db, dataDir)
if writeErr := whitelistSvc.WriteYAML(context.Background()); writeErr != nil {
logger.Log().WithError(writeErr).Warn("CrowdSec reconciliation: failed to write whitelist YAML on startup (non-fatal)")
}
startCtx, startCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer startCancel()

View File

@@ -0,0 +1,190 @@
package services
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Sentinel errors for CrowdSecWhitelistService operations.
var (
ErrWhitelistNotFound = errors.New("whitelist entry not found")
ErrInvalidIPOrCIDR = errors.New("invalid IP address or CIDR notation")
ErrDuplicateEntry = errors.New("entry already exists in whitelist")
)
const whitelistYAMLHeader = `name: charon-whitelist
description: "Charon-managed IP/CIDR whitelist"
filter: "evt.Meta.service == 'http'"
whitelist:
reason: "Charon managed whitelist"
`
// CrowdSecWhitelistService manages the CrowdSec IP/CIDR whitelist.
type CrowdSecWhitelistService struct {
db *gorm.DB
dataDir string
}
// NewCrowdSecWhitelistService creates a new CrowdSecWhitelistService.
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) {
var entries []models.CrowdSecWhitelist
if err := s.db.WithContext(ctx).Order("created_at ASC").Find(&entries).Error; err != nil {
return nil, fmt.Errorf("list whitelist entries: %w", err)
}
return entries, nil
}
// Add validates and persists a new whitelist entry, then regenerates the YAML file.
// Returns ErrInvalidIPOrCIDR for malformed input and ErrDuplicateEntry for conflicts.
func (s *CrowdSecWhitelistService) Add(ctx context.Context, ipOrCIDR, reason string) (*models.CrowdSecWhitelist, error) {
normalized, err := normalizeIPOrCIDR(strings.TrimSpace(ipOrCIDR))
if err != nil {
return nil, ErrInvalidIPOrCIDR
}
entry := models.CrowdSecWhitelist{
UUID: uuid.New().String(),
IPOrCIDR: normalized,
Reason: reason,
}
if err := s.db.WithContext(ctx).Create(&entry).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "UNIQUE constraint failed") {
return nil, ErrDuplicateEntry
}
return nil, fmt.Errorf("add whitelist entry: %w", err)
}
if err := s.WriteYAML(ctx); err != nil {
logger.Log().WithError(err).Warn("failed to write CrowdSec whitelist YAML after add (non-fatal)")
}
return &entry, nil
}
// Delete removes a whitelist entry by UUID and regenerates the YAML file.
// Returns ErrWhitelistNotFound if the UUID does not exist.
func (s *CrowdSecWhitelistService) Delete(ctx context.Context, id string) error {
result := s.db.WithContext(ctx).Where("uuid = ?", id).Delete(&models.CrowdSecWhitelist{})
if result.Error != nil {
return fmt.Errorf("delete whitelist entry: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrWhitelistNotFound
}
if err := s.WriteYAML(ctx); err != nil {
logger.Log().WithError(err).Warn("failed to write CrowdSec whitelist YAML after delete (non-fatal)")
}
return nil
}
// WriteYAML renders and atomically writes the CrowdSec whitelist YAML file.
// It is a no-op when dataDir is empty (unit-test mode).
func (s *CrowdSecWhitelistService) WriteYAML(ctx context.Context) error {
if s.dataDir == "" {
return nil
}
var entries []models.CrowdSecWhitelist
if err := s.db.WithContext(ctx).Order("created_at ASC").Find(&entries).Error; err != nil {
return fmt.Errorf("write whitelist yaml: query entries: %w", err)
}
var ips, cidrs []string
for _, e := range entries {
if strings.Contains(e.IPOrCIDR, "/") {
cidrs = append(cidrs, e.IPOrCIDR)
} else {
ips = append(ips, e.IPOrCIDR)
}
}
content := buildWhitelistYAML(ips, cidrs)
dir := filepath.Join(s.dataDir, "config", "parsers", "s02-enrich")
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("write whitelist yaml: create dir: %w", err)
}
target := filepath.Join(dir, "charon-whitelist.yaml")
tmp := target + ".tmp"
if err := os.WriteFile(tmp, content, 0o640); err != nil {
return fmt.Errorf("write whitelist yaml: write temp: %w", err)
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("write whitelist yaml: rename: %w", err)
}
return nil
}
// normalizeIPOrCIDR validates and normalizes an IP address or CIDR block.
// For CIDRs, the network address is returned (e.g. "10.0.0.1/8" → "10.0.0.0/8").
func normalizeIPOrCIDR(raw string) (string, error) {
if strings.Contains(raw, "/") {
ip, network, err := net.ParseCIDR(raw)
if err != nil {
return "", err
}
_ = ip
return network.String(), nil
}
ip := net.ParseIP(raw)
if ip == nil {
return "", fmt.Errorf("invalid IP: %q", raw)
}
return ip.String(), nil
}
// buildWhitelistYAML constructs the YAML content for the CrowdSec whitelist parser.
func buildWhitelistYAML(ips, cidrs []string) []byte {
var sb strings.Builder
sb.WriteString(whitelistYAMLHeader)
sb.WriteString(" ip:")
if len(ips) == 0 {
sb.WriteString(" []\n")
} else {
sb.WriteString("\n")
for _, ip := range ips {
sb.WriteString(" - \"")
sb.WriteString(ip)
sb.WriteString("\"\n")
}
}
sb.WriteString(" cidr:")
if len(cidrs) == 0 {
sb.WriteString(" []\n")
} else {
sb.WriteString("\n")
for _, cidr := range cidrs {
sb.WriteString(" - \"")
sb.WriteString(cidr)
sb.WriteString("\"\n")
}
}
return []byte(sb.String())
}

View File

@@ -0,0 +1,309 @@
package services_test
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func openWhitelistTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.CrowdSecWhitelist{}))
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func TestCrowdSecWhitelistService_List_Empty(t *testing.T) {
t.Parallel()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), "")
entries, err := svc.List(context.Background())
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestCrowdSecWhitelistService_Add_ValidIP(t *testing.T) {
t.Parallel()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), "")
entry, err := svc.Add(context.Background(), "1.2.3.4", "test reason")
require.NoError(t, err)
assert.NotEmpty(t, entry.UUID)
assert.Equal(t, "1.2.3.4", entry.IPOrCIDR)
assert.Equal(t, "test reason", entry.Reason)
}
func TestCrowdSecWhitelistService_Add_ValidCIDR(t *testing.T) {
t.Parallel()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), "")
entry, err := svc.Add(context.Background(), "192.168.1.0/24", "local net")
require.NoError(t, err)
assert.Equal(t, "192.168.1.0/24", entry.IPOrCIDR)
}
func TestCrowdSecWhitelistService_Add_NormalizesCIDR(t *testing.T) {
t.Parallel()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), "")
entry, err := svc.Add(context.Background(), "10.0.0.1/8", "normalize test")
require.NoError(t, err)
assert.Equal(t, "10.0.0.0/8", entry.IPOrCIDR)
}
func TestCrowdSecWhitelistService_Add_InvalidIP(t *testing.T) {
t.Parallel()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), "")
_, err := svc.Add(context.Background(), "not-an-ip", "")
assert.ErrorIs(t, err, services.ErrInvalidIPOrCIDR)
}
func TestCrowdSecWhitelistService_Add_Duplicate(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
svc := services.NewCrowdSecWhitelistService(db, "")
_, err := svc.Add(context.Background(), "5.5.5.5", "first")
require.NoError(t, err)
_, err = svc.Add(context.Background(), "5.5.5.5", "second")
assert.ErrorIs(t, err, services.ErrDuplicateEntry)
}
func TestCrowdSecWhitelistService_Delete_Existing(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
svc := services.NewCrowdSecWhitelistService(db, "")
entry, err := svc.Add(context.Background(), "6.6.6.6", "to delete")
require.NoError(t, err)
err = svc.Delete(context.Background(), entry.UUID)
require.NoError(t, err)
entries, err := svc.List(context.Background())
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestCrowdSecWhitelistService_Delete_NotFound(t *testing.T) {
t.Parallel()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), "")
err := svc.Delete(context.Background(), "00000000-0000-0000-0000-000000000000")
assert.ErrorIs(t, err, services.ErrWhitelistNotFound)
}
func TestCrowdSecWhitelistService_WriteYAML_EmptyDataDir(t *testing.T) {
t.Parallel()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), "")
err := svc.WriteYAML(context.Background())
assert.NoError(t, err)
}
func TestCrowdSecWhitelistService_WriteYAML_CreatesFile(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
db := openWhitelistTestDB(t)
svc := services.NewCrowdSecWhitelistService(db, tmpDir)
_, err := svc.Add(context.Background(), "1.1.1.1", "dns")
require.NoError(t, err)
_, err = svc.Add(context.Background(), "10.0.0.0/8", "internal")
require.NoError(t, err)
yamlPath := filepath.Join(tmpDir, "config", "parsers", "s02-enrich", "charon-whitelist.yaml")
content, err := os.ReadFile(yamlPath)
require.NoError(t, err)
s := string(content)
assert.Contains(t, s, "name: charon-whitelist")
assert.Contains(t, s, `"1.1.1.1"`)
assert.Contains(t, s, `"10.0.0.0/8"`)
}
func TestCrowdSecWhitelistService_WriteYAML_EmptyLists(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), tmpDir)
err := svc.WriteYAML(context.Background())
require.NoError(t, err)
yamlPath := filepath.Join(tmpDir, "config", "parsers", "s02-enrich", "charon-whitelist.yaml")
content, err := os.ReadFile(yamlPath)
require.NoError(t, err)
s := string(content)
assert.Contains(t, s, "ip: []")
assert.Contains(t, s, "cidr: []")
}
func TestCrowdSecWhitelistService_List_AfterAdd(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
svc := services.NewCrowdSecWhitelistService(db, "")
for i := 0; i < 3; i++ {
_, err := svc.Add(context.Background(), fmt.Sprintf("10.0.0.%d", i+1), "")
require.NoError(t, err)
}
entries, err := svc.List(context.Background())
require.NoError(t, err)
assert.Len(t, entries, 3)
}
func TestAdd_ValidIPv6_Success(t *testing.T) {
t.Parallel()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), "")
entry, err := svc.Add(context.Background(), "2001:db8::1", "ipv6 test")
require.NoError(t, err)
assert.Equal(t, "2001:db8::1", entry.IPOrCIDR)
entries, err := svc.List(context.Background())
require.NoError(t, err)
assert.Len(t, entries, 1)
assert.Equal(t, "2001:db8::1", entries[0].IPOrCIDR)
}
func TestCrowdSecWhitelistService_List_DBError(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
svc := services.NewCrowdSecWhitelistService(db, "")
sqlDB, err := db.DB()
require.NoError(t, err)
_ = sqlDB.Close()
_, err = svc.List(context.Background())
assert.Error(t, err)
}
func TestCrowdSecWhitelistService_Add_DBCreateError(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
svc := services.NewCrowdSecWhitelistService(db, "")
sqlDB, err := db.DB()
require.NoError(t, err)
_ = sqlDB.Close()
_, err = svc.Add(context.Background(), "1.2.3.4", "test")
assert.Error(t, err)
assert.NotErrorIs(t, err, services.ErrInvalidIPOrCIDR)
assert.NotErrorIs(t, err, services.ErrDuplicateEntry)
}
func TestCrowdSecWhitelistService_Delete_DBError(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
svc := services.NewCrowdSecWhitelistService(db, "")
sqlDB, err := db.DB()
require.NoError(t, err)
_ = sqlDB.Close()
err = svc.Delete(context.Background(), "some-uuid")
assert.Error(t, err)
assert.NotErrorIs(t, err, services.ErrWhitelistNotFound)
}
func TestCrowdSecWhitelistService_WriteYAML_DBError(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
tmpDir := t.TempDir()
svc := services.NewCrowdSecWhitelistService(db, tmpDir)
sqlDB, err := db.DB()
require.NoError(t, err)
_ = sqlDB.Close()
err = svc.WriteYAML(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "query entries")
}
func TestCrowdSecWhitelistService_WriteYAML_MkdirError(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
// Use a path under /dev/null which cannot have subdirectories
svc := services.NewCrowdSecWhitelistService(db, "/dev/null/impossible")
err := svc.WriteYAML(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "create dir")
}
func TestCrowdSecWhitelistService_WriteYAML_WriteFileError(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
tmpDir := t.TempDir()
svc := services.NewCrowdSecWhitelistService(db, tmpDir)
// Create a directory where the .tmp file would be written, causing WriteFile to fail
dir := filepath.Join(tmpDir, "config", "parsers", "s02-enrich")
require.NoError(t, os.MkdirAll(dir, 0o750))
tmpTarget := filepath.Join(dir, "charon-whitelist.yaml.tmp")
require.NoError(t, os.MkdirAll(tmpTarget, 0o750))
err := svc.WriteYAML(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "write temp")
}
func TestCrowdSecWhitelistService_Add_WriteYAMLWarning(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
// dataDir that will cause MkdirAll to fail inside WriteYAML (non-fatal)
svc := services.NewCrowdSecWhitelistService(db, "/dev/null/impossible")
entry, err := svc.Add(context.Background(), "2.2.2.2", "yaml warn test")
require.NoError(t, err)
assert.Equal(t, "2.2.2.2", entry.IPOrCIDR)
}
func TestCrowdSecWhitelistService_Delete_WriteYAMLWarning(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
// First add with empty dataDir so it succeeds
svcAdd := services.NewCrowdSecWhitelistService(db, "")
entry, err := svcAdd.Add(context.Background(), "3.3.3.3", "to delete")
require.NoError(t, err)
// Now create a service with a broken dataDir and delete
svcDel := services.NewCrowdSecWhitelistService(db, "/dev/null/impossible")
err = svcDel.Delete(context.Background(), entry.UUID)
require.NoError(t, err)
}
func TestCrowdSecWhitelistService_WriteYAML_RenameError(t *testing.T) {
t.Parallel()
db := openWhitelistTestDB(t)
tmpDir := t.TempDir()
svc := services.NewCrowdSecWhitelistService(db, tmpDir)
// Create target as a directory so rename (atomic replace) fails
dir := filepath.Join(tmpDir, "config", "parsers", "s02-enrich")
require.NoError(t, os.MkdirAll(dir, 0o750))
target := filepath.Join(dir, "charon-whitelist.yaml")
require.NoError(t, os.MkdirAll(target, 0o750))
err := svc.WriteYAML(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "rename")
}
func TestCrowdSecWhitelistService_Add_InvalidCIDR(t *testing.T) {
t.Parallel()
svc := services.NewCrowdSecWhitelistService(openWhitelistTestDB(t), "")
_, err := svc.Add(context.Background(), "not-an-ip/24", "invalid cidr with slash")
assert.ErrorIs(t, err, services.ErrInvalidIPOrCIDR)
}

View File

@@ -24,6 +24,7 @@ echo "Installing base parsers..."
cscli parsers install crowdsecurity/http-logs --force || echo "⚠️ Failed to install crowdsecurity/http-logs"
cscli parsers install crowdsecurity/syslog-logs --force || echo "⚠️ Failed to install crowdsecurity/syslog-logs"
cscli parsers install crowdsecurity/geoip-enrich --force || echo "⚠️ Failed to install crowdsecurity/geoip-enrich"
cscli parsers install crowdsecurity/whitelists --force || echo "⚠️ Failed to install crowdsecurity/whitelists"
# Install HTTP scenarios for attack detection
echo "Installing HTTP scenarios..."

View File

@@ -0,0 +1,460 @@
# Coverage Improvement Plan — Patch Coverage ≥ 90%
**Date**: 2026-05-02
**Status**: Draft — Awaiting Approval
**Priority**: High
**Archived Previous Plan**: Custom Certificate Upload & Management (Issue #22) → `docs/plans/archive/custom-cert-upload-management-spec-2026-05-02.md`
---
## 1. Introduction
This plan identifies exact uncovered branches across the six highest-gap backend source files and two frontend components, and specifies new test cases to close those gaps. The target is to raise overall patch coverage from **85.61% (206 missing lines)** to **≥ 90%**.
**Constraints**:
- No source file modifications — test files only
- Go tests placed in `*_patch_coverage_test.go` (same package as source)
- Frontend tests extend existing `__tests__/*.test.tsx` files
- Use testify (Go) and Vitest + React Testing Library (frontend)
---
## 2. Research Findings
### 2.1 Coverage Gap Summary
| Package | File | Missing Lines | Current Coverage |
|---|---|---|---|
| `handlers` | `certificate_handler.go` | ~54 | 70.28% |
| `services` | `certificate_service.go` | ~54 | 82.85% |
| `services` | `certificate_validator.go` | ~18 | 88.68% |
| `handlers` | `proxy_host_handler.go` | ~12 | 55.17% |
| `config` | `config.go` | ~8 | ~92% |
| `caddy` | `manager.go` | ~10 | ~88% |
| Frontend | `CertificateList.tsx` | moderate | — |
| Frontend | `CertificateUploadDialog.tsx` | moderate | — |
### 2.2 Test Infrastructure (Confirmed)
- **In-memory DB**: `gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})`
- **Mock auth**: `mockAuthMiddleware()` from `coverage_helpers_test.go`
- **Mock backup service**: `&mockBackupService{createFunc: ..., availableSpaceFunc: ...}`
- **Manager test hooks**: package-level `generateConfigFunc`, `validateConfigFunc`, `writeFileFunc` vars with `defer` restore pattern
- **Frontend mocks**: `vi.mock('../../hooks/...', ...)` and `vi.mock('react-i18next', ...)`
### 2.3 Existing Patch Test Files
| File | Existing Tests |
|---|---|
| `certificate_handler_patch_coverage_test.go` | `TestDelete_UUID_WithBackup_Success`, `_NotFound`, `_InUse` |
| `certificate_service_patch_coverage_test.go` | `TestExportCertificate_DER`, `_PFX`, `_P12`, `_UnsupportedFormat` |
| `certificate_validator_extra_coverage_test.go` | ECDSA/Ed25519 key match, `ConvertDERToPEM` valid/invalid |
| `manager_patch_coverage_test.go` | DNS provider encryption key paths |
| `proxy_host_handler_test.go` | Full CRUD + BulkUpdateACL + BulkUpdateSecurityHeaders |
| `proxy_host_handler_update_test.go` | Update edge cases, `ParseForwardPortField`, `ParseNullableUintField` |
---
## 3. Technical Specifications — Per-File Gap Analysis
### 3.1 `certificate_handler.go` — Export Re-Auth Path (~18 lines)
The `Export` handler re-authenticates the user when `include_key=true`. All six guard branches are uncovered.
**Gap location**: Lines ~260320 (password empty check, `user` context key extraction, `map[string]any` cast, `id` field lookup, DB user lookup, bcrypt check)
**New tests** (append to `certificate_handler_patch_coverage_test.go`):
| Test Name | Scenario | Expected |
|---|---|---|
| `TestExport_IncludeKey_MissingPassword` | POST with `include_key=true`, no `password` field | 403 |
| `TestExport_IncludeKey_NoUserContext` | No `"user"` key in gin context | 403 |
| `TestExport_IncludeKey_InvalidClaimsType` | `"user"` set to a plain string | 403 |
| `TestExport_IncludeKey_UserIDNotInClaims` | `user = map[string]any{}` with no `"id"` key | 403 |
| `TestExport_IncludeKey_UserNotFoundInDB` | Valid claims, no matching user row | 403 |
| `TestExport_IncludeKey_WrongPassword` | User in DB, wrong plaintext password submitted | 403 |
### 3.2 `certificate_handler.go` — Export Service Errors (~4 lines)
**Gap location**: After `ExportCertificate` call — ErrCertNotFound and generic error branches
| Test Name | Scenario | Expected |
|---|---|---|
| `TestExport_CertNotFound` | Unknown UUID | 404 |
| `TestExport_ServiceError` | Service returns non-not-found error | 500 |
### 3.3 `certificate_handler.go` — Delete Numeric-ID Error Paths (~12 lines)
**Gap location**: `IsCertificateInUse` error, disk space check, backup error, `DeleteCertificateByID` returning `ErrCertInUse` or generic error
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDelete_NumericID_UsageCheckError` | `IsCertificateInUse` returns error | 500 |
| `TestDelete_NumericID_LowDiskSpace` | `availableSpaceFunc` returns 0 | 507 |
| `TestDelete_NumericID_BackupError` | `createFunc` returns error | 500 |
| `TestDelete_NumericID_CertInUse_FromService` | `DeleteCertificateByID``ErrCertInUse` | 409 |
| `TestDelete_NumericID_DeleteError` | `DeleteCertificateByID` → generic error | 500 |
### 3.4 `certificate_handler.go` — Delete UUID Additional Error Paths (~8 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDelete_UUID_UsageCheckInternalError` | `IsCertificateInUseByUUID` returns non-ErrCertNotFound error | 500 |
| `TestDelete_UUID_LowDiskSpace` | `availableSpaceFunc` returns 0 | 507 |
| `TestDelete_UUID_BackupCreationError` | `createFunc` returns error | 500 |
| `TestDelete_UUID_CertInUse_FromService` | `DeleteCertificate``ErrCertInUse` | 409 |
### 3.5 `certificate_handler.go` — Upload/Validate File Open Errors (~8 lines)
**Gap location**: `file.Open()` calls on multipart key and chain form files returning errors
| Test Name | Scenario | Expected |
|---|---|---|
| `TestUpload_KeyFile_OpenError` | Valid cert file, malformed key multipart entry | 500 |
| `TestUpload_ChainFile_OpenError` | Valid cert+key, malformed chain multipart entry | 500 |
| `TestValidate_KeyFile_OpenError` | Valid cert, malformed key multipart entry | 500 |
| `TestValidate_ChainFile_OpenError` | Valid cert+key, malformed chain multipart entry | 500 |
### 3.6 `certificate_handler.go` — `sendDeleteNotification` Rate-Limit (~2 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestSendDeleteNotification_RateLimit` | Call `sendDeleteNotification` twice within 10-second window | Second call is a no-op |
---
### 3.7 `certificate_service.go` — `SyncFromDisk` Branches (~14 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestSyncFromDisk_StagingToProductionUpgrade` | DB has staging cert, disk has production cert for same domain | DB cert updated to production provider |
| `TestSyncFromDisk_ExpiryOnlyUpdate` | Disk cert content matches DB cert, only expiry changed | Only `expires_at` column updated |
| `TestSyncFromDisk_CertRootStatPermissionError` | `os.Chmod(certRoot, 0)` before sync; add skip guard `if os.Getuid() == 0 { t.Skip("chmod permission test cannot run as root") }` | No panic; logs error; function completes |
### 3.8 `certificate_service.go` — `ListCertificates` Background Goroutine (~4 lines)
**Gap location**: `initialized=true` && TTL expired path → spawns background goroutine
| Test Name | Scenario | Expected |
|---|---|---|
| `TestListCertificates_StaleCache_TriggersBackgroundSync` | `initialized=true`, `lastScan` = 10 min ago | Returns cached list without blocking; background sync completes |
*Use `require.Eventually(t, func() bool { return svc.lastScan.After(before) }, 2*time.Second, 10*time.Millisecond, "background sync did not update lastScan")` after the call — avoids flaky fixed sleeps.*
### 3.9 `certificate_service.go` — `GetDecryptedPrivateKey` Nil encSvc and Decrypt Failure (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestGetDecryptedPrivateKey_NoEncSvc` | Service with `nil` encSvc, cert has non-empty `PrivateKeyEncrypted` | Returns error |
| `TestGetDecryptedPrivateKey_DecryptFails` | encSvc configured, corrupted ciphertext in DB | Returns wrapped error |
### 3.10 `certificate_service.go` — `MigratePrivateKeys` Branches (~6 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestMigratePrivateKeys_NoEncSvc` | `encSvc == nil` | Returns nil; logs warning |
| `TestMigratePrivateKeys_WithRows` | DB has cert with `private_key` populated, valid encSvc | Row migrated: `private_key` cleared, `private_key_enc` set |
### 3.11 `certificate_service.go` — `UpdateCertificate` Errors (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestUpdateCertificate_NotFound` | Non-existent UUID | Returns `ErrCertNotFound` |
| `TestUpdateCertificate_DBSaveError` | Valid UUID, DB closed before Save | Returns wrapped error |
### 3.12 `certificate_service.go` — `DeleteCertificate` ACME File Cleanup (~8 lines)
**Gap location**: `cert.Provider == "letsencrypt"` branch → Walk certRoot and remove `.crt`/`.key`/`.json` files
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDeleteCertificate_LetsEncryptProvider_FileCleanup` | Create temp `.crt` matching cert domain, delete cert | `.crt` removed from disk |
| `TestDeleteCertificate_StagingProvider_FileCleanup` | Provider = `"letsencrypt-staging"` | Same cleanup behavior triggered |
### 3.13 `certificate_service.go` — `CheckExpiringCertificates` (~8 lines)
**Implementation** (lines ~9661020): queries `provider = 'custom'` certs expiring before `threshold`, iterates and sends notification for certs with `daysLeft <= warningDays`.
| Test Name | Scenario | Expected |
|---|---|---|
| `TestCheckExpiringCertificates_ExpiresInRange` | Custom cert `expires_at = now+5d`, warningDays=30 | Returns slice with 1 cert |
| `TestCheckExpiringCertificates_AlreadyExpired` | Custom cert `expires_at = yesterday` | Result contains cert with negative days |
| `TestCheckExpiringCertificates_DBError` | DB closed before query | Returns error |
---
### 3.14 `certificate_validator.go` — `DetectFormat` Password-Protected PFX (~2 lines)
**Gap location**: PFX where `pkcs12.DecodeAll("")` fails but first byte is `0x30` (ASN.1 SEQUENCE), DER parse also fails → returns `FormatPFX`
**New file**: `certificate_validator_patch_coverage_test.go`
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDetectFormat_PasswordProtectedPFX` | Generate PFX with non-empty password, call `DetectFormat` | Returns `FormatPFX` |
### 3.15 `certificate_validator.go` — `parsePEMPrivateKey` Additional Block Types (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestParsePEMPrivateKey_PKCS1RSA` | PEM block type `"RSA PRIVATE KEY"` (x509.MarshalPKCS1PrivateKey) | Returns RSA key |
| `TestParsePEMPrivateKey_EC` | PEM block type `"EC PRIVATE KEY"` (x509.MarshalECPrivateKey) | Returns ECDSA key |
### 3.16 `certificate_validator.go` — `detectKeyType` P-384 and Unknown Curves (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestDetectKeyType_ECDSAP384` | P-384 ECDSA key | Returns `"ECDSA-P384"` |
| `TestDetectKeyType_ECDSAUnknownCurve` | ECDSA key with custom/unknown curve (e.g. P-224) | Returns `"ECDSA"` |
### 3.17 `certificate_validator.go` — `ConvertPEMToPFX` Empty Chain (~2 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestConvertPEMToPFX_EmptyChain` | Valid cert+key PEM, empty chain string | Returns PFX bytes without error |
### 3.18 `certificate_validator.go` — `ConvertPEMToDER` Non-Certificate Block (~2 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestConvertPEMToDER_NonCertBlock` | PEM block type `"PRIVATE KEY"` | Returns nil data and error |
### 3.19 `certificate_validator.go` — `formatSerial` Nil BigInt (~2 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestFormatSerial_Nil` | `formatSerial(nil)` | Returns `""` |
---
### 3.20 `proxy_host_handler.go` — `generateForwardHostWarnings` Private IP (~2 lines)
**Gap location**: `net.ParseIP(forwardHost) != nil && network.IsPrivateIP(ip)` branch (non-Docker private IP)
**New file**: `proxy_host_handler_patch_coverage_test.go`
| Test Name | Scenario | Expected |
|---|---|---|
| `TestGenerateForwardHostWarnings_PrivateIP` | forwardHost = `"192.168.1.100"` (RFC-1918, non-Docker) | Returns warning with field `"forward_host"` |
### 3.21 `proxy_host_handler.go` — `BulkUpdateSecurityHeaders` Edge Cases (~4 lines)
| Test Name | Scenario | Expected |
|---|---|---|
| `TestBulkUpdateSecurityHeaders_AllFail_Rollback` | All UUIDs not found → `updated == 0` at end | 400, transaction rolled back |
| `TestBulkUpdateSecurityHeaders_ProfileDB_NonNotFoundError` | Profile lookup returns wrapped DB error | 500 |
---
### 3.22 Frontend: `CertificateList.tsx` — Untested Branches
**File**: `frontend/src/components/__tests__/CertificateList.test.tsx`
| Gap | New Test |
|---|---|
| `bulkDeleteMutation` success | `'calls bulkDeleteMutation.mutate with selected UUIDs on confirm'` |
| `bulkDeleteMutation` error | `'shows error toast on bulk delete failure'` |
| Sort direction toggle | `'toggles sort direction when same column clicked twice'` |
| `selectedIds` reconciliation | `'reconciles selectedIds when certificate list shrinks'` |
| Export dialog open | `'opens export dialog when export button clicked'` |
### 3.23 Frontend: `CertificateUploadDialog.tsx` — Untested Branches
**File**: `frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx`
| Gap | New Test |
|---|---|
| PFX hides key/chain zones | `'hides key and chain file inputs when PFX file selected'` |
| Upload success closes dialog | `'calls onOpenChange(false) on successful upload'` |
| Upload error shows toast | `'shows error toast when upload mutation fails'` |
| Validate result shown | `'displays validation result after validate clicked'` |
---
## 4. Implementation Plan
### Phase 1: Playwright Smoke Tests (Acceptance Gating)
Add smoke coverage to confirm certificate export and delete flows reach the backend.
**File**: `tests/certificate-coverage-smoke.spec.ts`
```typescript
import { test, expect } from '@playwright/test'
test.describe('Certificate Coverage Smoke', () => {
test('export dialog opens when export button clicked', async ({ page }) => {
await page.goto('/')
// navigate to Certificates, click export on a cert
// assert dialog visible
})
test('delete dialog opens for deletable certificate', async ({ page }) => {
await page.goto('/')
// assert delete confirmation dialog appears
})
})
```
### Phase 2: Backend — Handler Tests
**File**: `backend/internal/api/handlers/certificate_handler_patch_coverage_test.go`
**Action**: Append all tests from sections 3.13.6.
Setup pattern for handler tests:
```go
func setupCertHandlerTest(t *testing.T) (*gin.Engine, *CertificateHandler, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.User{}, &models.ProxyHost{}))
tmpDir := t.TempDir()
certSvc := services.NewCertificateService(tmpDir, db, nil)
backup := &mockBackupService{
availableSpaceFunc: func() (int64, error) { return 1 << 30, nil },
createFunc: func(string) (string, error) { return "/tmp/backup.db", nil },
}
h := NewCertificateHandler(certSvc, backup, nil)
h.SetDB(db)
r := gin.New()
r.Use(mockAuthMiddleware())
h.RegisterRoutes(r.Group("/api"))
return r, h, db
}
```
For `TestExport_IncludeKey_*` tests: inject user into gin context directly using a custom middleware wrapper that sets `"user"` (type `map[string]any`, field `"id"`) to the desired value.
### Phase 3: Backend — Service Tests
**File**: `backend/internal/services/certificate_service_patch_coverage_test.go`
**Action**: Append all tests from sections 3.73.13.
Setup pattern:
```go
func newTestSvc(t *testing.T) (*CertificateService, *gorm.DB, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
tmpDir := t.TempDir()
return NewCertificateService(tmpDir, db, nil), db, tmpDir
}
```
For `TestMigratePrivateKeys_WithRows`: use `db.Exec("INSERT INTO ssl_certificates (..., private_key) VALUES (...)` raw SQL to bypass GORM's `gorm:"-"` tag.
### Phase 4: Backend — Validator Tests
**File**: `backend/internal/services/certificate_validator_patch_coverage_test.go` (new)
Key helpers needed:
```go
// generatePKCS1RSAKeyPEM returns an RSA key in PKCS#1 "RSA PRIVATE KEY" PEM format.
func generatePKCS1RSAKeyPEM(t *testing.T) []byte {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
}
// generateECKeyPEM returns an EC key in "EC PRIVATE KEY" (SEC1) PEM format.
func generateECKeyPEM(t *testing.T, curve elliptic.Curve) []byte {
key, err := ecdsa.GenerateKey(curve, rand.Reader)
require.NoError(t, err)
b, err := x509.MarshalECPrivateKey(key)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b})
}
```
### Phase 5: Backend — Proxy Host Handler Tests
**File**: `backend/internal/api/handlers/proxy_host_handler_patch_coverage_test.go` (new)
Setup pattern mirrors existing `proxy_host_handler_test.go` — use in-memory SQLite, `mockAuthMiddleware`, and `mockCaddyManager` (already available via test hook vars).
### Phase 6: Frontend Tests
**Files**:
- `frontend/src/components/__tests__/CertificateList.test.tsx`
- `frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx`
Use existing mock structure; add new `it(...)` blocks inside existing `describe` blocks.
Frontend bulk delete success test pattern:
```typescript
it('calls bulkDeleteMutation.mutate with selected UUIDs on confirm', async () => {
const bulkDeleteFn = vi.fn()
mockUseBulkDeleteCertificates.mockReturnValue({
mutate: bulkDeleteFn,
isPending: false,
})
render(<CertificateList />)
// select checkboxes, click bulk delete, confirm dialog
expect(bulkDeleteFn).toHaveBeenCalledWith(['uuid-1', 'uuid-2'])
})
```
### Phase 7: Validation
1. `cd /projects/Charon && bash scripts/go-test-coverage.sh`
2. `cd /projects/Charon && bash scripts/frontend-test-coverage.sh`
3. `bash scripts/local-patch-report.sh` → verify `test-results/local-patch-report.md` shows ≥ 90%
4. `bash scripts/scan-gorm-security.sh --check` → zero CRITICAL/HIGH
---
## 5. Commit Slicing Strategy
**Decision**: One PR with 5 ordered, independently-reviewable commits.
**Rationale**: Four packages touched across two build systems (Go + Node). Atomic commits allow targeted revert if a mock approach proves brittle for a specific file, without rolling back unrelated coverage gains.
| # | Scope | Files | Dependencies | Validation Gate |
|---|---|---|---|---|
| **Commit 1** | Handler re-auth + delete + file-open errors | `certificate_handler_patch_coverage_test.go` (extend) | None | `go test ./backend/internal/api/handlers/...` |
| **Commit 2** | Service SyncFromDisk, ListCerts, GetDecryptedKey, Migrate, Update, Delete, CheckExpiring | `certificate_service_patch_coverage_test.go` (extend) | None | `go test ./backend/internal/services/...` |
| **Commit 3** | Validator DetectFormat, parsePEMPrivateKey, detectKeyType, ConvertPEMToPFX/DER, formatSerial | `certificate_validator_patch_coverage_test.go` (new) | Commit 2 not required (separate file) | `go test ./backend/internal/services/...` |
| **Commit 4** | Proxy host warnings + BulkUpdateSecurityHeaders edge cases | `proxy_host_handler_patch_coverage_test.go` (new) | None | `go test ./backend/internal/api/handlers/...` |
| **Commit 5** | Frontend CertificateList + CertificateUploadDialog | `CertificateList.test.tsx`, `CertificateUploadDialog.test.tsx` (extend) | None | `npm run test` |
**Rollback**: Any commit is safe to revert independently — all changes are additive test-only files.
**Contingency**: If the `Export` handler's re-auth tests require gin context injection that the current router wiring doesn't support cleanly, use a sub-router with a custom test middleware that pre-populates `"user"` (`map[string]any{"id": uint(1)}`) with the specific value under test, bypassing `mockAuthMiddleware` for those cases only.
---
## 6. Acceptance Criteria
- [ ] `go test -race ./backend/...` — all tests pass, no data races
- [ ] Backend patch coverage ≥ 90% for all modified Go files per `test-results/local-patch-report.md`
- [ ] `npm run test` — all Vitest tests pass
- [ ] Frontend patch coverage ≥ 90% for `CertificateList.tsx` and `CertificateUploadDialog.tsx`
- [ ] GORM security scan: zero CRITICAL/HIGH findings
- [ ] No new `//nolint` or `//nosec` directives introduced
- [ ] No source file modifications — test files only
- [ ] All new Go test names follow `TestFunctionName_Scenario` convention
- [ ] Previous spec archived to `docs/plans/archive/`
---
## 7. Estimated Coverage Impact
| File | Current | Estimated After | Lines Recovered |
|---|---|---|---|
| `certificate_handler.go` | 70.28% | ~85% | ~42 lines |
| `certificate_service.go` | 82.85% | ~92% | ~44 lines |
| `certificate_validator.go` | 88.68% | ~96% | ~18 lines |
| `proxy_host_handler.go` | 55.17% | ~60% | ~8 lines |
| `CertificateList.tsx` | moderate | high | ~15 lines |
| `CertificateUploadDialog.tsx` | moderate | high | ~12 lines |
| **Overall patch** | **85.61%** | **≥ 90%** | **~139 lines** |
> **Note**: Proxy host handler remains below 90% after this plan because the `Create`/`Update`/`Delete` handler paths require full Caddy manager mock integration. A follow-up plan should address these with a dedicated `mockCaddyManager` interface.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,226 @@
# QA Audit Report — CrowdSec IP Whitelist Management
**Feature Branch**: `feature/beta-release`
**Pull Request**: #952
**Repository**: Wikid82/Charon
**Audit Date**: 2026-04-16
**Auditor**: QA Security Agent
---
## Overall Verdict
### APPROVED WITH CONDITIONS
The CrowdSec IP Whitelist Management feature passes all critical quality and security gates. All feature-specific E2E tests pass across three browsers. Backend and frontend coverage exceed thresholds. No security vulnerabilities were found in the feature code. Two upstream HIGH CVEs in the Docker image and below-threshold overall patch coverage require tracking but do not block the release.
---
## Gate Results Summary
| # | Gate | Result | Detail |
|---|------|--------|--------|
| 1 | Playwright E2E | **PASS** | All CrowdSec whitelist tests passed; 14 pre-existing failures (unrelated) |
| 2 | Go Backend Coverage | **PASS** | 88.4% line coverage (threshold: 87%) |
| 3 | Frontend Coverage | **PASS** | 90.06% line coverage (threshold: 87%) |
| 4 | Patch Coverage | **WARN** | Overall 89.4% (threshold: 90%); Backend 88.0% PASS; Frontend 97.0% PASS |
| 5 | TypeScript Type Check | **PASS** | `npx tsc --noEmit` — 0 errors |
| 6 | Lefthook (Lint/Format) | **PASS** | All 6 hooks green |
| 7 | GORM Security Scan | **PASS** | 0 CRITICAL/HIGH/MEDIUM issues |
| 8 | Trivy Filesystem Scan | **PASS** | 0 CRITICAL/HIGH vulnerabilities |
| 9 | Trivy Docker Image Scan | **WARN** | 2 unique HIGH CVEs (upstream dependencies) |
| 10 | CodeQL SARIF Review | **PASS** | 1 pre-existing Go finding; 0 JS findings; 0 whitelist-related |
---
## Detailed Gate Analysis
### Gate 1 — Playwright E2E Tests
**Result**: PASS
**Browsers**: Chromium, Firefox, WebKit (all three)
**CrowdSec Whitelist-Specific Tests (10 tests)**: All PASSED
- Add whitelist entry with valid IP
- Add whitelist entry with valid CIDR
- Reject invalid IP/CIDR input
- Reject duplicate entry
- Delete whitelist entry
- Display whitelist table with entries
- Empty state display
- Whitelist tab visibility (local mode only)
- Form validation and error handling
- Toast notification on success/failure
**Pre-existing Failures (14 unique, unrelated to this feature)**:
- Certificate deletion tests (7): cert delete/bulk-delete operations
- Caddy import tests (3): conflict details, server detection, resolution
- Navigation test (1): main navigation item count
- User management tests (2): invite link copy, keyboard navigation
- Integration test (1): system health check
None of the pre-existing failures are related to the CrowdSec whitelist feature.
### Gate 2 — Go Backend Coverage
**Result**: PASS
**Coverage**: 88.4% line coverage
**Threshold**: 87%
### Gate 3 — Frontend Coverage
**Result**: PASS
**Coverage**: 90.06% line coverage (Statements: 89.03%, Branches: 85.84%, Functions: 85.85%)
**Threshold**: 87%
5 pre-existing test timeouts in `ProxyHostForm-dns.test.tsx` and `ProxyHostForm-dropdown-changes.test.tsx` — not whitelist-related.
### Gate 4 — Patch Coverage
**Result**: WARN (non-blocking)
| Scope | Changed Lines | Covered | Patch % | Status |
|-------|--------------|---------|---------|--------|
| Overall | 1689 | 1510 | 89.4% | WARN (threshold: 90%) |
| Backend | 1426 | 1255 | 88.0% | PASS (threshold: 85%) |
| Frontend | 263 | 255 | 97.0% | PASS (threshold: 85%) |
**CrowdSec-Specific Patch Coverage**:
- `crowdsec_handler.go`: 71.2% — 17 uncovered changed lines (error-handling branches)
- `crowdsec_whitelist_service.go`: 83.6% — 18 uncovered changed lines (YAML write failure, edge cases)
- `CrowdSecConfig.tsx`: 93.3% — 2 uncovered changed lines
**Recommendation**: Add targeted unit tests for error-handling branches in `crowdsec_handler.go` (lines 2712-2772) and `crowdsec_whitelist_service.go` (lines 47-148) to bring CrowdSec-specific patch coverage above 90%. This is tracked as a follow-up improvement and does not block release.
### Gate 5 — TypeScript Type Check
**Result**: PASS
`npx tsc --noEmit` from `frontend/` completed with 0 errors.
### Gate 6 — Lefthook (Lint/Format)
**Result**: PASS
All 6 hooks passed:
- `go-fmt`
- `go-vet`
- `go-staticcheck`
- `eslint`
- `prettier-check`
- `tsc-check`
### Gate 7 — GORM Security Scan
**Result**: PASS
`./scripts/scan-gorm-security.sh --check` — 0 CRITICAL, 0 HIGH, 0 MEDIUM issues.
No exposed IDs, secrets, or DTO embedding violations in CrowdSec whitelist models.
### Gate 8 — Trivy Filesystem Scan
**Result**: PASS
`trivy fs --scanners vuln --severity CRITICAL,HIGH --format table .` — 0 vulnerabilities detected in application source and dependencies.
### Gate 9 — Trivy Docker Image Scan
**Result**: WARN (non-blocking for this feature)
Image: `charon:local` (Alpine 3.23.3)
| CVE | Severity | Package | Installed | Fixed | Component |
|-----|----------|---------|-----------|-------|-----------|
| CVE-2026-34040 | HIGH | github.com/docker/docker | v28.5.2+incompatible | 29.3.1 | Charon Go binary (Moby authorization bypass) |
| CVE-2026-32286 | HIGH | github.com/jackc/pgproto3/v2 | v2.3.3 | No fix | CrowdSec binaries (PostgreSQL protocol DoS) |
**Analysis**:
- CVE-2026-34040: Moby authorization bypass — affects Docker API access control. Charon does not expose Docker API to untrusted networks. Low practical risk. Update `github.com/docker/docker` to v29.3.1 when available.
- CVE-2026-32286: PostgreSQL protocol DoS — present only in CrowdSec's `crowdsec` and `cscli` binaries, not in Charon's own code. Awaiting upstream fix from CrowdSec.
**Recommendation**: Track both CVEs for remediation. Neither impacts CrowdSec whitelist management functionality or Charon's own security posture directly.
### Gate 10 — CodeQL SARIF Review
**Result**: PASS
- **Go**: 1 pre-existing finding — `cookie-secure-not-set` at `auth_handler.go:152`. Not whitelist-related. Tracked separately.
- **JavaScript**: 0 findings.
- **CrowdSec whitelist**: 0 findings across both Go and JavaScript.
---
## Security Review — CrowdSec IP Whitelist Feature
### 1. IP/CIDR Input Validation
**Status**: SECURE
The `normalizeIPOrCIDR()` function in `crowdsec_whitelist_service.go` uses Go standard library functions `net.ParseIP()` and `net.ParseCIDR()` for validation. Invalid inputs are rejected with the sentinel error `ErrInvalidIPOrCIDR`. No user input passes through without validation.
### 2. YAML Injection Prevention
**Status**: SECURE
`buildWhitelistYAML()` uses a `strings.Builder` to construct YAML output. Only IP addresses and CIDR ranges that have already passed `normalizeIPOrCIDR()` validation are included. The normalized output from `net.ParseIP`/`net.ParseCIDR` cannot contain YAML metacharacters.
### 3. Path Traversal Protection
**Status**: SECURE
`WriteYAML()` uses hardcoded file paths (no user input in path construction). Atomic write pattern: writes to `.tmp` suffix, then `os.Rename()` to final path. No directory traversal vectors.
### 4. SQL Injection Prevention
**Status**: SECURE
All GORM queries use parameterized operations:
- `Where("uuid = ?", id)` for delete
- `Where("ip_or_cidr = ?", normalized)` for duplicate check
- Standard GORM `Create()` for inserts
No raw SQL or string concatenation.
### 5. Authentication & Authorization
**Status**: SECURE
All whitelist routes are registered under the admin route group in `routes.go`, which is protected by:
- Cerberus middleware (authentication/authorization enforcement)
- Emergency bypass middleware (for recovery scenarios only)
- Security headers and gzip middleware
No unauthenticated access to whitelist endpoints is possible.
### 6. Log Safety
**Status**: SECURE
Whitelist service logs only operational error context (e.g., "failed to write CrowdSec whitelist YAML after add"). No IP addresses, user data, or PII are written to logs. Other handler code uses `util.SanitizeForLog()` for user-controlled input in log messages.
---
## Conditions for Approval
These items are tracked as follow-up improvements and do not block merge:
1. **Patch Coverage Improvement**: Add targeted unit tests for error-handling branches in:
- `crowdsec_handler.go` (lines 2712-2772, 71.2% patch coverage)
- `crowdsec_whitelist_service.go` (lines 47-148, 83.6% patch coverage)
2. **Upstream CVE Tracking**:
- CVE-2026-34040: Update `github.com/docker/docker` to v29.3.1 when Go module is available
- CVE-2026-32286: Monitor CrowdSec upstream for `pgproto3` fix
3. **Pre-existing Test Failures**: 14 pre-existing E2E test failures (certificate deletion, caddy import, navigation, user management) should be tracked in existing issues. None are regressions from this feature.
---
## Artifacts
| Artifact | Location |
|----------|----------|
| Playwright HTML Report | `playwright-report/index.html` |
| Backend Coverage | `backend/coverage.txt` |
| Frontend Coverage | `frontend/coverage/lcov.info`, `frontend/coverage/coverage-summary.json` |
| Patch Coverage Report | `test-results/local-patch-report.md`, `test-results/local-patch-report.json` |
| GORM Security Scan | Inline (0 findings) |
| Trivy Filesystem Scan | Inline (0 findings) |
| Trivy Image Scan | `trivy-image-report.json` |
| CodeQL Go SARIF | `codeql-results-go.sarif` |
| CodeQL JS SARIF | `codeql-results-javascript.sarif` |

File diff suppressed because it is too large Load Diff

View File

@@ -33,19 +33,19 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.99.0",
"axios": "1.15.0",
"@tanstack/react-query": "^5.99.2",
"axios": "1.15.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"i18next": "^26.0.5",
"i18next": "^26.0.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.72.1",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.3",
"react-i18next": "^17.0.4",
"react-router-dom": "^7.14.1",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",
@@ -74,43 +74,43 @@
"@vitest/eslint-plugin": "^1.6.16",
"@vitest/ui": "^4.1.4",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.0",
"eslint": "^10.2.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-no-unsanitized": "^4.1.5",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-security": "^4.0.0",
"eslint-plugin-sonarjs": "^4.0.2",
"eslint-plugin-sonarjs": "^4.0.3",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"jsdom": "29.0.2",
"knip": "^6.4.1",
"knip": "^6.5.0",
"postcss": "^8.5.10",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.8",
"vite": "^8.0.9",
"vitest": "^4.1.4",
"zod-validation-error": "^5.0.0"
},
"overrides": {
"typescript": "^6.0.2",
"typescript": "^6.0.3",
"eslint-plugin-react-hooks": {
"eslint": "^10.2.0"
"eslint": "^10.2.1"
},
"eslint-plugin-jsx-a11y": {
"eslint": "^10.2.0"
"eslint": "^10.2.1"
},
"eslint-plugin-promise": {
"eslint": "^10.2.0"
"eslint": "^10.2.1"
},
"@vitejs/plugin-react": {
"vite": "8.0.8"
"vite": "8.0.9"
}
}
}

View File

@@ -116,6 +116,120 @@ describe('crowdsec API', () => {
})
})
describe('listCrowdsecDecisions', () => {
it('should call GET /admin/crowdsec/decisions and return data', async () => {
const mockData = {
decisions: [
{ id: '1', ip: '1.2.3.4', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'crowdsec' },
],
}
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await crowdsec.listCrowdsecDecisions()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/decisions')
expect(result).toEqual(mockData)
})
})
describe('banIP', () => {
it('should call POST /admin/crowdsec/ban with ip, duration, and reason', async () => {
vi.mocked(client.post).mockResolvedValue({})
await crowdsec.banIP('1.2.3.4', '24h', 'manual ban')
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/ban', {
ip: '1.2.3.4',
duration: '24h',
reason: 'manual ban',
})
})
})
describe('unbanIP', () => {
it('should call DELETE /admin/crowdsec/ban/{encoded ip}', async () => {
vi.mocked(client.delete).mockResolvedValue({})
await crowdsec.unbanIP('1.2.3.4')
expect(client.delete).toHaveBeenCalledWith('/admin/crowdsec/ban/1.2.3.4')
})
it('should URL-encode special characters in the IP', async () => {
vi.mocked(client.delete).mockResolvedValue({})
await crowdsec.unbanIP('::1')
expect(client.delete).toHaveBeenCalledWith('/admin/crowdsec/ban/%3A%3A1')
})
})
describe('getCrowdsecKeyStatus', () => {
it('should call GET /admin/crowdsec/key-status and return data', async () => {
const mockData = {
key_source: 'file' as const,
env_key_rejected: false,
current_key_preview: 'abc***xyz',
message: 'Key loaded from file',
}
vi.mocked(client.get).mockResolvedValue({ data: mockData })
const result = await crowdsec.getCrowdsecKeyStatus()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/key-status')
expect(result).toEqual(mockData)
})
})
describe('listWhitelists', () => {
it('should call GET /admin/crowdsec/whitelist and return the whitelist array', async () => {
const mockWhitelist = [
{
uuid: 'uuid-1',
ip_or_cidr: '192.168.1.1',
reason: 'Home',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
]
vi.mocked(client.get).mockResolvedValue({ data: { whitelist: mockWhitelist } })
const result = await crowdsec.listWhitelists()
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/whitelist')
expect(result).toEqual(mockWhitelist)
})
})
describe('addWhitelist', () => {
it('should call POST /admin/crowdsec/whitelist and return the created entry', async () => {
const payload = { ip_or_cidr: '192.168.1.1', reason: 'Home' }
const mockEntry = {
uuid: 'uuid-1',
ip_or_cidr: '192.168.1.1',
reason: 'Home',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
}
vi.mocked(client.post).mockResolvedValue({ data: mockEntry })
const result = await crowdsec.addWhitelist(payload)
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/whitelist', payload)
expect(result).toEqual(mockEntry)
})
})
describe('deleteWhitelist', () => {
it('should call DELETE /admin/crowdsec/whitelist/{uuid}', async () => {
vi.mocked(client.delete).mockResolvedValue({})
await crowdsec.deleteWhitelist('uuid-1')
expect(client.delete).toHaveBeenCalledWith('/admin/crowdsec/whitelist/uuid-1')
})
})
describe('default export', () => {
it('should export all functions', () => {
expect(crowdsec.default).toHaveProperty('startCrowdsec')
@@ -126,6 +240,14 @@ describe('crowdsec API', () => {
expect(crowdsec.default).toHaveProperty('listCrowdsecFiles')
expect(crowdsec.default).toHaveProperty('readCrowdsecFile')
expect(crowdsec.default).toHaveProperty('writeCrowdsecFile')
expect(crowdsec.default).toHaveProperty('listCrowdsecDecisions')
expect(crowdsec.default).toHaveProperty('banIP')
expect(crowdsec.default).toHaveProperty('unbanIP')
expect(crowdsec.default).toHaveProperty('getCrowdsecKeyStatus')
expect(crowdsec.default).toHaveProperty('listWhitelists')
expect(crowdsec.default).toHaveProperty('addWhitelist')
expect(crowdsec.default).toHaveProperty('deleteWhitelist')
})
})
})

View File

@@ -156,4 +156,31 @@ export async function getCrowdsecKeyStatus(): Promise<CrowdSecKeyStatus> {
return resp.data
}
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, getCrowdsecKeyStatus }
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}`)
}
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, getCrowdsecKeyStatus, listWhitelists, addWhitelist, deleteWhitelist }

View File

@@ -0,0 +1,155 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { renderHook, act, waitFor } from '@testing-library/react'
import React from 'react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import * as crowdsecApi from '../../api/crowdsec'
import * as toastUtil from '../../utils/toast'
import { createTestQueryClient } from '../../test/createTestQueryClient'
import { useWhitelistEntries, useAddWhitelist, useDeleteWhitelist } from '../useCrowdSecWhitelist'
import type { CrowdSecWhitelistEntry } from '../../api/crowdsec'
vi.mock('../../api/crowdsec', () => ({
listWhitelists: vi.fn(),
addWhitelist: vi.fn(),
deleteWhitelist: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
const wrapper = ({ children }: { children: React.ReactNode }) => {
const qc = createTestQueryClient()
return React.createElement(QueryClientProvider, { client: qc }, children)
}
const mockEntry: CrowdSecWhitelistEntry = {
uuid: 'abc-123',
ip_or_cidr: '192.168.1.1',
reason: 'trusted device',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
describe('useWhitelistEntries', () => {
beforeEach(() => vi.clearAllMocks())
it('returns whitelist entries on success', async () => {
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue([mockEntry])
const { result } = renderHook(() => useWhitelistEntries(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual([mockEntry])
})
it('returns empty array when no entries', async () => {
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue([])
const { result } = renderHook(() => useWhitelistEntries(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual([])
})
})
describe('useAddWhitelist', () => {
beforeEach(() => vi.clearAllMocks())
it('calls addWhitelist and shows success toast on success', async () => {
vi.mocked(crowdsecApi.addWhitelist).mockResolvedValue(mockEntry)
const { result } = renderHook(() => useAddWhitelist(), { wrapper })
await act(async () => {
result.current.mutate({ ip_or_cidr: '192.168.1.1', reason: 'test' })
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(crowdsecApi.addWhitelist).toHaveBeenCalledWith({ ip_or_cidr: '192.168.1.1', reason: 'test' })
expect(toastUtil.toast.success).toHaveBeenCalledWith('Whitelist entry added')
})
it('shows error toast with server message on failure', async () => {
vi.mocked(crowdsecApi.addWhitelist).mockRejectedValue(new Error('IP already whitelisted'))
const { result } = renderHook(() => useAddWhitelist(), { wrapper })
await act(async () => {
result.current.mutate({ ip_or_cidr: '10.0.0.0/8', reason: '' })
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(toastUtil.toast.error).toHaveBeenCalledWith('IP already whitelisted')
})
it('shows generic error toast for non-Error failures', async () => {
vi.mocked(crowdsecApi.addWhitelist).mockRejectedValue('unexpected')
const { result } = renderHook(() => useAddWhitelist(), { wrapper })
await act(async () => {
result.current.mutate({ ip_or_cidr: '10.0.0.1', reason: '' })
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(toastUtil.toast.error).toHaveBeenCalledWith('Failed to add whitelist entry')
})
})
describe('useDeleteWhitelist', () => {
beforeEach(() => vi.clearAllMocks())
it('calls deleteWhitelist and shows success toast on success', async () => {
vi.mocked(crowdsecApi.deleteWhitelist).mockResolvedValue(undefined)
const { result } = renderHook(() => useDeleteWhitelist(), { wrapper })
await act(async () => {
result.current.mutate('abc-123')
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(crowdsecApi.deleteWhitelist).toHaveBeenCalledWith('abc-123')
expect(toastUtil.toast.success).toHaveBeenCalledWith('Whitelist entry removed')
})
it('shows error toast with server message on failure', async () => {
vi.mocked(crowdsecApi.deleteWhitelist).mockRejectedValue(new Error('Entry not found'))
const { result } = renderHook(() => useDeleteWhitelist(), { wrapper })
await act(async () => {
result.current.mutate('bad-uuid')
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(toastUtil.toast.error).toHaveBeenCalledWith('Entry not found')
})
it('shows generic error toast for non-Error failures', async () => {
vi.mocked(crowdsecApi.deleteWhitelist).mockRejectedValue(null)
const { result } = renderHook(() => useDeleteWhitelist(), { wrapper })
await act(async () => {
result.current.mutate('some-uuid')
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(toastUtil.toast.error).toHaveBeenCalledWith('Failed to remove whitelist entry')
})
})

View File

@@ -0,0 +1,38 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { listWhitelists, addWhitelist, deleteWhitelist, type AddWhitelistPayload } from '../api/crowdsec'
import { toast } from '../utils/toast'
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')
},
})
}

View File

@@ -6,10 +6,11 @@ import { useTranslation } from 'react-i18next'
import { useNavigate, Link } from 'react-router-dom'
import { createBackup } from '../api/backups'
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, type CrowdSecDecision, statusCrowdsec, type CrowdSecStatus, startCrowdsec } from '../api/crowdsec'
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, type CrowdSecDecision, type CrowdSecWhitelistEntry, statusCrowdsec, type CrowdSecStatus, startCrowdsec } from '../api/crowdsec'
import { getFeatureFlags } from '../api/featureFlags'
import { listCrowdsecPresets, pullCrowdsecPreset, applyCrowdsecPreset, getCrowdsecPresetCache } from '../api/presets'
import { getSecurityStatus } from '../api/security'
import { getMyIP } from '../api/system'
import { CrowdSecBouncerKeyDisplay } from '../components/CrowdSecBouncerKeyDisplay'
import { ConfigReloadOverlay } from '../components/LoadingStates'
import { Button } from '../components/ui/Button'
@@ -19,6 +20,7 @@ import { Skeleton } from '../components/ui/Skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/Tabs'
import { CROWDSEC_PRESETS, type CrowdsecPreset } from '../data/crowdsecPresets'
import { useConsoleStatus, useEnrollConsole, useClearConsoleEnrollment } from '../hooks/useConsoleEnrollment'
import { useWhitelistEntries, useAddWhitelist, useDeleteWhitelist } from '../hooks/useCrowdSecWhitelist'
import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
import { toast } from '../utils/toast'
@@ -36,6 +38,8 @@ export default function CrowdSecConfig() {
const [showBanModal, setShowBanModal] = useState(false)
const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' })
const [confirmUnban, setConfirmUnban] = useState<CrowdSecDecision | null>(null)
const [whitelistForm, setWhitelistForm] = useState<{ ip_or_cidr: string; reason: string }>({ ip_or_cidr: '', reason: '' })
const [confirmDeleteWhitelist, setConfirmDeleteWhitelist] = useState<CrowdSecWhitelistEntry | null>(null)
const [isApplyingPreset, setIsApplyingPreset] = useState(false)
const [presetPreview, setPresetPreview] = useState<string>('')
const [presetMeta, setPresetMeta] = useState<{ cacheKey?: string; etag?: string; retrievedAt?: string; source?: string } | null>(null)
@@ -361,6 +365,25 @@ export default function CrowdSecConfig() {
},
})
const whitelistQuery = useWhitelistEntries()
const addWhitelistMutation = useAddWhitelist()
const deleteWhitelistMutation = useDeleteWhitelist()
const whitelistInlineError = addWhitelistMutation.error instanceof Error
? addWhitelistMutation.error.message
: addWhitelistMutation.error != null
? 'Failed to add entry'
: null
const handleAddMyIP = async () => {
try {
const result = await getMyIP()
setWhitelistForm((prev) => ({ ...prev, ip_or_cidr: result.ip }))
} catch {
toast.error('Failed to detect your IP address')
}
}
const handleExport = async () => {
const defaultName = buildCrowdsecExportFilename()
const filename = promptCrowdsecFilename(defaultName)
@@ -517,7 +540,9 @@ export default function CrowdSecConfig() {
pullPresetMutation.isPending ||
isApplyingPreset ||
banMutation.isPending ||
unbanMutation.isPending
unbanMutation.isPending ||
addWhitelistMutation.isPending ||
deleteWhitelistMutation.isPending
// Determine contextual message
const getMessage = () => {
@@ -539,6 +564,12 @@ export default function CrowdSecConfig() {
if (unbanMutation.isPending) {
return { message: 'Guardian lowers shield...', submessage: 'Unbanning IP address' }
}
if (addWhitelistMutation.isPending) {
return { message: 'Guardian updates list...', submessage: 'Adding IP to whitelist' }
}
if (deleteWhitelistMutation.isPending) {
return { message: 'Guardian updates list...', submessage: 'Removing from whitelist' }
}
return { message: 'Strengthening the guard...', submessage: 'Configuration in progress' }
}
@@ -565,6 +596,7 @@ export default function CrowdSecConfig() {
<TabsList>
<TabsTrigger value="config">{t('security.crowdsec.tabs.config', 'Configuration')}</TabsTrigger>
<TabsTrigger value="dashboard">{t('security.crowdsec.tabs.dashboard', 'Dashboard')}</TabsTrigger>
{isLocalMode && <TabsTrigger value="whitelist">{t('crowdsecConfig.whitelist.tabLabel', 'Whitelist')}</TabsTrigger>}
</TabsList>
<TabsContent value="dashboard" className="mt-4">
@@ -1241,6 +1273,135 @@ export default function CrowdSecConfig() {
</Card>
</TabsContent>
{isLocalMode && (
<TabsContent value="whitelist" className="mt-4">
<Card>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-green-400" />
<h3 className="text-md font-semibold">{t('crowdsecConfig.whitelist.title', 'IP Whitelist')}</h3>
</div>
<p className="text-sm text-gray-400">
{t('crowdsecConfig.whitelist.description', 'Whitelisted IPs and CIDRs are never blocked by CrowdSec, even if they trigger alerts.')}
</p>
{/* Add entry form */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-[180px]">
<Input
id="whitelist-ip"
label={t('crowdsecConfig.whitelist.ipLabel', 'IP or CIDR')}
placeholder="192.168.1.1 or 10.0.0.0/8"
value={whitelistForm.ip_or_cidr}
onChange={(e) => {
setWhitelistForm((prev) => ({ ...prev, ip_or_cidr: e.target.value }))
if (addWhitelistMutation.error) addWhitelistMutation.reset()
}}
error={whitelistInlineError ?? undefined}
errorTestId="whitelist-ip-error"
aria-required={true}
data-testid="whitelist-ip-input"
/>
</div>
<div className="flex-1 min-w-[180px]">
<Input
id="whitelist-reason"
label={t('crowdsecConfig.whitelist.reasonLabel', 'Reason')}
placeholder={t('crowdsecConfig.whitelist.reasonPlaceholder', 'Optional reason')}
value={whitelistForm.reason}
onChange={(e) => setWhitelistForm((prev) => ({ ...prev, reason: e.target.value }))}
data-testid="whitelist-reason-input"
/>
</div>
<div className="flex gap-2 pb-0.5">
<Button
variant="secondary"
size="sm"
type="button"
onClick={handleAddMyIP}
data-testid="whitelist-add-my-ip-btn"
>
{t('crowdsecConfig.whitelist.addMyIp', 'Add My IP')}
</Button>
<Button
size="sm"
type="button"
onClick={() => {
addWhitelistMutation.mutate(whitelistForm, {
onSuccess: () => setWhitelistForm({ ip_or_cidr: '', reason: '' }),
})
}}
disabled={!whitelistForm.ip_or_cidr.trim() || addWhitelistMutation.isPending}
isLoading={addWhitelistMutation.isPending}
data-testid="whitelist-add-btn"
>
{t('common.add', 'Add')}
</Button>
</div>
</div>
{/* Entries table */}
{whitelistQuery.isLoading ? (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : !whitelistQuery.data?.length ? (
<p className="text-sm text-gray-500" data-testid="whitelist-empty">
{t('crowdsecConfig.whitelist.none', 'No whitelist entries')}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-2 px-3 text-gray-400 font-medium" scope="col">
{t('crowdsecConfig.whitelist.columnIp', 'IP / CIDR')}
</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium" scope="col">
{t('crowdsecConfig.whitelist.columnReason', 'Reason')}
</th>
<th className="text-left py-2 px-3 text-gray-400 font-medium" scope="col">
{t('crowdsecConfig.whitelist.columnAdded', 'Added')}
</th>
<th className="text-right py-2 px-3 text-gray-400 font-medium" scope="col">
{t('crowdsecConfig.bannedIps.actions')}
</th>
</tr>
</thead>
<tbody>
{whitelistQuery.data.map((entry) => (
<tr key={entry.uuid} className="border-b border-gray-800 hover:bg-gray-800/50">
<td className="py-2 px-3 font-mono text-white">{entry.ip_or_cidr}</td>
<td className="py-2 px-3 text-gray-300">{entry.reason || '-'}</td>
<td className="py-2 px-3 text-gray-300">
{entry.created_at ? new Date(entry.created_at).toLocaleString() : '-'}
</td>
<td className="py-2 px-3 text-right">
<Button
variant="danger"
size="sm"
onClick={() => setConfirmDeleteWhitelist(entry)}
aria-label={`${t('crowdsecConfig.whitelist.deleteAriaLabel', 'Remove whitelist entry for')} ${entry.ip_or_cidr}`}
data-testid="whitelist-delete-btn"
>
<Trash2 className="h-3 w-3 mr-1" />
{t('crowdsecConfig.whitelist.delete', 'Delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Card>
</TabsContent>
)}
</Tabs>
</div>
@@ -1386,6 +1547,54 @@ export default function CrowdSecConfig() {
</div>
</div>
)}
{/* Delete Whitelist Entry Modal */}
{confirmDeleteWhitelist && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="whitelist-delete-modal-title"
>
<button
type="button"
className="absolute inset-0 bg-black/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
onClick={() => setConfirmDeleteWhitelist(null)}
aria-label={t('common.close')}
/>
<div
className="relative z-10 bg-gray-900 rounded-lg border border-gray-700 p-6 max-w-md w-full mx-4 shadow-xl"
onKeyDown={(e) => {
if (e.key === 'Escape') setConfirmDeleteWhitelist(null)
}}
>
<h2 id="whitelist-delete-modal-title" className="text-lg font-semibold text-white mb-2">
{t('crowdsecConfig.whitelist.deleteModal.title', 'Remove Whitelist Entry')}
</h2>
<p className="text-sm text-gray-300 mb-4">
{t('crowdsecConfig.whitelist.deleteModal.body', 'Remove {{ip}} from the whitelist? CrowdSec may then block this IP if it triggers alerts.', { ip: confirmDeleteWhitelist.ip_or_cidr })}
</p>
<div className="flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => setConfirmDeleteWhitelist(null)}
autoFocus
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="danger"
onClick={() => deleteWhitelistMutation.mutate(confirmDeleteWhitelist.uuid, {
onSuccess: () => setConfirmDeleteWhitelist(null),
})}
isLoading={deleteWhitelistMutation.isPending}
>
{t('crowdsecConfig.whitelist.deleteModal.submit', 'Remove')}
</Button>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -435,7 +435,7 @@ export default function Security() {
<ShieldAlert className={`w-5 h-5 ${crowdsecChecked ? 'text-success' : 'text-content-muted'}`} />
</div>
<div>
<CardTitle className="text-base">{t('security.crowdsec')}</CardTitle>
<CardTitle className="text-base">{t('security.crowdsec.title')}</CardTitle>
<CardDescription>{t('security.crowdsecDescription')}</CardDescription>
</div>
</div>
@@ -485,7 +485,7 @@ export default function Security() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" size="sm">{t('security.layer2')}</Badge>
<Badge variant="primary" size="sm">{t('security.acl')}</Badge>
<Badge variant="primary" size="sm">{t('security.acl.badge')}</Badge>
</div>
<Badge variant={status.acl.enabled ? 'success' : 'default'}>
{status.acl.enabled ? t('common.enabled') : t('common.disabled')}
@@ -538,7 +538,7 @@ export default function Security() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" size="sm">{t('security.layer3')}</Badge>
<Badge variant="primary" size="sm">{t('security.waf')}</Badge>
<Badge variant="primary" size="sm">{t('security.waf.badge')}</Badge>
</div>
<Badge variant={status.waf.enabled ? 'success' : 'default'}>
{status.waf.enabled ? t('common.enabled') : t('common.disabled')}

View File

@@ -0,0 +1,321 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AxiosError } from 'axios'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import * as backupsApi from '../../api/backups'
import * as crowdsecApi from '../../api/crowdsec'
import * as featureFlagsApi from '../../api/featureFlags'
import * as presetsApi from '../../api/presets'
import * as securityApi from '../../api/security'
import * as settingsApi from '../../api/settings'
import * as systemApi from '../../api/system'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { toast } from '../../utils/toast'
import CrowdSecConfig from '../CrowdSecConfig'
vi.mock('../../api/security')
vi.mock('../../api/crowdsec')
vi.mock('../../api/presets')
vi.mock('../../api/backups')
vi.mock('../../api/settings')
vi.mock('../../api/featureFlags')
vi.mock('../../api/system')
vi.mock('../../hooks/useConsoleEnrollment', () => ({
useConsoleStatus: vi.fn(() => ({
data: {
status: 'not_enrolled',
key_present: false,
last_error: null,
last_attempt_at: null,
enrolled_at: null,
last_heartbeat_at: null,
correlation_id: 'corr-1',
tenant: 'default',
agent_name: 'charon-agent',
},
isLoading: false,
isRefetching: false,
})),
useEnrollConsole: vi.fn(() => ({
mutateAsync: vi.fn().mockResolvedValue({ status: 'enrolling', key_present: false }),
isPending: false,
})),
useClearConsoleEnrollment: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
}))
vi.mock('../../components/CrowdSecBouncerKeyDisplay', () => ({
CrowdSecBouncerKeyDisplay: () => null,
}))
vi.mock('../../utils/crowdsecExport', () => ({
buildCrowdsecExportFilename: vi.fn(() => 'crowdsec-default.tar.gz'),
promptCrowdsecFilename: vi.fn(() => 'crowdsec.tar.gz'),
downloadCrowdsecExport: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}))
// The i18n mock in test setup returns the translation key when no translation is found.
// These constants keep assertions in sync with what the component actually renders.
const TAB_WHITELIST = 'crowdsecConfig.whitelist.tabLabel'
const MODAL_TITLE = 'crowdsecConfig.whitelist.deleteModal.title'
const BTN_REMOVE = 'crowdsecConfig.whitelist.deleteModal.submit'
const baseStatus = {
cerberus: { enabled: true },
crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
waf: { enabled: true, mode: 'enabled' as const },
rate_limit: { enabled: true },
acl: { enabled: true },
}
const axiosError = (status: number, message: string, data?: Record<string, unknown>) =>
new AxiosError(message, undefined, undefined, undefined, {
status,
statusText: String(status),
headers: {},
config: {},
data: data ?? { error: message },
} as never)
const mockWhitelistEntries = [
{
uuid: 'uuid-1',
ip_or_cidr: '192.168.1.1',
reason: 'Home IP',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
{
uuid: 'uuid-2',
ip_or_cidr: '10.0.0.0/8',
reason: 'LAN',
created_at: '2024-01-02T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
},
]
const renderPage = async () => {
renderWithQueryClient(<CrowdSecConfig />)
await waitFor(() => screen.getByText('CrowdSec Configuration'))
}
const goToWhitelistTab = async () => {
await userEvent.click(screen.getByRole('tab', { name: TAB_WHITELIST }))
await waitFor(() => screen.getByTestId('whitelist-ip-input'))
}
describe('CrowdSecConfig whitelist tab', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(baseStatus)
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123, lapi_ready: true })
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue(undefined)
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
vi.mocked(crowdsecApi.banIP).mockResolvedValue(undefined)
vi.mocked(crowdsecApi.unbanIP).mockResolvedValue(undefined)
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue(undefined)
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue([])
vi.mocked(crowdsecApi.addWhitelist).mockResolvedValue({
uuid: 'uuid-new',
ip_or_cidr: '1.2.3.4',
reason: '',
created_at: '',
updated_at: '',
})
vi.mocked(crowdsecApi.deleteWhitelist).mockResolvedValue(undefined)
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] })
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
status: 'pulled',
slug: '',
preview: '',
cache_key: '',
etag: '',
retrieved_at: '',
source: 'hub',
})
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({
status: 'applied',
backup: '',
reload_hint: false,
used_cscli: false,
cache_key: '',
slug: '',
})
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({
preview: '',
cache_key: '',
etag: '',
})
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
'feature.crowdsec.console_enrollment': false,
})
vi.mocked(systemApi.getMyIP).mockResolvedValue({ ip: '203.0.113.1', source: 'cloudflare' })
})
it('shows whitelist tab trigger in local mode', async () => {
await renderPage()
expect(screen.getByRole('tab', { name: TAB_WHITELIST })).toBeInTheDocument()
})
it('does not show whitelist tab in disabled mode', async () => {
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
...baseStatus,
crowdsec: { enabled: true, mode: 'disabled' as const, api_url: '' },
})
await renderPage()
expect(screen.queryByRole('tab', { name: TAB_WHITELIST })).not.toBeInTheDocument()
})
it('shows empty state when there are no whitelist entries', async () => {
await renderPage()
await goToWhitelistTab()
expect(screen.getByTestId('whitelist-empty')).toBeInTheDocument()
})
it('renders whitelist entries in the table', async () => {
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue(mockWhitelistEntries)
await renderPage()
await goToWhitelistTab()
expect(await screen.findByText('192.168.1.1')).toBeInTheDocument()
expect(screen.getByText('10.0.0.0/8')).toBeInTheDocument()
expect(screen.getByText('Home IP')).toBeInTheDocument()
expect(screen.getByText('LAN')).toBeInTheDocument()
})
it('submits a new whitelist entry', async () => {
await renderPage()
await goToWhitelistTab()
await userEvent.type(screen.getByTestId('whitelist-ip-input'), '1.2.3.4')
await userEvent.type(screen.getByTestId('whitelist-reason-input'), 'Test reason')
await userEvent.click(screen.getByTestId('whitelist-add-btn'))
await waitFor(() =>
expect(crowdsecApi.addWhitelist).toHaveBeenCalledWith({
ip_or_cidr: '1.2.3.4',
reason: 'Test reason',
}),
)
})
it('shows add-whitelist loading overlay while mutation is pending', async () => {
let resolveAdd!: (v: (typeof mockWhitelistEntries)[0]) => void
vi.mocked(crowdsecApi.addWhitelist).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveAdd = resolve
}),
)
await renderPage()
await goToWhitelistTab()
await userEvent.type(screen.getByTestId('whitelist-ip-input'), '1.2.3.4')
await userEvent.click(screen.getByTestId('whitelist-add-btn'))
await waitFor(() => expect(screen.getByText('Adding IP to whitelist')).toBeInTheDocument())
resolveAdd(mockWhitelistEntries[0])
})
it('displays inline error when adding a whitelist entry fails', async () => {
vi.mocked(crowdsecApi.addWhitelist).mockRejectedValueOnce(
axiosError(400, 'Invalid IP', { error: 'bad ip format' }),
)
await renderPage()
await goToWhitelistTab()
await userEvent.type(screen.getByTestId('whitelist-ip-input'), 'bad-ip')
await userEvent.click(screen.getByTestId('whitelist-add-btn'))
await waitFor(() => expect(screen.getByTestId('whitelist-ip-error')).toBeInTheDocument())
})
it('opens delete confirmation dialog', async () => {
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue(mockWhitelistEntries)
await renderPage()
await goToWhitelistTab()
await userEvent.click((await screen.findAllByTestId('whitelist-delete-btn'))[0])
expect(await screen.findByRole('dialog', { name: MODAL_TITLE })).toBeInTheDocument()
})
it('cancels whitelist deletion via Cancel button', async () => {
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue(mockWhitelistEntries)
await renderPage()
await goToWhitelistTab()
await userEvent.click((await screen.findAllByTestId('whitelist-delete-btn'))[0])
await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: MODAL_TITLE })).not.toBeInTheDocument(),
)
expect(crowdsecApi.deleteWhitelist).not.toHaveBeenCalled()
})
it('confirms whitelist entry deletion via Remove button', async () => {
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue(mockWhitelistEntries)
await renderPage()
await goToWhitelistTab()
await userEvent.click((await screen.findAllByTestId('whitelist-delete-btn'))[0])
await userEvent.click(await screen.findByRole('button', { name: BTN_REMOVE }))
await waitFor(() => expect(crowdsecApi.deleteWhitelist).toHaveBeenCalledWith('uuid-1'))
})
it('shows delete-whitelist loading overlay while mutation is pending', async () => {
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue(mockWhitelistEntries)
let resolveDelete!: () => void
vi.mocked(crowdsecApi.deleteWhitelist).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveDelete = resolve
}),
)
await renderPage()
await goToWhitelistTab()
await userEvent.click((await screen.findAllByTestId('whitelist-delete-btn'))[0])
await userEvent.click(await screen.findByRole('button', { name: BTN_REMOVE }))
await waitFor(() => expect(screen.getByText('Removing from whitelist')).toBeInTheDocument())
resolveDelete()
})
it('closes delete dialog on Escape key', async () => {
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue(mockWhitelistEntries)
await renderPage()
await goToWhitelistTab()
await userEvent.click((await screen.findAllByTestId('whitelist-delete-btn'))[0])
expect(await screen.findByRole('dialog', { name: MODAL_TITLE })).toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: MODAL_TITLE })).not.toBeInTheDocument(),
)
})
it('closes delete dialog when backdrop is clicked', async () => {
vi.mocked(crowdsecApi.listWhitelists).mockResolvedValue(mockWhitelistEntries)
await renderPage()
await goToWhitelistTab()
await userEvent.click((await screen.findAllByTestId('whitelist-delete-btn'))[0])
expect(await screen.findByRole('dialog', { name: MODAL_TITLE })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /close/i }))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: MODAL_TITLE })).not.toBeInTheDocument(),
)
})
it('fills IP input when Add My IP is clicked', async () => {
await renderPage()
await goToWhitelistTab()
await userEvent.click(screen.getByTestId('whitelist-add-my-ip-btn'))
await waitFor(() => {
const input = screen.getByTestId('whitelist-ip-input') as HTMLInputElement
expect(input.value).toBe('203.0.113.1')
})
})
it('shows error toast when Add My IP request fails', async () => {
vi.mocked(systemApi.getMyIP).mockRejectedValueOnce(new Error('network error'))
await renderPage()
await goToWhitelistTab()
await userEvent.click(screen.getByTestId('whitelist-add-my-ip-btn'))
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('Failed to detect your IP address'),
)
})
})

View File

@@ -27,7 +27,7 @@ vi.mock('../../hooks/useRemoteServers', () => ({
vi.mock('../../hooks/useCertificates', () => ({
useCertificates: () => ({
certificates: [
{ id: 1, status: 'valid', domain: 'test.com' },
{ id: 1, status: 'valid', domain: 'test.com', domains: 'test.com,www.test.com' },
{ id: 2, status: 'expired', domain: 'expired.com' },
],
isLoading: false,
@@ -84,4 +84,5 @@ describe('Dashboard page', () => {
// "1 valid" still renders even though cert.domains is undefined
expect(screen.getByText('1 valid')).toBeInTheDocument()
})
})

View File

@@ -73,6 +73,7 @@ const securityTranslations: Record<string, string> = {
'security.waf': 'WAF',
'security.rate': 'Rate',
'security.crowdsec': 'CrowdSec',
'security.crowdsec.title': 'CrowdSec',
'security.crowdsecDescription': 'IP Reputation',
'security.crowdsecProtects': 'Blocks known attackers, botnets, and malicious IPs',
'security.crowdsecDisabledDescription': 'Enable to block known malicious IPs',

View File

@@ -447,18 +447,23 @@ describe('UsersPage', () => {
const user = userEvent.setup()
expect(await screen.findByText('Invite User')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Invite User/i }))
expect(await screen.findByPlaceholderText('user@example.com')).toBeInTheDocument()
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
vi.useFakeTimers()
try {
const emailInput = screen.getByPlaceholderText('user@example.com')
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
await act(async () => {
await vi.advanceTimersByTimeAsync(550)
})
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
}, { timeout: 2000 })
// Look for the preview URL content with ellipsis replacing the token
await waitFor(() => {
expect(screen.getByText('https://charon.example.com/accept-invite?token=...')).toBeInTheDocument()
}, { timeout: 2000 })
} finally {
vi.useRealTimers()
}
})
it('debounces URL preview for 500ms', async () => {

196
package-lock.json generated
View File

@@ -19,8 +19,8 @@
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"tar": "^7.5.13",
"typescript": "^6.0.2",
"vite": "^8.0.8",
"typescript": "^6.0.3",
"vite": "^8.0.9",
"vitest": "^4.1.4"
}
},
@@ -231,29 +231,43 @@
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -381,9 +395,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -407,9 +421,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"cpu": [
"arm64"
],
@@ -424,9 +438,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"cpu": [
"arm64"
],
@@ -441,9 +455,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"cpu": [
"x64"
],
@@ -458,9 +472,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"cpu": [
"x64"
],
@@ -475,9 +489,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"cpu": [
"arm"
],
@@ -492,9 +506,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"cpu": [
"arm64"
],
@@ -509,9 +523,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"cpu": [
"arm64"
],
@@ -526,9 +540,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"cpu": [
"ppc64"
],
@@ -543,9 +557,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"cpu": [
"s390x"
],
@@ -560,9 +574,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"cpu": [
"x64"
],
@@ -577,9 +591,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"cpu": [
"x64"
],
@@ -594,9 +608,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"cpu": [
"arm64"
],
@@ -611,9 +625,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"cpu": [
"wasm32"
],
@@ -623,16 +637,16 @@
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.3"
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"cpu": [
"arm64"
],
@@ -647,9 +661,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"cpu": [
"x64"
],
@@ -664,9 +678,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"dev": true,
"license": "MIT"
},
@@ -3606,14 +3620,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.15"
"@oxc-project/types": "=0.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -3622,21 +3636,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
}
},
"node_modules/run-parallel": {
@@ -4014,9 +4028,9 @@
}
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4086,17 +4100,17 @@
}
},
"node_modules/vite": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15"
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.16",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"

View File

@@ -27,8 +27,8 @@
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"tar": "^7.5.13",
"typescript": "^6.0.2",
"vite": "^8.0.8",
"typescript": "^6.0.3",
"vite": "^8.0.9",
"vitest": "^4.1.4"
}
}

View File

@@ -0,0 +1,406 @@
import { test, expect, request as playwrightRequest } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import {
withSecurityEnabled,
captureSecurityState,
setSecurityModuleEnabled,
} from './utils/security-helpers';
import { getStorageStateAuthHeaders } from './utils/api-helpers';
import { STORAGE_STATE } from './constants';
/**
* CrowdSec IP Whitelist Management E2E Tests
*
* Tests the whitelist tab on the CrowdSec configuration page (/security/crowdsec).
* The tab is conditionally rendered: it only appears when CrowdSec mode is not 'disabled'.
*
* Uses IPs in the 10.99.x.x range to avoid conflicts with real network addresses.
*
* NOTE: Uses request.newContext({ storageState }) instead of the `request` fixture because
* the auth cookie has `secure: true` which the fixture won't send over HTTP, but
* Playwright's APIRequestContext does send it.
*/
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:8080';
const TEST_IP_PREFIX = '10.99';
function createRequestContext(): Promise<APIRequestContext> {
return playwrightRequest.newContext({
baseURL: BASE_URL,
storageState: STORAGE_STATE,
extraHTTPHeaders: getStorageStateAuthHeaders(),
});
}
test.describe('CrowdSec IP Whitelist Management', () => {
// Serial mode prevents the tab-visibility test (which disables CrowdSec) from
// racing with the local-mode tests (which require CrowdSec enabled).
test.describe.configure({ mode: 'serial' });
test.describe('tab visibility', () => {
test('whitelist tab is hidden when CrowdSec is disabled', async ({ page }) => {
const rc = await createRequestContext();
const originalState = await captureSecurityState(rc);
if (originalState.crowdsec) {
await setSecurityModuleEnabled(rc, 'crowdsec', false);
}
try {
await test.step('Navigate to CrowdSec config page', async () => {
await page.goto('/security/crowdsec');
await page.waitForLoadState('networkidle');
});
await test.step('Verify whitelist tab is not present', async () => {
await expect(page.getByRole('tab', { name: 'Whitelist' })).not.toBeVisible();
});
} finally {
if (originalState.crowdsec) {
await setSecurityModuleEnabled(rc, 'crowdsec', true);
}
await rc.dispose();
}
});
});
test.describe('with CrowdSec in local mode', () => {
let rc: APIRequestContext;
let cleanupSecurity: () => Promise<void>;
test.beforeAll(async () => {
rc = await createRequestContext();
cleanupSecurity = await withSecurityEnabled(rc, { crowdsec: true, cerberus: true });
// Wait for CrowdSec to enter local mode (may take a few seconds after enabling)
for (let attempt = 0; attempt < 15; attempt++) {
const statusResp = await rc.get('/api/v1/security/status');
if (statusResp.ok()) {
const status = await statusResp.json();
if (status.crowdsec?.mode !== 'disabled') break;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
});
test.afterAll(async () => {
// Remove any leftover test entries before restoring security state
const resp = await rc.get('/api/v1/admin/crowdsec/whitelist');
if (resp.ok()) {
const data = await resp.json();
for (const entry of (data.whitelist ?? []) as Array<{ uuid: string; ip_or_cidr: string }>) {
if (entry.ip_or_cidr.startsWith(TEST_IP_PREFIX)) {
await rc.delete(`/api/v1/admin/crowdsec/whitelist/${entry.uuid}`);
}
}
}
await cleanupSecurity?.();
await rc.dispose();
});
test.beforeEach(async ({ page }) => {
await test.step('Open CrowdSec Whitelist tab', async () => {
// CrowdSec may take time to enter local mode after being enabled.
// Retry navigation until the Whitelist tab is visible.
const maxAttempts = 15;
let tabFound = false;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await page.goto('/security/crowdsec');
// Wait for network to settle so React Query status fetch completes
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
const whitelistTab = page.getByRole('tab', { name: 'Whitelist' });
const visible = await whitelistTab.isVisible().catch(() => false);
if (visible) {
await whitelistTab.click();
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
tabFound = true;
break;
}
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (!tabFound) {
// Fail with a clear error message if tab never appeared
await expect(page.getByRole('tab', { name: 'Whitelist' })).toBeVisible({
timeout: 1000,
});
}
});
});
test('displays empty state when no whitelist entries exist', async ({ page }) => {
await test.step('Verify empty state message and snapshot', async () => {
const emptyEl = page.getByTestId('whitelist-empty');
await expect(emptyEl).toBeVisible();
await expect(emptyEl).toHaveText('No whitelist entries');
await expect(emptyEl).toMatchAriaSnapshot(`
- paragraph: No whitelist entries
`);
});
});
test('adds a valid IPv4 address to the whitelist', async ({ page }) => {
const testIP = `${TEST_IP_PREFIX}.1.10`;
let addedUUID: string | null = null;
try {
await test.step('Fill IP address and reason fields', async () => {
await page.getByTestId('whitelist-ip-input').fill(testIP);
await page.getByTestId('whitelist-reason-input').fill('IPv4 E2E test entry');
});
await test.step('Submit the form and capture response', async () => {
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes('/api/v1/admin/crowdsec/whitelist') &&
resp.request().method() === 'POST'
);
await page.getByTestId('whitelist-add-btn').click();
const response = await responsePromise;
expect(response.status()).toBe(201);
const body = await response.json();
addedUUID = body.uuid as string;
});
await test.step('Verify the entry appears in the table', async () => {
await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('cell', { name: 'IPv4 E2E test entry' })).toBeVisible({ timeout: 10_000 });
});
} finally {
if (addedUUID) {
await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`);
}
}
});
test('adds a valid CIDR range to the whitelist', async ({ page }) => {
const testCIDR = `${TEST_IP_PREFIX}.2.0/24`;
let addedUUID: string | null = null;
try {
await test.step('Fill CIDR notation and reason', async () => {
await page.getByTestId('whitelist-ip-input').fill(testCIDR);
await page.getByTestId('whitelist-reason-input').fill('CIDR E2E test range');
});
await test.step('Submit the form and capture response', async () => {
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes('/api/v1/admin/crowdsec/whitelist') &&
resp.request().method() === 'POST'
);
await page.getByTestId('whitelist-add-btn').click();
const response = await responsePromise;
expect(response.status()).toBe(201);
const body = await response.json();
addedUUID = body.uuid as string;
});
await test.step('Verify CIDR entry appears in the table', async () => {
await expect(page.getByRole('cell', { name: testCIDR, exact: true })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('cell', { name: 'CIDR E2E test range' })).toBeVisible({ timeout: 10_000 });
});
} finally {
if (addedUUID) {
await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`);
}
}
});
test('"Add My IP" button pre-fills the detected client IP', async ({ page }) => {
const ipResp = await rc.get('/api/v1/system/my-ip');
expect(ipResp.ok()).toBeTruthy();
const { ip: detectedIP } = await ipResp.json() as { ip: string };
await test.step('Click the "Add My IP" button', async () => {
await page.getByTestId('whitelist-add-my-ip-btn').click();
});
await test.step('Verify the IP input is pre-filled with the detected IP', async () => {
await expect(page.getByTestId('whitelist-ip-input')).toHaveValue(detectedIP);
});
});
test('shows an inline validation error for an invalid IP address', async ({ page }) => {
await test.step('Fill the IP field with an invalid value', async () => {
await page.getByTestId('whitelist-ip-input').fill('not-an-ip');
});
await test.step('Submit the form', async () => {
await page.getByTestId('whitelist-add-btn').click();
});
await test.step('Verify the inline error element is visible with an error message', async () => {
const errorEl = page.getByTestId('whitelist-ip-error');
await expect(errorEl).toBeVisible();
await expect(errorEl).toContainText(/invalid/i);
});
});
test('shows a conflict error when adding a duplicate whitelist entry', async ({ page }) => {
const testIP = `${TEST_IP_PREFIX}.3.10`;
let addedUUID: string | null = null;
try {
await test.step('Pre-seed the whitelist entry via API', async () => {
const addResp = await rc.post('/api/v1/admin/crowdsec/whitelist', {
data: { ip_or_cidr: testIP, reason: 'duplicate seed' },
});
expect(addResp.status()).toBe(201);
const body = await addResp.json();
addedUUID = body.uuid as string;
});
await test.step('Reload the whitelist tab to see the seeded entry', async () => {
await page.goto('/security/crowdsec');
const whitelistTab = page.getByRole('tab', { name: 'Whitelist' });
await expect(whitelistTab).toBeVisible({ timeout: 15_000 });
await whitelistTab.click();
await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible({ timeout: 10_000 });
});
await test.step('Attempt to add the same IP again', async () => {
await page.getByTestId('whitelist-ip-input').fill(testIP);
await page.getByTestId('whitelist-add-btn').click();
});
await test.step('Verify the conflict error is shown inline', async () => {
const errorEl = page.getByTestId('whitelist-ip-error');
await expect(errorEl).toBeVisible();
await expect(errorEl).toContainText(/already exists/i);
});
} finally {
if (addedUUID) {
await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`);
}
}
});
test('removes a whitelist entry via the delete confirmation modal', async ({ page }) => {
const testIP = `${TEST_IP_PREFIX}.4.10`;
let addedUUID: string | null = null;
try {
await test.step('Pre-seed a whitelist entry via API', async () => {
const addResp = await rc.post('/api/v1/admin/crowdsec/whitelist', {
data: { ip_or_cidr: testIP, reason: 'delete modal test' },
});
expect(addResp.status()).toBe(201);
const body = await addResp.json();
addedUUID = body.uuid as string;
});
await test.step('Reload the whitelist tab to see the seeded entry', async () => {
await page.goto('/security/crowdsec');
const whitelistTab = page.getByRole('tab', { name: 'Whitelist' });
await expect(whitelistTab).toBeVisible({ timeout: 15_000 });
await whitelistTab.click();
await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible({ timeout: 10_000 });
});
await test.step('Click the delete button for the entry', async () => {
const deleteBtn = page.getByRole('button', {
name: new RegExp(`Remove whitelist entry for ${testIP.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'),
});
await expect(deleteBtn).toBeVisible();
await deleteBtn.click();
});
await test.step('Verify the confirmation modal appears', async () => {
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await expect(modal.locator('#whitelist-delete-modal-title')).toHaveText(
'Remove Whitelist Entry'
);
await expect(modal).toMatchAriaSnapshot(`
- dialog:
- heading "Remove Whitelist Entry" [level=2]
`);
});
await test.step('Confirm deletion and verify the entry is removed', async () => {
const deleteResponsePromise = page.waitForResponse(
(resp) =>
resp.url().includes('/api/v1/admin/crowdsec/whitelist/') &&
resp.request().method() === 'DELETE'
);
await page.getByRole('button', { name: 'Remove', exact: true }).click();
const deleteResponse = await deleteResponsePromise;
expect(deleteResponse.ok()).toBeTruthy();
addedUUID = null; // cleaned up by the UI action
await expect(page.getByRole('cell', { name: testIP, exact: true })).not.toBeVisible();
await expect(page.getByTestId('whitelist-empty')).toBeVisible();
});
} finally {
// Fallback cleanup if the UI delete failed
if (addedUUID) {
await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`);
}
}
});
test('delete confirmation modal is dismissed by the Cancel button', async ({ page }) => {
const testIP = `${TEST_IP_PREFIX}.5.10`;
let addedUUID: string | null = null;
try {
await test.step('Pre-seed a whitelist entry via API', async () => {
const addResp = await rc.post('/api/v1/admin/crowdsec/whitelist', {
data: { ip_or_cidr: testIP, reason: 'cancel modal test' },
});
expect(addResp.status()).toBe(201);
const body = await addResp.json();
addedUUID = body.uuid as string;
});
await test.step('Reload the whitelist tab', async () => {
await page.goto('/security/crowdsec');
const whitelistTab = page.getByRole('tab', { name: 'Whitelist' });
await expect(whitelistTab).toBeVisible({ timeout: 15_000 });
await whitelistTab.click();
await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible({ timeout: 10_000 });
});
await test.step('Open the delete modal', async () => {
const deleteBtn = page.getByRole('button', {
name: new RegExp(`Remove whitelist entry for ${testIP.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i'),
});
await deleteBtn.click();
await expect(page.getByRole('dialog')).toBeVisible();
});
await test.step('Cancel and verify the entry is still present', async () => {
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByRole('cell', { name: testIP, exact: true })).toBeVisible();
});
} finally {
if (addedUUID) {
await rc.delete(`/api/v1/admin/crowdsec/whitelist/${addedUUID}`);
}
}
});
test('add button is disabled when the IP field is empty', async ({ page }) => {
await test.step('Verify add button is disabled with empty IP field', async () => {
const ipInput = page.getByTestId('whitelist-ip-input');
const addBtn = page.getByTestId('whitelist-add-btn');
await expect(ipInput).toHaveValue('');
await expect(addBtn).toBeDisabled();
});
await test.step('Button becomes enabled when IP is entered', async () => {
await page.getByTestId('whitelist-ip-input').fill('192.168.1.1');
await expect(page.getByTestId('whitelist-add-btn')).toBeEnabled();
});
await test.step('Button returns to disabled state when IP is cleared', async () => {
await page.getByTestId('whitelist-ip-input').clear();
await expect(page.getByTestId('whitelist-add-btn')).toBeDisabled();
});
});
});
});