Merge pull request #784 from Wikid82/nightly

Weekly Nightly Promotion
This commit is contained in:
Jeremy
2026-03-02 10:00:05 -05:00
committed by GitHub
234 changed files with 18609 additions and 3657 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,6 @@ on:
- cron: '0 3 * * 0' # Weekly: Sundays at 03:00 UTC
workflow_dispatch:
inputs:
registries:
description: 'Comma-separated registries to prune (ghcr,dockerhub)'
required: false
default: 'ghcr,dockerhub'
keep_days:
description: 'Number of days to retain images (unprotected)'
required: false
@@ -27,16 +23,17 @@ permissions:
contents: read
jobs:
prune:
prune-ghcr:
runs-on: ubuntu-latest
env:
OWNER: ${{ github.repository_owner }}
IMAGE_NAME: charon
REGISTRIES: ${{ github.event.inputs.registries || 'ghcr,dockerhub' }}
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
PROTECTED_REGEX: '["^v","^latest$","^main$","^develop$"]'
DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }}
PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]'
PRUNE_UNTAGGED: 'true'
PRUNE_SBOM_TAGS: 'true'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
@@ -45,21 +42,19 @@ jobs:
run: |
sudo apt-get update && sudo apt-get install -y jq curl
- name: Run container prune
- name: Run GHCR prune
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
chmod +x scripts/prune-container-images.sh
./scripts/prune-container-images.sh 2>&1 | tee prune-${{ github.run_id }}.log
chmod +x scripts/prune-ghcr.sh
./scripts/prune-ghcr.sh 2>&1 | tee prune-ghcr-${{ github.run_id }}.log
- name: Summarize prune results (space reclaimed)
if: ${{ always() }}
- name: Summarize GHCR results
if: always()
run: |
set -euo pipefail
SUMMARY_FILE=prune-summary.env
LOG_FILE=prune-${{ github.run_id }}.log
SUMMARY_FILE=prune-summary-ghcr.env
LOG_FILE=prune-ghcr-${{ github.run_id }}.log
human() {
local bytes=${1:-0}
@@ -67,7 +62,7 @@ jobs:
echo "0 B"
return
fi
awk -v b="$bytes" 'function human(x){ split("B KiB MiB GiB TiB",u," "); i=0; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1]} END{human(b)}'
awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }'
}
if [ -f "$SUMMARY_FILE" ]; then
@@ -77,34 +72,155 @@ jobs:
TOTAL_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
{
echo "## Container prune summary"
echo "## GHCR prune summary"
echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))"
echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))"
} >> "$GITHUB_STEP_SUMMARY"
printf 'PRUNE_SUMMARY: candidates=%s candidates_bytes=%s deleted=%s deleted_bytes=%s\n' \
"${TOTAL_CANDIDATES}" "${TOTAL_CANDIDATES_BYTES}" "${TOTAL_DELETED}" "${TOTAL_DELETED_BYTES}"
echo "Deleted approximately: $(human "${TOTAL_DELETED_BYTES}")"
echo "space_saved=$(human "${TOTAL_DELETED_BYTES}")" >> "$GITHUB_OUTPUT"
else
deleted_bytes=$(grep -oE '\( *approx +[0-9]+ bytes\)' "$LOG_FILE" | sed -E 's/.*approx +([0-9]+) bytes.*/\1/' | awk '{s+=$1} END {print s+0}' || true)
deleted_count=$(grep -cE 'deleting |DRY RUN: would delete' "$LOG_FILE" || true)
{
echo "## Container prune summary"
echo "## GHCR prune summary"
echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))"
} >> "$GITHUB_STEP_SUMMARY"
printf 'PRUNE_SUMMARY: deleted_approx=%s deleted_bytes=%s\n' "${deleted_count}" "${deleted_bytes}"
echo "Deleted approximately: $(human "${deleted_bytes}")"
echo "space_saved=$(human "${deleted_bytes}")" >> "$GITHUB_OUTPUT"
fi
- name: Upload prune artifacts
if: ${{ always() }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
- name: Upload GHCR prune artifacts
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: prune-log-${{ github.run_id }}
name: prune-ghcr-log-${{ github.run_id }}
path: |
prune-${{ github.run_id }}.log
prune-summary.env
prune-ghcr-${{ github.run_id }}.log
prune-summary-ghcr.env
prune-dockerhub:
runs-on: ubuntu-latest
env:
OWNER: ${{ github.repository_owner }}
IMAGE_NAME: charon
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }}
DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }}
PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install tools
run: |
sudo apt-get update && sudo apt-get install -y jq curl
- name: Run Docker Hub prune
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
chmod +x scripts/prune-dockerhub.sh
./scripts/prune-dockerhub.sh 2>&1 | tee prune-dockerhub-${{ github.run_id }}.log
- name: Summarize Docker Hub results
if: always()
run: |
set -euo pipefail
SUMMARY_FILE=prune-summary-dockerhub.env
LOG_FILE=prune-dockerhub-${{ github.run_id }}.log
human() {
local bytes=${1:-0}
if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then
echo "0 B"
return
fi
awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }'
}
if [ -f "$SUMMARY_FILE" ]; then
TOTAL_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
TOTAL_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
TOTAL_DELETED=$(grep -E '^TOTAL_DELETED=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
TOTAL_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
{
echo "## Docker Hub prune summary"
echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))"
echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))"
} >> "$GITHUB_STEP_SUMMARY"
else
deleted_bytes=$(grep -oE '\( *approx +[0-9]+ bytes\)' "$LOG_FILE" | sed -E 's/.*approx +([0-9]+) bytes.*/\1/' | awk '{s+=$1} END {print s+0}' || true)
deleted_count=$(grep -cE 'deleting |DRY RUN: would delete' "$LOG_FILE" || true)
{
echo "## Docker Hub prune summary"
echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload Docker Hub prune artifacts
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: prune-dockerhub-log-${{ github.run_id }}
path: |
prune-dockerhub-${{ github.run_id }}.log
prune-summary-dockerhub.env
summarize:
runs-on: ubuntu-latest
needs: [prune-ghcr, prune-dockerhub]
if: always()
steps:
- name: Download all artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
with:
pattern: prune-*-log-${{ github.run_id }}
merge-multiple: true
- name: Combined summary
run: |
set -euo pipefail
human() {
local bytes=${1:-0}
if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then
echo "0 B"
return
fi
awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }'
}
GHCR_CANDIDATES=0 GHCR_CANDIDATES_BYTES=0 GHCR_DELETED=0 GHCR_DELETED_BYTES=0
if [ -f prune-summary-ghcr.env ]; then
GHCR_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
GHCR_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
GHCR_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
GHCR_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
fi
HUB_CANDIDATES=0 HUB_CANDIDATES_BYTES=0 HUB_DELETED=0 HUB_DELETED_BYTES=0
if [ -f prune-summary-dockerhub.env ]; then
HUB_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
HUB_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
HUB_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
HUB_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
fi
TOTAL_CANDIDATES=$((GHCR_CANDIDATES + HUB_CANDIDATES))
TOTAL_CANDIDATES_BYTES=$((GHCR_CANDIDATES_BYTES + HUB_CANDIDATES_BYTES))
TOTAL_DELETED=$((GHCR_DELETED + HUB_DELETED))
TOTAL_DELETED_BYTES=$((GHCR_DELETED_BYTES + HUB_DELETED_BYTES))
{
echo "## Combined container prune summary"
echo ""
echo "| Registry | Candidates | Deleted | Space Reclaimed |"
echo "|----------|------------|---------|-----------------|"
echo "| GHCR | ${GHCR_CANDIDATES} | ${GHCR_DELETED} | $(human "${GHCR_DELETED_BYTES}") |"
echo "| Docker Hub | ${HUB_CANDIDATES} | ${HUB_DELETED} | $(human "${HUB_DELETED_BYTES}") |"
echo "| **Total** | **${TOTAL_CANDIDATES}** | **${TOTAL_DELETED}** | **$(human "${TOTAL_DELETED_BYTES}")** |"
} >> "$GITHUB_STEP_SUMMARY"
printf 'PRUNE_SUMMARY: candidates=%s candidates_bytes=%s deleted=%s deleted_bytes=%s\n' \
"${TOTAL_CANDIDATES}" "${TOTAL_CANDIDATES_BYTES}" "${TOTAL_DELETED}" "${TOTAL_DELETED_BYTES}"
echo "Total space reclaimed: $(human "${TOTAL_DELETED_BYTES}")"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -119,7 +119,7 @@ jobs:
# Generate SBOM using official Anchore action (auto-updated by Renovate)
- name: Generate and Verify SBOM
if: steps.image-check.outputs.exists == 'true'
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
with:
image: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
format: cyclonedx-json
@@ -144,7 +144,7 @@ jobs:
- name: Upload SBOM Artifact
if: steps.image-check.outputs.exists == 'true' && always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: sbom-${{ steps.tag.outputs.tag }}
path: sbom-verify.cyclonedx.json
@@ -324,7 +324,7 @@ jobs:
- name: Upload Vulnerability Scan Artifact
if: steps.validate-sbom.outputs.valid == 'true' && always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: vulnerability-scan-${{ steps.tag.outputs.tag }}
path: |

