Compare commits
268 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d771381a1 | |||
| 3570c05805 | |||
| bfe535d36a | |||
| aaf52475ee | |||
| cd35f6d8c7 | |||
| 85b0bb1f5e | |||
| 36d561bbb8 | |||
| fccb1f06ac | |||
| cf46ff0a3b | |||
| a66659476d | |||
| 7a8b0343e4 | |||
| cc3077d709 | |||
| d1362a7fba | |||
| 4e9e1919a8 | |||
| f19f53ed9a | |||
| f062dc206e | |||
| a97cb334a2 | |||
| cf52a943b5 | |||
| 46d0ecc4fb | |||
| 348c5e5405 | |||
| 25dbe82360 | |||
| fc404da455 | |||
| ed27fb0da9 | |||
| afbd50b43f | |||
| ad2d30b525 | |||
| a570a3327f | |||
| 0fd00575a2 | |||
| a3d1ae3742 | |||
| 6f408f62ba | |||
| e92e7edd70 | |||
| 4e4c4581ea | |||
| 3f12ca05a3 | |||
| a681d6aa30 | |||
| 3632d0d88c | |||
| a1a9ab2ece | |||
| 9c203914dd | |||
| 6cfe8ca9f2 | |||
| 938b170d98 | |||
| 9d6d2cbe53 | |||
| 136dd7ef62 | |||
| f0c754cc52 | |||
| 28be62dee0 | |||
| 49bfbf3f76 | |||
| 2f90d936bf | |||
| 4a60400af9 | |||
| 18d0c235fa | |||
| fe8225753b | |||
| 273fb3cf21 | |||
| e3b6693402 | |||
| ac915f14c7 | |||
| 5ee52dd4d6 | |||
| b5fd5d5774 | |||
| ae4f5936b3 | |||
| 5017fdf4c1 | |||
| f0eda7c93c | |||
| f60a99d0bd | |||
| 1440b2722e | |||
| f58c96d29f | |||
| 3b92700b5b | |||
| 5c0a543669 | |||
| 317b695efb | |||
| 077e3c1d2b | |||
| b5c5ab0bc3 | |||
| a6188bf2f1 | |||
| 2ecd6dd9d4 | |||
| 16752f4bb1 | |||
| a75dd2dcdd | |||
| 63e79664cc | |||
| 005b7bdf5b | |||
| 0f143af5bc | |||
| 76fb800922 | |||
| 58f5295652 | |||
| 0917a1ae95 | |||
| 409dc0526f | |||
| 10259146df | |||
| 8cbd907d82 | |||
| ff5ef35a0f | |||
| fbb86b1cc3 | |||
| 0f995edbd1 | |||
| aaddb88488 | |||
| f79f0218c5 | |||
| d94c9ba623 | |||
| 0241de69f4 | |||
| f20e789a16 | |||
| 6f5c8873f9 | |||
| 7a12ab7928 | |||
| 871adca270 | |||
| dbff270d22 | |||
| 8e1b9d91e2 | |||
| 67bcef32e4 | |||
| 739104e029 | |||
| 2204b7bd35 | |||
| fdbba5b838 | |||
| 4ff65c83be | |||
| 3409e204eb | |||
| 61bb19e6f3 | |||
| 3cc979f5b8 | |||
| ef8f237233 | |||
| 43a63007a7 | |||
| 404aa92ea0 | |||
| 94356e7d4e | |||
| 63c9976e5f | |||
| 09ef4f579e | |||
| fbd94a031e | |||
| 6483a25555 | |||
| 61b73bc57b | |||
| d77d618de0 | |||
| 2cd19d8964 | |||
| 61d4e12c56 | |||
| 5c5c1eabfc | |||
| d9cc0ead71 | |||
| b78798b877 | |||
| e90ad34c28 | |||
| 1a559e3c64 | |||
| a83967daa3 | |||
| e374d6f7d2 | |||
| 7723d291ce | |||
| 386fcd8276 | |||
| 10f5e5dd1d | |||
| 89281c4255 | |||
| de7861abea | |||
| 25443d3319 | |||
| be279ba864 | |||
| 5fe1cf9265 | |||
| cdf7948575 | |||
| b04b94e429 | |||
| 0ff19f66b6 | |||
| bf583927c1 | |||
| 6ed8d8054f | |||
| 5c4a558486 | |||
| 2024ad1373 | |||
| 5c0185d5eb | |||
| c9e4916d43 | |||
| 75d945f706 | |||
| 99ab2202a2 | |||
| feaae052ac | |||
| 476e65e7dd | |||
| 24a5773637 | |||
| 0eb0e43d60 | |||
| 6f98962981 | |||
| 2b3b5c3ff2 | |||
| eb5518092f | |||
| 1b10198d50 | |||
| 449d316174 | |||
| 9356756065 | |||
| 5b3e005f2b | |||
| 7654acc710 | |||
| afb2901618 | |||
| 117fd51082 | |||
| b66ba3ad4d | |||
| cbe238b27d | |||
| f814706fe2 | |||
| fc508d01d7 | |||
| ba880083be | |||
| b657235870 | |||
| 132b78b317 | |||
| 25cb0528e2 | |||
| e9acaa61cc | |||
| 218ce5658e | |||
| 08a17d7716 | |||
| f9c43d50c6 | |||
| e348b5b2a3 | |||
| 678b442f5e | |||
| 2470861c4a | |||
| 9e201126a9 | |||
| 5b67808d13 | |||
| 68e3bee684 | |||
| 4081003051 | |||
| bd2b1bd8b7 | |||
| 5e033e4bef | |||
| 06ba9bc438 | |||
| 3339208e53 | |||
| 4fad52aef5 | |||
| 9664e379ea | |||
| 1e126996cb | |||
| f4115a2977 | |||
| c6fd201f90 | |||
| 6ed988dc5b | |||
| f34a9c4f37 | |||
| 940c42f341 | |||
| 759cff5e7f | |||
| 5a626715d6 | |||
| 82d18f11a5 | |||
| fb5fdb8c4e | |||
| 8ff3f305db | |||
| 06ceb9ef6f | |||
| 5a3b143127 | |||
| d28add1a73 | |||
| 70d2465429 | |||
| 3cc5126267 | |||
| 26fde2d649 | |||
| da2db85bfc | |||
| ccdc719501 | |||
| ac720f95df | |||
| 1913e9d739 | |||
| a7be6c304d | |||
| d89b86675c | |||
| fb69f3da12 | |||
| e1c0173e3d | |||
| 46fe59cf0a | |||
| 4a398185c2 | |||
| 122030269e | |||
| 5b436a883d | |||
| a1c88de3c4 | |||
| a6c6ce550e | |||
| 1af04987e0 | |||
| ad31bacc1c | |||
| bab8414666 | |||
| 0deffd37e7 | |||
| a98c9ed311 | |||
| 12a04b4744 | |||
| d97c08bada | |||
| ce335ff342 | |||
| cb16ac05a2 | |||
| 0917edb863 | |||
| 4d0df36e5e | |||
| 7b1861f5a9 | |||
| 29f6664ab0 | |||
| 690480e181 | |||
| c156183666 | |||
| d8e6d8d9a9 | |||
| 7591d2cda8 | |||
| aa2e7a1685 | |||
| 9a683c3231 | |||
| e5cebc091d | |||
| 15cdaa8294 | |||
| 32f2d25d58 | |||
| a9dcc007e5 | |||
| bf53712b7c | |||
| 2b4f60615f | |||
| bbaad17e97 | |||
| bc4c7c1406 | |||
| e13b49cfd2 | |||
| 4d4a5d3adb | |||
| 7983de9f2a | |||
| 0034968919 | |||
| 6cec0a67eb | |||
| f56fa41301 | |||
| b1a1a7a238 | |||
| 8381790b0b | |||
| 65228c5ee8 | |||
| b531a840e8 | |||
| 5a2e11878b | |||
| fcc60a0aa3 | |||
| fdbf1a66cd | |||
| e8a513541f | |||
| bc9f2cf882 | |||
| 1329b00ed5 | |||
| a9c5b5b2d8 | |||
| 4b9508a9be | |||
| dc1426ae31 | |||
| 72bfca2dc3 | |||
| 09f9f7eb3d | |||
| 9e71dd218b | |||
| ee5350d675 | |||
| 9424aca5e2 | |||
| 8fa0950138 | |||
| 1315d7a3ef | |||
| 63d7c5c0c4 | |||
| 79c8e660f5 | |||
| 7b640cc0af | |||
| 1f2b4c7d5e | |||
| 441c3dc947 | |||
| 735b9fdd0e | |||
| 45458df1bf | |||
| 427babd3c1 | |||
| 3fa1074ea9 | |||
| 51d997c6fb |
+3
-1
@@ -94,7 +94,7 @@ Configure the application via `docker-compose.yml`:
|
||||
| `CHARON_ENV` | `production` | Set to `development` for verbose logging (`CPM_ENV` supported for backward compatibility). |
|
||||
| `CHARON_HTTP_PORT` | `8080` | Port for the Web UI (`CPM_HTTP_PORT` supported for backward compatibility). |
|
||||
| `CHARON_DB_PATH` | `/app/data/charon.db` | Path to the SQLite database (`CPM_DB_PATH` supported for backward compatibility). |
|
||||
| `CHARON_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API (`CPM_CADDY_ADMIN_API` supported for backward compatibility). |
|
||||
| `CHARON_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API (`CPM_CADDY_ADMIN_API` supported for backward compatibility). Must resolve to an internal allowlisted host on port `2019`. |
|
||||
| `CHARON_CADDY_CONFIG_ROOT` | `/config` | Path to Caddy autosave configuration directory. |
|
||||
| `CHARON_CADDY_LOG_DIR` | `/var/log/caddy` | Directory for Caddy access logs. |
|
||||
| `CHARON_CROWDSEC_LOG_DIR` | `/var/log/crowdsec` | Directory for CrowdSec logs. |
|
||||
@@ -218,6 +218,8 @@ environment:
|
||||
- CPM_CADDY_ADMIN_API=http://your-caddy-host:2019
|
||||
```
|
||||
|
||||
If using a non-localhost internal hostname, add it to `CHARON_SSRF_INTERNAL_HOST_ALLOWLIST`.
|
||||
|
||||
**Warning**: Charon will replace Caddy's entire configuration. Backup first!
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
@@ -32,6 +32,8 @@ services:
|
||||
#- CPM_SECURITY_RATELIMIT_ENABLED=false
|
||||
#- CPM_SECURITY_ACL_ENABLED=false
|
||||
- FEATURE_CERBERUS_ENABLED=true
|
||||
# Docker socket group access: copy docker-compose.override.example.yml
|
||||
# to docker-compose.override.yml and set your host's docker GID.
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery
|
||||
- crowdsec_data:/app/data/crowdsec
|
||||
|
||||
@@ -27,6 +27,8 @@ services:
|
||||
- FEATURE_CERBERUS_ENABLED=true
|
||||
# Emergency "break-glass" token for security reset when ACL blocks access
|
||||
- CHARON_EMERGENCY_TOKEN=03e4682c1164f0c1cb8e17c99bd1a2d9156b59824dde41af3bb67c513e5c5e92
|
||||
# Docker socket group access: copy docker-compose.override.example.yml
|
||||
# to docker-compose.override.yml and set your host's docker GID.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
cap_add:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Docker Compose override — copy to docker-compose.override.yml to activate.
|
||||
#
|
||||
# Use case: grant the container access to the host Docker socket so that
|
||||
# Charon can discover running containers.
|
||||
#
|
||||
# 1. cp docker-compose.override.example.yml docker-compose.override.yml
|
||||
# 2. Uncomment the service that matches your compose file:
|
||||
# - "charon" for docker-compose.local.yml
|
||||
# - "app" for docker-compose.dev.yml
|
||||
# 3. Replace <GID> with the output of: stat -c '%g' /var/run/docker.sock
|
||||
# 4. docker compose up -d
|
||||
|
||||
services:
|
||||
# Uncomment for docker-compose.local.yml
|
||||
charon:
|
||||
group_add:
|
||||
- "<GID>" # e.g. "988" — run: stat -c '%g' /var/run/docker.sock
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
# Uncomment for docker-compose.dev.yml
|
||||
app:
|
||||
group_add:
|
||||
- "<GID>" # e.g. "988" — run: stat -c '%g' /var/run/docker.sock
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -85,6 +85,7 @@ services:
|
||||
- playwright_data:/app/data
|
||||
- playwright_caddy_data:/data
|
||||
- playwright_caddy_config:/config
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8080/api/v1/health"]
|
||||
interval: 5s
|
||||
@@ -111,6 +112,7 @@ services:
|
||||
volumes:
|
||||
- playwright_crowdsec_data:/var/lib/crowdsec/data
|
||||
- playwright_crowdsec_config:/etc/crowdsec
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "version"]
|
||||
interval: 10s
|
||||
|
||||
@@ -49,6 +49,8 @@ services:
|
||||
# True tmpfs for E2E test data - fresh on every run, in-memory only
|
||||
# mode=1777 allows any user to write (container runs as non-root)
|
||||
- /app/data:size=100M,mode=1777
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health || exit 1"]
|
||||
interval: 5s
|
||||
|
||||
@@ -27,30 +27,24 @@ get_group_by_gid() {
|
||||
}
|
||||
|
||||
create_group_with_gid() {
|
||||
local gid="$1"
|
||||
local name="$2"
|
||||
|
||||
if command -v addgroup >/dev/null 2>&1; then
|
||||
addgroup -g "$gid" "$name" 2>/dev/null || true
|
||||
addgroup -g "$1" "$2" 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v groupadd >/dev/null 2>&1; then
|
||||
groupadd -g "$gid" "$name" 2>/dev/null || true
|
||||
groupadd -g "$1" "$2" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
add_user_to_group() {
|
||||
local user="$1"
|
||||
local group="$2"
|
||||
|
||||
if command -v addgroup >/dev/null 2>&1; then
|
||||
addgroup "$user" "$group" 2>/dev/null || true
|
||||
addgroup "$1" "$2" 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v usermod >/dev/null 2>&1; then
|
||||
usermod -aG "$group" "$user" 2>/dev/null || true
|
||||
usermod -aG "$2" "$1" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -142,8 +136,15 @@ if [ -S "/var/run/docker.sock" ] && is_root; then
|
||||
fi
|
||||
fi
|
||||
elif [ -S "/var/run/docker.sock" ]; then
|
||||
echo "Note: Docker socket mounted but container is running non-root; skipping docker.sock group setup."
|
||||
echo " If Docker discovery is needed, run with matching group permissions (e.g., --group-add)"
|
||||
DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo "unknown")
|
||||
echo "Note: Docker socket mounted (GID=$DOCKER_SOCK_GID) but container is running non-root; skipping docker.sock group setup."
|
||||
echo " If Docker discovery is needed, add 'group_add: [\"$DOCKER_SOCK_GID\"]' to your compose service."
|
||||
if [ "$DOCKER_SOCK_GID" = "0" ]; then
|
||||
if [ "${ALLOW_DOCKER_SOCK_GID_0:-false}" != "true" ]; then
|
||||
echo "⚠️ WARNING: Docker socket GID is 0 (root group). group_add: [\"0\"] grants root-group access."
|
||||
echo " Set ALLOW_DOCKER_SOCK_GID_0=true to acknowledge this risk."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Note: Docker socket not found. Docker container discovery will be unavailable."
|
||||
fi
|
||||
@@ -191,7 +192,7 @@ if command -v cscli >/dev/null; then
|
||||
echo "Initializing persistent CrowdSec configuration..."
|
||||
|
||||
# Check if .dist has content
|
||||
if [ -d "/etc/crowdsec.dist" ] && [ -n "$(ls -A /etc/crowdsec.dist 2>/dev/null)" ]; then
|
||||
if [ -d "/etc/crowdsec.dist" ] && find /etc/crowdsec.dist -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then
|
||||
echo "Copying config from /etc/crowdsec.dist..."
|
||||
if ! cp -r /etc/crowdsec.dist/* "$CS_CONFIG_DIR/"; then
|
||||
echo "ERROR: Failed to copy config from /etc/crowdsec.dist"
|
||||
@@ -208,7 +209,7 @@ if command -v cscli >/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Successfully initialized config from .dist directory"
|
||||
elif [ -d "/etc/crowdsec" ] && [ ! -L "/etc/crowdsec" ] && [ -n "$(ls -A /etc/crowdsec 2>/dev/null)" ]; then
|
||||
elif [ -d "/etc/crowdsec" ] && [ ! -L "/etc/crowdsec" ] && find /etc/crowdsec -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then
|
||||
echo "Copying config from /etc/crowdsec (fallback)..."
|
||||
if ! cp -r /etc/crowdsec/* "$CS_CONFIG_DIR/"; then
|
||||
echo "ERROR: Failed to copy config from /etc/crowdsec (fallback)"
|
||||
@@ -248,7 +249,7 @@ if command -v cscli >/dev/null; then
|
||||
echo "Expected: /etc/crowdsec -> /app/data/crowdsec/config"
|
||||
echo "This indicates a critical build-time issue. Symlink must be created at build time as root."
|
||||
echo "DEBUG: Directory check:"
|
||||
ls -la /etc/ | grep crowdsec || echo " (no crowdsec entry found)"
|
||||
find /etc -mindepth 1 -maxdepth 1 -name '*crowdsec*' -exec ls -ld {} \; 2>/dev/null || echo " (no crowdsec entry found)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,9 +3,9 @@ name: 'Management'
|
||||
description: 'Engineering Director. Delegates ALL research and execution. DO NOT ask it to debug code directly.'
|
||||
argument-hint: 'The high-level goal (e.g., "Build the new Proxy Host Dashboard widget")'
|
||||
|
||||
tools: vscode/extensions, vscode/getProjectSetupInfo, vscode/installExtension, vscode/memory, vscode/openIntegratedBrowser, vscode/runCommand, vscode/askQuestions, vscode/vscodeAPI, execute, read, agent, 'github/*', 'github/*', 'io.github.goreleaser/mcp/*', edit, search, web, 'github/*', '', 'playwright/*', todo, vscode.mermaid-chat-features/renderMermaidDiagram, github.vscode-pull-request-github/issue_fetch, github.vscode-pull-request-github/labels_fetch, github.vscode-pull-request-github/notification_fetch, github.vscode-pull-request-github/doSearch, github.vscode-pull-request-github/activePullRequest, github.vscode-pull-request-github/openPullRequest, ms-azuretools.vscode-containers/containerToolsConfig, ms-python.python/getPythonEnvironmentInfo, ms-python.python/getPythonExecutableCommand, ms-python.python/installPythonPackage, ms-python.python/configurePythonEnvironment
|
||||
tools: vscode/extensions, vscode/getProjectSetupInfo, vscode/installExtension, vscode/memory, vscode/runCommand, vscode/vscodeAPI, vscode/askQuestions, execute, read, agent, edit, search, web, 'github/*', 'playwright/*', 'github/*', 'github/*', 'io.github.goreleaser/mcp/*', 'mcp-refactor-typescript/*', 'microsoftdocs/mcp/*', browser, vscode.mermaid-chat-features/renderMermaidDiagram, github.vscode-pull-request-github/issue_fetch, github.vscode-pull-request-github/labels_fetch, github.vscode-pull-request-github/notification_fetch, github.vscode-pull-request-github/doSearch, github.vscode-pull-request-github/activePullRequest, github.vscode-pull-request-github/pullRequestStatusChecks, github.vscode-pull-request-github/openPullRequest, ms-azuretools.vscode-containers/containerToolsConfig, ms-python.python/getPythonEnvironmentInfo, ms-python.python/getPythonExecutableCommand, ms-python.python/installPythonPackage, ms-python.python/configurePythonEnvironment, todo
|
||||
|
||||
|
||||
model: GPT-5.3-Codex (copilot)
|
||||
target: vscode
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
@@ -33,7 +33,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
|
||||
5. **Parallel Execution**:
|
||||
- You may delegate to `runSubagent` multiple times in parallel if tasks are independent. The only exception is `QA_Security`, which must run last as this validates the entire codebase after all changes.
|
||||
6. **Implementation Choices**:
|
||||
- When faced with multiple implementation options, ALWAYS choose the "Prroper" fix over a "Quick" fix. This ensures long-term maintainability and saves double work. The "Quick" fix will only cause more work later when the "Proper" fix is eventually needed.
|
||||
- When faced with multiple implementation options, ALWAYS choose the "Long Term" fix over a "Quick" fix. This ensures long-term maintainability and saves double work. The "Quick" fix will only cause more work later when the "Long Term" fix is eventually needed.
|
||||
</global_context>
|
||||
|
||||
<workflow>
|
||||
@@ -67,6 +67,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
|
||||
- Execute in PR slices, one slice at a time, in dependency order.
|
||||
- Require each slice to pass review + QA gates before starting the next slice.
|
||||
- Keep every slice deployable and independently testable.
|
||||
- **MANDATORY**: Implementation agents must perform linting and type checks locally before declaring their slice "DONE". This is a critical step that must not be skipped to avoid broken commits and security issues.
|
||||
|
||||
5. **Phase 5: Review**:
|
||||
- **Supervisor**: Call `Supervisor` to review the implementation against the plan. Provide feedback and ensure alignment with best practices.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -126,7 +126,7 @@ graph TB
|
||||
| **HTTP Framework** | Gin | Latest | Routing, middleware, HTTP handling |
|
||||
| **Database** | SQLite | 3.x | Embedded database |
|
||||
| **ORM** | GORM | Latest | Database abstraction layer |
|
||||
| **Reverse Proxy** | Caddy Server | 2.11.0-beta.2 | Embedded HTTP/HTTPS proxy |
|
||||
| **Reverse Proxy** | Caddy Server | 2.11.2 | Embedded HTTP/HTTPS proxy |
|
||||
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
|
||||
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
|
||||
| **Metrics** | Prometheus Client | Latest | Application metrics |
|
||||
|
||||
+46
-1
@@ -36,6 +36,19 @@
|
||||
"platformAutomerge": true,
|
||||
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track caddy-security plugin version in Dockerfile",
|
||||
"managerFilePatterns": [
|
||||
"/^Dockerfile$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"ARG CADDY_SECURITY_VERSION=(?<currentValue>[^\\s]+)"
|
||||
],
|
||||
"depNameTemplate": "github.com/greenpau/caddy-security",
|
||||
"datasourceTemplate": "go",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track Go dependencies patched in Dockerfile for Caddy CVE fixes",
|
||||
@@ -117,13 +130,45 @@
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track GO_VERSION in Actions workflows",
|
||||
"fileMatch": ["^\\.github/workflows/.*\\.yml$"],
|
||||
"managerFilePatterns": ["/^\\.github/workflows/.*\\.yml$/"],
|
||||
"matchStrings": [
|
||||
"GO_VERSION: ['\"]?(?<currentValue>[\\d\\.]+)['\"]?"
|
||||
],
|
||||
"depNameTemplate": "golang/go",
|
||||
"datasourceTemplate": "golang-version",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track Syft version in workflows and scripts",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.github/workflows/nightly-build\\.yml$/",
|
||||
"/^\\.github/skills/security-scan-docker-image-scripts/run\\.sh$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"SYFT_VERSION=\\\"v(?<currentValue>[^\\\"\\s]+)\\\"",
|
||||
"set_default_env \\\"SYFT_VERSION\\\" \\\"v(?<currentValue>[^\\\"]+)\\\""
|
||||
],
|
||||
"depNameTemplate": "anchore/syft",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"versioningTemplate": "semver",
|
||||
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track Grype version in workflows and scripts",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.github/workflows/supply-chain-pr\\.yml$/",
|
||||
"/^\\.github/skills/security-scan-docker-image-scripts/run\\.sh$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"anchore/grype/main/install\\.sh \\| sh -s -- -b /usr/local/bin v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)",
|
||||
"set_default_env \\\"GRYPE_VERSION\\\" \\\"v(?<currentValue>[^\\\"]+)\\\""
|
||||
],
|
||||
"depNameTemplate": "anchore/grype",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"versioningTemplate": "semver",
|
||||
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
version: 1
|
||||
effective_date: 2026-02-25
|
||||
scope:
|
||||
- local pre-commit manual security hooks
|
||||
- github actions security workflows
|
||||
|
||||
defaults:
|
||||
blocking:
|
||||
- critical
|
||||
- high
|
||||
medium:
|
||||
mode: risk-based
|
||||
default_action: report
|
||||
require_sla: true
|
||||
default_sla_days: 14
|
||||
escalation:
|
||||
trigger: high-signal class or repeated finding
|
||||
action: require issue + owner + due date
|
||||
low:
|
||||
action: report
|
||||
|
||||
codeql:
|
||||
severity_mapping:
|
||||
error: high_or_critical
|
||||
warning: medium_or_lower
|
||||
note: informational
|
||||
blocking_levels:
|
||||
- error
|
||||
warning_policy:
|
||||
default_action: report
|
||||
escalation_high_signal_rule_ids:
|
||||
- go/request-forgery
|
||||
- js/missing-rate-limiting
|
||||
- js/insecure-randomness
|
||||
|
||||
trivy:
|
||||
blocking_severities:
|
||||
- CRITICAL
|
||||
- HIGH
|
||||
medium_policy:
|
||||
action: report
|
||||
escalation: issue-with-sla
|
||||
|
||||
grype:
|
||||
blocking_severities:
|
||||
- Critical
|
||||
- High
|
||||
medium_policy:
|
||||
action: report
|
||||
escalation: issue-with-sla
|
||||
|
||||
enforcement_contract:
|
||||
codeql_local_vs_ci: "local and ci block on codeql error-level findings only"
|
||||
supply_chain_medium: "medium vulnerabilities are non-blocking by default and require explicit triage"
|
||||
auth_regression_guard: "state-changing routes must remain protected by auth middleware"
|
||||
@@ -32,7 +32,7 @@ cd "${PROJECT_ROOT}"
|
||||
validate_project_structure "backend" "scripts/go-test-coverage.sh" || error_exit "Invalid project structure"
|
||||
|
||||
# Set default environment variables
|
||||
set_default_env "CHARON_MIN_COVERAGE" "85"
|
||||
set_default_env "CHARON_MIN_COVERAGE" "87"
|
||||
set_default_env "PERF_MAX_MS_GETSTATUS_P95" "25ms"
|
||||
set_default_env "PERF_MAX_MS_GETSTATUS_P95_PARALLEL" "50ms"
|
||||
set_default_env "PERF_MAX_MS_LISTDECISIONS_P95" "75ms"
|
||||
|
||||
@@ -32,7 +32,7 @@ cd "${PROJECT_ROOT}"
|
||||
validate_project_structure "frontend" "scripts/frontend-test-coverage.sh" || error_exit "Invalid project structure"
|
||||
|
||||
# Set default environment variables
|
||||
set_default_env "CHARON_MIN_COVERAGE" "85"
|
||||
set_default_env "CHARON_MIN_COVERAGE" "87"
|
||||
|
||||
# Execute the legacy script
|
||||
log_step "EXECUTION" "Running frontend tests with coverage"
|
||||
|
||||
@@ -3,6 +3,8 @@ name: Go Benchmark
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -33,7 +35,7 @@ jobs:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
@@ -3,6 +3,8 @@ name: Upload Coverage to Codecov
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_backend:
|
||||
@@ -17,7 +19,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -43,7 +45,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
@@ -152,7 +154,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
pull_request:
|
||||
branches: [main, nightly, development]
|
||||
push:
|
||||
branches: [main, nightly, development, 'feature/**', 'fix/**']
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 3 * * 1' # Mondays 03:00 UTC
|
||||
@@ -39,14 +39,19 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
# Use github.ref (full ref path) instead of github.ref_name:
|
||||
# - push/schedule: resolves to refs/heads/<branch>, checking out latest HEAD
|
||||
# - pull_request: resolves to refs/pull/<n>/merge, the correct PR merge ref
|
||||
# github.ref_name fails for PRs because it yields "<n>/merge" which checkout
|
||||
# interprets as a branch name (refs/heads/<n>/merge) that does not exist.
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Verify CodeQL parity guard
|
||||
if: matrix.language == 'go'
|
||||
run: bash scripts/ci/check-codeql-parity.sh
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -57,7 +62,7 @@ jobs:
|
||||
|
||||
- name: Setup Go
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: 1.26.0
|
||||
cache-dependency-path: backend/go.sum
|
||||
@@ -86,10 +91,10 @@ jobs:
|
||||
run: mkdir -p sarif-results
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
uses: github/codeql-action/autobuild@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
output: sarif-results/${{ matrix.language }}
|
||||
@@ -122,10 +127,28 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
EFFECTIVE_LEVELS_JQ='[
|
||||
.runs[] as $run
|
||||
| $run.results[]
|
||||
| . as $result
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| ((
|
||||
$result.level
|
||||
// (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| (.defaultConfiguration.level // empty)
|
||||
][0] // empty)
|
||||
// ""
|
||||
) | ascii_downcase)
|
||||
]'
|
||||
|
||||
echo "Found SARIF file: $SARIF_FILE"
|
||||
ERROR_COUNT=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$SARIF_FILE")
|
||||
WARNING_COUNT=$(jq '[.runs[].results[] | select(.level == "warning")] | length' "$SARIF_FILE")
|
||||
NOTE_COUNT=$(jq '[.runs[].results[] | select(.level == "note")] | length' "$SARIF_FILE")
|
||||
ERROR_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"error\")) | length" "$SARIF_FILE")
|
||||
WARNING_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"warning\")) | length" "$SARIF_FILE")
|
||||
NOTE_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"note\")) | length" "$SARIF_FILE")
|
||||
|
||||
{
|
||||
echo "**Findings:**"
|
||||
@@ -135,14 +158,32 @@ jobs:
|
||||
echo ""
|
||||
|
||||
if [ "$ERROR_COUNT" -gt 0 ]; then
|
||||
echo "❌ **CRITICAL:** High-severity security issues found!"
|
||||
echo "❌ **BLOCKING:** CodeQL error-level security issues found"
|
||||
echo ""
|
||||
echo "### Top Issues:"
|
||||
echo '```'
|
||||
jq -r '.runs[].results[] | select(.level == "error") | "\(.ruleId): \(.message.text)"' "$SARIF_FILE" | head -5
|
||||
# shellcheck disable=SC2016
|
||||
jq -r '
|
||||
.runs[] as $run
|
||||
| $run.results[]
|
||||
| . as $result
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| ((
|
||||
$result.level
|
||||
// (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| (.defaultConfiguration.level // empty)
|
||||
][0] // empty)
|
||||
// ""
|
||||
) | ascii_downcase) as $effectiveLevel
|
||||
| select($effectiveLevel == "error")
|
||||
| "\($effectiveLevel): \($result.ruleId // \"<unknown-rule>\"): \($result.message.text)"
|
||||
' "$SARIF_FILE" | head -5
|
||||
echo '```'
|
||||
else
|
||||
echo "✅ No high-severity issues found"
|
||||
echo "✅ No blocking CodeQL issues found"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -169,9 +210,26 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ERROR_COUNT=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$SARIF_FILE")
|
||||
# shellcheck disable=SC2016
|
||||
ERROR_COUNT=$(jq -r '[
|
||||
.runs[] as $run
|
||||
| $run.results[]
|
||||
| . as $result
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| ((
|
||||
$result.level
|
||||
// (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| (.defaultConfiguration.level // empty)
|
||||
][0] // empty)
|
||||
// ""
|
||||
) | ascii_downcase) as $effectiveLevel
|
||||
| select($effectiveLevel == "error")
|
||||
] | length' "$SARIF_FILE")
|
||||
|
||||
if [ "$ERROR_COUNT" -gt 0 ]; then
|
||||
echo "::error::CodeQL found $ERROR_COUNT high-severity security issues. Fix before merging."
|
||||
echo "::error::CodeQL found $ERROR_COUNT blocking findings (effective-level=error). Fix before merging. Policy: .github/security-severity-policy.yml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -5,10 +5,6 @@ on:
|
||||
- cron: '0 3 * * 0' # Weekly: Sundays at 03:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
registries:
|
||||
description: 'Comma-separated registries to prune (ghcr,dockerhub)'
|
||||
required: false
|
||||
default: 'ghcr,dockerhub'
|
||||
keep_days:
|
||||
description: 'Number of days to retain images (unprotected)'
|
||||
required: false
|
||||
@@ -27,16 +23,17 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prune:
|
||||
prune-ghcr:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
IMAGE_NAME: charon
|
||||
REGISTRIES: ${{ github.event.inputs.registries || 'ghcr,dockerhub' }}
|
||||
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
|
||||
KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
PROTECTED_REGEX: '["^v","^latest$","^main$","^develop$"]'
|
||||
DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }}
|
||||
PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]'
|
||||
PRUNE_UNTAGGED: 'true'
|
||||
PRUNE_SBOM_TAGS: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -45,21 +42,19 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y jq curl
|
||||
|
||||
- name: Run container prune
|
||||
- name: Run GHCR prune
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
chmod +x scripts/prune-container-images.sh
|
||||
./scripts/prune-container-images.sh 2>&1 | tee prune-${{ github.run_id }}.log
|
||||
chmod +x scripts/prune-ghcr.sh
|
||||
./scripts/prune-ghcr.sh 2>&1 | tee prune-ghcr-${{ github.run_id }}.log
|
||||
|
||||
- name: Summarize prune results (space reclaimed)
|
||||
if: ${{ always() }}
|
||||
- name: Summarize GHCR results
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SUMMARY_FILE=prune-summary.env
|
||||
LOG_FILE=prune-${{ github.run_id }}.log
|
||||
SUMMARY_FILE=prune-summary-ghcr.env
|
||||
LOG_FILE=prune-ghcr-${{ github.run_id }}.log
|
||||
|
||||
human() {
|
||||
local bytes=${1:-0}
|
||||
@@ -67,7 +62,7 @@ jobs:
|
||||
echo "0 B"
|
||||
return
|
||||
fi
|
||||
awk -v b="$bytes" 'function human(x){ split("B KiB MiB GiB TiB",u," "); i=0; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1]} END{human(b)}'
|
||||
awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }'
|
||||
}
|
||||
|
||||
if [ -f "$SUMMARY_FILE" ]; then
|
||||
@@ -77,34 +72,155 @@ jobs:
|
||||
TOTAL_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
|
||||
{
|
||||
echo "## Container prune summary"
|
||||
echo "## GHCR prune summary"
|
||||
echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))"
|
||||
echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
printf 'PRUNE_SUMMARY: candidates=%s candidates_bytes=%s deleted=%s deleted_bytes=%s\n' \
|
||||
"${TOTAL_CANDIDATES}" "${TOTAL_CANDIDATES_BYTES}" "${TOTAL_DELETED}" "${TOTAL_DELETED_BYTES}"
|
||||
echo "Deleted approximately: $(human "${TOTAL_DELETED_BYTES}")"
|
||||
echo "space_saved=$(human "${TOTAL_DELETED_BYTES}")" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
deleted_bytes=$(grep -oE '\( *approx +[0-9]+ bytes\)' "$LOG_FILE" | sed -E 's/.*approx +([0-9]+) bytes.*/\1/' | awk '{s+=$1} END {print s+0}' || true)
|
||||
deleted_count=$(grep -cE 'deleting |DRY RUN: would delete' "$LOG_FILE" || true)
|
||||
|
||||
{
|
||||
echo "## Container prune summary"
|
||||
echo "## GHCR prune summary"
|
||||
echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
printf 'PRUNE_SUMMARY: deleted_approx=%s deleted_bytes=%s\n' "${deleted_count}" "${deleted_bytes}"
|
||||
echo "Deleted approximately: $(human "${deleted_bytes}")"
|
||||
echo "space_saved=$(human "${deleted_bytes}")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload prune artifacts
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
- name: Upload GHCR prune artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: prune-log-${{ github.run_id }}
|
||||
name: prune-ghcr-log-${{ github.run_id }}
|
||||
path: |
|
||||
prune-${{ github.run_id }}.log
|
||||
prune-summary.env
|
||||
prune-ghcr-${{ github.run_id }}.log
|
||||
prune-summary-ghcr.env
|
||||
|
||||
prune-dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
IMAGE_NAME: charon
|
||||
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
|
||||
KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }}
|
||||
DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }}
|
||||
PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y jq curl
|
||||
|
||||
- name: Run Docker Hub prune
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
chmod +x scripts/prune-dockerhub.sh
|
||||
./scripts/prune-dockerhub.sh 2>&1 | tee prune-dockerhub-${{ github.run_id }}.log
|
||||
|
||||
- name: Summarize Docker Hub results
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SUMMARY_FILE=prune-summary-dockerhub.env
|
||||
LOG_FILE=prune-dockerhub-${{ github.run_id }}.log
|
||||
|
||||
human() {
|
||||
local bytes=${1:-0}
|
||||
if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then
|
||||
echo "0 B"
|
||||
return
|
||||
fi
|
||||
awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }'
|
||||
}
|
||||
|
||||
if [ -f "$SUMMARY_FILE" ]; then
|
||||
TOTAL_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
TOTAL_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
TOTAL_DELETED=$(grep -E '^TOTAL_DELETED=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
TOTAL_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
|
||||
{
|
||||
echo "## Docker Hub prune summary"
|
||||
echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))"
|
||||
echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
deleted_bytes=$(grep -oE '\( *approx +[0-9]+ bytes\)' "$LOG_FILE" | sed -E 's/.*approx +([0-9]+) bytes.*/\1/' | awk '{s+=$1} END {print s+0}' || true)
|
||||
deleted_count=$(grep -cE 'deleting |DRY RUN: would delete' "$LOG_FILE" || true)
|
||||
|
||||
{
|
||||
echo "## Docker Hub prune summary"
|
||||
echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload Docker Hub prune artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: prune-dockerhub-log-${{ github.run_id }}
|
||||
path: |
|
||||
prune-dockerhub-${{ github.run_id }}.log
|
||||
prune-summary-dockerhub.env
|
||||
|
||||
summarize:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prune-ghcr, prune-dockerhub]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
pattern: prune-*-log-${{ github.run_id }}
|
||||
merge-multiple: true
|
||||
|
||||
- name: Combined summary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
human() {
|
||||
local bytes=${1:-0}
|
||||
if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then
|
||||
echo "0 B"
|
||||
return
|
||||
fi
|
||||
awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }'
|
||||
}
|
||||
|
||||
GHCR_CANDIDATES=0 GHCR_CANDIDATES_BYTES=0 GHCR_DELETED=0 GHCR_DELETED_BYTES=0
|
||||
if [ -f prune-summary-ghcr.env ]; then
|
||||
GHCR_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
|
||||
GHCR_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
|
||||
GHCR_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
|
||||
GHCR_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
|
||||
fi
|
||||
|
||||
HUB_CANDIDATES=0 HUB_CANDIDATES_BYTES=0 HUB_DELETED=0 HUB_DELETED_BYTES=0
|
||||
if [ -f prune-summary-dockerhub.env ]; then
|
||||
HUB_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
|
||||
HUB_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
|
||||
HUB_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
|
||||
HUB_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
|
||||
fi
|
||||
|
||||
TOTAL_CANDIDATES=$((GHCR_CANDIDATES + HUB_CANDIDATES))
|
||||
TOTAL_CANDIDATES_BYTES=$((GHCR_CANDIDATES_BYTES + HUB_CANDIDATES_BYTES))
|
||||
TOTAL_DELETED=$((GHCR_DELETED + HUB_DELETED))
|
||||
TOTAL_DELETED_BYTES=$((GHCR_DELETED_BYTES + HUB_DELETED_BYTES))
|
||||
|
||||
{
|
||||
echo "## Combined container prune summary"
|
||||
echo ""
|
||||
echo "| Registry | Candidates | Deleted | Space Reclaimed |"
|
||||
echo "|----------|------------|---------|-----------------|"
|
||||
echo "| GHCR | ${GHCR_CANDIDATES} | ${GHCR_DELETED} | $(human "${GHCR_DELETED_BYTES}") |"
|
||||
echo "| Docker Hub | ${HUB_CANDIDATES} | ${HUB_DELETED} | $(human "${HUB_DELETED_BYTES}") |"
|
||||
echo "| **Total** | **${TOTAL_CANDIDATES}** | **${TOTAL_DELETED}** | **$(human "${TOTAL_DELETED_BYTES}")** |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
printf 'PRUNE_SUMMARY: candidates=%s candidates_bytes=%s deleted=%s deleted_bytes=%s\n' \
|
||||
"${TOTAL_CANDIDATES}" "${TOTAL_CANDIDATES_BYTES}" "${TOTAL_DELETED}" "${TOTAL_DELETED_BYTES}"
|
||||
echo "Total space reclaimed: $(human "${TOTAL_DELETED_BYTES}")"
|
||||
|
||||
@@ -23,7 +23,11 @@ name: Docker Build, Publish & Test
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["Docker Lint"]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.head_ref || github.ref_name }}
|
||||
@@ -38,7 +42,7 @@ env:
|
||||
TRIGGER_HEAD_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
TRIGGER_REF: ${{ github.event_name == 'workflow_run' && format('refs/heads/{0}', github.event.workflow_run.head_branch) || github.ref }}
|
||||
TRIGGER_HEAD_REF: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.head_ref }}
|
||||
TRIGGER_PR_NUMBER: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || github.event.pull_request.number }}
|
||||
TRIGGER_PR_NUMBER: ${{ github.event_name == 'workflow_run' && join(github.event.workflow_run.pull_requests.*.number, '') || github.event.pull_request.number }}
|
||||
TRIGGER_ACTOR: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.actor.login || github.actor }}
|
||||
|
||||
jobs:
|
||||
@@ -111,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
@@ -125,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -133,7 +137,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.skip.outputs.skip_build != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -339,7 +343,7 @@ jobs:
|
||||
|
||||
- name: Upload Image Artifact
|
||||
if: success() && steps.skip.outputs.skip_build != 'true' && env.TRIGGER_EVENT == 'pull_request'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ${{ env.TRIGGER_EVENT == 'pull_request' && format('pr-image-{0}', env.TRIGGER_PR_NUMBER) || 'push-image' }}
|
||||
path: /tmp/charon-pr-image.tar
|
||||
@@ -527,7 +531,7 @@ jobs:
|
||||
|
||||
- name: Run Trivy scan (table output)
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
format: 'table'
|
||||
@@ -538,7 +542,7 @@ jobs:
|
||||
- name: Run Trivy vulnerability scanner (SARIF)
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
id: trivy
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
format: 'sarif'
|
||||
@@ -558,15 +562,16 @@ jobs:
|
||||
|
||||
- name: Upload Trivy results
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
category: '.github/workflows/docker-build.yml:build-and-push'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Generate SBOM (Software Bill of Materials) for supply chain security
|
||||
# Only for production builds (main/development) - feature branches use downstream supply-chain-pr.yml
|
||||
- name: Generate SBOM
|
||||
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
|
||||
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
with:
|
||||
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
@@ -575,7 +580,7 @@ jobs:
|
||||
|
||||
# Create verifiable attestation for the SBOM
|
||||
- name: Attest SBOM
|
||||
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0
|
||||
uses: actions/attest-sbom@07e74fc4e78d1aad915e867f9a094073a9f71527 # v4.0.0
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -652,7 +657,7 @@ jobs:
|
||||
echo "image_ref=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${PR_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -684,7 +689,7 @@ jobs:
|
||||
echo "✅ Image freshness validated"
|
||||
|
||||
- name: Run Trivy scan on PR image (table output)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ steps.pr-image.outputs.image_ref }}
|
||||
format: 'table'
|
||||
@@ -693,7 +698,7 @@ jobs:
|
||||
|
||||
- name: Run Trivy scan on PR image (SARIF - blocking)
|
||||
id: trivy-scan
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ steps.pr-image.outputs.image_ref }}
|
||||
format: 'sarif'
|
||||
@@ -702,13 +707,47 @@ jobs:
|
||||
exit-code: '1' # Intended to block, but continued on error for now
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
- name: Check Trivy PR SARIF exists
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
id: trivy-pr-check
|
||||
run: |
|
||||
if [ -f trivy-pr-results.sarif ]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: 'docker-pr-image'
|
||||
|
||||
- name: Upload Trivy compatibility results (docker-build category)
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: '.github/workflows/docker-build.yml:build-and-push'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Trivy compatibility results (docker-publish alias)
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: '.github/workflows/docker-publish.yml:build-and-push'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Trivy compatibility results (nightly alias)
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: 'trivy-nightly'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Create scan summary
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
# Step 2: Set up Node.js (for building any JS-based doc tools)
|
||||
- name: 🔧 Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ on:
|
||||
default: false
|
||||
type: boolean
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
@@ -96,7 +95,7 @@ env:
|
||||
CI_LOG_LEVEL: 'verbose'
|
||||
|
||||
concurrency:
|
||||
group: e2e-split-${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -143,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.resolve-image.outputs.image_source == 'build'
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
@@ -151,7 +150,7 @@ jobs:
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.resolve-image.outputs.image_source == 'build'
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -191,7 +190,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker image artifact
|
||||
if: steps.resolve-image.outputs.image_source == 'build'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-image
|
||||
path: charon-e2e-image.tar
|
||||
@@ -225,14 +224,15 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -347,7 +347,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (Chromium Security)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-chromium-security
|
||||
path: playwright-report/
|
||||
@@ -355,7 +355,7 @@ jobs:
|
||||
|
||||
- name: Upload Chromium Security coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-chromium-security
|
||||
path: coverage/e2e/
|
||||
@@ -363,7 +363,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-chromium-security
|
||||
path: test-results/**/*.zip
|
||||
@@ -382,7 +382,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-chromium-security
|
||||
path: diagnostics/
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-chromium-security
|
||||
path: docker-logs-chromium-security.txt
|
||||
@@ -426,14 +426,15 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -448,7 +449,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -556,7 +557,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (Firefox Security)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-firefox-security
|
||||
path: playwright-report/
|
||||
@@ -564,7 +565,7 @@ jobs:
|
||||
|
||||
- name: Upload Firefox Security coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-firefox-security
|
||||
path: coverage/e2e/
|
||||
@@ -572,7 +573,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-firefox-security
|
||||
path: test-results/**/*.zip
|
||||
@@ -591,7 +592,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-firefox-security
|
||||
path: diagnostics/
|
||||
@@ -604,7 +605,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-firefox-security
|
||||
path: docker-logs-firefox-security.txt
|
||||
@@ -635,14 +636,15 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -657,7 +659,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -765,7 +767,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (WebKit Security)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-webkit-security
|
||||
path: playwright-report/
|
||||
@@ -773,7 +775,7 @@ jobs:
|
||||
|
||||
- name: Upload WebKit Security coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-webkit-security
|
||||
path: coverage/e2e/
|
||||
@@ -781,7 +783,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-webkit-security
|
||||
path: test-results/**/*.zip
|
||||
@@ -800,7 +802,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-webkit-security
|
||||
path: diagnostics/
|
||||
@@ -813,7 +815,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-webkit-security
|
||||
path: docker-logs-webkit-security.txt
|
||||
@@ -856,14 +858,47 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Preflight disk diagnostics (before cleanup)
|
||||
run: |
|
||||
echo "Disk usage before cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
- name: Preflight cleanup (best effort)
|
||||
run: |
|
||||
echo "Best-effort cleanup for CI runner"
|
||||
docker system prune -af || true
|
||||
rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true
|
||||
rm -f docker-logs-*.txt charon-e2e-image.tar || true
|
||||
|
||||
- name: Preflight disk diagnostics and threshold gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024))
|
||||
echo "Disk usage after cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}"
|
||||
FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}')
|
||||
FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}')
|
||||
|
||||
echo "Free bytes on /: $FREE_ROOT_BYTES"
|
||||
echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES"
|
||||
|
||||
if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then
|
||||
echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B"
|
||||
exit 42
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -878,7 +913,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -968,7 +1003,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (Chromium shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-chromium-shard-${{ matrix.shard }}
|
||||
path: playwright-report/
|
||||
@@ -976,7 +1011,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright output (Chromium shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-output-chromium-shard-${{ matrix.shard }}
|
||||
path: playwright-output/chromium-shard-${{ matrix.shard }}/
|
||||
@@ -984,7 +1019,7 @@ jobs:
|
||||
|
||||
- name: Upload Chromium coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-chromium-shard-${{ matrix.shard }}
|
||||
path: coverage/e2e/
|
||||
@@ -992,7 +1027,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-chromium-shard-${{ matrix.shard }}
|
||||
path: test-results/**/*.zip
|
||||
@@ -1011,7 +1046,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-chromium-shard-${{ matrix.shard }}
|
||||
path: diagnostics/
|
||||
@@ -1024,7 +1059,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-chromium-shard-${{ matrix.shard }}
|
||||
path: docker-logs-chromium-shard-${{ matrix.shard }}.txt
|
||||
@@ -1060,14 +1095,47 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Preflight disk diagnostics (before cleanup)
|
||||
run: |
|
||||
echo "Disk usage before cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
- name: Preflight cleanup (best effort)
|
||||
run: |
|
||||
echo "Best-effort cleanup for CI runner"
|
||||
docker system prune -af || true
|
||||
rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true
|
||||
rm -f docker-logs-*.txt charon-e2e-image.tar || true
|
||||
|
||||
- name: Preflight disk diagnostics and threshold gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024))
|
||||
echo "Disk usage after cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}"
|
||||
FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}')
|
||||
FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}')
|
||||
|
||||
echo "Free bytes on /: $FREE_ROOT_BYTES"
|
||||
echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES"
|
||||
|
||||
if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then
|
||||
echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B"
|
||||
exit 42
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -1082,7 +1150,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -1180,7 +1248,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (Firefox shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-firefox-shard-${{ matrix.shard }}
|
||||
path: playwright-report/
|
||||
@@ -1188,7 +1256,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright output (Firefox shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-output-firefox-shard-${{ matrix.shard }}
|
||||
path: playwright-output/firefox-shard-${{ matrix.shard }}/
|
||||
@@ -1196,7 +1264,7 @@ jobs:
|
||||
|
||||
- name: Upload Firefox coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-firefox-shard-${{ matrix.shard }}
|
||||
path: coverage/e2e/
|
||||
@@ -1204,7 +1272,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-firefox-shard-${{ matrix.shard }}
|
||||
path: test-results/**/*.zip
|
||||
@@ -1223,7 +1291,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-firefox-shard-${{ matrix.shard }}
|
||||
path: diagnostics/
|
||||
@@ -1236,7 +1304,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-firefox-shard-${{ matrix.shard }}
|
||||
path: docker-logs-firefox-shard-${{ matrix.shard }}.txt
|
||||
@@ -1272,14 +1340,47 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Preflight disk diagnostics (before cleanup)
|
||||
run: |
|
||||
echo "Disk usage before cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
- name: Preflight cleanup (best effort)
|
||||
run: |
|
||||
echo "Best-effort cleanup for CI runner"
|
||||
docker system prune -af || true
|
||||
rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true
|
||||
rm -f docker-logs-*.txt charon-e2e-image.tar || true
|
||||
|
||||
- name: Preflight disk diagnostics and threshold gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024))
|
||||
echo "Disk usage after cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}"
|
||||
FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}')
|
||||
FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}')
|
||||
|
||||
echo "Free bytes on /: $FREE_ROOT_BYTES"
|
||||
echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES"
|
||||
|
||||
if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then
|
||||
echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B"
|
||||
exit 42
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -1294,7 +1395,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -1392,7 +1493,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (WebKit shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: playwright-report-webkit-shard-${{ matrix.shard }}
|
||||
path: playwright-report/
|
||||
@@ -1400,7 +1501,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright output (WebKit shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: playwright-output-webkit-shard-${{ matrix.shard }}
|
||||
path: playwright-output/webkit-shard-${{ matrix.shard }}/
|
||||
@@ -1408,7 +1509,7 @@ jobs:
|
||||
|
||||
- name: Upload WebKit coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: e2e-coverage-webkit-shard-${{ matrix.shard }}
|
||||
path: coverage/e2e/
|
||||
@@ -1416,7 +1517,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: traces-webkit-shard-${{ matrix.shard }}
|
||||
path: test-results/**/*.zip
|
||||
@@ -1435,7 +1536,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: e2e-diagnostics-webkit-shard-${{ matrix.shard }}
|
||||
path: diagnostics/
|
||||
@@ -1448,7 +1549,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: docker-logs-webkit-shard-${{ matrix.shard }}
|
||||
path: docker-logs-webkit-shard-${{ matrix.shard }}.txt
|
||||
|
||||
@@ -103,11 +103,12 @@ jobs:
|
||||
const workflows = [
|
||||
{ id: 'e2e-tests-split.yml' },
|
||||
{ id: 'codecov-upload.yml', inputs: { run_backend: 'true', run_frontend: 'true' } },
|
||||
{ id: 'security-pr.yml' },
|
||||
{ id: 'supply-chain-verify.yml' },
|
||||
{ id: 'codeql.yml' },
|
||||
];
|
||||
|
||||
core.info('Skipping security-pr.yml: PR-only workflow intentionally excluded from nightly non-PR dispatch');
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const { data: workflowRuns } = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
@@ -161,13 +162,13 @@ jobs:
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -175,7 +176,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: env.HAS_DOCKERHUB_TOKEN == 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -220,14 +221,66 @@ jobs:
|
||||
echo "- ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate SBOM
|
||||
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
|
||||
id: sbom_primary
|
||||
continue-on-error: true
|
||||
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
with:
|
||||
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}
|
||||
format: cyclonedx-json
|
||||
output-file: sbom-nightly.json
|
||||
syft-version: v1.42.1
|
||||
|
||||
- name: Generate SBOM fallback with pinned Syft
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${{ steps.sbom_primary.outcome }}" == "success" ]] && [[ -s sbom-nightly.json ]] && jq -e . sbom-nightly.json >/dev/null 2>&1; then
|
||||
echo "Primary SBOM generation succeeded with valid JSON; skipping fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Primary SBOM generation failed or produced missing/invalid output; using deterministic Syft fallback"
|
||||
|
||||
SYFT_VERSION="v1.42.1"
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
TARBALL="syft_${SYFT_VERSION#v}_${OS}_${ARCH}.tar.gz"
|
||||
BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}"
|
||||
|
||||
curl -fsSLo "$TARBALL" "${BASE_URL}/${TARBALL}"
|
||||
curl -fsSLo checksums.txt "${BASE_URL}/syft_${SYFT_VERSION#v}_checksums.txt"
|
||||
|
||||
grep " ${TARBALL}$" checksums.txt > checksum_line.txt
|
||||
sha256sum -c checksum_line.txt
|
||||
|
||||
tar -xzf "$TARBALL" syft
|
||||
chmod +x syft
|
||||
|
||||
./syft "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}" -o cyclonedx-json=sbom-nightly.json
|
||||
|
||||
- name: Verify SBOM artifact
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s sbom-nightly.json
|
||||
jq -e . sbom-nightly.json >/dev/null
|
||||
jq -e '
|
||||
.bomFormat == "CycloneDX"
|
||||
and (.specVersion | type == "string" and length > 0)
|
||||
and has("version")
|
||||
and has("metadata")
|
||||
and (.components | type == "array")
|
||||
' sbom-nightly.json >/dev/null
|
||||
|
||||
- name: Upload SBOM artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: sbom-nightly
|
||||
path: sbom-nightly.json
|
||||
@@ -277,7 +330,7 @@ jobs:
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -331,7 +384,7 @@ jobs:
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download SBOM
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: sbom-nightly
|
||||
|
||||
@@ -343,22 +396,128 @@ jobs:
|
||||
severity-cutoff: high
|
||||
|
||||
- name: Scan with Trivy
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-nightly.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-nightly.sarif'
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-nightly.sarif'
|
||||
category: 'trivy-nightly'
|
||||
|
||||
- name: Check for critical CVEs
|
||||
- name: Security severity policy summary
|
||||
run: |
|
||||
if grep -q "CRITICAL" trivy-nightly.sarif; then
|
||||
echo "❌ Critical vulnerabilities found in nightly build"
|
||||
{
|
||||
echo "## 🔐 Nightly Supply Chain Severity Policy"
|
||||
echo ""
|
||||
echo "- Blocking: Critical, High"
|
||||
echo "- Medium: non-blocking by default (report + triage SLA)"
|
||||
echo "- Policy file: .github/security-severity-policy.yml"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check for Critical/High CVEs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
jq -e . trivy-nightly.sarif >/dev/null
|
||||
|
||||
CRITICAL_COUNT=$(jq -r '
|
||||
[
|
||||
.runs[] as $run
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| $run.results[]?
|
||||
| . as $result
|
||||
| (
|
||||
(
|
||||
if (($result.ruleIndex | type) == "number") then
|
||||
($rules[$result.ruleIndex].properties["security-severity"] // empty)
|
||||
else
|
||||
empty
|
||||
end
|
||||
)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| .properties["security-severity"]
|
||||
][0] // empty)
|
||||
// empty
|
||||
) as $securitySeverity
|
||||
| (try ($securitySeverity | tonumber) catch empty) as $score
|
||||
| select($score != null and $score >= 9.0)
|
||||
] | length
|
||||
' trivy-nightly.sarif)
|
||||
|
||||
HIGH_COUNT=$(jq -r '
|
||||
[
|
||||
.runs[] as $run
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| $run.results[]?
|
||||
| . as $result
|
||||
| (
|
||||
(
|
||||
if (($result.ruleIndex | type) == "number") then
|
||||
($rules[$result.ruleIndex].properties["security-severity"] // empty)
|
||||
else
|
||||
empty
|
||||
end
|
||||
)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| .properties["security-severity"]
|
||||
][0] // empty)
|
||||
// empty
|
||||
) as $securitySeverity
|
||||
| (try ($securitySeverity | tonumber) catch empty) as $score
|
||||
| select($score != null and $score >= 7.0 and $score < 9.0)
|
||||
] | length
|
||||
' trivy-nightly.sarif)
|
||||
|
||||
MEDIUM_COUNT=$(jq -r '
|
||||
[
|
||||
.runs[] as $run
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| $run.results[]?
|
||||
| . as $result
|
||||
| (
|
||||
(
|
||||
if (($result.ruleIndex | type) == "number") then
|
||||
($rules[$result.ruleIndex].properties["security-severity"] // empty)
|
||||
else
|
||||
empty
|
||||
end
|
||||
)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| .properties["security-severity"]
|
||||
][0] // empty)
|
||||
// empty
|
||||
) as $securitySeverity
|
||||
| (try ($securitySeverity | tonumber) catch empty) as $score
|
||||
| select($score != null and $score >= 4.0 and $score < 7.0)
|
||||
] | length
|
||||
' trivy-nightly.sarif)
|
||||
|
||||
{
|
||||
echo "- Structured SARIF counts: CRITICAL=${CRITICAL_COUNT}, HIGH=${HIGH_COUNT}, MEDIUM=${MEDIUM_COUNT}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$CRITICAL_COUNT" -gt 0 ]; then
|
||||
echo "❌ Critical vulnerabilities found in nightly build (${CRITICAL_COUNT})"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No critical vulnerabilities found"
|
||||
|
||||
if [ "$HIGH_COUNT" -gt 0 ]; then
|
||||
echo "❌ High vulnerabilities found in nightly build (${HIGH_COUNT})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$MEDIUM_COUNT" -gt 0 ]; then
|
||||
echo "::warning::Medium vulnerabilities found in nightly build (${MEDIUM_COUNT}). Non-blocking by policy; triage with SLA per .github/security-severity-policy.yml"
|
||||
fi
|
||||
|
||||
echo "✅ No Critical/High vulnerabilities found"
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
(github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'development')
|
||||
steps:
|
||||
- name: Set up Node (for github-script)
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Quality Checks
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- nightly
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -18,6 +21,27 @@ env:
|
||||
GOTOOLCHAIN: auto
|
||||
|
||||
jobs:
|
||||
auth-route-protection-contract:
|
||||
name: Auth Route Protection Contract
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Run auth protection contract tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd backend
|
||||
go test ./internal/api/routes -run 'TestRegister_StateChangingRoutesRequireAuthentication|TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist|TestRegister_AuthenticatedRoutes' -count=1 -v
|
||||
|
||||
codecov-trigger-parity-guard:
|
||||
name: Codecov Trigger/Comment Parity Guard
|
||||
runs-on: ubuntu-latest
|
||||
@@ -113,7 +137,7 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
@@ -225,7 +249,7 @@ jobs:
|
||||
bash "scripts/repo_health_check.sh"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
@@ -20,6 +20,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
if: ${{ !contains(github.ref_name, '-candidate') && !contains(github.ref_name, '-rc') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Use the built-in GITHUB_TOKEN by default for GitHub API operations.
|
||||
@@ -32,13 +33,25 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Enforce PR-2 release promotion guard
|
||||
env:
|
||||
REPO_VARS_JSON: ${{ toJSON(vars) }}
|
||||
run: |
|
||||
PR2_GATE_STATUS="$(printf '%s' "$REPO_VARS_JSON" | jq -r '.CHARON_PR2_GATES_PASSED // "false"')"
|
||||
if [[ "$PR2_GATE_STATUS" != "true" ]]; then
|
||||
echo "::error::Releasable tag promotion is blocked until PR-2 security/retirement gates pass."
|
||||
echo "::error::Set repository variable CHARON_PR2_GATES_PASSED=true only after PR-2 approval."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Renovate
|
||||
uses: renovatebot/github-action@d65ef9e20512193cc070238b49c3873a361cd50c # v46.1.1
|
||||
uses: renovatebot/github-action@7b4b65bf31e07d4e3e51708d07700fb41bc03166 # v46.1.3
|
||||
with:
|
||||
configurationFile: .github/renovate.json
|
||||
token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
- name: Upload health output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: repo-health-output
|
||||
path: |
|
||||
|
||||
+222
-117
@@ -4,18 +4,22 @@
|
||||
name: Security Scan (PR)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Docker Build, Publish & Test"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to scan (optional)'
|
||||
required: false
|
||||
description: 'PR number to scan'
|
||||
required: true
|
||||
type: string
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
|
||||
concurrency:
|
||||
group: security-pr-${{ github.event.workflow_run.event || github.event_name }}-${{ github.event.workflow_run.head_branch || github.ref }}
|
||||
group: security-pr-${{ github.event_name == 'workflow_run' && github.event.workflow_run.event || github.event_name }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -23,16 +27,18 @@ jobs:
|
||||
name: Trivy Binary Scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Run for: manual dispatch, PR builds, or any push builds from docker-build
|
||||
# Run for manual dispatch, direct PR/push, or successful upstream workflow_run
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'pull_request' ||
|
||||
((github.event.workflow_run.event == 'push' || github.event.workflow_run.pull_requests[0].number != null) &&
|
||||
(github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success'))
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.status == 'completed' &&
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
@@ -41,27 +47,65 @@ jobs:
|
||||
# actions/checkout v4.2.2
|
||||
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Extract PR number from workflow_run
|
||||
id: pr-info
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# Manual dispatch - use input or fail gracefully
|
||||
if [[ -n "${{ inputs.pr_number }}" ]]; then
|
||||
echo "pr_number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Using manually provided PR number: ${{ inputs.pr_number }}"
|
||||
else
|
||||
echo "⚠️ No PR number provided for manual dispatch"
|
||||
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Push event detected; using local image path"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "pr_number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Pull request event detected: PR #${{ github.event.pull_request.number }}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
INPUT_PR_NUMBER="${{ inputs.pr_number }}"
|
||||
if [[ -z "${INPUT_PR_NUMBER}" ]]; then
|
||||
echo "❌ workflow_dispatch requires inputs.pr_number"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${INPUT_PR_NUMBER}" =~ ^[0-9]+$ ]]; then
|
||||
echo "❌ reason_category=invalid_input"
|
||||
echo "reason=workflow_dispatch pr_number must be digits-only"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_NUMBER="${INPUT_PR_NUMBER}"
|
||||
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Using manually provided PR number: ${PR_NUMBER}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then
|
||||
# Explicit contract validation happens in the dedicated guard step.
|
||||
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -n "${{ github.event.workflow_run.pull_requests[0].number || '' }}" ]]; then
|
||||
echo "pr_number=${{ github.event.workflow_run.pull_requests[0].number }}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Found PR number from workflow_run payload: ${{ github.event.workflow_run.pull_requests[0].number }}"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract PR number from context
|
||||
HEAD_SHA="${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}"
|
||||
HEAD_SHA="${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "🔍 Looking for PR with head SHA: ${HEAD_SHA}"
|
||||
|
||||
# Query GitHub API for PR associated with this commit
|
||||
@@ -73,21 +117,38 @@ jobs:
|
||||
|
||||
if [[ -n "${PR_NUMBER}" ]]; then
|
||||
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Found PR number: ${PR_NUMBER}"
|
||||
else
|
||||
echo "⚠️ Could not find PR number for SHA: ${HEAD_SHA}"
|
||||
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
||||
echo "❌ Could not determine PR number for workflow_run SHA: ${HEAD_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if this is a push event (not a PR)
|
||||
if [[ "${{ github.event_name }}" == "push" || "${{ github.event.workflow_run.event }}" == "push" || -z "${PR_NUMBER}" ]]; then
|
||||
HEAD_BRANCH="${{ github.event.workflow_run.head_branch || github.ref_name }}"
|
||||
echo "is_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Detected push build from branch: ${HEAD_BRANCH}"
|
||||
else
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
- name: Validate workflow_run trust boundary and event contract
|
||||
if: github.event_name == 'workflow_run'
|
||||
run: |
|
||||
if [[ "${{ github.event.workflow_run.name }}" != "Docker Build, Publish & Test" ]]; then
|
||||
echo "❌ reason_category=unexpected_upstream_workflow"
|
||||
echo "workflow_name=${{ github.event.workflow_run.name }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then
|
||||
echo "❌ reason_category=unsupported_upstream_event"
|
||||
echo "upstream_event=${{ github.event.workflow_run.event }}"
|
||||
echo "run_id=${{ github.event.workflow_run.id }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event.workflow_run.head_repository.full_name }}" != "${{ github.repository }}" ]]; then
|
||||
echo "❌ reason_category=untrusted_upstream_repository"
|
||||
echo "upstream_head_repository=${{ github.event.workflow_run.head_repository.full_name }}"
|
||||
echo "expected_repository=${{ github.repository }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ workflow_run trust boundary and event contract validated"
|
||||
|
||||
- name: Build Docker image (Local)
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
run: |
|
||||
@@ -97,95 +158,149 @@ jobs:
|
||||
|
||||
- name: Check for PR image artifact
|
||||
id: check-artifact
|
||||
if: (steps.pr-info.outputs.pr_number != '' || steps.pr-info.outputs.is_push == 'true') && github.event_name != 'push' && github.event_name != 'pull_request'
|
||||
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Determine artifact name based on event type
|
||||
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
|
||||
ARTIFACT_NAME="push-image"
|
||||
else
|
||||
PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}"
|
||||
ARTIFACT_NAME="pr-image-${PR_NUMBER}"
|
||||
PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}"
|
||||
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then
|
||||
echo "❌ reason_category=invalid_input"
|
||||
echo "reason=Resolved PR number must be digits-only"
|
||||
exit 1
|
||||
fi
|
||||
RUN_ID="${{ github.event.workflow_run.id }}"
|
||||
|
||||
ARTIFACT_NAME="pr-image-${PR_NUMBER}"
|
||||
RUN_ID="${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || '' }}"
|
||||
|
||||
echo "🔍 Checking for artifact: ${ARTIFACT_NAME}"
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# For manual dispatch, find the most recent workflow run with this artifact
|
||||
RUN_ID=$(gh api \
|
||||
# Manual replay path: find latest successful docker-build pull_request run for this PR.
|
||||
RUNS_JSON=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?status=success&per_page=10" \
|
||||
--jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "")
|
||||
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?event=pull_request&status=success&per_page=100" 2>&1)
|
||||
RUNS_STATUS=$?
|
||||
|
||||
if [[ ${RUNS_STATUS} -ne 0 ]]; then
|
||||
echo "❌ reason_category=api_error"
|
||||
echo "reason=Failed to query workflow runs for PR lookup"
|
||||
echo "upstream_run_id=unknown"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
echo "api_output=${RUNS_JSON}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUN_ID=$(printf '%s' "${RUNS_JSON}" | jq -r --argjson pr "${PR_NUMBER}" '.workflow_runs[] | select((.pull_requests // []) | any(.number == $pr)) | .id' | head -n 1)
|
||||
|
||||
if [[ -z "${RUN_ID}" ]]; then
|
||||
echo "⚠️ No successful workflow runs found"
|
||||
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
echo "❌ reason_category=not_found"
|
||||
echo "reason=No successful docker-build pull_request run found for PR #${PR_NUMBER}"
|
||||
echo "upstream_run_id=unknown"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ -z "${RUN_ID}" ]]; then
|
||||
# If triggered by push/pull_request, RUN_ID is empty. Find recent run for this commit.
|
||||
HEAD_SHA="${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "🔍 Searching for workflow run for SHA: ${HEAD_SHA}"
|
||||
# Retry a few times as the run might be just starting or finishing
|
||||
for i in {1..3}; do
|
||||
RUN_ID=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?head_sha=${HEAD_SHA}&status=success&per_page=1" \
|
||||
--jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "")
|
||||
if [[ -n "${RUN_ID}" ]]; then break; fi
|
||||
echo "⏳ Waiting for workflow run to appear/complete... ($i/3)"
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Check if the artifact exists in the workflow run
|
||||
ARTIFACT_ID=$(gh api \
|
||||
ARTIFACTS_JSON=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||
--jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .id" 2>/dev/null || echo "")
|
||||
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" 2>&1)
|
||||
ARTIFACTS_STATUS=$?
|
||||
|
||||
if [[ -n "${ARTIFACT_ID}" ]]; then
|
||||
echo "artifact_exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "artifact_id=${ARTIFACT_ID}" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})"
|
||||
else
|
||||
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo "⚠️ Artifact not found: ${ARTIFACT_NAME}"
|
||||
echo "ℹ️ This is expected for non-PR builds or if the image was not uploaded"
|
||||
if [[ ${ARTIFACTS_STATUS} -ne 0 ]]; then
|
||||
echo "❌ reason_category=api_error"
|
||||
echo "reason=Failed to query artifacts for upstream run"
|
||||
echo "upstream_run_id=${RUN_ID}"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
echo "api_output=${ARTIFACTS_JSON}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Skip if no artifact
|
||||
if: ((steps.pr-info.outputs.pr_number == '' && steps.pr-info.outputs.is_push != 'true') || steps.check-artifact.outputs.artifact_exists != 'true') && github.event_name != 'push' && github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "ℹ️ Skipping security scan - no PR image artifact available"
|
||||
echo "This is expected for:"
|
||||
echo " - Pushes to main/release branches"
|
||||
echo " - PRs where Docker build failed"
|
||||
echo " - Manual dispatch without PR number"
|
||||
exit 0
|
||||
ARTIFACT_ID=$(printf '%s' "${ARTIFACTS_JSON}" | jq -r --arg name "${ARTIFACT_NAME}" '.artifacts[] | select(.name == $name) | .id' | head -n 1)
|
||||
|
||||
if [[ -z "${ARTIFACT_ID}" ]]; then
|
||||
echo "❌ reason_category=not_found"
|
||||
echo "reason=Required artifact was not found"
|
||||
echo "upstream_run_id=${RUN_ID}"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "artifact_exists=true"
|
||||
echo "artifact_id=${ARTIFACT_ID}"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})"
|
||||
|
||||
- name: Download PR image artifact
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true'
|
||||
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
|
||||
# actions/download-artifact v4.1.8
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3
|
||||
with:
|
||||
name: ${{ steps.pr-info.outputs.is_push == 'true' && 'push-image' || format('pr-image-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
name: ${{ steps.check-artifact.outputs.artifact_name }}
|
||||
run-id: ${{ steps.check-artifact.outputs.run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Load Docker image
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true'
|
||||
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
|
||||
id: load-image
|
||||
run: |
|
||||
echo "📦 Loading Docker image..."
|
||||
docker load < charon-pr-image.tar
|
||||
echo "✅ Docker image loaded"
|
||||
|
||||
if [[ ! -r "charon-pr-image.tar" ]]; then
|
||||
echo "❌ ERROR: Artifact image tar is missing or unreadable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST_TAGS=""
|
||||
if tar -tf charon-pr-image.tar | grep -qx "manifest.json"; then
|
||||
MANIFEST_TAGS=$(tar -xOf charon-pr-image.tar manifest.json 2>/dev/null | jq -r '.[]?.RepoTags[]?' 2>/dev/null | sed '/^$/d' || true)
|
||||
else
|
||||
echo "⚠️ manifest.json not found in artifact tar; will try docker-load-image-id fallback"
|
||||
fi
|
||||
|
||||
LOAD_OUTPUT=$(docker load < charon-pr-image.tar 2>&1)
|
||||
echo "${LOAD_OUTPUT}"
|
||||
|
||||
SOURCE_IMAGE_REF=""
|
||||
SOURCE_RESOLUTION_MODE=""
|
||||
|
||||
while IFS= read -r tag; do
|
||||
[[ -z "${tag}" ]] && continue
|
||||
if docker image inspect "${tag}" >/dev/null 2>&1; then
|
||||
SOURCE_IMAGE_REF="${tag}"
|
||||
SOURCE_RESOLUTION_MODE="manifest_tag"
|
||||
break
|
||||
fi
|
||||
done <<< "${MANIFEST_TAGS}"
|
||||
|
||||
if [[ -z "${SOURCE_IMAGE_REF}" ]]; then
|
||||
LOAD_IMAGE_ID=$(printf '%s\n' "${LOAD_OUTPUT}" | sed -nE 's/^Loaded image ID: (sha256:[0-9a-f]+)$/\1/p' | head -n1)
|
||||
if [[ -n "${LOAD_IMAGE_ID}" ]] && docker image inspect "${LOAD_IMAGE_ID}" >/dev/null 2>&1; then
|
||||
SOURCE_IMAGE_REF="${LOAD_IMAGE_ID}"
|
||||
SOURCE_RESOLUTION_MODE="load_image_id"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${SOURCE_IMAGE_REF}" ]]; then
|
||||
echo "❌ ERROR: Could not resolve a valid image reference from manifest tags or docker load image ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker tag "${SOURCE_IMAGE_REF}" "charon:artifact"
|
||||
|
||||
{
|
||||
echo "source_image_ref=${SOURCE_IMAGE_REF}"
|
||||
echo "source_resolution_mode=${SOURCE_RESOLUTION_MODE}"
|
||||
echo "image_ref=charon:artifact"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "✅ Docker image resolved via ${SOURCE_RESOLUTION_MODE} and tagged as charon:artifact"
|
||||
docker images | grep charon
|
||||
|
||||
- name: Extract charon binary from container
|
||||
@@ -214,31 +329,10 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Normalize image name for reference
|
||||
IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
|
||||
BRANCH_NAME="${{ github.event.workflow_run.head_branch }}"
|
||||
if [[ -z "${BRANCH_NAME}" ]]; then
|
||||
echo "❌ ERROR: Branch name is empty for push build"
|
||||
exit 1
|
||||
fi
|
||||
# Normalize branch name for Docker tag (replace / and other special chars with -)
|
||||
# This matches docker/metadata-action behavior: type=ref,event=branch
|
||||
TAG_SAFE_BRANCH="${BRANCH_NAME//\//-}"
|
||||
IMAGE_REF="ghcr.io/${IMAGE_NAME}:${TAG_SAFE_BRANCH}"
|
||||
elif [[ -n "${{ steps.pr-info.outputs.pr_number }}" ]]; then
|
||||
IMAGE_REF="ghcr.io/${IMAGE_NAME}:pr-${{ steps.pr-info.outputs.pr_number }}"
|
||||
else
|
||||
echo "❌ ERROR: Cannot determine image reference"
|
||||
echo " - is_push: ${{ steps.pr-info.outputs.is_push }}"
|
||||
echo " - pr_number: ${{ steps.pr-info.outputs.pr_number }}"
|
||||
echo " - branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate the image reference format
|
||||
if [[ ! "${IMAGE_REF}" =~ ^ghcr\.io/[a-z0-9_-]+/[a-z0-9_-]+:[a-zA-Z0-9._-]+$ ]]; then
|
||||
echo "❌ ERROR: Invalid image reference format: ${IMAGE_REF}"
|
||||
# For workflow_run artifact path, always use locally tagged image from loaded artifact.
|
||||
IMAGE_REF="${{ steps.load-image.outputs.image_ref }}"
|
||||
if [[ -z "${IMAGE_REF}" ]]; then
|
||||
echo "❌ ERROR: Loaded artifact image reference is empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -268,7 +362,7 @@ jobs:
|
||||
- name: Run Trivy filesystem scan (SARIF output)
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
# aquasecurity/trivy-action v0.33.1
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: ${{ steps.extract.outputs.binary_path }}
|
||||
@@ -277,19 +371,30 @@ jobs:
|
||||
severity: 'CRITICAL,HIGH,MEDIUM'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check Trivy SARIF output exists
|
||||
if: always() && (steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
id: trivy-sarif-check
|
||||
run: |
|
||||
if [[ -f trivy-binary-results.sarif ]]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo "ℹ️ No Trivy SARIF output found; skipping SARIF/artifact upload steps"
|
||||
fi
|
||||
|
||||
- name: Upload Trivy SARIF to GitHub Security
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
|
||||
# github/codeql-action v4
|
||||
uses: github/codeql-action/upload-sarif@710e2945787622b429f8982cacb154faa182de18
|
||||
uses: github/codeql-action/upload-sarif@a5b959e10d29aec4f277040b4d27d0f6bea2322a
|
||||
with:
|
||||
sarif_file: 'trivy-binary-results.sarif'
|
||||
category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event.workflow_run.head_branch) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Trivy filesystem scan (fail on CRITICAL/HIGH)
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
# aquasecurity/trivy-action v0.33.1
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: ${{ steps.extract.outputs.binary_path }}
|
||||
@@ -298,11 +403,11 @@ jobs:
|
||||
exit-code: '1'
|
||||
|
||||
- name: Upload scan artifacts
|
||||
if: always() && (steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
|
||||
# actions/upload-artifact v4.4.3
|
||||
uses: actions/upload-artifact@47309c993abb98030a35d55ef7ff34b7fa1074b5
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
|
||||
with:
|
||||
name: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event.workflow_run.head_branch) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
name: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
path: |
|
||||
trivy-binary-results.sarif
|
||||
retention-days: 14
|
||||
@@ -312,7 +417,7 @@ jobs:
|
||||
run: |
|
||||
{
|
||||
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
|
||||
echo "## 🔒 Security Scan Results - Branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
echo "## 🔒 Security Scan Results - Branch: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }}"
|
||||
else
|
||||
echo "## 🔒 Security Scan Results - PR #${{ steps.pr-info.outputs.pr_number }}"
|
||||
fi
|
||||
|
||||
@@ -6,7 +6,7 @@ name: Weekly Security Rebuild
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * 0' # Sundays at 02:00 UTC
|
||||
- cron: '0 12 * * 2' # Tuesdays at 12:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_rebuild:
|
||||
@@ -36,13 +36,18 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
# Explicitly fetch the current HEAD of the ref at run time, not the
|
||||
# SHA that was frozen when this scheduled job was queued. Without this,
|
||||
# a queued job can run days later with stale code.
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Normalize image name
|
||||
run: |
|
||||
echo "IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
@@ -56,7 +61,7 @@ jobs:
|
||||
echo "Base image digest: $DIGEST"
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -88,7 +93,7 @@ jobs:
|
||||
BASE_IMAGE=${{ steps.base-image.outputs.digest }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner (CRITICAL+HIGH)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'table'
|
||||
@@ -98,7 +103,7 @@ jobs:
|
||||
|
||||
- name: Run Trivy vulnerability scanner (SARIF)
|
||||
id: trivy-sarif
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
@@ -106,12 +111,12 @@ jobs:
|
||||
severity: 'CRITICAL,HIGH,MEDIUM'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-weekly-results.sarif'
|
||||
|
||||
- name: Run Trivy vulnerability scanner (JSON for artifact)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'json'
|
||||
@@ -119,7 +124,7 @@ jobs:
|
||||
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
|
||||
|
||||
- name: Upload Trivy JSON results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: trivy-weekly-scan-${{ github.run_number }}
|
||||
path: trivy-weekly-results.json
|
||||
|
||||
@@ -11,6 +11,8 @@ on:
|
||||
type: string
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: supply-chain-pr-${{ github.event.workflow_run.event || github.event_name }}-${{ github.event.workflow_run.head_branch || github.ref }}
|
||||
@@ -264,7 +266,7 @@ jobs:
|
||||
# Generate SBOM using official Anchore action (auto-updated by Renovate)
|
||||
- name: Generate SBOM
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
|
||||
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
id: sbom
|
||||
with:
|
||||
image: ${{ steps.set-target.outputs.image_name }}
|
||||
@@ -337,9 +339,30 @@ jobs:
|
||||
echo " Low: ${LOW_COUNT}"
|
||||
echo " Total: ${TOTAL_COUNT}"
|
||||
|
||||
- name: Security severity policy summary
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
run: |
|
||||
CRITICAL_COUNT="${{ steps.vuln-summary.outputs.critical_count }}"
|
||||
HIGH_COUNT="${{ steps.vuln-summary.outputs.high_count }}"
|
||||
MEDIUM_COUNT="${{ steps.vuln-summary.outputs.medium_count }}"
|
||||
|
||||
{
|
||||
echo "## 🔐 Supply Chain Severity Policy"
|
||||
echo ""
|
||||
echo "- Blocking: Critical, High"
|
||||
echo "- Medium: non-blocking by default (report + triage SLA)"
|
||||
echo "- Policy file: .github/security-severity-policy.yml"
|
||||
echo ""
|
||||
echo "Current scan counts: Critical=${CRITICAL_COUNT}, High=${HIGH_COUNT}, Medium=${MEDIUM_COUNT}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [[ "${MEDIUM_COUNT}" -gt 0 ]]; then
|
||||
echo "::warning::${MEDIUM_COUNT} medium vulnerabilities found. Non-blocking by policy; create/maintain triage issue with SLA per .github/security-severity-policy.yml"
|
||||
fi
|
||||
|
||||
- name: Upload SARIF to GitHub Security
|
||||
if: steps.check-artifact.outputs.artifact_found == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
sarif_file: grype-results.sarif
|
||||
@@ -348,7 +371,7 @@ jobs:
|
||||
- name: Upload supply chain artifacts
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
# actions/upload-artifact v4.6.0
|
||||
uses: actions/upload-artifact@47309c993abb98030a35d55ef7ff34b7fa1074b5
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
|
||||
with:
|
||||
name: ${{ steps.pr-number.outputs.is_push == 'true' && format('supply-chain-{0}', steps.sanitize.outputs.branch) || format('supply-chain-pr-{0}', steps.pr-number.outputs.pr_number) }}
|
||||
path: |
|
||||
@@ -433,10 +456,11 @@ jobs:
|
||||
|
||||
echo "✅ PR comment posted"
|
||||
|
||||
- name: Fail on critical vulnerabilities
|
||||
- name: Fail on Critical/High vulnerabilities
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
run: |
|
||||
CRITICAL_COUNT="${{ steps.vuln-summary.outputs.critical_count }}"
|
||||
HIGH_COUNT="${{ steps.vuln-summary.outputs.high_count }}"
|
||||
|
||||
if [[ "${CRITICAL_COUNT}" -gt 0 ]]; then
|
||||
echo "🚨 Found ${CRITICAL_COUNT} CRITICAL vulnerabilities!"
|
||||
@@ -444,4 +468,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No critical vulnerabilities found"
|
||||
if [[ "${HIGH_COUNT}" -gt 0 ]]; then
|
||||
echo "🚨 Found ${HIGH_COUNT} HIGH vulnerabilities!"
|
||||
echo "Please review the vulnerability report and address high severity issues before merging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No Critical/High vulnerabilities found"
|
||||
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
# Generate SBOM using official Anchore action (auto-updated by Renovate)
|
||||
- name: Generate and Verify SBOM
|
||||
if: steps.image-check.outputs.exists == 'true'
|
||||
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
|
||||
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
with:
|
||||
image: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
||||
format: cyclonedx-json
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
|
||||
- name: Upload SBOM Artifact
|
||||
if: steps.image-check.outputs.exists == 'true' && always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: sbom-${{ steps.tag.outputs.tag }}
|
||||
path: sbom-verify.cyclonedx.json
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
|
||||
- name: Upload Vulnerability Scan Artifact
|
||||
if: steps.validate-sbom.outputs.valid == 'true' && always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: vulnerability-scan-${{ steps.tag.outputs.tag }}
|
||||
path: |
|
||||
|
||||
@@ -5,9 +5,9 @@ name: Weekly Nightly to Main Promotion
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every Monday at 10:30 UTC (5:30am EST / 6:30am EDT)
|
||||
# Every Monday at 12:00 UTC (7:00am EST / 8:00am EDT)
|
||||
# Offset from nightly sync (09:00 UTC) to avoid schedule race and allow validation completion.
|
||||
- cron: '30 10 * * 1'
|
||||
- cron: '0 12 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
|
||||
@@ -113,7 +113,7 @@ repos:
|
||||
stages: [manual] # Only runs when explicitly called
|
||||
- id: frontend-type-check
|
||||
name: Frontend TypeScript Check
|
||||
entry: bash -c 'cd frontend && npm run type-check'
|
||||
entry: bash -c 'cd frontend && npx tsc --noEmit'
|
||||
language: system
|
||||
files: '^frontend/.*\.(ts|tsx)$'
|
||||
pass_filenames: false
|
||||
|
||||
Vendored
+163
@@ -724,6 +724,13 @@
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Security: Caddy PR-1 Compatibility Matrix",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && bash scripts/caddy-compat-matrix.sh --candidate-version 2.11.2 --patch-scenarios A,B,C --platforms linux/amd64,linux/arm64 --smoke-set boot_caddy,plugin_modules,config_validate,admin_api_health --output-dir test-results/caddy-compat --docs-report docs/reports/caddy-compatibility-matrix.md",
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Skill)",
|
||||
"type": "shell",
|
||||
@@ -808,6 +815,162 @@
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shards 1/4-4/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=chromium --shard=1/4 --output=playwright-output/chromium-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=chromium --shard=2/4 --output=playwright-output/chromium-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=chromium --shard=3/4 --output=playwright-output/chromium-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=chromium --shard=4/4 --output=playwright-output/chromium-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 1/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=chromium --shard=1/4 --output=playwright-output/chromium-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 2/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=chromium --shard=2/4 --output=playwright-output/chromium-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 3/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=chromium --shard=3/4 --output=playwright-output/chromium-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 4/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=chromium --shard=4/4 --output=playwright-output/chromium-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shards 1/4-4/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=webkit --shard=1/4 --output=playwright-output/webkit-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=webkit --shard=2/4 --output=playwright-output/webkit-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=webkit --shard=3/4 --output=playwright-output/webkit-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=webkit --shard=4/4 --output=playwright-output/webkit-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 1/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=webkit --shard=1/4 --output=playwright-output/webkit-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 2/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=webkit --shard=2/4 --output=playwright-output/webkit-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 3/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=webkit --shard=3/4 --output=playwright-output/webkit-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 4/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=webkit --shard=4/4 --output=playwright-output/webkit-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Security Suite",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=true PLAYWRIGHT_SKIP_SECURITY_DEPS=0 npx playwright test --project=security-tests --output=playwright-output/chromium-security tests/security",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (FireFox) - Security Suite",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=true PLAYWRIGHT_SKIP_SECURITY_DEPS=0 npx playwright test --project=firefox --output=playwright-output/firefox-security tests/security",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Security Suite",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=true PLAYWRIGHT_SKIP_SECURITY_DEPS=0 npx playwright test --project=webkit --output=playwright-output/webkit-security tests/security",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright with Coverage",
|
||||
"type": "shell",
|
||||
|
||||
+11
-3
@@ -126,7 +126,7 @@ graph TB
|
||||
| **HTTP Framework** | Gin | Latest | Routing, middleware, HTTP handling |
|
||||
| **Database** | SQLite | 3.x | Embedded database |
|
||||
| **ORM** | GORM | Latest | Database abstraction layer |
|
||||
| **Reverse Proxy** | Caddy Server | 2.11.0-beta.2 | Embedded HTTP/HTTPS proxy |
|
||||
| **Reverse Proxy** | Caddy Server | 2.11.2 | Embedded HTTP/HTTPS proxy |
|
||||
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
|
||||
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
|
||||
| **Metrics** | Prometheus Client | Latest | Application metrics |
|
||||
@@ -1259,6 +1259,14 @@ go test ./integration/...
|
||||
9. **Release Notes:** Generate changelog from commits
|
||||
10. **Notify:** Send release notification (Discord, email)
|
||||
|
||||
**Mandatory rollout gates (sign-off block):**
|
||||
|
||||
1. Digest freshness and index digest parity across GHCR and Docker Hub
|
||||
2. Per-arch digest parity across GHCR and Docker Hub
|
||||
3. SBOM and vulnerability scans against immutable refs (`image@sha256:...`)
|
||||
4. Artifact freshness timestamps after push
|
||||
5. Evidence block with required rollout verification fields
|
||||
|
||||
### Supply Chain Security
|
||||
|
||||
**Components:**
|
||||
@@ -1292,10 +1300,10 @@ cosign verify \
|
||||
wikid82/charon:latest
|
||||
|
||||
# Inspect SBOM
|
||||
syft wikid82/charon:latest -o json
|
||||
syft ghcr.io/wikid82/charon@sha256:<index-digest> -o json
|
||||
|
||||
# Scan for vulnerabilities
|
||||
grype wikid82/charon:latest
|
||||
grype ghcr.io/wikid82/charon@sha256:<index-digest>
|
||||
```
|
||||
|
||||
### Rollback Strategy
|
||||
|
||||
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
- Fixed: Added robust validation and debug logging for Docker image tags to prevent invalid reference errors.
|
||||
- Fixed: Removed log masking for image references and added manifest validation to debug CI failures.
|
||||
- **Proxy Hosts**: Fixed ACL and Security Headers dropdown selections so create/edit saves now keep the selected values (including clearing to none) after submit and reload.
|
||||
- **CI**: Fixed Docker image reference output so integration jobs never pull an empty image ref
|
||||
- **E2E Test Reliability**: Resolved test timeout issues affecting CI/CD pipeline stability
|
||||
- Fixed config reload overlay blocking test interactions
|
||||
|
||||
+38
-13
@@ -14,8 +14,13 @@ ARG BUILD_DEBUG=0
|
||||
# avoid accidentally pulling a v3 major release. Renovate can still update
|
||||
# this ARG to a specific v2.x tag when desired.
|
||||
## Try to build the requested Caddy v2.x tag (Renovate can update this ARG).
|
||||
## If the requested tag isn't available, fall back to a known-good v2.11.0-beta.2 build.
|
||||
ARG CADDY_VERSION=2.11.0-beta.2
|
||||
## If the requested tag isn't available, fall back to a known-good v2.11.2 build.
|
||||
ARG CADDY_VERSION=2.11.2
|
||||
ARG CADDY_CANDIDATE_VERSION=2.11.2
|
||||
ARG CADDY_USE_CANDIDATE=0
|
||||
ARG CADDY_PATCH_SCENARIO=B
|
||||
# renovate: datasource=go depName=github.com/greenpau/caddy-security
|
||||
ARG CADDY_SECURITY_VERSION=1.1.36
|
||||
## When an official caddy image tag isn't available on the host, use a
|
||||
## plain Alpine base image and overwrite its caddy binary with our
|
||||
## xcaddy-built binary in the later COPY step. This avoids relying on
|
||||
@@ -65,7 +70,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# ---- Frontend Builder ----
|
||||
# Build the frontend using the BUILDPLATFORM to avoid arm64 musl Rollup native issues
|
||||
# renovate: datasource=docker depName=node
|
||||
FROM --platform=$BUILDPLATFORM node:24.13.1-alpine AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy frontend package files
|
||||
@@ -131,7 +136,7 @@ RUN set -eux; \
|
||||
# Note: xx-go install puts binaries in /go/bin/TARGETOS_TARGETARCH/dlv if cross-compiling.
|
||||
# We find it and move it to /go/bin/dlv so it's in a consistent location for the next stage.
|
||||
# renovate: datasource=go depName=github.com/go-delve/delve
|
||||
ARG DLV_VERSION=1.26.0
|
||||
ARG DLV_VERSION=1.26.1
|
||||
# hadolint ignore=DL3059,DL4006
|
||||
RUN CGO_ENABLED=0 xx-go install github.com/go-delve/delve/cmd/dlv@v${DLV_VERSION} && \
|
||||
DLV_PATH=$(find /go/bin -name dlv -type f | head -n 1) && \
|
||||
@@ -196,6 +201,10 @@ FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS caddy-builder
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG CADDY_VERSION
|
||||
ARG CADDY_CANDIDATE_VERSION
|
||||
ARG CADDY_USE_CANDIDATE
|
||||
ARG CADDY_PATCH_SCENARIO
|
||||
ARG CADDY_SECURITY_VERSION
|
||||
# renovate: datasource=go depName=github.com/caddyserver/xcaddy
|
||||
ARG XCADDY_VERSION=0.4.5
|
||||
|
||||
@@ -213,11 +222,18 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
sh -c 'set -e; \
|
||||
CADDY_TARGET_VERSION="${CADDY_VERSION}"; \
|
||||
if [ "${CADDY_USE_CANDIDATE}" = "1" ]; then \
|
||||
CADDY_TARGET_VERSION="${CADDY_CANDIDATE_VERSION}"; \
|
||||
fi; \
|
||||
echo "Using Caddy target version: v${CADDY_TARGET_VERSION}"; \
|
||||
echo "Using Caddy patch scenario: ${CADDY_PATCH_SCENARIO}"; \
|
||||
export XCADDY_SKIP_CLEANUP=1; \
|
||||
echo "Stage 1: Generate go.mod with xcaddy..."; \
|
||||
# Run xcaddy to generate the build directory and go.mod
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_VERSION} \
|
||||
--with github.com/greenpau/caddy-security \
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_TARGET_VERSION} \
|
||||
--with github.com/caddyserver/caddy/v2@v${CADDY_TARGET_VERSION} \
|
||||
--with github.com/greenpau/caddy-security@v${CADDY_SECURITY_VERSION} \
|
||||
--with github.com/corazawaf/coraza-caddy/v2 \
|
||||
--with github.com/hslatman/caddy-crowdsec-bouncer@v0.10.0 \
|
||||
--with github.com/zhangjiayin/caddy-geoip2 \
|
||||
@@ -239,12 +255,21 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go get github.com/expr-lang/expr@v1.17.7; \
|
||||
# renovate: datasource=go depName=github.com/hslatman/ipstore
|
||||
go get github.com/hslatman/ipstore@v0.4.0; \
|
||||
# NOTE: smallstep/certificates (pulled by caddy-security stack) currently
|
||||
# uses legacy nebula APIs removed in nebula v1.10+, which causes compile
|
||||
# failures in authority/provisioner. Keep this pinned to a known-compatible
|
||||
# v1.9.x release until upstream stack supports nebula v1.10+.
|
||||
# renovate: datasource=go depName=github.com/slackhq/nebula
|
||||
go get github.com/slackhq/nebula@v1.9.7; \
|
||||
if [ "${CADDY_PATCH_SCENARIO}" = "A" ]; then \
|
||||
# Rollback scenario: keep explicit nebula pin if upstream compatibility regresses.
|
||||
# NOTE: smallstep/certificates (pulled by caddy-security stack) currently
|
||||
# uses legacy nebula APIs removed in nebula v1.10+, which causes compile
|
||||
# failures in authority/provisioner. Keep this pinned to a known-compatible
|
||||
# v1.9.x release until upstream stack supports nebula v1.10+.
|
||||
# renovate: datasource=go depName=github.com/slackhq/nebula
|
||||
go get github.com/slackhq/nebula@v1.9.7; \
|
||||
elif [ "${CADDY_PATCH_SCENARIO}" = "B" ] || [ "${CADDY_PATCH_SCENARIO}" = "C" ]; then \
|
||||
# Default PR-2 posture: retire explicit nebula pin and use upstream resolution.
|
||||
echo "Skipping nebula pin for scenario ${CADDY_PATCH_SCENARIO}"; \
|
||||
else \
|
||||
echo "Unsupported CADDY_PATCH_SCENARIO=${CADDY_PATCH_SCENARIO}"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
# Clean up go.mod and ensure all dependencies are resolved
|
||||
go mod tidy; \
|
||||
echo "Dependencies patched successfully"; \
|
||||
@@ -392,7 +417,7 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"]
|
||||
# Note: In production, users should provide their own MaxMind license key
|
||||
# This uses the publicly available GeoLite2 database
|
||||
# In CI, timeout quickly rather than retrying to save build time
|
||||
ARG GEOLITE2_COUNTRY_SHA256=86fe00e0272865b8bec79defca2e9fb19ad0cf4458697992e1a37ba89077c13a
|
||||
ARG GEOLITE2_COUNTRY_SHA256=aa154fc6bcd712644de232a4abcdd07dac1f801308c0b6f93dbc2b375443da7b
|
||||
RUN mkdir -p /app/data/geoip && \
|
||||
if [ -n "$CI" ]; then \
|
||||
echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \
|
||||
|
||||
@@ -94,6 +94,19 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
> **Docker Socket Access:** Charon runs as a non-root user. If you mount the Docker socket for container discovery, the container needs permission to read it. Find your socket's group ID and add it to the compose file:
|
||||
>
|
||||
> ```bash
|
||||
> stat -c '%g' /var/run/docker.sock
|
||||
> ```
|
||||
>
|
||||
> Then add `group_add: ["<gid>"]` under your service (replace `<gid>` with the number from the command above). For example, if the result is `998`:
|
||||
>
|
||||
> ```yaml
|
||||
> group_add:
|
||||
> - "998"
|
||||
> ```
|
||||
|
||||
### 2️⃣ Generate encryption key:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
|
||||
+4
-4
@@ -25,11 +25,10 @@ We take security seriously. If you discover a security vulnerability in Charon,
|
||||
- Impact assessment
|
||||
- Suggested fix (if applicable)
|
||||
|
||||
**Alternative Method**: Email
|
||||
**Alternative Method**: GitHub Issues (Public)
|
||||
|
||||
- Send to: `security@charon.dev` (if configured)
|
||||
- Use PGP encryption (key available below, if applicable)
|
||||
- Include same information as GitHub advisory
|
||||
1. Go to <https://github.com/Wikid82/Charon/issues>
|
||||
2. Create a new issue with the same information as above
|
||||
|
||||
### What to Include
|
||||
|
||||
@@ -125,6 +124,7 @@ For complete technical details, see:
|
||||
|
||||
### Infrastructure Security
|
||||
|
||||
- **Non-root by default**: Charon runs as an unprivileged user (`charon`, uid 1000) inside the container. Docker socket access is granted via a minimal supplemental group matching the host socket's GID—never by running as root. If the socket GID is `0` (root group), Charon requires explicit opt-in before granting access.
|
||||
- **Container isolation**: Docker-based deployment
|
||||
- **Minimal attack surface**: Alpine Linux base image
|
||||
- **Dependency scanning**: Regular Trivy and govulncheck scans
|
||||
|
||||
+57
-17
@@ -19,36 +19,76 @@ Example: `0.1.0-alpha`, `1.0.0-beta.1`, `2.0.0-rc.2`
|
||||
|
||||
## Creating a Release
|
||||
|
||||
### Automated Release Process
|
||||
### Canonical Release Process (Tag-Derived CI)
|
||||
|
||||
1. **Update version** in `.version` file:
|
||||
1. **Create and push a release tag**:
|
||||
|
||||
```bash
|
||||
echo "1.0.0" > .version
|
||||
git tag -a v1.0.0 -m "Release v1.0.0"
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
2. **Commit version bump**:
|
||||
2. **GitHub Actions automatically**:
|
||||
- Runs release workflow from the pushed tag (`.github/workflows/release-goreleaser.yml`)
|
||||
- Builds and publishes release artifacts/images through CI (`.github/workflows/docker-build.yml`)
|
||||
- Creates/updates GitHub Release metadata
|
||||
|
||||
3. **Container tags are published**:
|
||||
- `v1.0.0` (exact version)
|
||||
- `1.0` (minor version)
|
||||
- `1` (major version)
|
||||
- `latest` (for non-prerelease on main branch)
|
||||
|
||||
### Legacy/Optional `.version` Path
|
||||
|
||||
The `.version` file is optional and not the canonical release trigger.
|
||||
|
||||
Use it only when you need local/version-file parity checks:
|
||||
|
||||
1. **Set `.version` locally (optional)**:
|
||||
|
||||
```bash
|
||||
git add .version
|
||||
git commit -m "chore: bump version to 1.0.0"
|
||||
echo "1.0.0" > .version
|
||||
```
|
||||
|
||||
3. **Create and push tag**:
|
||||
2. **Validate `.version` matches the latest tag**:
|
||||
|
||||
```bash
|
||||
git tag -a v1.0.0 -m "Release v1.0.0"
|
||||
git push origin v1.0.0
|
||||
bash scripts/check-version-match-tag.sh
|
||||
```
|
||||
|
||||
4. **GitHub Actions automatically**:
|
||||
- Creates GitHub Release with changelog
|
||||
- Builds multi-arch Docker images (amd64, arm64)
|
||||
- Publishes to GitHub Container Registry with tags:
|
||||
- `v1.0.0` (exact version)
|
||||
- `1.0` (minor version)
|
||||
- `1` (major version)
|
||||
- `latest` (for non-prerelease on main branch)
|
||||
### Deterministic Rollout Verification Gates (Mandatory)
|
||||
|
||||
Release sign-off is blocked until all items below pass in the same validation
|
||||
run.
|
||||
|
||||
Enforcement points:
|
||||
|
||||
- Release sign-off checklist/process (mandatory): All gates below remain required for release sign-off.
|
||||
- CI-supported checks (current): `.github/workflows/docker-build.yml` and `.github/workflows/supply-chain-verify.yml` enforce the subset currently implemented in workflows.
|
||||
- Manual validation required until CI parity: Validate any not-yet-implemented workflow gates via VS Code tasks `Security: Full Supply Chain Audit`, `Security: Verify SBOM`, `Security: Generate SLSA Provenance`, and `Security: Sign with Cosign`.
|
||||
- Optional version-file parity check: `Utility: Check Version Match Tag` (script: `scripts/check-version-match-tag.sh`).
|
||||
|
||||
- [ ] **Digest freshness/parity:** Capture pre-push and post-push index digests
|
||||
for the target tag in GHCR and Docker Hub, confirm expected freshness,
|
||||
and confirm cross-registry index digest parity.
|
||||
- [ ] **Per-arch parity:** Confirm per-platform (`linux/amd64`, `linux/arm64`,
|
||||
and any published platform) digest parity between GHCR and Docker Hub.
|
||||
- [ ] **Immutable digest scanning:** Run SBOM and vulnerability scans against
|
||||
immutable refs only, using `image@sha256:<index-digest>`.
|
||||
- [ ] **Artifact freshness:** Confirm scan artifacts are generated after the
|
||||
push timestamp and in the same validation run.
|
||||
- [ ] **Evidence block present:** Include the mandatory evidence block fields
|
||||
listed below.
|
||||
|
||||
#### Mandatory Evidence Block Fields
|
||||
|
||||
- Tag name
|
||||
- Index digest (`sha256:...`)
|
||||
- Per-arch digests (platform -> digest)
|
||||
- Scan tool versions
|
||||
- Push timestamp and scan timestamp(s)
|
||||
- Artifact file names generated in this run
|
||||
|
||||
## Container Image Tags
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ linters:
|
||||
- ineffassign # Ineffectual assignments
|
||||
- unused # Unused code detection
|
||||
- gosec # Security checks (critical issues only)
|
||||
linters-settings:
|
||||
settings:
|
||||
govet:
|
||||
enable:
|
||||
- shadow
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# golangci-lint configuration
|
||||
version: 2
|
||||
version: "2"
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
@@ -14,7 +14,7 @@ linters:
|
||||
- staticcheck
|
||||
- unused
|
||||
- errcheck
|
||||
linters-settings:
|
||||
settings:
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
|
||||
@@ -260,7 +260,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Register import handler with config dependencies
|
||||
routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
|
||||
routes.RegisterImportHandler(router, db, cfg, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
|
||||
|
||||
// Check for mounted Caddyfile on startup
|
||||
if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil {
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestResetPasswordCommand_Succeeds(t *testing.T) {
|
||||
}
|
||||
|
||||
email := "user@example.com"
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: "admin", Enabled: true}
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: models.RoleAdmin, Enabled: true}
|
||||
user.PasswordHash = "$2a$10$example_hashed_password"
|
||||
if err = db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
@@ -257,7 +257,7 @@ func TestMain_ResetPasswordCommand_InProcess(t *testing.T) {
|
||||
}
|
||||
|
||||
email := "user@example.com"
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: "admin", Enabled: true}
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: models.RoleAdmin, Enabled: true}
|
||||
user.PasswordHash = "$2a$10$example_hashed_password"
|
||||
user.FailedLoginAttempts = 3
|
||||
if err = db.Create(&user).Error; err != nil {
|
||||
@@ -311,7 +311,8 @@ func TestMain_DefaultStartupGracefulShutdown_Subprocess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("find free http port: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o750); err != nil {
|
||||
err = os.MkdirAll(filepath.Dir(dbPath), 0o750)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir db dir: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,11 +64,13 @@ func main() {
|
||||
jsonOutPath := resolvePath(repoRoot, *jsonOutFlag)
|
||||
mdOutPath := resolvePath(repoRoot, *mdOutFlag)
|
||||
|
||||
if err := assertFileExists(backendCoveragePath, "backend coverage file"); err != nil {
|
||||
err = assertFileExists(backendCoveragePath, "backend coverage file")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := assertFileExists(frontendCoveragePath, "frontend coverage file"); err != nil {
|
||||
err = assertFileExists(frontendCoveragePath, "frontend coverage file")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -235,7 +235,8 @@ func TestGitDiffAndWriters(t *testing.T) {
|
||||
t.Fatalf("expected empty diff for HEAD...HEAD, got: %q", diffContent)
|
||||
}
|
||||
|
||||
if _, err := gitDiff(repoRoot, "bad-baseline"); err == nil {
|
||||
_, err = gitDiff(repoRoot, "bad-baseline")
|
||||
if err == nil {
|
||||
t.Fatal("expected gitDiff failure for invalid baseline")
|
||||
}
|
||||
|
||||
@@ -263,7 +264,8 @@ func TestGitDiffAndWriters(t *testing.T) {
|
||||
}
|
||||
|
||||
jsonPath := filepath.Join(t.TempDir(), "report.json")
|
||||
if err := writeJSON(jsonPath, report); err != nil {
|
||||
err = writeJSON(jsonPath, report)
|
||||
if err != nil {
|
||||
t.Fatalf("writeJSON should succeed: %v", err)
|
||||
}
|
||||
// #nosec G304 -- Test reads artifact path created by this test.
|
||||
@@ -276,7 +278,8 @@ func TestGitDiffAndWriters(t *testing.T) {
|
||||
}
|
||||
|
||||
markdownPath := filepath.Join(t.TempDir(), "report.md")
|
||||
if err := writeMarkdown(markdownPath, report, "backend/coverage.txt", "frontend/coverage/lcov.info"); err != nil {
|
||||
err = writeMarkdown(markdownPath, report, "backend/coverage.txt", "frontend/coverage/lcov.info")
|
||||
if err != nil {
|
||||
t.Fatalf("writeMarkdown should succeed: %v", err)
|
||||
}
|
||||
// #nosec G304 -- Test reads artifact path created by this test.
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestSeedMain_ForceAdminUpdatesExistingUserPassword(t *testing.T) {
|
||||
UUID: "existing-user",
|
||||
Email: "admin@localhost",
|
||||
Name: "Old Name",
|
||||
Role: "viewer",
|
||||
Role: models.RolePassthrough,
|
||||
Enabled: false,
|
||||
PasswordHash: "$2a$10$example_hashed_password",
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestSeedMain_ForceAdminWithoutPasswordUpdatesMetadata(t *testing.T) {
|
||||
UUID: "existing-user-no-pass",
|
||||
Email: "admin@localhost",
|
||||
Name: "Old Name",
|
||||
Role: "viewer",
|
||||
Role: models.RolePassthrough,
|
||||
Enabled: false,
|
||||
PasswordHash: "$2a$10$example_hashed_password",
|
||||
}
|
||||
|
||||
+23
-21
@@ -5,7 +5,7 @@ go 1.26
|
||||
require (
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -29,8 +29,8 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
@@ -42,16 +42,16 @@ require (
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -66,6 +66,7 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
||||
@@ -73,28 +74,29 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
modernc.org/libc v1.69.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
)
|
||||
|
||||
+84
-52
@@ -6,10 +6,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -37,16 +37,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -64,21 +64,23 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -118,6 +120,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -136,21 +140,20 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -161,45 +164,52 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
@@ -207,6 +217,8 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
@@ -229,11 +241,31 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
|
||||
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
|
||||
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/notifications"
|
||||
)
|
||||
|
||||
func TestNotificationHTTPWrapperIntegration_RetriesOn429AndSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
current := atomic.AddInt32(&calls, 1)
|
||||
if current == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wrapper := notifications.NewNotifyHTTPWrapper()
|
||||
result, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
|
||||
URL: server.URL,
|
||||
Body: []byte(`{"message":"hello"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected retry success, got error: %v", err)
|
||||
}
|
||||
if result.Attempts != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", result.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHTTPWrapperIntegration_DoesNotRetryOn400(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wrapper := notifications.NewNotifyHTTPWrapper()
|
||||
_, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
|
||||
URL: server.URL,
|
||||
Body: []byte(`{"message":"hello"}`),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-retryable 400 error")
|
||||
}
|
||||
if atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("expected one request attempt, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHTTPWrapperIntegration_RejectsTokenizedQueryWithoutEcho(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wrapper := notifications.NewNotifyHTTPWrapper()
|
||||
secret := "pr1-secret-token-value"
|
||||
_, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
|
||||
URL: "http://example.com/hook?token=" + secret,
|
||||
Body: []byte(`{"message":"hello"}`),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected tokenized query rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "query authentication is not allowed") {
|
||||
t.Fatalf("expected sanitized query-auth rejection, got: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("error must not echo secret token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHTTPWrapperIntegration_HeaderAllowlistSafety(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var seenAuthHeader string
|
||||
var seenCookieHeader string
|
||||
var seenGotifyKey string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seenAuthHeader = r.Header.Get("Authorization")
|
||||
seenCookieHeader = r.Header.Get("Cookie")
|
||||
seenGotifyKey = r.Header.Get("X-Gotify-Key")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wrapper := notifications.NewNotifyHTTPWrapper()
|
||||
_, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
|
||||
URL: server.URL,
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer should-not-leak",
|
||||
"Cookie": "session=should-not-leak",
|
||||
"X-Gotify-Key": "allowed-token",
|
||||
},
|
||||
Body: []byte(`{"message":"hello"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
if seenAuthHeader != "" {
|
||||
t.Fatalf("authorization header must be stripped")
|
||||
}
|
||||
if seenCookieHeader != "" {
|
||||
t.Fatalf("cookie header must be stripped")
|
||||
}
|
||||
if seenGotifyKey != "allowed-token" {
|
||||
t.Fatalf("expected X-Gotify-Key to pass through")
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,7 @@ func TestSecurityHandler_UpdateConfig_ApplyCaddyError(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/security/config", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -190,6 +191,7 @@ func TestSecurityHandler_GenerateBreakGlass_Error(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/security/breakglass", http.NoBody)
|
||||
|
||||
h.GenerateBreakGlass(c)
|
||||
@@ -252,6 +254,7 @@ func TestSecurityHandler_UpsertRuleSet_Error(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/security/rulesets", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -277,6 +280,7 @@ func TestSecurityHandler_CreateDecision_LogError(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -297,6 +301,7 @@ func TestSecurityHandler_DeleteRuleSet_Error(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Params = gin.Params{{Key: "id", Value: "999"}}
|
||||
|
||||
h.DeleteRuleSet(c)
|
||||
|
||||
@@ -127,18 +127,20 @@ func isLocalRequest(c *gin.Context) bool {
|
||||
|
||||
// setSecureCookie sets an auth cookie with security best practices
|
||||
// - HttpOnly: prevents JavaScript access (XSS protection)
|
||||
// - Secure: derived from request scheme to allow HTTP/IP logins when needed
|
||||
// - Secure: true for HTTPS; false only for local non-HTTPS loopback flows
|
||||
// - SameSite: Strict for HTTPS, Lax for HTTP/IP to allow forward-auth redirects
|
||||
func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
|
||||
scheme := requestScheme(c)
|
||||
secure := scheme == "https"
|
||||
secure := true
|
||||
sameSite := http.SameSiteStrictMode
|
||||
if scheme != "https" {
|
||||
sameSite = http.SameSiteLaxMode
|
||||
if isLocalRequest(c) {
|
||||
secure = false
|
||||
}
|
||||
}
|
||||
|
||||
if isLocalRequest(c) {
|
||||
secure = false
|
||||
sameSite = http.SameSiteLaxMode
|
||||
}
|
||||
|
||||
@@ -152,7 +154,7 @@ func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
|
||||
maxAge, // maxAge in seconds
|
||||
"/", // path
|
||||
domain, // domain (empty = current host)
|
||||
secure, // secure (HTTPS only in production)
|
||||
secure, // secure (always true)
|
||||
true, // httpOnly (no JS access)
|
||||
)
|
||||
}
|
||||
@@ -379,7 +381,7 @@ func (h *AuthHandler) Verify(c *gin.Context) {
|
||||
|
||||
// Set headers for downstream services
|
||||
c.Header("X-Forwarded-User", user.Email)
|
||||
c.Header("X-Forwarded-Groups", user.Role)
|
||||
c.Header("X-Forwarded-Groups", string(user.Role))
|
||||
c.Header("X-Forwarded-Name", user.Name)
|
||||
|
||||
// Return 200 OK - access granted
|
||||
|
||||
@@ -94,10 +94,28 @@ func TestSetSecureCookie_HTTP_Lax(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
c := cookies[0]
|
||||
assert.False(t, c.Secure)
|
||||
assert.True(t, c.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, c.SameSite)
|
||||
}
|
||||
|
||||
func TestSetSecureCookie_HTTP_Loopback_Insecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest("POST", "http://127.0.0.1:8080/login", http.NoBody)
|
||||
req.Host = "127.0.0.1:8080"
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
ctx.Request = req
|
||||
|
||||
setSecureCookie(ctx, "auth_token", "abc", 60)
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
func TestSetSecureCookie_ForwardedHTTPS_LocalhostForcesInsecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -115,7 +133,7 @@ func TestSetSecureCookie_ForwardedHTTPS_LocalhostForcesInsecure(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
@@ -136,7 +154,7 @@ func TestSetSecureCookie_ForwardedHTTPS_LoopbackForcesInsecure(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
@@ -158,7 +176,7 @@ func TestSetSecureCookie_ForwardedHostLocalhostForcesInsecure(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
@@ -180,7 +198,7 @@ func TestSetSecureCookie_OriginLoopbackForcesInsecure(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
@@ -412,7 +430,7 @@ func TestAuthHandler_Me(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "me@example.com",
|
||||
Name: "Me User",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(user)
|
||||
|
||||
@@ -612,7 +630,7 @@ func TestAuthHandler_Verify_ValidToken(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -643,7 +661,7 @@ func TestAuthHandler_Verify_BearerToken(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "bearer@example.com",
|
||||
Name: "Bearer User",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -672,7 +690,7 @@ func TestAuthHandler_Verify_DisabledUser(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "disabled@example.com",
|
||||
Name: "Disabled User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
@@ -712,7 +730,7 @@ func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "denied@example.com",
|
||||
Name: "Denied User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeDenyAll,
|
||||
}
|
||||
@@ -777,7 +795,7 @@ func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "status@example.com",
|
||||
Name: "Status User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -810,7 +828,7 @@ func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "disabled2@example.com",
|
||||
Name: "Disabled User 2",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
@@ -862,7 +880,7 @@ func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "allowall@example.com",
|
||||
Name: "Allow All User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeAllowAll,
|
||||
}
|
||||
@@ -899,7 +917,7 @@ func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "denyall@example.com",
|
||||
Name: "Deny All User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeDenyAll,
|
||||
}
|
||||
@@ -938,7 +956,7 @@ func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "permitted@example.com",
|
||||
Name: "Permitted User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeDenyAll,
|
||||
PermittedHosts: []models.ProxyHost{*host1}, // Only host1
|
||||
@@ -1093,7 +1111,7 @@ func TestAuthHandler_Logout_InvalidatesBearerSession(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "logout-session@example.com",
|
||||
Name: "Logout Session",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -1224,7 +1242,7 @@ func TestAuthHandler_Refresh(t *testing.T) {
|
||||
|
||||
handler, db := setupAuthHandler(t)
|
||||
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: "user", Enabled: true}
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, user.SetPassword("password123"))
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
@@ -1314,7 +1332,7 @@ func TestAuthHandler_Verify_UsesOriginalHostFallback(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "originalhost@example.com",
|
||||
Name: "Original Host User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeAllowAll,
|
||||
}
|
||||
|
||||
@@ -71,10 +71,14 @@ func (h *DockerHandler) ListContainers(c *gin.Context) {
|
||||
if err != nil {
|
||||
var unavailableErr *services.DockerUnavailableError
|
||||
if errors.As(err, &unavailableErr) {
|
||||
details := unavailableErr.Details()
|
||||
if details == "" {
|
||||
details = "Cannot connect to Docker. Please ensure Docker is running and the socket is accessible (e.g., /var/run/docker.sock is mounted)."
|
||||
}
|
||||
log.WithFields(map[string]any{"server_id": util.SanitizeForLog(serverID), "host": util.SanitizeForLog(host), "error": util.SanitizeForLog(err.Error())}).Warn("docker unavailable")
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Docker daemon unavailable",
|
||||
"details": "Cannot connect to Docker. Please ensure Docker is running and the socket is accessible (e.g., /var/run/docker.sock is mounted).",
|
||||
"details": details,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"))}
|
||||
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"), "Local Docker socket is mounted but not accessible by current process")}
|
||||
remoteSvc := &fakeRemoteServerService{}
|
||||
h := NewDockerHandler(dockerSvc, remoteSvc)
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T)
|
||||
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
|
||||
// Verify the new details field is included in the response
|
||||
assert.Contains(t, w.Body.String(), "details")
|
||||
assert.Contains(t, w.Body.String(), "Docker is running")
|
||||
assert.Contains(t, w.Body.String(), "not accessible by current process")
|
||||
}
|
||||
|
||||
func TestDockerHandler_ListContainers_ServerIDResolvesToTCPHost(t *testing.T) {
|
||||
@@ -360,3 +360,47 @@ func TestDockerHandler_ListContainers_GenericError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerHandler_ListContainers_503FallbackDetailsWhenEmpty(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("socket error"))}
|
||||
remoteSvc := &fakeRemoteServerService{}
|
||||
h := NewDockerHandler(dockerSvc, remoteSvc)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
|
||||
assert.Contains(t, w.Body.String(), "docker.sock is mounted")
|
||||
}
|
||||
|
||||
func TestDockerHandler_ListContainers_503DetailsWithGroupGuidance(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
groupDetails := `Local Docker socket is mounted but not accessible by current process (uid=1000 gid=1000). Process groups (1000) do not include socket gid 988; run container with matching supplemental group (e.g., --group-add 988 or compose group_add: ["988"]).`
|
||||
dockerSvc := &fakeDockerService{
|
||||
err: services.NewDockerUnavailableError(errors.New("EACCES"), groupDetails),
|
||||
}
|
||||
remoteSvc := &fakeRemoteServerService{}
|
||||
h := NewDockerHandler(dockerSvc, remoteSvc)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?host=local", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
|
||||
assert.Contains(t, w.Body.String(), "--group-add 988")
|
||||
assert.Contains(t, w.Body.String(), "group_add")
|
||||
}
|
||||
|
||||
@@ -384,10 +384,7 @@ func (h *EmergencyHandler) syncSecurityState(ctx context.Context) {
|
||||
// POST /api/v1/emergency/token/generate
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -437,10 +434,7 @@ func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
|
||||
// GET /api/v1/emergency/token/status
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -458,10 +452,7 @@ func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
|
||||
// DELETE /api/v1/emergency/token
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -485,10 +476,7 @@ func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
|
||||
// PATCH /api/v1/emergency/token/expiration
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) UpdateTokenExpiration(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ var defaultFlags = []string{
|
||||
"feature.notifications.engine.notify_v1.enabled",
|
||||
"feature.notifications.service.discord.enabled",
|
||||
"feature.notifications.service.gotify.enabled",
|
||||
"feature.notifications.service.webhook.enabled",
|
||||
"feature.notifications.legacy.fallback_enabled",
|
||||
"feature.notifications.security_provider_events.enabled", // Blocker 3: Add security_provider_events gate
|
||||
}
|
||||
@@ -42,6 +43,7 @@ var defaultFlagValues = map[string]bool{
|
||||
"feature.notifications.engine.notify_v1.enabled": false,
|
||||
"feature.notifications.service.discord.enabled": false,
|
||||
"feature.notifications.service.gotify.enabled": false,
|
||||
"feature.notifications.service.webhook.enabled": false,
|
||||
"feature.notifications.legacy.fallback_enabled": false,
|
||||
"feature.notifications.security_provider_events.enabled": false, // Blocker 3: Default disabled for this stage
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
|
||||
// GetStatus returns current import session status.
|
||||
func (h *ImportHandler) GetStatus(c *gin.Context) {
|
||||
if !requireAuthenticatedAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
@@ -155,6 +159,10 @@ func (h *ImportHandler) GetStatus(c *gin.Context) {
|
||||
|
||||
// GetPreview returns parsed hosts and conflicts for review.
|
||||
func (h *ImportHandler) GetPreview(c *gin.Context) {
|
||||
if !requireAuthenticatedAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
|
||||
@@ -340,6 +340,11 @@ func TestCommitAndCancel_InvalidSessionUUID(t *testing.T) {
|
||||
r.ServeHTTP(wCommit, reqCommit)
|
||||
assert.Equal(t, http.StatusBadRequest, wCommit.Code)
|
||||
|
||||
wCancelMissing := httptest.NewRecorder()
|
||||
reqCancelMissing, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel", http.NoBody)
|
||||
r.ServeHTTP(wCancelMissing, reqCancelMissing)
|
||||
assert.Equal(t, http.StatusBadRequest, wCancelMissing.Code)
|
||||
|
||||
wCancel := httptest.NewRecorder()
|
||||
reqCancel, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel?session_uuid=.", http.NoBody)
|
||||
r.ServeHTTP(wCancel, reqCancel)
|
||||
|
||||
@@ -310,6 +310,11 @@ func (h *JSONImportHandler) Cancel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.SessionUUID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up session if it exists
|
||||
jsonImportSessionsMu.Lock()
|
||||
delete(jsonImportSessions, req.SessionUUID)
|
||||
|
||||
@@ -497,6 +497,62 @@ func TestJSONImportHandler_ConflictDetection(t *testing.T) {
|
||||
assert.Contains(t, conflictDetails, "conflict.com")
|
||||
}
|
||||
|
||||
func TestJSONImportHandler_Cancel_RequiresValidJSONBody(t *testing.T) {
|
||||
db := setupJSONTestDB(t)
|
||||
handler := NewJSONImportHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api := router.Group("/api/v1")
|
||||
handler.RegisterRoutes(api)
|
||||
|
||||
t.Run("missing body", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", http.NoBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString("{"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("empty object payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session_uuid required", resp["error"])
|
||||
})
|
||||
|
||||
t.Run("missing session_uuid payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString(`{"foo":"bar"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session_uuid required", resp["error"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestJSONImportHandler_IsCharonFormat(t *testing.T) {
|
||||
db := setupJSONTestDB(t)
|
||||
handler := NewJSONImportHandler(db)
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/trace"
|
||||
)
|
||||
|
||||
func setupNotificationCoverageDB(t *testing.T) *gorm.DB {
|
||||
@@ -319,6 +321,159 @@ func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) {
|
||||
assert.Equal(t, 400, w.Code)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "gotify",
|
||||
"url": "https://gotify.example/message",
|
||||
"token": "super-secret-client-token",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Set(string(trace.RequestIDKey), "req-token-reject-1")
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "TOKEN_WRITE_ONLY", resp["code"])
|
||||
assert.Equal(t, "validation", resp["category"])
|
||||
assert.Equal(t, "Gotify token is accepted only on provider create/update", resp["error"])
|
||||
assert.Equal(t, "req-token-reject-1", resp["request_id"])
|
||||
assert.NotContains(t, w.Body.String(), "super-secret-client-token")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_RejectsGotifyTokenWithWhitespace(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "gotify",
|
||||
"token": " secret-with-space ",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
|
||||
assert.NotContains(t, w.Body.String(), "secret-with-space")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_NilError(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(nil)
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_FAILED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Equal(t, "Provider test failed", message)
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_DefaultStatusCode(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("provider returned status 500"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_REMOTE_REJECTED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "HTTP 500")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_GenericError(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("something completely unexpected"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_FAILED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Equal(t, "Provider test failed", message)
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_InvalidDiscordWebhookURL(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("invalid discord webhook url"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_URL_INVALID", code)
|
||||
assert.Equal(t, "validation", category)
|
||||
assert.Contains(t, message, "Provider URL")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_URLValidation(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("destination URL validation failed"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_URL_INVALID", code)
|
||||
assert.Equal(t, "validation", category)
|
||||
assert.Contains(t, message, "Provider URL")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_AuthRejected(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: provider returned status 401"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_AUTH_REJECTED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "rejected authentication")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_EndpointNotFound(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: provider returned status 404"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_ENDPOINT_NOT_FOUND", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "endpoint was not found")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_UnreachableEndpoint(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_UNREACHABLE", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "Could not reach provider endpoint")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_DNSLookupFailed(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: dns lookup failed"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_DNS_FAILED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "DNS lookup failed")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_ConnectionRefused(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: connection refused"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_CONNECTION_REFUSED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "refused the connection")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_Timeout(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: request timed out"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_TIMEOUT", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "timed out")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_TLSHandshakeFailed(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: tls handshake failed"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_TLS_FAILED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "TLS handshake failed")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Templates(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
@@ -625,3 +780,258 @@ func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"template": "minimal",
|
||||
"token": "secret-token-value",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/preview", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Preview(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
ID: "update-type-test",
|
||||
Name: "Discord Provider",
|
||||
Type: "discord",
|
||||
URL: "https://discord.com/api/webhooks/123/abc",
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "Changed Type Provider",
|
||||
"type": "gotify",
|
||||
"url": "https://gotify.example.com",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Params = gin.Params{{Key: "id", Value: "update-type-test"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/providers/update-type-test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Update(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_TYPE_IMMUTABLE")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "MISSING_PROVIDER_ID")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
"id": "nonexistent-provider",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, 404, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_EmptyProviderURL(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
ID: "empty-url-test",
|
||||
Name: "Empty URL Provider",
|
||||
Type: "discord",
|
||||
URL: "",
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
"id": "empty-url-test",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_CONFIG_MISSING")
|
||||
}
|
||||
|
||||
func TestIsProviderValidationError_Comprehensive(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
expect bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"invalid_custom_template", errors.New("invalid custom template: missing field"), true},
|
||||
{"rendered_template", errors.New("rendered template exceeds maximum"), true},
|
||||
{"failed_to_parse", errors.New("failed to parse template: unexpected end"), true},
|
||||
{"failed_to_render", errors.New("failed to render template: missing key"), true},
|
||||
{"invalid_discord_webhook", errors.New("invalid Discord webhook URL"), true},
|
||||
{"unrelated_error", errors.New("database connection failed"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expect, isProviderValidationError(tc.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
ID: "unsupported-type",
|
||||
Name: "Custom Provider",
|
||||
Type: "slack",
|
||||
URL: "https://hooks.slack.com/test",
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "Updated Slack Provider",
|
||||
"url": "https://hooks.slack.com/updated",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Params = gin.Params{{Key: "id", Value: "unsupported-type"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/providers/unsupported-type", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Update(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "UNSUPPORTED_PROVIDER_TYPE")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
ID: "gotify-keep-token",
|
||||
Name: "Gotify Provider",
|
||||
Type: "gotify",
|
||||
URL: "https://gotify.example.com",
|
||||
Token: "existing-secret-token",
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "Updated Gotify",
|
||||
"url": "https://gotify.example.com/new",
|
||||
"template": "minimal",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Params = gin.Params{{Key: "id", Value: "gotify-keep-token"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/providers/gotify-keep-token", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Update(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var updated models.NotificationProvider
|
||||
require.NoError(t, db.Where("id = ?", "gotify-keep-token").First(&updated).Error)
|
||||
assert.Equal(t, "existing-secret-token", updated.Token)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_ReadDBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
_ = db.Migrator().DropTable(&models.NotificationProvider{})
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
"id": "some-provider",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, 500, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_READ_FAILED")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents tests that create rejects non-Discord providers with security events.
|
||||
// TestBlocker3_CreateProviderValidationWithSecurityEvents verifies supported/unsupported provider handling with security events enabled.
|
||||
func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -31,15 +31,16 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
|
||||
service := services.NewNotificationService(db)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Test cases: non-Discord provider types with security events enabled
|
||||
// Test cases: provider types with security events enabled
|
||||
testCases := []struct {
|
||||
name string
|
||||
providerType string
|
||||
wantStatus int
|
||||
}{
|
||||
{"webhook", "webhook"},
|
||||
{"slack", "slack"},
|
||||
{"gotify", "gotify"},
|
||||
{"email", "email"},
|
||||
{"webhook", "webhook", http.StatusCreated},
|
||||
{"gotify", "gotify", http.StatusCreated},
|
||||
{"slack", "slack", http.StatusBadRequest},
|
||||
{"email", "email", http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -69,14 +70,15 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
|
||||
// Call Create
|
||||
handler.Create(c)
|
||||
|
||||
// Blocker 3: Should reject with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider with security events")
|
||||
assert.Equal(t, tc.wantStatus, w.Code)
|
||||
|
||||
// Verify error message
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, response["error"], "discord", "Error should mention Discord")
|
||||
if tc.wantStatus == http.StatusBadRequest {
|
||||
assert.Contains(t, response["code"], "UNSUPPORTED_PROVIDER_TYPE")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -129,8 +131,7 @@ func TestBlocker3_CreateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
|
||||
assert.Equal(t, http.StatusCreated, w.Code, "Should accept Discord provider with security events")
|
||||
}
|
||||
|
||||
// TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents tests that create NOW REJECTS non-Discord providers even without security events.
|
||||
// NOTE: This test was updated for Discord-only rollout (current_spec.md) - now globally rejects all non-Discord.
|
||||
// TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents verifies webhook create without security events remains accepted.
|
||||
func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -172,17 +173,10 @@ func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testin
|
||||
// Call Create
|
||||
handler.Create(c)
|
||||
|
||||
// Discord-only rollout: Now REJECTS with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider (Discord-only rollout)")
|
||||
|
||||
// Verify error message
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, response["error"], "discord", "Error should mention Discord")
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
|
||||
// TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents tests that update rejects non-Discord providers with security events.
|
||||
// TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents verifies webhook update with security events is allowed in PR-1 scope.
|
||||
func TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -235,14 +229,7 @@ func TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
|
||||
// Call Update
|
||||
handler.Update(c)
|
||||
|
||||
// Blocker 3: Should reject with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider update with security events")
|
||||
|
||||
// Verify error message
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, response["error"], "discord", "Error should mention Discord")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
// TestBlocker3_UpdateProviderAcceptsDiscordWithSecurityEvents tests that update accepts Discord providers with security events.
|
||||
@@ -302,7 +289,7 @@ func TestBlocker3_UpdateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Should accept Discord provider update with security events")
|
||||
}
|
||||
|
||||
// TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly tests that having any security event enabled enforces Discord-only.
|
||||
// TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly tests webhook remains accepted with security flags in PR-1 scope.
|
||||
func TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -353,9 +340,8 @@ func TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly(t *testing.T) {
|
||||
// Call Create
|
||||
handler.Create(c)
|
||||
|
||||
// Blocker 3: Should reject with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code,
|
||||
"Should reject webhook provider with %s enabled", field)
|
||||
assert.Equal(t, http.StatusCreated, w.Code,
|
||||
"Should accept webhook provider with %s enabled", field)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -407,5 +393,5 @@ func TestBlocker3_UpdateProvider_DatabaseError(t *testing.T) {
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "provider not found", response["error"])
|
||||
assert.Equal(t, "Provider not found", response["error"])
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestDiscordOnly_CreateRejectsNonDiscord tests that create globally rejects non-Discord providers.
|
||||
// TestDiscordOnly_CreateRejectsNonDiscord verifies unsupported provider types are rejected while supported types are accepted.
|
||||
func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -30,13 +30,15 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
providerType string
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{"webhook", "webhook"},
|
||||
{"slack", "slack"},
|
||||
{"gotify", "gotify"},
|
||||
{"telegram", "telegram"},
|
||||
{"generic", "generic"},
|
||||
{"email", "email"},
|
||||
{"webhook", "webhook", http.StatusCreated, ""},
|
||||
{"gotify", "gotify", http.StatusCreated, ""},
|
||||
{"slack", "slack", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"telegram", "telegram", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"generic", "generic", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"email", "email", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -61,13 +63,14 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider")
|
||||
assert.Equal(t, tc.wantStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PROVIDER_TYPE_DISCORD_ONLY", response["code"])
|
||||
assert.Contains(t, response["error"], "discord")
|
||||
if tc.wantCode != "" {
|
||||
assert.Equal(t, tc.wantCode, response["code"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -156,8 +159,8 @@ func TestDiscordOnly_UpdateRejectsTypeMutation(t *testing.T) {
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "DEPRECATED_PROVIDER_TYPE_IMMUTABLE", response["code"])
|
||||
assert.Contains(t, response["error"], "cannot change provider type")
|
||||
assert.Equal(t, "PROVIDER_TYPE_IMMUTABLE", response["code"])
|
||||
assert.Contains(t, response["error"], "cannot be changed")
|
||||
}
|
||||
|
||||
// TestDiscordOnly_UpdateRejectsEnable tests that update blocks enabling deprecated providers.
|
||||
@@ -205,13 +208,7 @@ func TestDiscordOnly_UpdateRejectsEnable(t *testing.T) {
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject enabling deprecated provider")
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "DEPRECATED_PROVIDER_CANNOT_ENABLE", response["code"])
|
||||
assert.Contains(t, response["error"], "cannot enable deprecated")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
// TestDiscordOnly_UpdateAllowsDisabledDeprecated tests that update allows updating disabled deprecated providers (except type/enable).
|
||||
@@ -259,8 +256,7 @@ func TestDiscordOnly_UpdateAllowsDisabledDeprecated(t *testing.T) {
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
// Should still reject because type must be discord
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord type even for read-only fields")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
// TestDiscordOnly_UpdateAcceptsDiscord tests that update accepts Discord provider updates.
|
||||
@@ -360,21 +356,21 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
|
||||
expectedCode string
|
||||
}{
|
||||
{
|
||||
name: "create_non_discord",
|
||||
name: "create_unsupported",
|
||||
setupFunc: func(db *gorm.DB) string {
|
||||
return ""
|
||||
},
|
||||
requestFunc: func(id string) (*http.Request, gin.Params) {
|
||||
payload := map[string]interface{}{
|
||||
"name": "Test",
|
||||
"type": "webhook",
|
||||
"type": "slack",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body))
|
||||
return req, nil
|
||||
},
|
||||
expectedCode: "PROVIDER_TYPE_DISCORD_ONLY",
|
||||
expectedCode: "UNSUPPORTED_PROVIDER_TYPE",
|
||||
},
|
||||
{
|
||||
name: "update_type_mutation",
|
||||
@@ -399,34 +395,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/notifications/providers/"+id, bytes.NewBuffer(body))
|
||||
return req, []gin.Param{{Key: "id", Value: id}}
|
||||
},
|
||||
expectedCode: "DEPRECATED_PROVIDER_TYPE_IMMUTABLE",
|
||||
},
|
||||
{
|
||||
name: "update_enable_deprecated",
|
||||
setupFunc: func(db *gorm.DB) string {
|
||||
provider := models.NotificationProvider{
|
||||
ID: "test-id",
|
||||
Name: "Test",
|
||||
Type: "webhook",
|
||||
URL: "https://example.com",
|
||||
Enabled: false,
|
||||
MigrationState: "deprecated",
|
||||
}
|
||||
db.Create(&provider)
|
||||
return "test-id"
|
||||
},
|
||||
requestFunc: func(id string) (*http.Request, gin.Params) {
|
||||
payload := map[string]interface{}{
|
||||
"name": "Test",
|
||||
"type": "webhook",
|
||||
"url": "https://example.com",
|
||||
"enabled": true,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/notifications/providers/"+id, bytes.NewBuffer(body))
|
||||
return req, []gin.Param{{Key: "id", Value: id}}
|
||||
},
|
||||
expectedCode: "DEPRECATED_PROVIDER_CANNOT_ENABLE",
|
||||
expectedCode: "PROVIDER_TYPE_IMMUTABLE",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/trace"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -25,6 +27,7 @@ type notificationProviderUpsertRequest struct {
|
||||
URL string `json:"url"`
|
||||
Config string `json:"config"`
|
||||
Template string `json:"template"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NotifyProxyHosts bool `json:"notify_proxy_hosts"`
|
||||
NotifyRemoteServers bool `json:"notify_remote_servers"`
|
||||
@@ -37,6 +40,16 @@ type notificationProviderUpsertRequest struct {
|
||||
NotifySecurityCrowdSecDecisions bool `json:"notify_security_crowdsec_decisions"`
|
||||
}
|
||||
|
||||
type notificationProviderTestRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Config string `json:"config"`
|
||||
Template string `json:"template"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
func (r notificationProviderUpsertRequest) toModel() models.NotificationProvider {
|
||||
return models.NotificationProvider{
|
||||
Name: r.Name,
|
||||
@@ -44,6 +57,7 @@ func (r notificationProviderUpsertRequest) toModel() models.NotificationProvider
|
||||
URL: r.URL,
|
||||
Config: r.Config,
|
||||
Template: r.Template,
|
||||
Token: strings.TrimSpace(r.Token),
|
||||
Enabled: r.Enabled,
|
||||
NotifyProxyHosts: r.NotifyProxyHosts,
|
||||
NotifyRemoteServers: r.NotifyRemoteServers,
|
||||
@@ -57,6 +71,70 @@ func (r notificationProviderUpsertRequest) toModel() models.NotificationProvider
|
||||
}
|
||||
}
|
||||
|
||||
func providerRequestID(c *gin.Context) string {
|
||||
if value, ok := c.Get(string(trace.RequestIDKey)); ok {
|
||||
if requestID, ok := value.(string); ok {
|
||||
return requestID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func respondSanitizedProviderError(c *gin.Context, status int, code, category, message string) {
|
||||
response := gin.H{
|
||||
"error": message,
|
||||
"code": code,
|
||||
"category": category,
|
||||
}
|
||||
if requestID := providerRequestID(c); requestID != "" {
|
||||
response["request_id"] = requestID
|
||||
}
|
||||
c.JSON(status, response)
|
||||
}
|
||||
|
||||
var providerStatusCodePattern = regexp.MustCompile(`provider returned status\s+(\d{3})`)
|
||||
|
||||
func classifyProviderTestFailure(err error) (code string, category string, message string) {
|
||||
if err == nil {
|
||||
return "PROVIDER_TEST_FAILED", "dispatch", "Provider test failed"
|
||||
}
|
||||
|
||||
errText := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
|
||||
if strings.Contains(errText, "destination url validation failed") ||
|
||||
strings.Contains(errText, "invalid webhook url") ||
|
||||
strings.Contains(errText, "invalid discord webhook url") {
|
||||
return "PROVIDER_TEST_URL_INVALID", "validation", "Provider URL is invalid or blocked. Verify the URL and try again"
|
||||
}
|
||||
|
||||
if statusMatch := providerStatusCodePattern.FindStringSubmatch(errText); len(statusMatch) == 2 {
|
||||
switch statusMatch[1] {
|
||||
case "401", "403":
|
||||
return "PROVIDER_TEST_AUTH_REJECTED", "dispatch", "Provider rejected authentication. Verify your Gotify token"
|
||||
case "404":
|
||||
return "PROVIDER_TEST_ENDPOINT_NOT_FOUND", "dispatch", "Provider endpoint was not found. Verify the provider URL path"
|
||||
default:
|
||||
return "PROVIDER_TEST_REMOTE_REJECTED", "dispatch", fmt.Sprintf("Provider rejected the test request (HTTP %s)", statusMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(errText, "outbound request failed") || strings.Contains(errText, "failed to send webhook") {
|
||||
switch {
|
||||
case strings.Contains(errText, "dns lookup failed"):
|
||||
return "PROVIDER_TEST_DNS_FAILED", "dispatch", "DNS lookup failed for provider host. Verify the hostname in the provider URL"
|
||||
case strings.Contains(errText, "connection refused"):
|
||||
return "PROVIDER_TEST_CONNECTION_REFUSED", "dispatch", "Provider host refused the connection. Verify port and service availability"
|
||||
case strings.Contains(errText, "request timed out"):
|
||||
return "PROVIDER_TEST_TIMEOUT", "dispatch", "Provider request timed out. Verify network route and provider responsiveness"
|
||||
case strings.Contains(errText, "tls handshake failed"):
|
||||
return "PROVIDER_TEST_TLS_FAILED", "dispatch", "TLS handshake failed. Verify HTTPS certificate and URL scheme"
|
||||
}
|
||||
return "PROVIDER_TEST_UNREACHABLE", "dispatch", "Could not reach provider endpoint. Verify URL, DNS, and network connectivity"
|
||||
}
|
||||
|
||||
return "PROVIDER_TEST_FAILED", "dispatch", "Provider test failed"
|
||||
}
|
||||
|
||||
func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler {
|
||||
return NewNotificationProviderHandlerWithDeps(service, nil, "")
|
||||
}
|
||||
@@ -71,6 +149,10 @@ func (h *NotificationProviderHandler) List(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers"})
|
||||
return
|
||||
}
|
||||
for i := range providers {
|
||||
providers[i].HasToken = providers[i].Token != ""
|
||||
providers[i].Token = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, providers)
|
||||
}
|
||||
|
||||
@@ -81,16 +163,13 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
|
||||
|
||||
var req notificationProviderUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid notification provider payload")
|
||||
return
|
||||
}
|
||||
|
||||
// Discord-only enforcement for this rollout
|
||||
if req.Type != "discord" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "only discord provider type is supported in this release; additional providers will be enabled in future releases after validation",
|
||||
"code": "PROVIDER_TYPE_DISCORD_ONLY",
|
||||
})
|
||||
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,15 +185,17 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
|
||||
if err := h.service.CreateProvider(&provider); err != nil {
|
||||
// If it's a validation error from template parsing, return 400
|
||||
if isProviderValidationError(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_VALIDATION_FAILED", "validation", "Notification provider validation failed")
|
||||
return
|
||||
}
|
||||
if respondPermissionError(c, h.securityService, "notification_provider_save_failed", err, h.dataRoot) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
|
||||
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_CREATE_FAILED", "internal", "Failed to create provider")
|
||||
return
|
||||
}
|
||||
provider.HasToken = provider.Token != ""
|
||||
provider.Token = ""
|
||||
c.JSON(http.StatusCreated, provider)
|
||||
}
|
||||
|
||||
@@ -126,7 +207,7 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req notificationProviderUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid notification provider payload")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,39 +215,29 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
|
||||
var existing models.NotificationProvider
|
||||
if err := h.service.DB.Where("id = ?", id).First(&existing).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
respondSanitizedProviderError(c, http.StatusNotFound, "PROVIDER_NOT_FOUND", "validation", "Provider not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch provider"})
|
||||
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_READ_FAILED", "internal", "Failed to read provider")
|
||||
return
|
||||
}
|
||||
|
||||
// Block type mutation for existing non-Discord providers
|
||||
if existing.Type != "discord" && req.Type != existing.Type {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "cannot change provider type for deprecated non-discord providers; delete and recreate as discord provider instead",
|
||||
"code": "DEPRECATED_PROVIDER_TYPE_IMMUTABLE",
|
||||
})
|
||||
if strings.TrimSpace(req.Type) != "" && strings.TrimSpace(req.Type) != existing.Type {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_TYPE_IMMUTABLE", "validation", "Provider type cannot be changed")
|
||||
return
|
||||
}
|
||||
|
||||
// Block enable mutation for existing non-Discord providers
|
||||
if existing.Type != "discord" && req.Enabled && !existing.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "cannot enable deprecated non-discord providers; only discord providers can be enabled",
|
||||
"code": "DEPRECATED_PROVIDER_CANNOT_ENABLE",
|
||||
})
|
||||
providerType := strings.ToLower(strings.TrimSpace(existing.Type))
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
|
||||
// Discord-only enforcement for this rollout (new providers or type changes)
|
||||
if req.Type != "discord" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "only discord provider type is supported in this release; additional providers will be enabled in future releases after validation",
|
||||
"code": "PROVIDER_TYPE_DISCORD_ONLY",
|
||||
})
|
||||
return
|
||||
if providerType == "gotify" && strings.TrimSpace(req.Token) == "" {
|
||||
// Keep existing token if update payload omits token
|
||||
req.Token = existing.Token
|
||||
}
|
||||
req.Type = existing.Type
|
||||
|
||||
provider := req.toModel()
|
||||
provider.ID = id
|
||||
@@ -179,15 +250,17 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
|
||||
|
||||
if err := h.service.UpdateProvider(&provider); err != nil {
|
||||
if isProviderValidationError(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_VALIDATION_FAILED", "validation", "Notification provider validation failed")
|
||||
return
|
||||
}
|
||||
if respondPermissionError(c, h.securityService, "notification_provider_save_failed", err, h.dataRoot) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
|
||||
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_UPDATE_FAILED", "internal", "Failed to update provider")
|
||||
return
|
||||
}
|
||||
provider.HasToken = provider.Token != ""
|
||||
provider.Token = ""
|
||||
c.JSON(http.StatusOK, provider)
|
||||
}
|
||||
|
||||
@@ -221,16 +294,44 @@ func (h *NotificationProviderHandler) Delete(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *NotificationProviderHandler) Test(c *gin.Context) {
|
||||
var req notificationProviderTestRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid test payload")
|
||||
return
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if providerType == "gotify" && strings.TrimSpace(req.Token) != "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Gotify token is accepted only on provider create/update")
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(req.ID)
|
||||
if providerID == "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "MISSING_PROVIDER_ID", "validation", "Trusted provider ID is required for test dispatch")
|
||||
return
|
||||
}
|
||||
|
||||
var provider models.NotificationProvider
|
||||
if err := c.ShouldBindJSON(&provider); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
if err := h.service.DB.Where("id = ?", providerID).First(&provider).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
respondSanitizedProviderError(c, http.StatusNotFound, "PROVIDER_NOT_FOUND", "validation", "Provider not found")
|
||||
return
|
||||
}
|
||||
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_READ_FAILED", "internal", "Failed to read provider")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(provider.URL) == "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_CONFIG_MISSING", "validation", "Trusted provider configuration is incomplete")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.TestProvider(provider); err != nil {
|
||||
// Create internal notification for the failure
|
||||
_, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed: %v", provider.Name, err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
_, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed", provider.Name))
|
||||
code, category, message := classifyProviderTestFailure(err)
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, code, category, message)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
|
||||
@@ -249,9 +350,15 @@ func (h *NotificationProviderHandler) Templates(c *gin.Context) {
|
||||
func (h *NotificationProviderHandler) Preview(c *gin.Context) {
|
||||
var raw map[string]any
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid preview payload")
|
||||
return
|
||||
}
|
||||
if tokenValue, ok := raw["token"]; ok {
|
||||
if tokenText, isString := tokenValue.(string); isString && strings.TrimSpace(tokenText) != "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Gotify token is accepted only on provider create/update")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var provider models.NotificationProvider
|
||||
// Marshal raw into provider to get proper types
|
||||
@@ -279,7 +386,8 @@ func (h *NotificationProviderHandler) Preview(c *gin.Context) {
|
||||
|
||||
rendered, parsed, err := h.service.RenderTemplate(provider, payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
|
||||
_ = rendered
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TEMPLATE_PREVIEW_FAILED", "validation", "Template preview failed")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
|
||||
|
||||
@@ -120,25 +120,60 @@ func TestNotificationProviderHandler_Templates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
// Test with invalid provider (should fail validation or service check)
|
||||
// Since we don't have notification dispatch mocked easily here,
|
||||
// we expect it might fail or pass depending on service implementation.
|
||||
// Looking at service code, TestProvider should validate and dispatch.
|
||||
// If URL is invalid, it should error.
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "discord",
|
||||
URL: "invalid-url",
|
||||
stored := models.NotificationProvider{
|
||||
ID: "trusted-provider-id",
|
||||
Name: "Stored Provider",
|
||||
Type: "discord",
|
||||
URL: "invalid-url",
|
||||
Enabled: true,
|
||||
}
|
||||
body, _ := json.Marshal(provider)
|
||||
require.NoError(t, db.Create(&stored).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"id": stored.ID,
|
||||
"type": "discord",
|
||||
"url": "https://discord.com/api/webhooks/123/override",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// It should probably fail with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_TEST_URL_INVALID")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_RequiresTrustedProviderID(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
"url": "https://discord.com/api/webhooks/123/abc",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "MISSING_PROVIDER_ID")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_ReturnsNotFoundForUnknownProvider(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]any{
|
||||
"id": "missing-provider-id",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Errors(t *testing.T) {
|
||||
@@ -248,8 +283,8 @@ func TestNotificationProviderHandler_CreateRejectsDiscordIPHost(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid Discord webhook URL")
|
||||
assert.Contains(t, w.Body.String(), "IP address hosts are not allowed")
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_VALIDATION_FAILED")
|
||||
assert.Contains(t, w.Body.String(), "validation")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_CreateAcceptsDiscordHostname(t *testing.T) {
|
||||
@@ -378,3 +413,100 @@ func TestNotificationProviderHandler_UpdatePreservesServerManagedMigrationFields
|
||||
require.NotNil(t, dbProvider.LastMigratedAt)
|
||||
assert.Equal(t, now, dbProvider.LastMigratedAt.UTC().Round(time.Second))
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_List_ReturnsHasTokenTrue(t *testing.T) {
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
p := models.NotificationProvider{
|
||||
ID: "tok-true",
|
||||
Name: "Gotify With Token",
|
||||
Type: "gotify",
|
||||
URL: "https://gotify.example.com",
|
||||
Token: "secret-app-token",
|
||||
}
|
||||
require.NoError(t, db.Create(&p).Error)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var raw []map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
require.Len(t, raw, 1)
|
||||
assert.Equal(t, true, raw[0]["has_token"])
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_List_ReturnsHasTokenFalse(t *testing.T) {
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
p := models.NotificationProvider{
|
||||
ID: "tok-false",
|
||||
Name: "Discord No Token",
|
||||
Type: "discord",
|
||||
URL: "https://discord.com/api/webhooks/123/abc",
|
||||
}
|
||||
require.NoError(t, db.Create(&p).Error)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var raw []map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
require.Len(t, raw, 1)
|
||||
assert.Equal(t, false, raw[0]["has_token"])
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_List_NeverExposesRawToken(t *testing.T) {
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
p := models.NotificationProvider{
|
||||
ID: "tok-hidden",
|
||||
Name: "Secret Gotify",
|
||||
Type: "gotify",
|
||||
URL: "https://gotify.example.com",
|
||||
Token: "super-secret-value",
|
||||
}
|
||||
require.NoError(t, db.Create(&p).Error)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), "super-secret-value")
|
||||
|
||||
var raw []map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
require.Len(t, raw, 1)
|
||||
_, hasTokenField := raw[0]["token"]
|
||||
assert.False(t, hasTokenField, "raw token field must not appear in JSON response")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Create_ResponseHasHasToken(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "New Gotify",
|
||||
"type": "gotify",
|
||||
"url": "https://gotify.example.com",
|
||||
"token": "app-token-123",
|
||||
"template": "minimal",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var raw map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
assert.Equal(t, true, raw["has_token"])
|
||||
assert.NotContains(t, w.Body.String(), "app-token-123")
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestUpdate_BlockTypeMutationForNonDiscord(t *testing.T) {
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "DEPRECATED_PROVIDER_TYPE_IMMUTABLE", response["code"])
|
||||
assert.Equal(t, "PROVIDER_TYPE_IMMUTABLE", response["code"])
|
||||
}
|
||||
|
||||
// TestUpdate_AllowTypeMutationForDiscord verifies Discord can be updated
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -293,6 +294,11 @@ func (h *NPMImportHandler) Cancel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.SessionUUID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up session if it exists
|
||||
npmImportSessionsMu.Lock()
|
||||
delete(npmImportSessions, req.SessionUUID)
|
||||
|
||||
@@ -453,6 +453,62 @@ func TestNPMImportHandler_Cancel(t *testing.T) {
|
||||
assert.Equal(t, http.StatusNotFound, commitW.Code)
|
||||
}
|
||||
|
||||
func TestNPMImportHandler_Cancel_RequiresValidJSONBody(t *testing.T) {
|
||||
db := setupNPMTestDB(t)
|
||||
handler := NewNPMImportHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api := router.Group("/api/v1")
|
||||
handler.RegisterRoutes(api)
|
||||
|
||||
t.Run("missing body", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", http.NoBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString("{"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("empty object payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session_uuid required", resp["error"])
|
||||
})
|
||||
|
||||
t.Run("missing session_uuid payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString(`{"foo":"bar"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session_uuid required", resp["error"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestNPMImportHandler_ConvertNPMToImportResult(t *testing.T) {
|
||||
db := setupNPMTestDB(t)
|
||||
handler := NewNPMImportHandler(db)
|
||||
|
||||
@@ -24,10 +24,19 @@ func requireAdmin(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func requireAuthenticatedAdmin(c *gin.Context) bool {
|
||||
if _, exists := c.Get("userID"); !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authorization header required",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return requireAdmin(c)
|
||||
}
|
||||
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
role, _ := c.Get("role")
|
||||
roleStr, _ := role.(string)
|
||||
return roleStr == "admin"
|
||||
return c.GetString("role") == string(models.RoleAdmin)
|
||||
}
|
||||
|
||||
func respondPermissionError(c *gin.Context, securityService *services.SecurityService, action string, err error, path string) bool {
|
||||
|
||||
@@ -168,3 +168,34 @@ func TestLogPermissionAudit_ActorFallback(t *testing.T) {
|
||||
assert.Equal(t, "permissions", audit.EventCategory)
|
||||
assert.Contains(t, audit.Details, fmt.Sprintf("\"admin\":%v", false))
|
||||
}
|
||||
|
||||
func TestRequireAuthenticatedAdmin_NoUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, rec := newTestContextWithRequest()
|
||||
result := requireAuthenticatedAdmin(ctx)
|
||||
assert.False(t, result)
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "Authorization header required")
|
||||
}
|
||||
|
||||
func TestRequireAuthenticatedAdmin_UserIDPresentAndAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, _ := newTestContextWithRequest()
|
||||
ctx.Set("userID", uint(1))
|
||||
ctx.Set("role", "admin")
|
||||
result := requireAuthenticatedAdmin(ctx)
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestRequireAuthenticatedAdmin_UserIDPresentButNotAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, rec := newTestContextWithRequest()
|
||||
ctx.Set("userID", uint(1))
|
||||
ctx.Set("role", "user")
|
||||
result := requireAuthenticatedAdmin(ctx)
|
||||
assert.False(t, result)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ func generateForwardHostWarnings(forwardHost string) []ProxyHostWarning {
|
||||
// ProxyHostHandler handles CRUD operations for proxy hosts.
|
||||
type ProxyHostHandler struct {
|
||||
service *services.ProxyHostService
|
||||
db *gorm.DB
|
||||
caddyManager *caddy.Manager
|
||||
notificationService *services.NotificationService
|
||||
uptimeService *services.UptimeService
|
||||
@@ -183,6 +184,74 @@ func parseNullableUintField(value any, fieldName string) (*uint, bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProxyHostHandler) resolveAccessListReference(value any) (*uint, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsedID, _, parseErr := parseNullableUintField(value, "access_list_id")
|
||||
if parseErr == nil {
|
||||
return parsedID, nil
|
||||
}
|
||||
|
||||
uuidValue, isString := value.(string)
|
||||
if !isString {
|
||||
return nil, parseErr
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(uuidValue)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var acl models.AccessList
|
||||
if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&acl).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("access list not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to resolve access list")
|
||||
}
|
||||
|
||||
id := acl.ID
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
func (h *ProxyHostHandler) resolveSecurityHeaderProfileReference(value any) (*uint, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsedID, _, parseErr := parseNullableUintField(value, "security_header_profile_id")
|
||||
if parseErr == nil {
|
||||
return parsedID, nil
|
||||
}
|
||||
|
||||
uuidValue, isString := value.(string)
|
||||
if !isString {
|
||||
return nil, parseErr
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(uuidValue)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, err := uuid.Parse(trimmed); err != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
|
||||
var profile models.SecurityHeaderProfile
|
||||
if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&profile).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("security header profile not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to resolve security header profile")
|
||||
}
|
||||
|
||||
id := profile.ID
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
func parseForwardPortField(value any) (int, error) {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
@@ -221,6 +290,7 @@ func parseForwardPortField(value any) (int, error) {
|
||||
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService, uptimeService *services.UptimeService) *ProxyHostHandler {
|
||||
return &ProxyHostHandler{
|
||||
service: services.NewProxyHostService(db),
|
||||
db: db,
|
||||
caddyManager: caddyManager,
|
||||
notificationService: ns,
|
||||
uptimeService: uptimeService,
|
||||
@@ -252,8 +322,38 @@ func (h *ProxyHostHandler) List(c *gin.Context) {
|
||||
|
||||
// Create creates a new proxy host.
|
||||
func (h *ProxyHostHandler) Create(c *gin.Context) {
|
||||
var payload map[string]any
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if rawAccessListRef, ok := payload["access_list_id"]; ok {
|
||||
resolvedAccessListID, resolveErr := h.resolveAccessListReference(rawAccessListRef)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
payload["access_list_id"] = resolvedAccessListID
|
||||
}
|
||||
|
||||
if rawSecurityHeaderRef, ok := payload["security_header_profile_id"]; ok {
|
||||
resolvedSecurityHeaderID, resolveErr := h.resolveSecurityHeaderProfileReference(rawSecurityHeaderRef)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
payload["security_header_profile_id"] = resolvedSecurityHeaderID
|
||||
}
|
||||
|
||||
payloadBytes, marshalErr := json.Marshal(payload)
|
||||
if marshalErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
||||
return
|
||||
}
|
||||
|
||||
var host models.ProxyHost
|
||||
if err := c.ShouldBindJSON(&host); err != nil {
|
||||
if err := json.Unmarshal(payloadBytes, &host); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -313,6 +413,11 @@ func (h *ProxyHostHandler) Create(c *gin.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// Trigger immediate uptime monitor creation + health check (non-blocking)
|
||||
if h.uptimeService != nil {
|
||||
go h.uptimeService.SyncAndCheckForHost(host.ID)
|
||||
}
|
||||
|
||||
// Generate advisory warnings for private/Docker IPs
|
||||
warnings := generateForwardHostWarnings(host.ForwardHost)
|
||||
|
||||
@@ -430,12 +535,12 @@ func (h *ProxyHostHandler) Update(c *gin.Context) {
|
||||
host.CertificateID = parsedID
|
||||
}
|
||||
if v, ok := payload["access_list_id"]; ok {
|
||||
parsedID, _, parseErr := parseNullableUintField(v, "access_list_id")
|
||||
if parseErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": parseErr.Error()})
|
||||
resolvedAccessListID, resolveErr := h.resolveAccessListReference(v)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
host.AccessListID = parsedID
|
||||
host.AccessListID = resolvedAccessListID
|
||||
}
|
||||
|
||||
if v, ok := payload["dns_provider_id"]; ok {
|
||||
@@ -453,54 +558,12 @@ func (h *ProxyHostHandler) Update(c *gin.Context) {
|
||||
|
||||
// Security Header Profile: update only if provided
|
||||
if v, ok := payload["security_header_profile_id"]; ok {
|
||||
logger := middleware.GetRequestLogger(c)
|
||||
// Sanitize user-provided values for log injection protection (CWE-117)
|
||||
safeUUID := sanitizeForLog(uuidStr)
|
||||
logger.WithField("host_uuid", safeUUID).WithField("raw_value", sanitizeForLog(fmt.Sprintf("%v", v))).Debug("Processing security_header_profile_id update")
|
||||
|
||||
if v == nil {
|
||||
logger.WithField("host_uuid", safeUUID).Debug("Setting security_header_profile_id to nil")
|
||||
host.SecurityHeaderProfileID = nil
|
||||
} else {
|
||||
conversionSuccess := false
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
logger.Debug("Received security_header_profile_id as float64")
|
||||
if id, ok := safeFloat64ToUint(t); ok {
|
||||
host.SecurityHeaderProfileID = &id
|
||||
conversionSuccess = true
|
||||
logger.Info("Successfully converted security_header_profile_id from float64")
|
||||
} else {
|
||||
logger.Warn("Failed to convert security_header_profile_id from float64: value is negative or not a valid uint")
|
||||
}
|
||||
case int:
|
||||
logger.Debug("Received security_header_profile_id as int")
|
||||
if id, ok := safeIntToUint(t); ok {
|
||||
host.SecurityHeaderProfileID = &id
|
||||
conversionSuccess = true
|
||||
logger.Info("Successfully converted security_header_profile_id from int")
|
||||
} else {
|
||||
logger.Warn("Failed to convert security_header_profile_id from int: value is negative")
|
||||
}
|
||||
case string:
|
||||
logger.Debug("Received security_header_profile_id as string")
|
||||
if n, err := strconv.ParseUint(t, 10, 32); err == nil {
|
||||
id := uint(n)
|
||||
host.SecurityHeaderProfileID = &id
|
||||
conversionSuccess = true
|
||||
logger.WithField("host_uuid", safeUUID).WithField("profile_id", id).Info("Successfully converted security_header_profile_id from string")
|
||||
} else {
|
||||
logger.Warn("Failed to parse security_header_profile_id from string")
|
||||
}
|
||||
default:
|
||||
logger.Warn("Unsupported type for security_header_profile_id")
|
||||
}
|
||||
|
||||
if !conversionSuccess {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid security_header_profile_id: unable to convert value %v of type %T to uint", v, v)})
|
||||
return
|
||||
}
|
||||
resolvedSecurityHeaderID, resolveErr := h.resolveSecurityHeaderProfileReference(v)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
host.SecurityHeaderProfileID = resolvedSecurityHeaderID
|
||||
}
|
||||
|
||||
// Locations: replace only if provided
|
||||
@@ -587,11 +650,10 @@ func (h *ProxyHostHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// check if we should also delete associated uptime monitors (query param: delete_uptime=true)
|
||||
deleteUptime := c.DefaultQuery("delete_uptime", "false") == "true"
|
||||
|
||||
if deleteUptime && h.uptimeService != nil {
|
||||
// Find all monitors referencing this proxy host and delete each
|
||||
// Always clean up associated uptime monitors when deleting a proxy host.
|
||||
// The query param delete_uptime=true is kept for backward compatibility but
|
||||
// cleanup now runs unconditionally to prevent orphaned monitors.
|
||||
if h.uptimeService != nil {
|
||||
var monitors []models.UptimeMonitor
|
||||
if err := h.uptimeService.DB.Where("proxy_host_id = ?", host.ID).Find(&monitors).Error; err == nil {
|
||||
for _, m := range monitors {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -44,6 +45,219 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
return r, db
|
||||
}
|
||||
|
||||
func setupTestRouterWithReferenceTables(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&models.ProxyHost{},
|
||||
&models.Location{},
|
||||
&models.AccessList{},
|
||||
&models.SecurityHeaderProfile{},
|
||||
&models.Notification{},
|
||||
&models.NotificationProvider{},
|
||||
))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
h := NewProxyHostHandler(db, nil, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
return r, db
|
||||
}
|
||||
|
||||
func setupTestRouterWithUptime(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&models.ProxyHost{},
|
||||
&models.Location{},
|
||||
&models.Notification{},
|
||||
&models.NotificationProvider{},
|
||||
&models.UptimeMonitor{},
|
||||
&models.UptimeHeartbeat{},
|
||||
&models.UptimeHost{},
|
||||
&models.Setting{},
|
||||
))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
us := services.NewUptimeService(db, ns)
|
||||
h := NewProxyHostHandler(db, nil, ns, us)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
return r, db
|
||||
}
|
||||
|
||||
func TestProxyHostHandler_ResolveAccessListReference_TargetedBranches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, db := setupTestRouterWithReferenceTables(t)
|
||||
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
|
||||
|
||||
resolved, err := h.resolveAccessListReference(true)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resolved)
|
||||
require.Contains(t, err.Error(), "invalid access_list_id")
|
||||
|
||||
resolved, err = h.resolveAccessListReference(" ")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, resolved)
|
||||
|
||||
acl := models.AccessList{UUID: uuid.NewString(), Name: "resolve-acl", Type: "ip", Enabled: true}
|
||||
require.NoError(t, db.Create(&acl).Error)
|
||||
|
||||
resolved, err = h.resolveAccessListReference(acl.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resolved)
|
||||
require.Equal(t, acl.ID, *resolved)
|
||||
}
|
||||
|
||||
func TestProxyHostHandler_ResolveSecurityHeaderReference_TargetedBranches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, db := setupTestRouterWithReferenceTables(t)
|
||||
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
|
||||
|
||||
resolved, err := h.resolveSecurityHeaderProfileReference(" ")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, resolved)
|
||||
|
||||
profile := models.SecurityHeaderProfile{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "resolve-security-profile",
|
||||
IsPreset: false,
|
||||
SecurityScore: 90,
|
||||
}
|
||||
require.NoError(t, db.Create(&profile).Error)
|
||||
|
||||
resolved, err = h.resolveSecurityHeaderProfileReference(profile.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resolved)
|
||||
require.Equal(t, profile.ID, *resolved)
|
||||
|
||||
resolved, err = h.resolveSecurityHeaderProfileReference(uuid.NewString())
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resolved)
|
||||
require.Contains(t, err.Error(), "security header profile not found")
|
||||
|
||||
require.NoError(t, db.Migrator().DropTable(&models.SecurityHeaderProfile{}))
|
||||
resolved, err = h.resolveSecurityHeaderProfileReference(uuid.NewString())
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resolved)
|
||||
require.Contains(t, err.Error(), "failed to resolve security header profile")
|
||||
}
|
||||
|
||||
func TestProxyHostCreate_ReferenceResolution_TargetedBranches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, db := setupTestRouterWithReferenceTables(t)
|
||||
|
||||
acl := models.AccessList{UUID: uuid.NewString(), Name: "create-acl", Type: "ip", Enabled: true}
|
||||
require.NoError(t, db.Create(&acl).Error)
|
||||
|
||||
profile := models.SecurityHeaderProfile{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "create-security-profile",
|
||||
IsPreset: false,
|
||||
SecurityScore: 85,
|
||||
}
|
||||
require.NoError(t, db.Create(&profile).Error)
|
||||
|
||||
t.Run("creates host when references are valid UUIDs", func(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"name": "Create Ref Success",
|
||||
"domain_names": "create-ref-success.example.com",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "localhost",
|
||||
"forward_port": 8080,
|
||||
"enabled": true,
|
||||
"access_list_id": acl.UUID,
|
||||
"security_header_profile_id": profile.UUID,
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusCreated, resp.Code)
|
||||
|
||||
var created models.ProxyHost
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
|
||||
require.NotNil(t, created.AccessListID)
|
||||
require.Equal(t, acl.ID, *created.AccessListID)
|
||||
require.NotNil(t, created.SecurityHeaderProfileID)
|
||||
require.Equal(t, profile.ID, *created.SecurityHeaderProfileID)
|
||||
})
|
||||
|
||||
t.Run("returns bad request for invalid access list reference type", func(t *testing.T) {
|
||||
body := `{"name":"Create ACL Type Error","domain_names":"create-acl-type-error.example.com","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true,"access_list_id":true}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
})
|
||||
|
||||
t.Run("returns bad request for missing security header profile", func(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"name": "Create Security Missing",
|
||||
"domain_names": "create-security-missing.example.com",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "localhost",
|
||||
"forward_port": 8080,
|
||||
"enabled": true,
|
||||
"security_header_profile_id": uuid.NewString(),
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProxyHostCreate_TriggersAsyncUptimeSyncWhenServiceConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, db := setupTestRouterWithUptime(t)
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
domain := strings.TrimPrefix(upstream.URL, "http://")
|
||||
body := fmt.Sprintf(`{"name":"Uptime Hook","domain_names":"%s","forward_scheme":"http","forward_host":"app-service","forward_port":8080,"enabled":true}`, domain)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusCreated, resp.Code)
|
||||
|
||||
var created models.ProxyHost
|
||||
require.NoError(t, db.Where("domain_names = ?", domain).First(&created).Error)
|
||||
|
||||
var count int64
|
||||
require.Eventually(t, func() bool {
|
||||
db.Model(&models.UptimeMonitor{}).Where("proxy_host_id = ?", created.ID).Count(&count)
|
||||
return count > 0
|
||||
}, 3*time.Second, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestProxyHostLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := setupTestRouter(t)
|
||||
|
||||
@@ -75,6 +75,203 @@ func createTestSecurityHeaderProfile(t *testing.T, db *gorm.DB, name string) mod
|
||||
return profile
|
||||
}
|
||||
|
||||
// createTestAccessList creates an access list for testing.
|
||||
func createTestAccessList(t *testing.T, db *gorm.DB, name string) models.AccessList {
|
||||
t.Helper()
|
||||
acl := models.AccessList{
|
||||
UUID: uuid.NewString(),
|
||||
Name: name,
|
||||
Type: "ip",
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&acl).Error)
|
||||
return acl
|
||||
}
|
||||
|
||||
func TestProxyHostUpdate_AccessListID_Transitions_NoUnrelatedMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, db := setupUpdateTestRouter(t)
|
||||
|
||||
aclOne := createTestAccessList(t, db, "ACL One")
|
||||
aclTwo := createTestAccessList(t, db, "ACL Two")
|
||||
|
||||
host := models.ProxyHost{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Access List Transition Host",
|
||||
DomainNames: "acl-transition.test.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
SSLForced: true,
|
||||
Application: "none",
|
||||
AccessListID: &aclOne.ID,
|
||||
}
|
||||
require.NoError(t, db.Create(&host).Error)
|
||||
|
||||
assertUnrelatedFields := func(t *testing.T, current models.ProxyHost) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "Access List Transition Host", current.Name)
|
||||
assert.Equal(t, "acl-transition.test.com", current.DomainNames)
|
||||
assert.Equal(t, "localhost", current.ForwardHost)
|
||||
assert.Equal(t, 8080, current.ForwardPort)
|
||||
assert.True(t, current.SSLForced)
|
||||
assert.Equal(t, "none", current.Application)
|
||||
}
|
||||
|
||||
runUpdate := func(t *testing.T, update map[string]any) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(update)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
// value -> value
|
||||
runUpdate(t, map[string]any{"access_list_id": aclTwo.ID})
|
||||
var updated models.ProxyHost
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
require.NotNil(t, updated.AccessListID)
|
||||
assert.Equal(t, aclTwo.ID, *updated.AccessListID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
|
||||
// value -> null
|
||||
runUpdate(t, map[string]any{"access_list_id": nil})
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
assert.Nil(t, updated.AccessListID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
|
||||
// null -> value
|
||||
runUpdate(t, map[string]any{"access_list_id": aclOne.ID})
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
require.NotNil(t, updated.AccessListID)
|
||||
assert.Equal(t, aclOne.ID, *updated.AccessListID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
}
|
||||
|
||||
func TestProxyHostUpdate_AccessListID_UUIDNotFound_ReturnsBadRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, db := setupUpdateTestRouter(t)
|
||||
|
||||
host := createTestProxyHost(t, db, "acl-uuid-not-found")
|
||||
|
||||
updateBody := map[string]any{
|
||||
"name": "ACL UUID Not Found",
|
||||
"domain_names": "acl-uuid-not-found.test.com",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "localhost",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": uuid.NewString(),
|
||||
}
|
||||
body, _ := json.Marshal(updateBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
|
||||
assert.Contains(t, result["error"], "access list not found")
|
||||
}
|
||||
|
||||
func TestProxyHostUpdate_AccessListID_ResolveQueryFailure_ReturnsBadRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, db := setupUpdateTestRouter(t)
|
||||
|
||||
host := createTestProxyHost(t, db, "acl-resolve-query-failure")
|
||||
|
||||
require.NoError(t, db.Migrator().DropTable(&models.AccessList{}))
|
||||
|
||||
updateBody := map[string]any{
|
||||
"name": "ACL Resolve Query Failure",
|
||||
"domain_names": "acl-resolve-query-failure.test.com",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "localhost",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": uuid.NewString(),
|
||||
}
|
||||
body, _ := json.Marshal(updateBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
|
||||
assert.Contains(t, result["error"], "failed to resolve access list")
|
||||
}
|
||||
|
||||
func TestProxyHostUpdate_SecurityHeaderProfileID_Transitions_NoUnrelatedMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, db := setupUpdateTestRouter(t)
|
||||
|
||||
profileOne := createTestSecurityHeaderProfile(t, db, "Security Profile One")
|
||||
profileTwo := createTestSecurityHeaderProfile(t, db, "Security Profile Two")
|
||||
|
||||
host := models.ProxyHost{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Security Profile Transition Host",
|
||||
DomainNames: "security-transition.test.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 9090,
|
||||
Enabled: true,
|
||||
SSLForced: true,
|
||||
Application: "none",
|
||||
SecurityHeaderProfileID: &profileOne.ID,
|
||||
}
|
||||
require.NoError(t, db.Create(&host).Error)
|
||||
|
||||
assertUnrelatedFields := func(t *testing.T, current models.ProxyHost) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "Security Profile Transition Host", current.Name)
|
||||
assert.Equal(t, "security-transition.test.com", current.DomainNames)
|
||||
assert.Equal(t, "localhost", current.ForwardHost)
|
||||
assert.Equal(t, 9090, current.ForwardPort)
|
||||
assert.True(t, current.SSLForced)
|
||||
assert.Equal(t, "none", current.Application)
|
||||
}
|
||||
|
||||
runUpdate := func(t *testing.T, update map[string]any) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(update)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
// value -> value
|
||||
runUpdate(t, map[string]any{"security_header_profile_id": fmt.Sprintf("%d", profileTwo.ID)})
|
||||
var updated models.ProxyHost
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
require.NotNil(t, updated.SecurityHeaderProfileID)
|
||||
assert.Equal(t, profileTwo.ID, *updated.SecurityHeaderProfileID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
|
||||
// value -> null
|
||||
runUpdate(t, map[string]any{"security_header_profile_id": ""})
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
assert.Nil(t, updated.SecurityHeaderProfileID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
|
||||
// null -> value
|
||||
runUpdate(t, map[string]any{"security_header_profile_id": fmt.Sprintf("%d", profileOne.ID)})
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
require.NotNil(t, updated.SecurityHeaderProfileID)
|
||||
assert.Equal(t, profileOne.ID, *updated.SecurityHeaderProfileID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
}
|
||||
|
||||
// TestProxyHostUpdate_EnableStandardHeaders_Null tests updating enable_standard_headers to null.
|
||||
func TestProxyHostUpdate_EnableStandardHeaders_Null(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -307,7 +307,7 @@ func TestSecurityEventIntakeR6Intact(t *testing.T) {
|
||||
Email: "admin@example.com",
|
||||
Name: "Admin User",
|
||||
PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz", // Dummy bcrypt hash
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(adminUser).Error)
|
||||
|
||||
@@ -59,6 +59,10 @@ func TestSecurityHandler_ReloadGeoIP_NotInitialized(t *testing.T) {
|
||||
|
||||
h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/reload", h.ReloadGeoIP)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -75,6 +79,10 @@ func TestSecurityHandler_ReloadGeoIP_LoadError(t *testing.T) {
|
||||
h.SetGeoIPService(&services.GeoIPService{}) // dbPath empty => Load() will error
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/reload", h.ReloadGeoIP)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -90,6 +98,10 @@ func TestSecurityHandler_LookupGeoIP_MissingIPAddress(t *testing.T) {
|
||||
|
||||
h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/lookup", h.LookupGeoIP)
|
||||
|
||||
payload := []byte(`{}`)
|
||||
@@ -109,6 +121,10 @@ func TestSecurityHandler_LookupGeoIP_ServiceUnavailable(t *testing.T) {
|
||||
h.SetGeoIPService(&services.GeoIPService{}) // present but not loaded
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/lookup", h.LookupGeoIP)
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{"ip_address": "8.8.8.8"})
|
||||
|
||||
@@ -261,6 +261,10 @@ func (h *SecurityHandler) GetConfig(c *gin.Context) {
|
||||
|
||||
// UpdateConfig creates or updates the SecurityConfig in DB
|
||||
func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var payload models.SecurityConfig
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
@@ -290,6 +294,10 @@ func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
|
||||
|
||||
// GenerateBreakGlass generates a break-glass token and returns the plaintext token once
|
||||
func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.svc.GenerateBreakGlassToken("default")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"})
|
||||
@@ -316,6 +324,10 @@ func (h *SecurityHandler) ListDecisions(c *gin.Context) {
|
||||
|
||||
// CreateDecision creates a manual decision (override) - for now no checks besides payload
|
||||
func (h *SecurityHandler) CreateDecision(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var payload models.SecurityDecision
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
@@ -371,6 +383,10 @@ func (h *SecurityHandler) ListRuleSets(c *gin.Context) {
|
||||
|
||||
// UpsertRuleSet uploads or updates a ruleset
|
||||
func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var payload models.SecurityRuleSet
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
@@ -401,6 +417,10 @@ func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
|
||||
|
||||
// DeleteRuleSet removes a ruleset by id
|
||||
func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
idParam := c.Param("id")
|
||||
if idParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
@@ -610,6 +630,10 @@ func (h *SecurityHandler) GetGeoIPStatus(c *gin.Context) {
|
||||
|
||||
// ReloadGeoIP reloads the GeoIP database from disk.
|
||||
func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.geoipSvc == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "GeoIP service not initialized",
|
||||
@@ -641,6 +665,10 @@ func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
|
||||
|
||||
// LookupGeoIP performs a GeoIP lookup for a given IP address.
|
||||
func (h *SecurityHandler) LookupGeoIP(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
}
|
||||
@@ -707,6 +735,10 @@ func (h *SecurityHandler) GetWAFExclusions(c *gin.Context) {
|
||||
|
||||
// AddWAFExclusion adds a rule exclusion to the WAF configuration
|
||||
func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req WAFExclusionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})
|
||||
@@ -786,6 +818,10 @@ func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
|
||||
|
||||
// DeleteWAFExclusion removes a rule exclusion by rule_id
|
||||
func (h *SecurityHandler) DeleteWAFExclusion(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
ruleIDParam := c.Param("rule_id")
|
||||
if ruleIDParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})
|
||||
@@ -1039,10 +1075,7 @@ func (h *SecurityHandler) PatchRateLimit(c *gin.Context) {
|
||||
// toggleSecurityModule is a helper function that handles enabling/disabling security modules
|
||||
// It updates the setting, invalidates cache, and triggers Caddy config reload
|
||||
func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string, enabled bool) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ func TestSecurityHandler_CreateDecision_SQLInjection(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/decisions", h.CreateDecision)
|
||||
|
||||
// Attempt SQL injection via payload fields
|
||||
@@ -143,6 +147,10 @@ func TestSecurityHandler_UpsertRuleSet_MassivePayload(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
|
||||
|
||||
// Try to submit a 3MB payload (should be rejected by service)
|
||||
@@ -175,6 +183,10 @@ func TestSecurityHandler_UpsertRuleSet_EmptyName(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -203,6 +215,10 @@ func TestSecurityHandler_CreateDecision_EmptyFields(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/decisions", h.CreateDecision)
|
||||
|
||||
testCases := []struct {
|
||||
@@ -347,6 +363,10 @@ func TestSecurityAudit_DeleteRuleSet_InvalidID(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/api/v1/security/rulesets/:id", h.DeleteRuleSet)
|
||||
|
||||
testCases := []struct {
|
||||
@@ -388,6 +408,10 @@ func TestSecurityHandler_UpsertRuleSet_XSSInContent(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
|
||||
router.GET("/api/v1/security/rulesets", h.ListRuleSets)
|
||||
|
||||
@@ -433,6 +457,10 @@ func TestSecurityHandler_UpdateConfig_RateLimitBounds(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.PUT("/api/v1/security/config", h.UpdateConfig)
|
||||
|
||||
testCases := []struct {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_MutatorsRequireAdmin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityRuleSet{}, &models.SecurityDecision{}, &models.SecurityAudit{}))
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("userID", uint(123))
|
||||
c.Set("role", "user")
|
||||
c.Next()
|
||||
})
|
||||
|
||||
router.POST("/security/config", handler.UpdateConfig)
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
router.POST("/security/rulesets", handler.UpsertRuleSet)
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
url string
|
||||
body string
|
||||
}{
|
||||
{name: "update-config", method: http.MethodPost, url: "/security/config", body: `{"name":"default"}`},
|
||||
{name: "generate-breakglass", method: http.MethodPost, url: "/security/breakglass/generate", body: `{}`},
|
||||
{name: "create-decision", method: http.MethodPost, url: "/security/decisions", body: `{"ip":"1.2.3.4","action":"block"}`},
|
||||
{name: "upsert-ruleset", method: http.MethodPost, url: "/security/rulesets", body: `{"name":"owasp-crs","mode":"block","content":"x"}`},
|
||||
{name: "delete-ruleset", method: http.MethodDelete, url: "/security/rulesets/1", body: ""},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.url, bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,10 @@ func TestSecurityHandler_GenerateBreakGlass_ReturnsToken(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -251,6 +255,10 @@ func TestSecurityHandler_Enable_Disable_WithAdminWhitelistAndToken(t *testing.T)
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
api := router.Group("/api/v1")
|
||||
api.POST("/security/enable", handler.Enable)
|
||||
api.POST("/security/disable", handler.Disable)
|
||||
|
||||
@@ -27,6 +27,10 @@ func TestSecurityHandler_UpdateConfig_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/config", handler.UpdateConfig)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -55,6 +59,10 @@ func TestSecurityHandler_UpdateConfig_DefaultName(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/config", handler.UpdateConfig)
|
||||
|
||||
// Payload without name - should default to "default"
|
||||
@@ -78,6 +86,10 @@ func TestSecurityHandler_UpdateConfig_InvalidPayload(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/config", handler.UpdateConfig)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -193,6 +205,10 @@ func TestSecurityHandler_CreateDecision_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -218,6 +234,10 @@ func TestSecurityHandler_CreateDecision_MissingIP(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -240,6 +260,10 @@ func TestSecurityHandler_CreateDecision_MissingAction(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -262,6 +286,10 @@ func TestSecurityHandler_CreateDecision_InvalidPayload(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -306,6 +334,10 @@ func TestSecurityHandler_UpsertRuleSet_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/rulesets", handler.UpsertRuleSet)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -330,6 +362,10 @@ func TestSecurityHandler_UpsertRuleSet_MissingName(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/rulesets", handler.UpsertRuleSet)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -353,6 +389,10 @@ func TestSecurityHandler_UpsertRuleSet_InvalidPayload(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/rulesets", handler.UpsertRuleSet)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -375,6 +415,10 @@ func TestSecurityHandler_DeleteRuleSet_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -395,6 +439,10 @@ func TestSecurityHandler_DeleteRuleSet_NotFound(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -411,6 +459,10 @@ func TestSecurityHandler_DeleteRuleSet_InvalidID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -427,6 +479,10 @@ func TestSecurityHandler_DeleteRuleSet_EmptyID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
// Note: This route pattern won't match empty ID, but testing the handler directly
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
@@ -509,6 +565,10 @@ func TestSecurityHandler_Enable_WithValidBreakGlassToken(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
router.POST("/security/enable", handler.Enable)
|
||||
|
||||
@@ -600,6 +660,10 @@ func TestSecurityHandler_Disable_FromRemoteWithToken(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
router.POST("/security/disable", func(c *gin.Context) {
|
||||
c.Request.RemoteAddr = "192.168.1.100:12345" // Remote IP
|
||||
@@ -689,6 +753,10 @@ func TestSecurityHandler_GenerateBreakGlass_NoConfig(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -30,6 +30,10 @@ func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}))
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
api := r.Group("/api/v1")
|
||||
cfg := config.SecurityConfig{}
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
@@ -148,6 +152,10 @@ func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) {
|
||||
m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
api := r.Group("/api/v1")
|
||||
cfg := config.SecurityConfig{}
|
||||
h := NewSecurityHandler(cfg, db, m)
|
||||
|
||||
@@ -110,6 +110,10 @@ func TestSecurityHandler_AddWAFExclusion_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -140,6 +144,10 @@ func TestSecurityHandler_AddWAFExclusion_WithTarget(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -175,6 +183,10 @@ func TestSecurityHandler_AddWAFExclusion_ToExistingConfig(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
|
||||
@@ -215,6 +227,10 @@ func TestSecurityHandler_AddWAFExclusion_Duplicate(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
// Try to add duplicate
|
||||
@@ -244,6 +260,10 @@ func TestSecurityHandler_AddWAFExclusion_DuplicateWithDifferentTarget(t *testing
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
// Add same rule_id with different target - should succeed
|
||||
@@ -268,6 +288,10 @@ func TestSecurityHandler_AddWAFExclusion_MissingRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -290,6 +314,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
// Zero rule_id
|
||||
@@ -313,6 +341,10 @@ func TestSecurityHandler_AddWAFExclusion_NegativeRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -335,6 +367,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidPayload(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -358,6 +394,10 @@ func TestSecurityHandler_DeleteWAFExclusion_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
|
||||
@@ -394,6 +434,10 @@ func TestSecurityHandler_DeleteWAFExclusion_WithTarget(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
|
||||
@@ -430,6 +474,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NotFound(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -446,6 +494,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NoConfig(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -462,6 +514,10 @@ func TestSecurityHandler_DeleteWAFExclusion_InvalidRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -478,6 +534,10 @@ func TestSecurityHandler_DeleteWAFExclusion_ZeroRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -494,6 +554,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NegativeRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -533,6 +597,10 @@ func TestSecurityHandler_WAFExclusion_FullWorkflow(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +38,15 @@ type SettingsHandler struct {
|
||||
DataRoot string
|
||||
}
|
||||
|
||||
const (
|
||||
settingCaddyKeepaliveIdle = "caddy.keepalive_idle"
|
||||
settingCaddyKeepaliveCount = "caddy.keepalive_count"
|
||||
minCaddyKeepaliveIdleDuration = time.Second
|
||||
maxCaddyKeepaliveIdleDuration = 24 * time.Hour
|
||||
minCaddyKeepaliveCount = 1
|
||||
maxCaddyKeepaliveCount = 100
|
||||
)
|
||||
|
||||
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
DB: db,
|
||||
@@ -65,14 +75,43 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Convert to map for easier frontend consumption
|
||||
settingsMap := make(map[string]string)
|
||||
settingsMap := make(map[string]any)
|
||||
for _, s := range settings {
|
||||
if isSensitiveSettingKey(s.Key) {
|
||||
hasSecret := strings.TrimSpace(s.Value) != ""
|
||||
settingsMap[s.Key] = "********"
|
||||
settingsMap[s.Key+".has_secret"] = hasSecret
|
||||
settingsMap[s.Key+".last_updated"] = s.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
continue
|
||||
}
|
||||
|
||||
settingsMap[s.Key] = s.Value
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, settingsMap)
|
||||
}
|
||||
|
||||
func isSensitiveSettingKey(key string) bool {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
|
||||
sensitiveFragments := []string{
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"webhook",
|
||||
}
|
||||
|
||||
for _, fragment := range sensitiveFragments {
|
||||
if strings.Contains(normalizedKey, fragment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type UpdateSettingRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
@@ -109,6 +148,11 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateOptionalKeepaliveSetting(req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
setting := models.Setting{
|
||||
Key: req.Key,
|
||||
Value: req.Value,
|
||||
@@ -247,6 +291,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateOptionalKeepaliveSetting(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
setting := models.Setting{
|
||||
Key: key,
|
||||
Value: value,
|
||||
@@ -284,6 +332,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "invalid caddy.keepalive_idle") || strings.Contains(err.Error(), "invalid caddy.keepalive_count") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
|
||||
return
|
||||
}
|
||||
@@ -401,6 +453,53 @@ func validateAdminWhitelist(whitelist string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOptionalKeepaliveSetting(key, value string) error {
|
||||
switch key {
|
||||
case settingCaddyKeepaliveIdle:
|
||||
return validateKeepaliveIdleValue(value)
|
||||
case settingCaddyKeepaliveCount:
|
||||
return validateKeepaliveCountValue(value)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateKeepaliveIdleValue(value string) error {
|
||||
idle := strings.TrimSpace(value)
|
||||
if idle == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
d, err := time.ParseDuration(idle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid caddy.keepalive_idle")
|
||||
}
|
||||
|
||||
if d < minCaddyKeepaliveIdleDuration || d > maxCaddyKeepaliveIdleDuration {
|
||||
return fmt.Errorf("invalid caddy.keepalive_idle")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateKeepaliveCountValue(value string) error {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
count, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid caddy.keepalive_count")
|
||||
}
|
||||
|
||||
if count < minCaddyKeepaliveCount || count > maxCaddyKeepaliveCount {
|
||||
return fmt.Errorf("invalid caddy.keepalive_count")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) syncAdminWhitelist(whitelist string) error {
|
||||
return h.syncAdminWhitelistWithDB(h.DB, whitelist)
|
||||
}
|
||||
@@ -433,6 +532,10 @@ type SMTPConfigRequest struct {
|
||||
|
||||
// GetSMTPConfig returns the current SMTP configuration.
|
||||
func (h *SettingsHandler) GetSMTPConfig(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.MailService.GetSMTPConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch SMTP configuration"})
|
||||
|
||||
@@ -182,6 +182,31 @@ func TestSettingsHandler_GetSettings(t *testing.T) {
|
||||
assert.Equal(t, "test_value", response["test_key"])
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSettings_MasksSensitiveValues(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
db.Create(&models.Setting{Key: "smtp_password", Value: "super-secret-password", Category: "smtp", Type: "string"})
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.GET("/settings", handler.GetSettings)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/settings", http.NoBody)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "********", response["smtp_password"])
|
||||
assert.Equal(t, true, response["smtp_password.has_secret"])
|
||||
_, hasRaw := response["super-secret-password"]
|
||||
assert.False(t, hasRaw)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSettings_DatabaseError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
@@ -413,6 +438,58 @@ func TestSettingsHandler_UpdateSetting_InvalidAdminWhitelist(t *testing.T) {
|
||||
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_InvalidKeepaliveIdle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.POST("/settings", handler.UpdateSetting)
|
||||
|
||||
payload := map[string]string{
|
||||
"key": "caddy.keepalive_idle",
|
||||
"value": "bad-duration",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_idle")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_ValidKeepaliveCount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.POST("/settings", handler.UpdateSetting)
|
||||
|
||||
payload := map[string]string{
|
||||
"key": "caddy.keepalive_count",
|
||||
"value": "9",
|
||||
"category": "caddy",
|
||||
"type": "number",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var setting models.Setting
|
||||
err := db.Where("key = ?", "caddy.keepalive_count").First(&setting).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "9", setting.Value)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
@@ -538,6 +615,64 @@ func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) {
|
||||
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_InvalidKeepaliveCount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.PATCH("/config", handler.PatchConfig)
|
||||
|
||||
payload := map[string]any{
|
||||
"caddy": map[string]any{
|
||||
"keepalive_count": 0,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_count")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_ValidKeepaliveSettings(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.PATCH("/config", handler.PatchConfig)
|
||||
|
||||
payload := map[string]any{
|
||||
"caddy": map[string]any{
|
||||
"keepalive_idle": "30s",
|
||||
"keepalive_count": 12,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var idle models.Setting
|
||||
err := db.Where("key = ?", "caddy.keepalive_idle").First(&idle).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "30s", idle.Value)
|
||||
|
||||
var count models.Setting
|
||||
err = db.Where("key = ?", "caddy.keepalive_count").First(&count).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "12", count.Value)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_ReloadFailureReturns500(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
@@ -864,6 +999,25 @@ func TestSettingsHandler_GetSMTPConfig_DatabaseError(t *testing.T) {
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSMTPConfig_NonAdminForbidden(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, _ := setupSettingsHandlerWithMail(t)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "user")
|
||||
c.Set("userID", uint(2))
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/api/v1/settings/smtp", handler.GetSMTPConfig)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/settings/smtp", http.NoBody)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSMTPConfig_NonAdmin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, _ := setupSettingsHandlerWithMail(t)
|
||||
|
||||
@@ -22,13 +22,15 @@ import (
|
||||
|
||||
type UserHandler struct {
|
||||
DB *gorm.DB
|
||||
AuthService *services.AuthService
|
||||
MailService *services.MailService
|
||||
securitySvc *services.SecurityService
|
||||
}
|
||||
|
||||
func NewUserHandler(db *gorm.DB) *UserHandler {
|
||||
func NewUserHandler(db *gorm.DB, authService *services.AuthService) *UserHandler {
|
||||
return &UserHandler{
|
||||
DB: db,
|
||||
AuthService: authService,
|
||||
MailService: services.NewMailService(db),
|
||||
securitySvc: services.NewSecurityService(db),
|
||||
}
|
||||
@@ -103,6 +105,18 @@ type SetupRequest struct {
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
func isSetupConflictError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errText := strings.ToLower(err.Error())
|
||||
return strings.Contains(errText, "unique constraint failed") ||
|
||||
strings.Contains(errText, "duplicate key") ||
|
||||
strings.Contains(errText, "database is locked") ||
|
||||
strings.Contains(errText, "database table is locked")
|
||||
}
|
||||
|
||||
// Setup creates the initial admin user and configures the ACME email.
|
||||
func (h *UserHandler) Setup(c *gin.Context) {
|
||||
// 1. Check if setup is allowed
|
||||
@@ -129,7 +143,7 @@ func (h *UserHandler) Setup(c *gin.Context) {
|
||||
UUID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
APIKey: uuid.New().String(),
|
||||
}
|
||||
@@ -160,6 +174,17 @@ func (h *UserHandler) Setup(c *gin.Context) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
var postTxCount int64
|
||||
if countErr := h.DB.Model(&models.User{}).Count(&postTxCount).Error; countErr == nil && postTxCount > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
|
||||
return
|
||||
}
|
||||
|
||||
if isSetupConflictError(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Setup conflict: setup already in progress or completed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -174,8 +199,21 @@ func (h *UserHandler) Setup(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// rejectPassthrough aborts with 403 if the caller is a passthrough user.
|
||||
// Returns true if the request was rejected (caller should return).
|
||||
func rejectPassthrough(c *gin.Context, action string) bool {
|
||||
if c.GetString("role") == string(models.RolePassthrough) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot " + action})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RegenerateAPIKey generates a new API key for the authenticated user.
|
||||
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
if rejectPassthrough(c, "manage API keys") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
@@ -189,11 +227,19 @@ func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"api_key": apiKey})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "API key regenerated successfully",
|
||||
"has_api_key": true,
|
||||
"api_key_masked": maskSecretForResponse(apiKey),
|
||||
"api_key_updated": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GetProfile returns the current user's profile including API key.
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
if rejectPassthrough(c, "access profile") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
@@ -207,11 +253,12 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"api_key": user.APIKey,
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"has_api_key": strings.TrimSpace(user.APIKey) != "",
|
||||
"api_key_masked": maskSecretForResponse(user.APIKey),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -223,6 +270,9 @@ type UpdateProfileRequest struct {
|
||||
|
||||
// UpdateProfile updates the authenticated user's profile.
|
||||
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
if rejectPassthrough(c, "update profile") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
@@ -280,9 +330,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
|
||||
// ListUsers returns all users (admin only).
|
||||
func (h *UserHandler) ListUsers(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -326,9 +374,7 @@ type CreateUserRequest struct {
|
||||
|
||||
// CreateUser creates a new user with a password (admin only).
|
||||
func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -340,7 +386,12 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
|
||||
// Default role to "user"
|
||||
if req.Role == "" {
|
||||
req.Role = "user"
|
||||
req.Role = string(models.RoleUser)
|
||||
}
|
||||
|
||||
if !models.UserRole(req.Role).IsValid() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default permission mode to "allow_all"
|
||||
@@ -363,7 +414,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
UUID: uuid.New().String(),
|
||||
Email: strings.ToLower(req.Email),
|
||||
Name: req.Name,
|
||||
Role: req.Role,
|
||||
Role: models.UserRole(req.Role),
|
||||
Enabled: true,
|
||||
APIKey: uuid.New().String(),
|
||||
PermissionMode: models.PermissionMode(req.PermissionMode),
|
||||
@@ -431,9 +482,7 @@ func generateSecureToken(length int) (string, error) {
|
||||
|
||||
// InviteUser creates a new user with an invite token and sends an email (admin only).
|
||||
func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -447,7 +496,12 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
|
||||
// Default role to "user"
|
||||
if req.Role == "" {
|
||||
req.Role = "user"
|
||||
req.Role = string(models.RoleUser)
|
||||
}
|
||||
|
||||
if !models.UserRole(req.Role).IsValid() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default permission mode to "allow_all"
|
||||
@@ -477,7 +531,7 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
user := models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Email: strings.ToLower(req.Email),
|
||||
Role: req.Role,
|
||||
Role: models.UserRole(req.Role),
|
||||
Enabled: false, // Disabled until invite is accepted
|
||||
APIKey: uuid.New().String(),
|
||||
PermissionMode: models.PermissionMode(req.PermissionMode),
|
||||
@@ -548,14 +602,14 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token": inviteToken, // Return token in case email fails
|
||||
"invite_url": inviteURL,
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token_masked": maskSecretForResponse(inviteToken),
|
||||
"invite_url": redactInviteURL(inviteURL),
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -566,9 +620,7 @@ type PreviewInviteURLRequest struct {
|
||||
|
||||
// PreviewInviteURL returns what the invite URL would look like with current settings.
|
||||
func (h *UserHandler) PreviewInviteURL(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -612,9 +664,7 @@ func getAppName(db *gorm.DB) string {
|
||||
|
||||
// GetUser returns a single user by ID (admin only).
|
||||
func (h *UserHandler) GetUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -663,11 +713,17 @@ type UpdateUserRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpdateUser updates an existing user (admin only).
|
||||
// UpdateUser updates an existing user (admin only for management fields, self-service for name/password).
|
||||
func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
currentRole := c.GetString("role")
|
||||
currentUserIDRaw, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
return
|
||||
}
|
||||
currentUserID, ok := currentUserIDRaw.(uint)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -685,11 +741,31 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isSelf := uint(id) == currentUserID
|
||||
isCallerAdmin := currentRole == string(models.RoleAdmin)
|
||||
|
||||
// Non-admin users can only update their own name and password via this endpoint.
|
||||
// Email changes require password verification and must go through PUT /user/profile.
|
||||
if !isCallerAdmin {
|
||||
if !isSelf {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
if req.Email != "" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Email changes must be made via your profile settings"})
|
||||
return
|
||||
}
|
||||
if req.Role != "" || req.Enabled != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot modify role or enabled status"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
|
||||
if req.Name != "" {
|
||||
@@ -698,21 +774,37 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
|
||||
if req.Email != "" {
|
||||
email := strings.ToLower(req.Email)
|
||||
// Check if email is taken by another user
|
||||
var count int64
|
||||
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; err == nil && count > 0 {
|
||||
if dbErr := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; dbErr == nil && count > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
|
||||
return
|
||||
}
|
||||
updates["email"] = email
|
||||
}
|
||||
|
||||
needsSessionInvalidation := false
|
||||
|
||||
if req.Role != "" {
|
||||
updates["role"] = req.Role
|
||||
newRole := models.UserRole(req.Role)
|
||||
if !newRole.IsValid() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
if newRole != user.Role {
|
||||
// Self-demotion prevention
|
||||
if isSelf {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot change your own role"})
|
||||
return
|
||||
}
|
||||
|
||||
updates["role"] = string(newRole)
|
||||
needsSessionInvalidation = true
|
||||
}
|
||||
}
|
||||
|
||||
if req.Password != nil {
|
||||
if err := user.SetPassword(*req.Password); err != nil {
|
||||
if hashErr := user.SetPassword(*req.Password); hashErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
@@ -721,14 +813,82 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
updates["locked_until"] = nil
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
if req.Enabled != nil && *req.Enabled != user.Enabled {
|
||||
// Prevent self-disable
|
||||
if isSelf && !*req.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot disable your own account"})
|
||||
return
|
||||
}
|
||||
|
||||
updates["enabled"] = *req.Enabled
|
||||
if !*req.Enabled {
|
||||
needsSessionInvalidation = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the last-admin checks and the actual update in a transaction to prevent
|
||||
// race conditions: two concurrent requests could both read adminCount==2
|
||||
// and both proceed, leaving zero admins.
|
||||
err = h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// Re-fetch user inside transaction for consistent state
|
||||
if txErr := tx.First(&user, id).Error; txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
// Last-admin protection for role demotion
|
||||
if newRoleStr, ok := updates["role"]; ok {
|
||||
newRole := models.UserRole(newRoleStr.(string))
|
||||
if user.Role == models.RoleAdmin && newRole != models.RoleAdmin {
|
||||
var adminCount int64
|
||||
// Policy: count only enabled admins. This is stricter than "WHERE role = ?"
|
||||
// because a disabled admin cannot act; treating them as non-existent
|
||||
// prevents leaving the system with only disabled admins.
|
||||
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
return fmt.Errorf("cannot demote the last admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last-admin protection for disabling
|
||||
if enabledVal, ok := updates["enabled"]; ok {
|
||||
if enabled, isBool := enabledVal.(bool); isBool && !enabled {
|
||||
if user.Role == models.RoleAdmin {
|
||||
var adminCount int64
|
||||
// Policy: count only enabled admins (same rationale as above).
|
||||
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
return fmt.Errorf("cannot disable the last admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if txErr := tx.Model(&user).Updates(updates).Error; txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if errMsg == "cannot demote the last admin" || errMsg == "cannot disable the last admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot" + errMsg[len("cannot"):]})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := h.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
if needsSessionInvalidation && h.AuthService != nil {
|
||||
if invErr := h.AuthService.InvalidateSessions(user.ID); invErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to invalidate sessions"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.logUserAudit(c, "user_update", &user, map[string]any{
|
||||
@@ -751,13 +911,12 @@ func mapsKeys(values map[string]any) []string {
|
||||
|
||||
// DeleteUser deletes a user (admin only).
|
||||
func (h *UserHandler) DeleteUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
currentUserID, _ := c.Get("userID")
|
||||
currentUserIDRaw, _ := c.Get("userID")
|
||||
currentUserID, _ := currentUserIDRaw.(uint)
|
||||
|
||||
idParam := c.Param("id")
|
||||
id, err := strconv.ParseUint(idParam, 10, 32)
|
||||
@@ -767,7 +926,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Prevent self-deletion
|
||||
if uint(id) == currentUserID.(uint) {
|
||||
if uint(id) == currentUserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete your own account"})
|
||||
return
|
||||
}
|
||||
@@ -805,9 +964,7 @@ type UpdateUserPermissionsRequest struct {
|
||||
|
||||
// ResendInvite regenerates and resends an invitation to a pending user (admin only).
|
||||
func (h *UserHandler) ResendInvite(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -862,21 +1019,35 @@ func (h *UserHandler) ResendInvite(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token": inviteToken,
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token_masked": maskSecretForResponse(inviteToken),
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
})
|
||||
}
|
||||
|
||||
func maskSecretForResponse(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "********"
|
||||
}
|
||||
|
||||
func redactInviteURL(inviteURL string) string {
|
||||
if strings.TrimSpace(inviteURL) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "[REDACTED]"
|
||||
}
|
||||
|
||||
// UpdateUserPermissions updates a user's permission mode and host exceptions (admin only).
|
||||
func (h *UserHandler) UpdateUserPermissions(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func setupUserCoverageDB(t *testing.T) *gorm.DB {
|
||||
func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Drop table to cause error
|
||||
_ = db.Migrator().DropTable(&models.User{})
|
||||
@@ -40,7 +40,7 @@ func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
|
||||
func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Drop table to cause error
|
||||
_ = db.Migrator().DropTable(&models.User{})
|
||||
@@ -57,10 +57,10 @@ func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
|
||||
func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Create a user to mark setup as complete
|
||||
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: "admin"}
|
||||
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: models.RoleAdmin}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
|
||||
func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -91,7 +91,7 @@ func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
|
||||
func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -105,7 +105,7 @@ func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
|
||||
func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Drop table to cause error
|
||||
_ = db.Migrator().DropTable(&models.User{})
|
||||
@@ -123,7 +123,7 @@ func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
|
||||
func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -137,7 +137,7 @@ func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
|
||||
func TestUserHandler_GetProfile_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -152,7 +152,7 @@ func TestUserHandler_GetProfile_NotFound(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -166,7 +166,7 @@ func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -182,7 +182,7 @@ func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"name": "Updated",
|
||||
@@ -203,14 +203,14 @@ func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Create two users
|
||||
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: "admin", APIKey: "key1"}
|
||||
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: models.RoleAdmin, APIKey: "key1"}
|
||||
_ = user1.SetPassword("password123")
|
||||
db.Create(user1)
|
||||
|
||||
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: "admin", APIKey: "key2"}
|
||||
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: models.RoleAdmin, APIKey: "key2"}
|
||||
_ = user2.SetPassword("password123")
|
||||
db.Create(user2)
|
||||
|
||||
@@ -236,9 +236,9 @@ func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
@@ -263,9 +263,9 @@ func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_WrongPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ func TestUserLoginAfterEmailChange(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authHandler := NewAuthHandler(authService)
|
||||
userHandler := NewUserHandler(db)
|
||||
userHandler := NewUserHandler(db, nil)
|
||||
|
||||
// Setup Router
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -37,7 +38,7 @@ func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("role", user.Role)
|
||||
c.Set("role", string(user.Role))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -95,15 +96,15 @@ func extractAuthCookieToken(c *gin.Context) string {
|
||||
return token
|
||||
}
|
||||
|
||||
func RequireRole(role string) gin.HandlerFunc {
|
||||
func RequireRole(role models.UserRole) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userRole, exists := c.Get("role")
|
||||
if !exists {
|
||||
userRole := c.GetString("role")
|
||||
if userRole == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if userRole.(string) != role && userRole.(string) != "admin" {
|
||||
if userRole != string(role) && userRole != string(models.RoleAdmin) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
return
|
||||
}
|
||||
@@ -111,3 +112,14 @@ func RequireRole(role string) gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireManagementAccess() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
if role == string(models.RolePassthrough) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Pass-through users cannot access management features"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,3 +427,61 @@ func TestExtractAuthCookieToken_IgnoresNonAuthCookies(t *testing.T) {
|
||||
token := extractAuthCookieToken(ctx)
|
||||
assert.Equal(t, "", token)
|
||||
}
|
||||
|
||||
func TestRequireManagementAccess_PassthroughBlocked(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RolePassthrough))
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireManagementAccess())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Pass-through users cannot access management features")
|
||||
}
|
||||
|
||||
func TestRequireManagementAccess_UserAllowed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RoleUser))
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireManagementAccess())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestRequireManagementAccess_AdminAllowed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RoleAdmin))
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireManagementAccess())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user