View File

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

View File

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

View File

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

163
.vscode/tasks.json vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package handlers
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -14,6 +15,7 @@ import (
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/trace"
)
func setupNotificationCoverageDB(t *testing.T) *gorm.DB {
@@ -319,6 +321,159 @@ func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) {
assert.Equal(t, 400, w.Code)
}
func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
"type": "gotify",
"url": "https://gotify.example/message",
"token": "super-secret-client-token",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Set(string(trace.RequestIDKey), "req-token-reject-1")
c.Request = httptest.NewRequest(http.MethodPost, "/providers/test", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Test(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "TOKEN_WRITE_ONLY", resp["code"])
assert.Equal(t, "validation", resp["category"])
assert.Equal(t, "Gotify token is accepted only on provider create/update", resp["error"])
assert.Equal(t, "req-token-reject-1", resp["request_id"])
assert.NotContains(t, w.Body.String(), "super-secret-client-token")
}
func TestNotificationProviderHandler_Test_RejectsGotifyTokenWithWhitespace(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
"type": "gotify",
"token": " secret-with-space ",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest(http.MethodPost, "/providers/test", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Test(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
assert.NotContains(t, w.Body.String(), "secret-with-space")
}
func TestClassifyProviderTestFailure_NilError(t *testing.T) {
code, category, message := classifyProviderTestFailure(nil)
assert.Equal(t, "PROVIDER_TEST_FAILED", code)
assert.Equal(t, "dispatch", category)
assert.Equal(t, "Provider test failed", message)
}
func TestClassifyProviderTestFailure_DefaultStatusCode(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("provider returned status 500"))
assert.Equal(t, "PROVIDER_TEST_REMOTE_REJECTED", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "HTTP 500")
}
func TestClassifyProviderTestFailure_GenericError(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("something completely unexpected"))
assert.Equal(t, "PROVIDER_TEST_FAILED", code)
assert.Equal(t, "dispatch", category)
assert.Equal(t, "Provider test failed", message)
}
func TestClassifyProviderTestFailure_InvalidDiscordWebhookURL(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("invalid discord webhook url"))
assert.Equal(t, "PROVIDER_TEST_URL_INVALID", code)
assert.Equal(t, "validation", category)
assert.Contains(t, message, "Provider URL")
}
func TestClassifyProviderTestFailure_URLValidation(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("destination URL validation failed"))
assert.Equal(t, "PROVIDER_TEST_URL_INVALID", code)
assert.Equal(t, "validation", category)
assert.Contains(t, message, "Provider URL")
}
func TestClassifyProviderTestFailure_AuthRejected(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: provider returned status 401"))
assert.Equal(t, "PROVIDER_TEST_AUTH_REJECTED", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "rejected authentication")
}
func TestClassifyProviderTestFailure_EndpointNotFound(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: provider returned status 404"))
assert.Equal(t, "PROVIDER_TEST_ENDPOINT_NOT_FOUND", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "endpoint was not found")
}
func TestClassifyProviderTestFailure_UnreachableEndpoint(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed"))
assert.Equal(t, "PROVIDER_TEST_UNREACHABLE", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "Could not reach provider endpoint")
}
func TestClassifyProviderTestFailure_DNSLookupFailed(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: dns lookup failed"))
assert.Equal(t, "PROVIDER_TEST_DNS_FAILED", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "DNS lookup failed")
}
func TestClassifyProviderTestFailure_ConnectionRefused(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: connection refused"))
assert.Equal(t, "PROVIDER_TEST_CONNECTION_REFUSED", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "refused the connection")
}
func TestClassifyProviderTestFailure_Timeout(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: request timed out"))
assert.Equal(t, "PROVIDER_TEST_TIMEOUT", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "timed out")
}
func TestClassifyProviderTestFailure_TLSHandshakeFailed(t *testing.T) {
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: tls handshake failed"))
assert.Equal(t, "PROVIDER_TEST_TLS_FAILED", code)
assert.Equal(t, "dispatch", category)
assert.Contains(t, message, "TLS handshake failed")
}
func TestNotificationProviderHandler_Templates(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
@@ -625,3 +780,258 @@ func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) {
assert.Equal(t, 400, w.Code)
}
func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
"template": "minimal",
"token": "secret-token-value",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers/preview", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Preview(c)
assert.Equal(t, 400, w.Code)
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
}
func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
existing := models.NotificationProvider{
ID: "update-type-test",
Name: "Discord Provider",
Type: "discord",
URL: "https://discord.com/api/webhooks/123/abc",
}
require.NoError(t, db.Create(&existing).Error)
payload := map[string]any{
"name": "Changed Type Provider",
"type": "gotify",
"url": "https://gotify.example.com",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "update-type-test"}}
c.Request = httptest.NewRequest("PUT", "/providers/update-type-test", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Update(c)
assert.Equal(t, 400, w.Code)
assert.Contains(t, w.Body.String(), "PROVIDER_TYPE_IMMUTABLE")
}
func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
"type": "discord",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Test(c)
assert.Equal(t, 400, w.Code)
assert.Contains(t, w.Body.String(), "MISSING_PROVIDER_ID")
}
func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
payload := map[string]any{
"type": "discord",
"id": "nonexistent-provider",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Test(c)
assert.Equal(t, 404, w.Code)
assert.Contains(t, w.Body.String(), "PROVIDER_NOT_FOUND")
}
func TestNotificationProviderHandler_Test_EmptyProviderURL(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
existing := models.NotificationProvider{
ID: "empty-url-test",
Name: "Empty URL Provider",
Type: "discord",
URL: "",
}
require.NoError(t, db.Create(&existing).Error)
payload := map[string]any{
"type": "discord",
"id": "empty-url-test",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Test(c)
assert.Equal(t, 400, w.Code)
assert.Contains(t, w.Body.String(), "PROVIDER_CONFIG_MISSING")
}
func TestIsProviderValidationError_Comprehensive(t *testing.T) {
cases := []struct {
name string
err error
expect bool
}{
{"nil", nil, false},
{"invalid_custom_template", errors.New("invalid custom template: missing field"), true},
{"rendered_template", errors.New("rendered template exceeds maximum"), true},
{"failed_to_parse", errors.New("failed to parse template: unexpected end"), true},
{"failed_to_render", errors.New("failed to render template: missing key"), true},
{"invalid_discord_webhook", errors.New("invalid Discord webhook URL"), true},
{"unrelated_error", errors.New("database connection failed"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expect, isProviderValidationError(tc.err))
})
}
}
func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
existing := models.NotificationProvider{
ID: "unsupported-type",
Name: "Custom Provider",
Type: "slack",
URL: "https://hooks.slack.com/test",
}
require.NoError(t, db.Create(&existing).Error)
payload := map[string]any{
"name": "Updated Slack Provider",
"url": "https://hooks.slack.com/updated",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "unsupported-type"}}
c.Request = httptest.NewRequest("PUT", "/providers/unsupported-type", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Update(c)
assert.Equal(t, 400, w.Code)
assert.Contains(t, w.Body.String(), "UNSUPPORTED_PROVIDER_TYPE")
}
func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
existing := models.NotificationProvider{
ID: "gotify-keep-token",
Name: "Gotify Provider",
Type: "gotify",
URL: "https://gotify.example.com",
Token: "existing-secret-token",
}
require.NoError(t, db.Create(&existing).Error)
payload := map[string]any{
"name": "Updated Gotify",
"url": "https://gotify.example.com/new",
"template": "minimal",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Params = gin.Params{{Key: "id", Value: "gotify-keep-token"}}
c.Request = httptest.NewRequest("PUT", "/providers/gotify-keep-token", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Update(c)
assert.Equal(t, 200, w.Code)
var updated models.NotificationProvider
require.NoError(t, db.Where("id = ?", "gotify-keep-token").First(&updated).Error)
assert.Equal(t, "existing-secret-token", updated.Token)
}
func TestNotificationProviderHandler_Test_ReadDBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationCoverageDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationProviderHandler(svc)
_ = db.Migrator().DropTable(&models.NotificationProvider{})
payload := map[string]any{
"type": "discord",
"id": "some-provider",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
setAdminContext(c)
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Test(c)
assert.Equal(t, 500, w.Code)
assert.Contains(t, w.Body.String(), "PROVIDER_READ_FAILED")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -168,3 +168,34 @@ func TestLogPermissionAudit_ActorFallback(t *testing.T) {
assert.Equal(t, "permissions", audit.EventCategory)
assert.Contains(t, audit.Details, fmt.Sprintf("\"admin\":%v", false))
}
func TestRequireAuthenticatedAdmin_NoUserID(t *testing.T) {
t.Parallel()
ctx, rec := newTestContextWithRequest()
result := requireAuthenticatedAdmin(ctx)
assert.False(t, result)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Contains(t, rec.Body.String(), "Authorization header required")
}
func TestRequireAuthenticatedAdmin_UserIDPresentAndAdmin(t *testing.T) {
t.Parallel()
ctx, _ := newTestContextWithRequest()
ctx.Set("userID", uint(1))
ctx.Set("role", "admin")
result := requireAuthenticatedAdmin(ctx)
assert.True(t, result)
}
func TestRequireAuthenticatedAdmin_UserIDPresentButNotAdmin(t *testing.T) {
t.Parallel()
ctx, rec := newTestContextWithRequest()
ctx.Set("userID", uint(1))
ctx.Set("role", "user")
result := requireAuthenticatedAdmin(ctx)
assert.False(t, result)
assert.Equal(t, http.StatusForbidden, rec.Code)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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