Compare commits
154 Commits
v0.22.0
...
hotfix/log
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd58f9d99a | ||
|
|
f33ab83b7c | ||
|
|
6777f6e8ff | ||
|
|
9d6ecd8f73 | ||
|
|
0c2a9d0ee8 | ||
|
|
c71e6fef30 | ||
|
|
3186676f94 | ||
|
|
b108f11bb4 | ||
|
|
d56e8a0f7f | ||
|
|
b76c1d7efc | ||
|
|
cbb2f42a2b | ||
|
|
fd056c05a7 | ||
|
|
2f76b4eadc | ||
|
|
fde59a94ae | ||
|
|
7409862140 | ||
|
|
065ac87815 | ||
|
|
d6d810f1a2 | ||
|
|
05c71988c0 | ||
|
|
3e32610ea1 | ||
|
|
be502b7533 | ||
|
|
4e81a982aa | ||
|
|
c977c6f9a4 | ||
|
|
7416229ba3 | ||
|
|
9000c1f4ba | ||
|
|
7423e64bc5 | ||
|
|
1d5f46980d | ||
|
|
e09efa42a8 | ||
|
|
e99be20bae | ||
|
|
6ce858e52e | ||
|
|
f41bd485e3 | ||
|
|
2fc5b10d3d | ||
|
|
f3d69b0116 | ||
|
|
13c5f8356c | ||
|
|
95c3adfa61 | ||
|
|
ef71f66029 | ||
|
|
317bff326b | ||
|
|
542d4ff3ee | ||
|
|
82a55da026 | ||
|
|
0535f50d89 | ||
|
|
fc5cb0eb88 | ||
|
|
524d363e27 | ||
|
|
e2ebdb37f0 | ||
|
|
539dd1bff4 | ||
|
|
f8ec567a35 | ||
|
|
c758c9d3ab | ||
|
|
424dc43652 | ||
|
|
b0001e4d50 | ||
|
|
a77b6c5d3e | ||
|
|
3414c7c941 | ||
|
|
332872c7f5 | ||
|
|
c499c57296 | ||
|
|
912bb7c577 | ||
|
|
6a37a906ce | ||
|
|
0f823956c6 | ||
|
|
703108051a | ||
|
|
795486e5b2 | ||
|
|
799ca8c5f9 | ||
|
|
9cc7393e7b | ||
|
|
791e812c3c | ||
|
|
187c3aea68 | ||
|
|
d7de28a040 | ||
|
|
d1baf6f1b0 | ||
|
|
3201830405 | ||
|
|
728a55f1d8 | ||
|
|
d3ef8d83b3 | ||
|
|
c4e8d6c8ae | ||
|
|
698ad86d17 | ||
|
|
2240c4c629 | ||
|
|
65b82a8e08 | ||
|
|
8032fb5b41 | ||
|
|
56fde3cbe1 | ||
|
|
bccbb708f1 | ||
|
|
80b1ed7fab | ||
|
|
e68035fe30 | ||
|
|
80ecb7de7f | ||
|
|
75cd0a4d9c | ||
|
|
2824a731f5 | ||
|
|
2dbb00036d | ||
|
|
0ad0c2f2c4 | ||
|
|
104f0eb6ee | ||
|
|
c144bb2b97 | ||
|
|
f50b05519b | ||
|
|
ca3c1085ac | ||
|
|
4cee4f01f3 | ||
|
|
82e2134333 | ||
|
|
6add11f1d2 | ||
|
|
744b6aeff5 | ||
|
|
92310a8b3e | ||
|
|
d74ea47e2c | ||
|
|
c665f62700 | ||
|
|
37471141e8 | ||
|
|
81497beb4b | ||
|
|
2d40f34ff0 | ||
|
|
801760add1 | ||
|
|
4ebf8d23fe | ||
|
|
77a7368c5d | ||
|
|
51a01c4f7b | ||
|
|
13d31dd922 | ||
|
|
c9bb303a7d | ||
|
|
6ebfd417e3 | ||
|
|
b527470e75 | ||
|
|
89b4d88eb1 | ||
|
|
a69f698440 | ||
|
|
ee224adcf1 | ||
|
|
5bbae48b6b | ||
|
|
abcfd62b21 | ||
|
|
10d952a22e | ||
|
|
635caf0f9a | ||
|
|
2266a8d051 | ||
|
|
b292a1b793 | ||
|
|
bf398a1cb2 | ||
|
|
e7c98e5526 | ||
|
|
99ff0a34e3 | ||
|
|
c42b7f5a5b | ||
|
|
ed89295012 | ||
|
|
834907cb5d | ||
|
|
e295a1f64c | ||
|
|
7cec4d7979 | ||
|
|
132bbbd657 | ||
|
|
833220f1cb | ||
|
|
e1e422bfc6 | ||
|
|
e4b6ce62cd | ||
|
|
396d01595e | ||
|
|
6a13e648ea | ||
|
|
5fa0cff274 | ||
|
|
bcb2748f89 | ||
|
|
e68a6039b9 | ||
|
|
0199f93994 | ||
|
|
f2cf5c3508 | ||
|
|
1d39756713 | ||
|
|
71455ef88f | ||
|
|
99b8ed875e | ||
|
|
8242666678 | ||
|
|
5aade0456e | ||
|
|
479f56f3e8 | ||
|
|
8c7a55eaa2 | ||
|
|
924b8227b5 | ||
|
|
c3fa29d13c | ||
|
|
e5dab58b42 | ||
|
|
22496a44a8 | ||
|
|
87e6762611 | ||
|
|
ddc79865bc | ||
|
|
6ee185c538 | ||
|
|
367943b543 | ||
|
|
08e7eb7525 | ||
|
|
35ca99866a | ||
|
|
2f83526966 | ||
|
|
5a58404e1b | ||
|
|
8ea907066b | ||
|
|
ffe5d951e0 | ||
|
|
e5af7d98d1 | ||
|
|
27c252600a | ||
|
|
c32cce2a88 | ||
|
|
c01c6c6225 |
@@ -9,13 +9,12 @@
|
||||
.git/
|
||||
.gitignore
|
||||
.github/
|
||||
.pre-commit-config.yaml
|
||||
codecov.yml
|
||||
.goreleaser.yaml
|
||||
.sourcery.yml
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Python (pre-commit, tooling)
|
||||
# Python (tooling)
|
||||
# -----------------------------------------------------------------------------
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
9
.github/agents/Backend_Dev.agent.md
vendored
9
.github/agents/Backend_Dev.agent.md
vendored
@@ -45,7 +45,7 @@ Your priority is writing code that is clean, tested, and secure by default.
|
||||
- **Step 3 (The Logic)**:
|
||||
- Implement the handler in `internal/api/handlers`.
|
||||
- **Step 4 (Lint and Format)**:
|
||||
- Run `pre-commit run --all-files` to ensure code quality.
|
||||
- Run `lefthook run pre-commit` to ensure code quality.
|
||||
- **Step 5 (The Green Light)**:
|
||||
- Run `go test ./...`.
|
||||
- **CRITICAL**: If it fails, fix the *Code*, NOT the *Test* (unless the test was wrong about the contract).
|
||||
@@ -57,8 +57,7 @@ Your priority is writing code that is clean, tested, and secure by default.
|
||||
- **Conditional GORM Gate**: If task changes include model/database-related
|
||||
files (`backend/internal/models/**`, GORM query logic, migrations), run
|
||||
GORM scanner in check mode and treat CRITICAL/HIGH findings as blocking:
|
||||
- Run: `pre-commit run --hook-stage manual gorm-security-scan --all-files`
|
||||
OR `./scripts/scan-gorm-security.sh --check`
|
||||
- Run: `lefthook run pre-commit` (which includes manual gorm-security-scan) OR `./scripts/scan-gorm-security.sh --check`
|
||||
- Policy: Process-blocking gate even while automation is manual stage
|
||||
- **Local Patch Coverage Preflight (MANDATORY)**: Run VS Code task `Test: Local Patch Report` or `bash scripts/local-patch-report.sh` before backend coverage runs.
|
||||
- Ensure artifacts exist: `test-results/local-patch-report.md` and `test-results/local-patch-report.json`.
|
||||
@@ -69,9 +68,9 @@ Your priority is writing code that is clean, tested, and secure by default.
|
||||
- **Manual Script**: Execute `/projects/Charon/scripts/go-test-coverage.sh` from the root directory
|
||||
- **Minimum**: 85% coverage (configured via `CHARON_MIN_COVERAGE` or `CPM_MIN_COVERAGE`)
|
||||
- **Critical**: If coverage drops below threshold, write additional tests immediately. Do not skip this step.
|
||||
- **Why**: Coverage tests are in manual stage of pre-commit for performance. You MUST run them via VS Code tasks or scripts before completing your task.
|
||||
- **Why**: Coverage tests are in manual stage of lefthook for performance. You MUST run them via VS Code tasks or scripts before completing your task.
|
||||
- Ensure coverage goals are met as well as all tests pass. Just because Tests pass does not mean you are done. Goal Coverage Needs to be met even if the tests to get us there are outside the scope of your task. At this point, your task is to maintain coverage goal and all tests pass because we cannot commit changes if they fail.
|
||||
- Run `pre-commit run --all-files` as final check (this runs fast hooks only; coverage was verified above).
|
||||
- Run `lefthook run pre-commit` as final check (this runs fast hooks only; coverage was verified above).
|
||||
</workflow>
|
||||
|
||||
<constraints>
|
||||
|
||||
2
.github/agents/Frontend_Dev.agent.md
vendored
2
.github/agents/Frontend_Dev.agent.md
vendored
@@ -48,7 +48,7 @@ You are a SENIOR REACT/TYPESCRIPT ENGINEER with deep expertise in:
|
||||
- Run tests with `npm test` in `frontend/` directory
|
||||
|
||||
4. **Quality Checks**:
|
||||
- Run `pre-commit run --all-files` to ensure linting and formatting
|
||||
- Run `lefthook run pre-commit` to ensure linting and formatting
|
||||
- Ensure accessibility with proper ARIA attributes
|
||||
</workflow>
|
||||
|
||||
|
||||
16
.github/agents/Management.agent.md
vendored
16
.github/agents/Management.agent.md
vendored
@@ -24,12 +24,12 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
|
||||
4. **Team Roster**:
|
||||
- `Planning`: The Architect. (Delegate research & planning here).
|
||||
- `Supervisor`: The Senior Advisor. (Delegate plan review here).
|
||||
- `Backend_Dev`: The Engineer. (Delegate Go implementation here).
|
||||
- `Frontend_Dev`: The Designer. (Delegate React implementation here).
|
||||
- `QA_Security`: The Auditor. (Delegate verification and testing here).
|
||||
- `Docs_Writer`: The Scribe. (Delegate docs here).
|
||||
- `Backend Dev`: The Engineer. (Delegate Go implementation here).
|
||||
- `Frontend Dev`: The Designer. (Delegate React implementation here).
|
||||
- `QA Security`: The Auditor. (Delegate verification and testing here).
|
||||
- `Docs Writer`: The Scribe. (Delegate docs here).
|
||||
- `DevOps`: The Packager. (Delegate CI/CD and infrastructure here).
|
||||
- `Playwright_Dev`: The E2E Specialist. (Delegate Playwright test creation and maintenance here).
|
||||
- `Playwright Dev`: The E2E Specialist. (Delegate Playwright test creation and maintenance here).
|
||||
5. **Parallel Execution**:
|
||||
- You may delegate to `runSubagent` multiple times in parallel if tasks are independent. The only exception is `QA_Security`, which must run last as this validates the entire codebase after all changes.
|
||||
6. **Implementation Choices**:
|
||||
@@ -43,7 +43,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
|
||||
- **Identify Goal**: Understand the user's request.
|
||||
- **STOP**: Do not look at the code. Do not run `list_dir`. No code is to be changed or implemented until there is a fundamentally sound plan of action that has been approved by the user.
|
||||
- **Action**: Immediately call `Planning` subagent.
|
||||
- *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible. Break the plan into phases based on the least amount of requests. Include a PR Slicing Strategy section that decides whether to split work into multiple PRs and, when split, defines PR-1/PR-2/PR-3 scope, dependencies, and acceptance criteria. Review and suggest updaetes to `.gitignore`, `codecov.yml`, `.dockerignore`, and `Dockerfile` if necessary. Return only when the plan is complete."
|
||||
- *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible. Break the plan into phases based on the least amount of requests. Include a Commit Slicing Strategy section that decides whether to split work into multiple PRs and, when split, defines PR-1/PR-2/PR-3 scope, dependencies, and acceptance criteria. Review and suggest updaetes to `.gitignore`, `codecov.yml`, `.dockerignore`, and `Dockerfile` if necessary. Return only when the plan is complete."
|
||||
- **Task Specifics**:
|
||||
- If the task is to just run tests or audits, there is no need for a plan. Directly call `QA_Security` to perform the tests and write the report. If issues are found, return to `Planning` for a remediation plan and delegate the fixes to the corresponding subagents.
|
||||
|
||||
@@ -59,7 +59,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
|
||||
- **Ask**: "Plan created. Shall I authorize the construction?"
|
||||
|
||||
4. **Phase 4: Execution (Waterfall)**:
|
||||
- **Single-PR or Multi-PR Decision**: Read the PR Slicing Strategy in `docs/plans/current_spec.md`.
|
||||
- **Single-PR or Multi-PR Decision**: Read the Commit Slicing Strategy in `docs/plans/current_spec.md`.
|
||||
- **If single PR**:
|
||||
- **Backend**: Call `Backend_Dev` with the plan file.
|
||||
- **Frontend**: Call `Frontend_Dev` with the plan file.
|
||||
@@ -73,7 +73,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
|
||||
- **Supervisor**: Call `Supervisor` to review the implementation against the plan. Provide feedback and ensure alignment with best practices.
|
||||
|
||||
6. **Phase 6: Audit**:
|
||||
- **QA**: Call `QA_Security` to meticulously test current implementation as well as regression test. Run all linting, security tasks, and manual pre-commit checks. Write a report to `docs/reports/qa_report.md`. Start back at Phase 1 if issues are found.
|
||||
- **QA**: Call `QA_Security` to meticulously test current implementation as well as regression test. Run all linting, security tasks, and manual lefthook checks. Write a report to `docs/reports/qa_report.md`. Start back at Phase 1 if issues are found.
|
||||
|
||||
7. **Phase 7: Closure**:
|
||||
- **Docs**: Call `Docs_Writer`.
|
||||
|
||||
2
.github/agents/Planning.agent.md
vendored
2
.github/agents/Planning.agent.md
vendored
@@ -44,7 +44,7 @@ You are a PRINCIPAL ARCHITECT responsible for technical planning and system desi
|
||||
- Include acceptance criteria
|
||||
- Break down into implementable tasks using examples, diagrams, and tables
|
||||
- Estimate complexity for each component
|
||||
- Add a **PR Slicing Strategy** section with:
|
||||
- Add a **Commit Slicing Strategy** section with:
|
||||
- Decision: single PR or multiple PRs
|
||||
- Trigger reasons (scope, risk, cross-domain changes, review size)
|
||||
- Ordered PR slices (`PR-1`, `PR-2`, ...), each with scope, files, dependencies, and validation gates
|
||||
|
||||
@@ -130,7 +130,7 @@ graph TB
|
||||
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
|
||||
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
|
||||
| **Metrics** | Prometheus Client | Latest | Application metrics |
|
||||
| **Notifications** | Shoutrrr | Latest | Multi-platform alerts |
|
||||
| **Notifications** | Notify | Latest | Multi-platform alerts |
|
||||
| **Docker Client** | Docker SDK | Latest | Container discovery |
|
||||
| **Logging** | Logrus + Lumberjack | Latest | Structured logging with rotation |
|
||||
|
||||
@@ -1263,8 +1263,8 @@ docker exec charon /app/scripts/restore-backup.sh \
|
||||
- Future: Dynamic plugin loading for custom providers
|
||||
|
||||
2. **Notification Channels:**
|
||||
- Shoutrrr provides 40+ channels (Discord, Slack, Email, etc.)
|
||||
- Custom channels via Shoutrrr service URLs
|
||||
- Notify provides multi-platform channels (Discord, Slack, Gotify, etc.)
|
||||
- Provider-based configuration with per-channel feature flags
|
||||
|
||||
3. **Authentication Providers:**
|
||||
- Current: Local database authentication
|
||||
|
||||
14
.github/instructions/copilot-instructions.md
vendored
14
.github/instructions/copilot-instructions.md
vendored
@@ -67,7 +67,7 @@ Before proposing ANY code change or fix, you must build a mental map of the feat
|
||||
|
||||
- **Run**: `cd backend && go run ./cmd/api`.
|
||||
- **Test**: `go test ./...`.
|
||||
- **Static Analysis (BLOCKING)**: Fast linters run automatically on every commit via pre-commit hooks.
|
||||
- **Static Analysis (BLOCKING)**: Fast linters run automatically on every commit via lefthook pre-commit-phase hooks.
|
||||
- **Staticcheck errors MUST be fixed** - commits are BLOCKED until resolved
|
||||
- Manual run: `make lint-fast` or VS Code task "Lint: Staticcheck (Fast)"
|
||||
- Staticcheck-only: `make lint-staticcheck-only`
|
||||
@@ -79,7 +79,7 @@ Before proposing ANY code change or fix, you must build a mental map of the feat
|
||||
- **Security**: Sanitize all file paths using `filepath.Clean`. Use `fmt.Errorf("context: %w", err)` for error wrapping.
|
||||
- **Graceful Shutdown**: Long-running work must respect `server.Run(ctx)`.
|
||||
|
||||
### Troubleshooting Pre-Commit Staticcheck Failures
|
||||
### Troubleshooting Lefthook Staticcheck Failures
|
||||
|
||||
**Common Issues:**
|
||||
|
||||
@@ -175,7 +175,7 @@ Before marking an implementation task as complete, perform the following in orde
|
||||
- **Exclusions**: Skip this gate for docs-only (`**/*.md`) or frontend-only (`frontend/**`) changes
|
||||
- **Run One Of**:
|
||||
- VS Code task: `Lint: GORM Security Scan`
|
||||
- Pre-commit: `pre-commit run --hook-stage manual gorm-security-scan --all-files`
|
||||
- Lefthook: `lefthook run pre-commit` (includes gorm-security-scan)
|
||||
- Direct: `./scripts/scan-gorm-security.sh --check`
|
||||
- **Gate Enforcement**: DoD is process-blocking until scanner reports zero
|
||||
CRITICAL/HIGH findings, even while automation remains in manual stage
|
||||
@@ -189,15 +189,15 @@ Before marking an implementation task as complete, perform the following in orde
|
||||
- **Expected Behavior**: Report may warn (non-blocking rollout), but artifact generation is mandatory.
|
||||
|
||||
3. **Security Scans** (MANDATORY - Zero Tolerance):
|
||||
- **CodeQL Go Scan**: Run VS Code task "Security: CodeQL Go Scan (CI-Aligned)" OR `pre-commit run codeql-go-scan --all-files`
|
||||
- **CodeQL Go Scan**: Run VS Code task "Security: CodeQL Go Scan (CI-Aligned)" OR `lefthook run pre-commit`
|
||||
- Must use `security-and-quality` suite (CI-aligned)
|
||||
- **Zero high/critical (error-level) findings allowed**
|
||||
- Medium/low findings should be documented and triaged
|
||||
- **CodeQL JS Scan**: Run VS Code task "Security: CodeQL JS Scan (CI-Aligned)" OR `pre-commit run codeql-js-scan --all-files`
|
||||
- **CodeQL JS Scan**: Run VS Code task "Security: CodeQL JS Scan (CI-Aligned)" OR `lefthook run pre-commit`
|
||||
- Must use `security-and-quality` suite (CI-aligned)
|
||||
- **Zero high/critical (error-level) findings allowed**
|
||||
- Medium/low findings should be documented and triaged
|
||||
- **Validate Findings**: Run `pre-commit run codeql-check-findings --all-files` to check for HIGH/CRITICAL issues
|
||||
- **Validate Findings**: Run `lefthook run pre-commit` to check for HIGH/CRITICAL issues
|
||||
- **Trivy Container Scan**: Run VS Code task "Security: Trivy Scan" for container/dependency vulnerabilities
|
||||
- **Results Viewing**:
|
||||
- Primary: VS Code SARIF Viewer extension (`MS-SarifVSCode.sarif-viewer`)
|
||||
@@ -210,7 +210,7 @@ Before marking an implementation task as complete, perform the following in orde
|
||||
- Database creation: `--threads=0 --overwrite`
|
||||
- Analysis: `--sarif-add-baseline-file-info`
|
||||
|
||||
4. **Pre-Commit Triage**: Run `pre-commit run --all-files`.
|
||||
4. **Lefthook Triage**: Run `lefthook run pre-commit`.
|
||||
- If errors occur, **fix them immediately**.
|
||||
- If logic errors occur, analyze and propose a fix.
|
||||
- Do not output code that violates pre-commit standards.
|
||||
|
||||
2
.github/instructions/go.instructions.md
vendored
2
.github/instructions/go.instructions.md
vendored
@@ -353,7 +353,7 @@ Follow idiomatic Go practices and community standards when writing Go code. Thes
|
||||
### Development Practices
|
||||
|
||||
- Run tests before committing
|
||||
- Use pre-commit hooks for formatting and linting
|
||||
- Use lefthook pre-commit-phase hooks for formatting and linting
|
||||
- Keep commits focused and atomic
|
||||
- Write meaningful commit messages
|
||||
- Review diffs before committing
|
||||
|
||||
@@ -9,7 +9,7 @@ description: 'Repository structure guidelines to maintain organized file placeme
|
||||
|
||||
The repository root should contain ONLY:
|
||||
|
||||
- Essential config files (`.gitignore`, `.pre-commit-config.yaml`, `Makefile`, etc.)
|
||||
- Essential config files (`.gitignore`, `Makefile`, etc.)
|
||||
- Standard project files (`README.md`, `CONTRIBUTING.md`, `LICENSE`, `CHANGELOG.md`)
|
||||
- Go workspace files (`go.work`, `go.work.sum`)
|
||||
- VS Code workspace (`Chiron.code-workspace`)
|
||||
|
||||
@@ -28,7 +28,7 @@ runSubagent({
|
||||
- Parallel: run `QA and Security`, `DevOps` and `Doc Writer` in parallel for CI / QA checks and documentation.
|
||||
- Return: a JSON summary with `subagent_results`, `overall_status`, and aggregated artifacts.
|
||||
|
||||
2.1) Multi-PR Slicing Protocol
|
||||
2.1) Multi-Commit Slicing Protocol
|
||||
|
||||
- If a task is large or high-risk, split into PR slices and execute in order.
|
||||
- Each slice must have:
|
||||
|
||||
40
.github/renovate.json
vendored
40
.github/renovate.json
vendored
@@ -27,7 +27,10 @@
|
||||
"rebaseWhen": "auto",
|
||||
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true
|
||||
"enabled": true,
|
||||
"dependencyDashboardApproval": false,
|
||||
"automerge": false,
|
||||
"labels": ["security", "vulnerability"]
|
||||
},
|
||||
|
||||
"rangeStrategy": "bump",
|
||||
@@ -66,12 +69,45 @@
|
||||
"description": "Track Alpine base image digest in Dockerfile for security updates",
|
||||
"managerFilePatterns": ["/^Dockerfile$/"],
|
||||
"matchStrings": [
|
||||
"#\\s*renovate:\\s*datasource=docker\\s+depName=alpine.*\\nARG CADDY_IMAGE=alpine:(?<currentValue>[^\\s@]+@sha256:[a-f0-9]+)"
|
||||
"#\\s*renovate:\\s*datasource=docker\\s+depName=alpine.*\\nARG ALPINE_IMAGE=alpine:(?<currentValue>[^@\\s]+)@(?<currentDigest>sha256:[a-f0-9]+)"
|
||||
],
|
||||
"depNameTemplate": "alpine",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track Go toolchain version ARG in Dockerfile",
|
||||
"managerFilePatterns": ["/^Dockerfile$/"],
|
||||
"matchStrings": [
|
||||
"#\\s*renovate:\\s*datasource=docker\\s+depName=golang.*\\nARG GO_VERSION=(?<currentValue>[^\\s]+)"
|
||||
],
|
||||
"depNameTemplate": "golang",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track expr-lang version ARG in Dockerfile",
|
||||
"managerFilePatterns": ["/^Dockerfile$/"],
|
||||
"matchStrings": [
|
||||
"#\\s*renovate:\\s*datasource=go\\s+depName=github\\.com/expr-lang/expr.*\\nARG EXPR_LANG_VERSION=(?<currentValue>[^\\s]+)"
|
||||
],
|
||||
"depNameTemplate": "github.com/expr-lang/expr",
|
||||
"datasourceTemplate": "go",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track golang.org/x/net version ARG in Dockerfile",
|
||||
"managerFilePatterns": ["/^Dockerfile$/"],
|
||||
"matchStrings": [
|
||||
"#\\s*renovate:\\s*datasource=go\\s+depName=golang\\.org/x/net.*\\nARG XNET_VERSION=(?<currentValue>[^\\s]+)"
|
||||
],
|
||||
"depNameTemplate": "golang.org/x/net",
|
||||
"datasourceTemplate": "go",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track Delve version in Dockerfile",
|
||||
|
||||
2
.github/skills/README.md
vendored
2
.github/skills/README.md
vendored
@@ -63,7 +63,7 @@ Agent Skills are self-documenting, AI-discoverable task definitions that combine
|
||||
|
||||
| Skill Name | Category | Description | Status |
|
||||
|------------|----------|-------------|--------|
|
||||
| [qa-precommit-all](./qa-precommit-all.SKILL.md) | qa | Run all pre-commit hooks on entire codebase | ✅ Active |
|
||||
| [qa-lefthook-all](./qa-lefthook-all.SKILL.md) | qa | Run all lefthook pre-commit‑phase hooks on entire codebase | ✅ Active |
|
||||
|
||||
### Utility Skills
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version: "1.26.1"
|
||||
|
||||
- name: Run GORM Security Scanner
|
||||
id: gorm-scan
|
||||
|
||||
349
.github/skills/qa-lefthook-all.SKILL.md
vendored
Normal file
349
.github/skills/qa-lefthook-all.SKILL.md
vendored
Normal file
@@ -0,0 +1,349 @@
|
||||
---
|
||||
# agentskills.io specification v1.0
|
||||
name: "qa-lefthook-all"
|
||||
version: "1.0.0"
|
||||
description: "Run all lefthook pre-commit-phase hooks for comprehensive code quality validation"
|
||||
author: "Charon Project"
|
||||
license: "MIT"
|
||||
tags:
|
||||
- "qa"
|
||||
- "quality"
|
||||
- "pre-commit"
|
||||
- "linting"
|
||||
- "validation"
|
||||
compatibility:
|
||||
os:
|
||||
- "linux"
|
||||
- "darwin"
|
||||
shells:
|
||||
- "bash"
|
||||
requirements:
|
||||
- name: "python3"
|
||||
version: ">=3.8"
|
||||
optional: false
|
||||
- name: "lefthook"
|
||||
version: ">=0.14"
|
||||
optional: false
|
||||
environment_variables:
|
||||
- name: "SKIP"
|
||||
description: "Comma-separated list of hook IDs to skip"
|
||||
default: ""
|
||||
required: false
|
||||
parameters:
|
||||
- name: "files"
|
||||
type: "string"
|
||||
description: "Specific files to check (default: all staged files)"
|
||||
default: "--all-files"
|
||||
required: false
|
||||
outputs:
|
||||
- name: "validation_report"
|
||||
type: "stdout"
|
||||
description: "Results of all pre-commit hook executions"
|
||||
- name: "exit_code"
|
||||
type: "number"
|
||||
description: "0 if all hooks pass, non-zero if any fail"
|
||||
metadata:
|
||||
category: "qa"
|
||||
subcategory: "quality"
|
||||
execution_time: "medium"
|
||||
risk_level: "low"
|
||||
ci_cd_safe: true
|
||||
requires_network: false
|
||||
idempotent: true
|
||||
---
|
||||
|
||||
# QA Pre-commit All
|
||||
|
||||
## Overview
|
||||
|
||||
Executes all configured lefthook pre-commit-phase hooks to validate code quality, formatting, security, and best practices across the entire codebase. This skill runs checks for Python, Go, JavaScript/TypeScript, Markdown, YAML, and more.
|
||||
|
||||
This skill is designed for CI/CD pipelines and local quality validation before committing code.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.8 or higher installed and in PATH
|
||||
- Python virtual environment activated (`.venv`)
|
||||
- Pre-commit installed in virtual environment: `pip install pre-commit`
|
||||
- Pre-commit hooks installed: `pre-commit install`
|
||||
- All language-specific tools installed (Go, Node.js, etc.)
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Run all pre-commit-phase hooks on all files:
|
||||
|
||||
```bash
|
||||
cd /path/to/charon
|
||||
lefthook run pre-commit
|
||||
```
|
||||
|
||||
### Staged Files Only
|
||||
|
||||
Run lefthook on staged files only (faster):
|
||||
|
||||
```bash
|
||||
lefthook run pre-commit --staged
|
||||
```
|
||||
|
||||
### Specific Hook
|
||||
|
||||
Run only a specific hook by ID:
|
||||
|
||||
```bash
|
||||
lefthook run pre-commit --hooks=trailing-whitespace
|
||||
```
|
||||
|
||||
### Skip Specific Hooks
|
||||
|
||||
Skip certain hooks during execution:
|
||||
|
||||
```bash
|
||||
SKIP=prettier,eslint .github/skills/scripts/skill-runner.sh qa-precommit-all
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| files | string | No | --all-files | File selection mode (--all-files or staged) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| SKIP | No | "" | Comma-separated hook IDs to skip |
|
||||
| PRE_COMMIT_HOME | No | ~/.cache/pre-commit | Pre-commit cache directory |
|
||||
|
||||
## Outputs
|
||||
|
||||
- **Success Exit Code**: 0 (all hooks passed)
|
||||
- **Error Exit Codes**: Non-zero (one or more hooks failed)
|
||||
- **Output**: Detailed results from each hook
|
||||
|
||||
## Pre-commit Hooks Included
|
||||
|
||||
The following hooks are configured in `.pre-commit-config.yaml`:
|
||||
|
||||
### General Hooks
|
||||
- **trailing-whitespace**: Remove trailing whitespace
|
||||
- **end-of-file-fixer**: Ensure files end with newline
|
||||
- **check-yaml**: Validate YAML syntax
|
||||
- **check-json**: Validate JSON syntax
|
||||
- **check-merge-conflict**: Detect merge conflict markers
|
||||
- **check-added-large-files**: Prevent committing large files
|
||||
|
||||
### Python Hooks
|
||||
- **black**: Code formatting
|
||||
- **isort**: Import sorting
|
||||
- **flake8**: Linting
|
||||
- **mypy**: Type checking
|
||||
|
||||
### Go Hooks
|
||||
- **gofmt**: Code formatting
|
||||
- **go-vet**: Static analysis
|
||||
- **golangci-lint**: Comprehensive linting
|
||||
|
||||
### JavaScript/TypeScript Hooks
|
||||
- **prettier**: Code formatting
|
||||
- **eslint**: Linting and code quality
|
||||
|
||||
### Markdown Hooks
|
||||
- **markdownlint**: Markdown linting and formatting
|
||||
|
||||
### Security Hooks
|
||||
- **detect-private-key**: Prevent committing private keys
|
||||
- **detect-aws-credentials**: Prevent committing AWS credentials
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Full Quality Check
|
||||
|
||||
```bash
|
||||
# Run all hooks on all files
|
||||
source .venv/bin/activate
|
||||
.github/skills/scripts/skill-runner.sh qa-precommit-all
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Trim Trailing Whitespace.....................................Passed
|
||||
Fix End of Files.............................................Passed
|
||||
Check Yaml...................................................Passed
|
||||
Check JSON...................................................Passed
|
||||
Check for merge conflicts....................................Passed
|
||||
Check for added large files..................................Passed
|
||||
black........................................................Passed
|
||||
isort........................................................Passed
|
||||
prettier.....................................................Passed
|
||||
eslint.......................................................Passed
|
||||
markdownlint.................................................Passed
|
||||
```
|
||||
|
||||
### Example 2: Quick Staged Files Check
|
||||
|
||||
```bash
|
||||
# Run only on staged files (faster for pre-commit)
|
||||
.github/skills/scripts/skill-runner.sh qa-precommit-all staged
|
||||
```
|
||||
|
||||
### Example 3: Skip Slow Hooks
|
||||
|
||||
```bash
|
||||
# Skip time-consuming hooks for quick validation
|
||||
SKIP=golangci-lint,mypy .github/skills/scripts/skill-runner.sh qa-precommit-all
|
||||
```
|
||||
|
||||
### Example 4: CI/CD Pipeline Integration
|
||||
|
||||
```yaml
|
||||
# GitHub Actions example
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit
|
||||
|
||||
- name: Run QA Pre-commit Checks
|
||||
run: .github/skills/scripts/skill-runner.sh qa-precommit-all
|
||||
```
|
||||
|
||||
### Example 5: Auto-fix Mode
|
||||
|
||||
```bash
|
||||
# Some hooks can auto-fix issues
|
||||
# Run twice: first to fix, second to validate
|
||||
.github/skills/scripts/skill-runner.sh qa-precommit-all || \
|
||||
.github/skills/scripts/skill-runner.sh qa-precommit-all
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Virtual environment not activated**:
|
||||
```bash
|
||||
Error: pre-commit not found
|
||||
Solution: source .venv/bin/activate
|
||||
```
|
||||
|
||||
**Pre-commit not installed**:
|
||||
```bash
|
||||
Error: pre-commit command not available
|
||||
Solution: pip install pre-commit
|
||||
```
|
||||
|
||||
**Hooks not installed**:
|
||||
```bash
|
||||
Error: Run 'pre-commit install'
|
||||
Solution: pre-commit install
|
||||
```
|
||||
|
||||
**Hook execution failed**:
|
||||
```bash
|
||||
Hook X failed
|
||||
Solution: Review error output and fix reported issues
|
||||
```
|
||||
|
||||
**Language tool missing**:
|
||||
```bash
|
||||
Error: golangci-lint not found
|
||||
Solution: Install required language tools
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- **0**: All hooks passed
|
||||
- **1**: One or more hooks failed
|
||||
- **Other**: Hook execution error
|
||||
|
||||
## Hook Fixing Strategies
|
||||
|
||||
### Auto-fixable Issues
|
||||
These hooks automatically fix issues:
|
||||
- `trailing-whitespace`
|
||||
- `end-of-file-fixer`
|
||||
- `black`
|
||||
- `isort`
|
||||
- `prettier`
|
||||
- `gofmt`
|
||||
|
||||
**Workflow**: Run pre-commit, review changes, commit fixed files
|
||||
|
||||
### Manual Fixes Required
|
||||
These hooks only report issues:
|
||||
- `check-yaml`
|
||||
- `check-json`
|
||||
- `flake8`
|
||||
- `eslint`
|
||||
- `markdownlint`
|
||||
- `go-vet`
|
||||
- `golangci-lint`
|
||||
|
||||
**Workflow**: Review errors, manually fix code, re-run pre-commit
|
||||
|
||||
## Related Skills
|
||||
|
||||
- [test-backend-coverage](./test-backend-coverage.SKILL.md) - Backend test coverage
|
||||
- [test-frontend-coverage](./test-frontend-coverage.SKILL.md) - Frontend test coverage
|
||||
- [security-scan-trivy](./security-scan-trivy.SKILL.md) - Security scanning
|
||||
|
||||
## Notes
|
||||
|
||||
- Pre-commit hooks cache their environments for faster execution
|
||||
- First run may be slow while environments are set up
|
||||
- Subsequent runs are much faster (seconds vs minutes)
|
||||
- Hooks run in parallel where possible
|
||||
- Failed hooks stop execution (fail-fast behavior)
|
||||
- Use `SKIP` to bypass specific hooks temporarily
|
||||
- Recommended to run before every commit
|
||||
- Can be integrated into Git pre-commit hook for automatic checks
|
||||
- Cache location: `~/.cache/pre-commit` (configurable)
|
||||
|
||||
## Performance Tips
|
||||
|
||||
- **Initial Setup**: First run takes longer (installing hook environments)
|
||||
- **Incremental**: Run on staged files only for faster feedback
|
||||
- **Parallel**: Pre-commit runs compatible hooks in parallel
|
||||
- **Cache**: Hook environments are cached and reused
|
||||
- **Skip**: Use `SKIP` to bypass slow hooks during development
|
||||
|
||||
## Integration with Git
|
||||
|
||||
To automatically run on every commit:
|
||||
|
||||
```bash
|
||||
# Install Git pre-commit hook
|
||||
pre-commit install
|
||||
|
||||
# Now pre-commit runs automatically on git commit
|
||||
git commit -m "Your commit message"
|
||||
```
|
||||
|
||||
To bypass pre-commit hook temporarily:
|
||||
|
||||
```bash
|
||||
git commit --no-verify -m "Emergency commit"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Pre-commit configuration is in `.pre-commit-config.yaml`. To update hooks:
|
||||
|
||||
```bash
|
||||
# Update to latest versions
|
||||
pre-commit autoupdate
|
||||
|
||||
# Clean cache and re-install
|
||||
pre-commit clean
|
||||
pre-commit install --install-hooks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-12-20
|
||||
**Maintained by**: Charon Project
|
||||
**Source**: `pre-commit run --all-files`
|
||||
26
.github/skills/qa-precommit-all.SKILL.md
vendored
26
.github/skills/qa-precommit-all.SKILL.md
vendored
@@ -1,8 +1,8 @@
|
||||
---
|
||||
# agentskills.io specification v1.0
|
||||
name: "qa-precommit-all"
|
||||
name: "qa-lefthook-all"
|
||||
version: "1.0.0"
|
||||
description: "Run all pre-commit hooks for comprehensive code quality validation"
|
||||
description: "Run all lefthook pre-commit-phase hooks for comprehensive code quality validation"
|
||||
author: "Charon Project"
|
||||
license: "MIT"
|
||||
tags:
|
||||
@@ -21,15 +21,11 @@ requirements:
|
||||
- name: "python3"
|
||||
version: ">=3.8"
|
||||
optional: false
|
||||
- name: "pre-commit"
|
||||
version: ">=2.0"
|
||||
- name: "lefthook"
|
||||
version: ">=0.14"
|
||||
optional: false
|
||||
environment_variables:
|
||||
- name: "PRE_COMMIT_HOME"
|
||||
description: "Pre-commit cache directory"
|
||||
default: "~/.cache/pre-commit"
|
||||
required: false
|
||||
- name: "SKIP"
|
||||
- name: "SKIP"
|
||||
description: "Comma-separated list of hook IDs to skip"
|
||||
default: ""
|
||||
required: false
|
||||
@@ -60,7 +56,7 @@ metadata:
|
||||
|
||||
## Overview
|
||||
|
||||
Executes all configured pre-commit hooks to validate code quality, formatting, security, and best practices across the entire codebase. This skill runs checks for Python, Go, JavaScript/TypeScript, Markdown, YAML, and more.
|
||||
Executes all configured lefthook pre-commit-phase hooks to validate code quality, formatting, security, and best practices across the entire codebase. This skill runs checks for Python, Go, JavaScript/TypeScript, Markdown, YAML, and more.
|
||||
|
||||
This skill is designed for CI/CD pipelines and local quality validation before committing code.
|
||||
|
||||
@@ -76,19 +72,19 @@ This skill is designed for CI/CD pipelines and local quality validation before c
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Run all hooks on all files:
|
||||
Run all pre-commit-phase hooks on all files:
|
||||
|
||||
```bash
|
||||
cd /path/to/charon
|
||||
.github/skills/scripts/skill-runner.sh qa-precommit-all
|
||||
lefthook run pre-commit
|
||||
```
|
||||
|
||||
### Staged Files Only
|
||||
|
||||
Run hooks on staged files only (faster):
|
||||
Run lefthook on staged files only (faster):
|
||||
|
||||
```bash
|
||||
.github/skills/scripts/skill-runner.sh qa-precommit-all staged
|
||||
lefthook run pre-commit --staged
|
||||
```
|
||||
|
||||
### Specific Hook
|
||||
@@ -96,7 +92,7 @@ Run hooks on staged files only (faster):
|
||||
Run only a specific hook by ID:
|
||||
|
||||
```bash
|
||||
SKIP="" .github/skills/scripts/skill-runner.sh qa-precommit-all trailing-whitespace
|
||||
lefthook run pre-commit --hooks=trailing-whitespace
|
||||
```
|
||||
|
||||
### Skip Specific Hooks
|
||||
|
||||
2
.github/skills/security-scan-codeql.SKILL.md
vendored
2
.github/skills/security-scan-codeql.SKILL.md
vendored
@@ -251,7 +251,7 @@ Solution: Verify source-root points to correct directory
|
||||
|
||||
- [security-scan-trivy](./security-scan-trivy.SKILL.md) - Container/dependency vulnerabilities
|
||||
- [security-scan-go-vuln](./security-scan-go-vuln.SKILL.md) - Go-specific CVE checking
|
||||
- [qa-precommit-all](./qa-precommit-all.SKILL.md) - Pre-commit quality checks
|
||||
- [qa-lefthook-all](./qa-lefthook-all.SKILL.md) - Lefthook pre-commit-phase quality checks
|
||||
|
||||
## CI Alignment
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ fi
|
||||
# Check Grype
|
||||
if ! command -v grype >/dev/null 2>&1; then
|
||||
log_error "Grype not found - install from: https://github.com/anchore/grype"
|
||||
log_error "Installation: curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.107.0"
|
||||
log_error "Installation: curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.109.1"
|
||||
error_exit "Grype is required for vulnerability scanning" 2
|
||||
fi
|
||||
|
||||
@@ -50,8 +50,8 @@ SYFT_INSTALLED_VERSION=$(syft version | grep -oP 'Version:\s*\Kv?[0-9]+\.[0-9]+\
|
||||
GRYPE_INSTALLED_VERSION=$(grype version | grep -oP 'Version:\s*\Kv?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown")
|
||||
|
||||
# Set defaults matching CI workflow
|
||||
set_default_env "SYFT_VERSION" "v1.17.0"
|
||||
set_default_env "GRYPE_VERSION" "v0.107.0"
|
||||
set_default_env "SYFT_VERSION" "v1.42.2"
|
||||
set_default_env "GRYPE_VERSION" "v0.109.1"
|
||||
set_default_env "IMAGE_TAG" "charon:local"
|
||||
set_default_env "FAIL_ON_SEVERITY" "Critical,High"
|
||||
|
||||
|
||||
2
.github/skills/security-scan-gorm.SKILL.md
vendored
2
.github/skills/security-scan-gorm.SKILL.md
vendored
@@ -545,7 +545,7 @@ Solution: Add suppression comment: // gorm-scanner:ignore [reason]
|
||||
|
||||
- [security-scan-trivy](./security-scan-trivy.SKILL.md) - Container vulnerability scanning
|
||||
- [security-scan-codeql](./security-scan-codeql.SKILL.md) - Static analysis for Go/JS
|
||||
- [qa-precommit-all](./qa-precommit-all.SKILL.md) - Pre-commit quality checks
|
||||
- [qa-lefthook-all](./qa-lefthook-all.SKILL.md) - Lefthook pre-commit-phase quality checks
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
2
.github/skills/security-scan-trivy.SKILL.md
vendored
2
.github/skills/security-scan-trivy.SKILL.md
vendored
@@ -227,7 +227,7 @@ Solution: Review and remediate reported vulnerabilities
|
||||
## Related Skills
|
||||
|
||||
- [security-scan-go-vuln](./security-scan-go-vuln.SKILL.md) - Go-specific vulnerability checking
|
||||
- [qa-precommit-all](./qa-precommit-all.SKILL.md) - Pre-commit quality checks
|
||||
- [qa-lefthook-all](./qa-lefthook-all.SKILL.md) - Lefthook pre-commit-phase quality checks
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
2
.github/workflows/auto-changelog.yml
vendored
2
.github/workflows/auto-changelog.yml
vendored
@@ -21,6 +21,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
- name: Draft Release
|
||||
uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6
|
||||
uses: release-drafter/release-drafter@6a93d829887aa2e0748befe2e808c66c0ec6e4c7 # v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
3
.github/workflows/benchmark.yml
vendored
3
.github/workflows/benchmark.yml
vendored
@@ -12,7 +12,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.26.0'
|
||||
GO_VERSION: '1.26.1'
|
||||
GOTOOLCHAIN: auto
|
||||
|
||||
# Minimal permissions at workflow level; write permissions granted at job level for push only
|
||||
@@ -38,6 +38,7 @@ jobs:
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Run Benchmark
|
||||
|
||||
3
.github/workflows/codecov-upload.yml
vendored
3
.github/workflows/codecov-upload.yml
vendored
@@ -23,7 +23,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.26.0'
|
||||
GO_VERSION: '1.26.1'
|
||||
NODE_VERSION: '24.12.0'
|
||||
GOTOOLCHAIN: auto
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
# SECURITY: Keep pull_request (not pull_request_target) for secret-bearing backend tests.
|
||||
|
||||
9
.github/workflows/codeql.yml
vendored
9
.github/workflows/codeql.yml
vendored
@@ -15,6 +15,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
GOTOOLCHAIN: auto
|
||||
GO_VERSION: '1.26.1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -51,7 +52,7 @@ jobs:
|
||||
run: bash scripts/ci/check-codeql-parity.sh
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -64,7 +65,7 @@ jobs:
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: 1.26.0
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Verify Go toolchain and build
|
||||
@@ -91,10 +92,10 @@ jobs:
|
||||
run: mkdir -p sarif-results
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
output: sarif-results/${{ matrix.language }}
|
||||
|
||||
2
.github/workflows/container-prune.yml
vendored
2
.github/workflows/container-prune.yml
vendored
@@ -172,7 +172,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: prune-*-log-${{ github.run_id }}
|
||||
merge-multiple: true
|
||||
|
||||
39
.github/workflows/docker-build.yml
vendored
39
.github/workflows/docker-build.yml
vendored
@@ -118,13 +118,14 @@ jobs:
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
- name: Resolve Alpine base image digest
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
id: caddy
|
||||
id: alpine
|
||||
run: |
|
||||
docker pull alpine:3.23.3
|
||||
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' alpine:3.23.3)
|
||||
ALPINE_TAG=$(grep -m1 'ARG ALPINE_IMAGE=' Dockerfile | sed 's/ARG ALPINE_IMAGE=alpine://' | cut -d'@' -f1)
|
||||
docker pull "alpine:${ALPINE_TAG}"
|
||||
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "alpine:${ALPINE_TAG}")
|
||||
echo "image=$DIGEST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
@@ -199,7 +200,7 @@ jobs:
|
||||
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -271,7 +272,7 @@ jobs:
|
||||
--build-arg "VERSION=${{ steps.meta.outputs.version }}"
|
||||
--build-arg "BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}"
|
||||
--build-arg "VCS_REF=${{ env.TRIGGER_HEAD_SHA }}"
|
||||
--build-arg "CADDY_IMAGE=${{ steps.caddy.outputs.image }}"
|
||||
--build-arg "ALPINE_IMAGE=${{ steps.alpine.outputs.image }}"
|
||||
--iidfile /tmp/image-digest.txt
|
||||
.
|
||||
)
|
||||
@@ -531,23 +532,25 @@ jobs:
|
||||
|
||||
- name: Run Trivy scan (table output)
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
format: 'table'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
exit-code: '0'
|
||||
version: 'v0.69.3'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner (SARIF)
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
id: trivy
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
version: 'v0.69.3'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check Trivy SARIF exists
|
||||
@@ -562,7 +565,7 @@ jobs:
|
||||
|
||||
- name: Upload Trivy results
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
category: '.github/workflows/docker-build.yml:build-and-push'
|
||||
@@ -571,7 +574,7 @@ jobs:
|
||||
# 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@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1
|
||||
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 }}
|
||||
@@ -591,7 +594,7 @@ jobs:
|
||||
# Install Cosign for keyless signing
|
||||
- name: Install Cosign
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
|
||||
# Sign GHCR image with keyless signing (Sigstore/Fulcio)
|
||||
- name: Sign GHCR Image
|
||||
@@ -689,22 +692,24 @@ jobs:
|
||||
echo "✅ Image freshness validated"
|
||||
|
||||
- name: Run Trivy scan on PR image (table output)
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: ${{ steps.pr-image.outputs.image_ref }}
|
||||
format: 'table'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
exit-code: '0'
|
||||
version: 'v0.69.3'
|
||||
|
||||
- name: Run Trivy scan on PR image (SARIF - blocking)
|
||||
id: trivy-scan
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: ${{ steps.pr-image.outputs.image_ref }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-pr-results.sarif'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
exit-code: '1' # Intended to block, but continued on error for now
|
||||
version: 'v0.69.3'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check Trivy PR SARIF exists
|
||||
@@ -719,14 +724,14 @@ jobs:
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: 'docker-pr-image'
|
||||
|
||||
- name: Upload Trivy compatibility results (docker-build category)
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: '.github/workflows/docker-build.yml:build-and-push'
|
||||
@@ -734,7 +739,7 @@ jobs:
|
||||
|
||||
- name: Upload Trivy compatibility results (docker-publish alias)
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: '.github/workflows/docker-publish.yml:build-and-push'
|
||||
@@ -742,7 +747,7 @@ jobs:
|
||||
|
||||
- name: Upload Trivy compatibility results (nightly alias)
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: 'trivy-nightly'
|
||||
|
||||
19
.github/workflows/e2e-tests-split.yml
vendored
19
.github/workflows/e2e-tests-split.yml
vendored
@@ -83,7 +83,7 @@ on:
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
GO_VERSION: '1.26.0'
|
||||
GO_VERSION: '1.26.1'
|
||||
GOTOOLCHAIN: auto
|
||||
DOCKERHUB_REGISTRY: docker.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/charon
|
||||
@@ -145,6 +145,7 @@ jobs:
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
cache: true
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
@@ -169,12 +170,12 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.resolve-image.outputs.image_source == 'build'
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Build Docker image
|
||||
id: build-image
|
||||
if: steps.resolve-image.outputs.image_source == 'build'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -247,7 +248,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -449,7 +450,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -659,7 +660,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -913,7 +914,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -1150,7 +1151,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -1395,7 +1396,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
|
||||
157
.github/workflows/nightly-build.yml
vendored
157
.github/workflows/nightly-build.yml
vendored
@@ -15,7 +15,7 @@ on:
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.26.0'
|
||||
GO_VERSION: '1.26.1'
|
||||
NODE_VERSION: '24.12.0'
|
||||
GOTOOLCHAIN: auto
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
@@ -148,14 +148,13 @@ jobs:
|
||||
id-token: write
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
digest: ${{ steps.resolve_digest.outputs.digest }}
|
||||
|
||||
steps:
|
||||
- name: Checkout nightly branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'nightly' }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set lowercase image name
|
||||
@@ -165,7 +164,18 @@ jobs:
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Resolve Alpine base image digest
|
||||
id: alpine
|
||||
run: |
|
||||
ALPINE_IMAGE_REF=$(grep -m1 'ARG ALPINE_IMAGE=' Dockerfile | cut -d'=' -f2-)
|
||||
if [[ -z "$ALPINE_IMAGE_REF" ]]; then
|
||||
echo "::error::Failed to parse ALPINE_IMAGE from Dockerfile"
|
||||
exit 1
|
||||
fi
|
||||
echo "Resolved Alpine image: ${ALPINE_IMAGE_REF}"
|
||||
echo "image=${ALPINE_IMAGE_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
@@ -184,7 +194,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -199,7 +209,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -210,22 +220,52 @@ jobs:
|
||||
VERSION=nightly-${{ github.sha }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
BUILD_DATE=${{ github.event.repository.pushed_at }}
|
||||
ALPINE_IMAGE=${{ steps.alpine.outputs.image }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Resolve and export image digest
|
||||
id: resolve_digest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DIGEST="${{ steps.build.outputs.digest }}"
|
||||
|
||||
if [[ -z "$DIGEST" ]]; then
|
||||
echo "Build action digest empty; querying GHCR registry API..."
|
||||
GHCR_TOKEN=$(curl -sf \
|
||||
-u "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://ghcr.io/token?scope=repository:${{ env.IMAGE_NAME }}:pull&service=ghcr.io" \
|
||||
| jq -r '.token')
|
||||
DIGEST=$(curl -sfI \
|
||||
-H "Authorization: Bearer ${GHCR_TOKEN}" \
|
||||
-H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json" \
|
||||
"https://ghcr.io/v2/${{ env.IMAGE_NAME }}/manifests/nightly" \
|
||||
| grep -i '^docker-content-digest:' | awk '{print $2}' | tr -d '\r' || true)
|
||||
[[ -n "$DIGEST" ]] && echo "Resolved from GHCR API: ${DIGEST}"
|
||||
fi
|
||||
|
||||
if [[ -z "$DIGEST" ]]; then
|
||||
echo "::error::Could not determine image digest from step output or GHCR registry API"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESOLVED_DIGEST=${DIGEST}" >> "$GITHUB_ENV"
|
||||
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
||||
echo "Exported digest: ${DIGEST}"
|
||||
|
||||
- name: Record nightly image digest
|
||||
run: |
|
||||
echo "## 🧾 Nightly Image Digest" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.resolve_digest.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate SBOM
|
||||
id: sbom_primary
|
||||
continue-on-error: true
|
||||
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1
|
||||
with:
|
||||
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}
|
||||
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.resolve_digest.outputs.digest }}
|
||||
format: cyclonedx-json
|
||||
output-file: sbom-nightly.json
|
||||
syft-version: v1.42.1
|
||||
@@ -242,7 +282,7 @@ jobs:
|
||||
|
||||
echo "Primary SBOM generation failed or produced missing/invalid output; using deterministic Syft fallback"
|
||||
|
||||
SYFT_VERSION="v1.42.1"
|
||||
SYFT_VERSION="v1.42.2"
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
@@ -263,7 +303,12 @@ jobs:
|
||||
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
|
||||
DIGEST="${{ steps.resolve_digest.outputs.digest }}"
|
||||
if [[ -z "$DIGEST" ]]; then
|
||||
echo "::error::Digest from resolve_digest step is empty; the digest-resolution step did not complete successfully"
|
||||
exit 1
|
||||
fi
|
||||
./syft "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}" -o cyclonedx-json=sbom-nightly.json
|
||||
|
||||
- name: Verify SBOM artifact
|
||||
if: always()
|
||||
@@ -288,13 +333,13 @@ jobs:
|
||||
|
||||
# Install Cosign for keyless signing
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
|
||||
# Sign GHCR image with keyless signing (Sigstore/Fulcio)
|
||||
- name: Sign GHCR Image
|
||||
run: |
|
||||
echo "Signing GHCR nightly image with keyless signing..."
|
||||
cosign sign --yes "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}"
|
||||
cosign sign --yes "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.resolve_digest.outputs.digest }}"
|
||||
echo "✅ GHCR nightly image signed successfully"
|
||||
|
||||
# Sign Docker Hub image with keyless signing (Sigstore/Fulcio)
|
||||
@@ -302,7 +347,7 @@ jobs:
|
||||
if: env.HAS_DOCKERHUB_TOKEN == 'true'
|
||||
run: |
|
||||
echo "Signing Docker Hub nightly image with keyless signing..."
|
||||
cosign sign --yes "${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}"
|
||||
cosign sign --yes "${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.resolve_digest.outputs.digest }}"
|
||||
echo "✅ Docker Hub nightly image signed successfully"
|
||||
|
||||
# Attach SBOM to Docker Hub image
|
||||
@@ -310,7 +355,7 @@ jobs:
|
||||
if: env.HAS_DOCKERHUB_TOKEN == 'true'
|
||||
run: |
|
||||
echo "Attaching SBOM to Docker Hub nightly image..."
|
||||
cosign attach sbom --sbom sbom-nightly.json "${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}"
|
||||
cosign attach sbom --sbom sbom-nightly.json "${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.resolve_digest.outputs.digest }}"
|
||||
echo "✅ SBOM attached to Docker Hub nightly image"
|
||||
|
||||
test-nightly-image:
|
||||
@@ -324,7 +369,7 @@ jobs:
|
||||
- name: Checkout nightly branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'nightly' }}
|
||||
|
||||
- name: Set lowercase image name
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
@@ -341,9 +386,10 @@ jobs:
|
||||
|
||||
- name: Run container smoke test
|
||||
run: |
|
||||
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ needs.build-and-push-nightly.outputs.digest }}"
|
||||
docker run --name charon-nightly -d \
|
||||
-p 8080:8080 \
|
||||
"${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ needs.build-and-push-nightly.outputs.digest }}"
|
||||
"${IMAGE_REF}"
|
||||
|
||||
# Wait for container to start
|
||||
sleep 10
|
||||
@@ -378,13 +424,13 @@ jobs:
|
||||
- name: Checkout nightly branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: nightly
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'nightly' }}
|
||||
|
||||
- name: Set lowercase image name
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download SBOM
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: sbom-nightly
|
||||
|
||||
@@ -396,14 +442,16 @@ jobs:
|
||||
severity-cutoff: high
|
||||
|
||||
- name: Scan with Trivy
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-nightly.outputs.digest }}
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ needs.build-and-push-nightly.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-nightly.sarif'
|
||||
version: 'v0.69.3'
|
||||
trivyignores: '.trivyignore'
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
with:
|
||||
sarif_file: 'trivy-nightly.sarif'
|
||||
category: 'trivy-nightly'
|
||||
@@ -506,18 +554,81 @@ jobs:
|
||||
echo "- Structured SARIF counts: CRITICAL=${CRITICAL_COUNT}, HIGH=${HIGH_COUNT}, MEDIUM=${MEDIUM_COUNT}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# List all Critical/High/Medium findings with details for triage
|
||||
# shellcheck disable=SC2016
|
||||
LIST_FINDINGS='
|
||||
.runs[] as $run
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| $run.results[]?
|
||||
| . as $result
|
||||
| (
|
||||
(
|
||||
if (($result.ruleIndex | type) == "number") then
|
||||
($rules[$result.ruleIndex] // {})
|
||||
else
|
||||
{}
|
||||
end
|
||||
) as $ruleByIndex
|
||||
| (
|
||||
[$rules[]? | select((.id // "") == ($result.ruleId // ""))][0] // {}
|
||||
) as $ruleById
|
||||
| ($ruleByIndex // $ruleById) as $rule
|
||||
| ($rule.properties["security-severity"] // null) as $sev
|
||||
| (try ($sev | tonumber) catch null) as $score
|
||||
| select($score != null and $score >= 4.0)
|
||||
| {
|
||||
id: ($result.ruleId // "unknown"),
|
||||
score: $score,
|
||||
severity: (
|
||||
if $score >= 9.0 then "CRITICAL"
|
||||
elif $score >= 7.0 then "HIGH"
|
||||
else "MEDIUM"
|
||||
end
|
||||
),
|
||||
message: ($result.message.text // $rule.shortDescription.text // "no description")[0:120]
|
||||
}
|
||||
)
|
||||
'
|
||||
|
||||
echo ""
|
||||
echo "=== Vulnerability Details ==="
|
||||
jq -r "[ ${LIST_FINDINGS} ] | sort_by(-.score) | .[] | \"\\(.severity) (\\(.score)): \\(.id) — \\(.message)\"" trivy-nightly.sarif || true
|
||||
echo "============================="
|
||||
echo ""
|
||||
|
||||
if [ "$CRITICAL_COUNT" -gt 0 ]; then
|
||||
echo "❌ Critical vulnerabilities found in nightly build (${CRITICAL_COUNT})"
|
||||
{
|
||||
echo ""
|
||||
echo "### ❌ Critical CVEs blocking nightly"
|
||||
echo '```'
|
||||
jq -r "[ ${LIST_FINDINGS} | select(.severity == \"CRITICAL\") ] | sort_by(-.score) | .[] | \"\\(.id) (score: \\(.score)): \\(.message)\"" trivy-nightly.sarif || true
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$HIGH_COUNT" -gt 0 ]; then
|
||||
echo "❌ High vulnerabilities found in nightly build (${HIGH_COUNT})"
|
||||
{
|
||||
echo ""
|
||||
echo "### ❌ High CVEs blocking nightly"
|
||||
echo '```'
|
||||
jq -r "[ ${LIST_FINDINGS} | select(.severity == \"HIGH\") ] | sort_by(-.score) | .[] | \"\\(.id) (score: \\(.score)): \\(.message)\"" trivy-nightly.sarif || true
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
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"
|
||||
{
|
||||
echo ""
|
||||
echo "### ⚠️ Medium CVEs (non-blocking)"
|
||||
echo '```'
|
||||
jq -r "[ ${LIST_FINDINGS} | select(.severity == \"MEDIUM\") ] | sort_by(-.score) | .[] | \"\\(.id) (score: \\(.score)): \\(.message)\"" trivy-nightly.sarif || true
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
echo "✅ No Critical/High vulnerabilities found"
|
||||
|
||||
4
.github/workflows/quality-checks.yml
vendored
4
.github/workflows/quality-checks.yml
vendored
@@ -16,7 +16,7 @@ permissions:
|
||||
checks: write
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.26.0'
|
||||
GO_VERSION: '1.26.1'
|
||||
NODE_VERSION: '24.12.0'
|
||||
GOTOOLCHAIN: auto
|
||||
|
||||
@@ -34,6 +34,7 @@ jobs:
|
||||
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
|
||||
@@ -140,6 +141,7 @@ jobs:
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Repo health check
|
||||
|
||||
3
.github/workflows/release-goreleaser.yml
vendored
3
.github/workflows/release-goreleaser.yml
vendored
@@ -10,7 +10,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.26.0'
|
||||
GO_VERSION: '1.26.1'
|
||||
NODE_VERSION: '24.12.0'
|
||||
GOTOOLCHAIN: auto
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set up Node.js
|
||||
|
||||
2
.github/workflows/renovate.yml
vendored
2
.github/workflows/renovate.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Renovate
|
||||
uses: renovatebot/github-action@7b4b65bf31e07d4e3e51708d07700fb41bc03166 # v46.1.3
|
||||
uses: renovatebot/github-action@0b17c4eb901eca44d018fb25744a50a74b2042df # v46.1.4
|
||||
with:
|
||||
configurationFile: .github/renovate.json
|
||||
token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/security-pr.yml
vendored
4
.github/workflows/security-pr.yml
vendored
@@ -240,7 +240,7 @@ jobs:
|
||||
- name: Download PR image artifact
|
||||
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
|
||||
# actions/download-artifact v4.1.8
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
name: ${{ steps.check-artifact.outputs.artifact_name }}
|
||||
run-id: ${{ steps.check-artifact.outputs.run_id }}
|
||||
@@ -385,7 +385,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to GitHub Security
|
||||
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
|
||||
# github/codeql-action v4
|
||||
uses: github/codeql-action/upload-sarif@a5b959e10d29aec4f277040b4d27d0f6bea2322a
|
||||
uses: github/codeql-action/upload-sarif@1a97b0f94ec9297d6f58aefe5a6b5441c045bed4
|
||||
with:
|
||||
sarif_file: 'trivy-binary-results.sarif'
|
||||
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) }}
|
||||
|
||||
17
.github/workflows/security-weekly-rebuild.yml
vendored
17
.github/workflows/security-weekly-rebuild.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Resolve Debian base image digest
|
||||
id: base-image
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
- name: Build Docker image (NO CACHE)
|
||||
id: build
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -93,35 +93,38 @@ jobs:
|
||||
BASE_IMAGE=${{ steps.base-image.outputs.digest }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner (CRITICAL+HIGH)
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'table'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
exit-code: '1' # Fail workflow if vulnerabilities found
|
||||
version: 'v0.69.3'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner (SARIF)
|
||||
id: trivy-sarif
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-weekly-results.sarif'
|
||||
severity: 'CRITICAL,HIGH,MEDIUM'
|
||||
version: 'v0.69.3'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
with:
|
||||
sarif_file: 'trivy-weekly-results.sarif'
|
||||
|
||||
- name: Run Trivy vulnerability scanner (JSON for artifact)
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'json'
|
||||
output: 'trivy-weekly-results.json'
|
||||
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
|
||||
version: 'v0.69.3'
|
||||
|
||||
- name: Upload Trivy JSON results
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
|
||||
6
.github/workflows/supply-chain-pr.yml
vendored
6
.github/workflows/supply-chain-pr.yml
vendored
@@ -266,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@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1
|
||||
id: sbom
|
||||
with:
|
||||
image: ${{ steps.set-target.outputs.image_name }}
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
- name: Install Grype
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.107.1
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.109.1
|
||||
|
||||
- name: Scan for vulnerabilities
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
@@ -362,7 +362,7 @@ jobs:
|
||||
|
||||
- name: Upload SARIF to GitHub Security
|
||||
if: steps.check-artifact.outputs.artifact_found == 'true'
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
sarif_file: grype-results.sarif
|
||||
|
||||
2
.github/workflows/supply-chain-verify.yml
vendored
2
.github/workflows/supply-chain-verify.yml
vendored
@@ -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@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1
|
||||
with:
|
||||
image: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
||||
format: cyclonedx-json
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -78,6 +78,11 @@ backend/node_modules/
|
||||
backend/package.json
|
||||
backend/package-lock.json
|
||||
|
||||
# Root-level artifact files (non-documentation)
|
||||
FIREFOX_E2E_FIXES_SUMMARY.md
|
||||
verify-security-state-for-ui-tests
|
||||
categories.txt
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Databases
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -297,6 +302,7 @@ docs/plans/current_spec_notes.md
|
||||
tests/etc/passwd
|
||||
trivy-image-report.json
|
||||
trivy-fs-report.json
|
||||
trivy-report.json
|
||||
backend/# Tools Configuration.md
|
||||
docs/plans/requirements.md
|
||||
docs/plans/design.md
|
||||
|
||||
@@ -50,7 +50,7 @@ ignore:
|
||||
as of 2026-01-16. Risk accepted: Charon does not directly use untgz or
|
||||
process untrusted tar archives. Attack surface limited to base OS utilities.
|
||||
Monitoring Alpine security feed for upstream patch.
|
||||
expiry: "2026-01-23" # Re-evaluate in 7 days
|
||||
expiry: "2026-03-14" # Re-evaluate in 7 days
|
||||
|
||||
# Action items when this suppression expires:
|
||||
# 1. Check Alpine security feed: https://security.alpinelinux.org/
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
# NOTE: golangci-lint-fast now includes test files (_test.go) to catch security
|
||||
# issues earlier. The fast config uses gosec with critical-only checks (G101,
|
||||
# G110, G305, G401, G501, G502, G503) for acceptable performance.
|
||||
# Last updated: 2026-02-02
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
exclude: '^(frontend/(coverage|dist|node_modules|\.vite)/|.*\.tsbuildinfo$)'
|
||||
- id: trailing-whitespace
|
||||
exclude: '^(frontend/(coverage|dist|node_modules|\.vite)/|.*\.tsbuildinfo$)'
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=2500']
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.10.0.1
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
name: shellcheck
|
||||
exclude: '^(frontend/(coverage|dist|node_modules|\.vite)/|test-results|codeql-agent-results)/'
|
||||
args: ['--severity=error']
|
||||
- repo: https://github.com/rhysd/actionlint
|
||||
rev: v1.7.10
|
||||
hooks:
|
||||
- id: actionlint
|
||||
name: actionlint (GitHub Actions)
|
||||
files: '^\.github/workflows/.*\.ya?ml$'
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: dockerfile-check
|
||||
name: dockerfile validation
|
||||
entry: tools/dockerfile_check.sh
|
||||
language: script
|
||||
files: "Dockerfile.*"
|
||||
pass_filenames: true
|
||||
- id: go-test-coverage
|
||||
name: Go Test Coverage (Manual)
|
||||
entry: scripts/go-test-coverage.sh
|
||||
language: script
|
||||
files: '\.go$'
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
stages: [manual] # Only runs when explicitly called
|
||||
- id: go-vet
|
||||
name: Go Vet
|
||||
entry: bash -c 'cd backend && go vet ./...'
|
||||
language: system
|
||||
files: '\.go$'
|
||||
pass_filenames: false
|
||||
- id: golangci-lint-fast
|
||||
name: golangci-lint (Fast Linters - BLOCKING)
|
||||
entry: scripts/pre-commit-hooks/golangci-lint-fast.sh
|
||||
language: script
|
||||
files: '\.go$'
|
||||
# Test files are now included to catch security issues (gosec critical checks)
|
||||
pass_filenames: false
|
||||
description: "Runs fast, essential linters (staticcheck, govet, errcheck, ineffassign, unused, gosec critical) - BLOCKS commits on failure"
|
||||
- id: check-version-match
|
||||
name: Check .version matches latest Git tag
|
||||
entry: bash -c 'scripts/check-version-match-tag.sh'
|
||||
language: system
|
||||
files: '\.version$'
|
||||
pass_filenames: false
|
||||
- id: check-lfs-large-files
|
||||
name: Prevent large files that are not tracked by LFS
|
||||
entry: bash scripts/pre-commit-hooks/check-lfs-for-large-files.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
always_run: true
|
||||
- id: block-codeql-db-commits
|
||||
name: Prevent committing CodeQL DB artifacts
|
||||
entry: bash scripts/pre-commit-hooks/block-codeql-db-commits.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
always_run: true
|
||||
- id: block-data-backups-commit
|
||||
name: Prevent committing data/backups files
|
||||
entry: bash scripts/pre-commit-hooks/block-data-backups-commit.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
always_run: true
|
||||
|
||||
# === MANUAL/CI-ONLY HOOKS ===
|
||||
# These are slow and should only run on-demand or in CI
|
||||
# Run manually with: pre-commit run golangci-lint-full --all-files
|
||||
- id: go-test-race
|
||||
name: Go Test Race (Manual)
|
||||
entry: bash -c 'cd backend && go test -race ./...'
|
||||
language: system
|
||||
files: '\.go$'
|
||||
pass_filenames: false
|
||||
stages: [manual] # Only runs when explicitly called
|
||||
|
||||
- id: golangci-lint-full
|
||||
name: golangci-lint (Full - Manual)
|
||||
entry: scripts/pre-commit-hooks/golangci-lint-full.sh
|
||||
language: script
|
||||
files: '\.go$'
|
||||
pass_filenames: false
|
||||
stages: [manual] # Only runs when explicitly called
|
||||
|
||||
- id: hadolint
|
||||
name: Hadolint Dockerfile Check (Manual)
|
||||
entry: bash -c 'docker run --rm -i hadolint/hadolint < Dockerfile'
|
||||
language: system
|
||||
files: 'Dockerfile'
|
||||
pass_filenames: false
|
||||
stages: [manual] # Only runs when explicitly called
|
||||
- id: frontend-type-check
|
||||
name: Frontend TypeScript Check
|
||||
entry: bash -c 'cd frontend && npx tsc --noEmit'
|
||||
language: system
|
||||
files: '^frontend/.*\.(ts|tsx)$'
|
||||
pass_filenames: false
|
||||
- id: frontend-lint
|
||||
name: Frontend Lint (Fix)
|
||||
entry: bash -c 'cd frontend && npm run lint -- --fix'
|
||||
language: system
|
||||
files: '^frontend/.*\.(ts|tsx|js|jsx)$'
|
||||
pass_filenames: false
|
||||
|
||||
- id: frontend-test-coverage
|
||||
name: Frontend Test Coverage (Manual)
|
||||
entry: scripts/frontend-test-coverage.sh
|
||||
language: script
|
||||
files: '^frontend/.*\\.(ts|tsx|js|jsx)$'
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
stages: [manual]
|
||||
|
||||
- id: security-scan
|
||||
name: Security Vulnerability Scan (Manual)
|
||||
entry: scripts/security-scan.sh
|
||||
language: script
|
||||
files: '(\.go$|go\.mod$|go\.sum$)'
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
stages: [manual] # Only runs when explicitly called
|
||||
|
||||
- id: codeql-go-scan
|
||||
name: CodeQL Go Security Scan (Manual - Slow)
|
||||
entry: scripts/pre-commit-hooks/codeql-go-scan.sh
|
||||
language: script
|
||||
files: '\.go$'
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
stages: [manual] # Performance: 30-60s, only run on-demand
|
||||
|
||||
- id: codeql-js-scan
|
||||
name: CodeQL JavaScript/TypeScript Security Scan (Manual - Slow)
|
||||
entry: scripts/pre-commit-hooks/codeql-js-scan.sh
|
||||
language: script
|
||||
files: '^frontend/.*\.(ts|tsx|js|jsx)$'
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
stages: [manual] # Performance: 30-60s, only run on-demand
|
||||
|
||||
- id: codeql-check-findings
|
||||
name: Block HIGH/CRITICAL CodeQL Findings
|
||||
entry: scripts/pre-commit-hooks/codeql-check-findings.sh
|
||||
language: script
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
stages: [manual] # Only runs after CodeQL scans
|
||||
|
||||
- id: codeql-parity-check
|
||||
name: CodeQL Suite/Trigger Parity Guard (Manual)
|
||||
entry: scripts/ci/check-codeql-parity.sh
|
||||
language: script
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
stages: [manual]
|
||||
|
||||
- id: gorm-security-scan
|
||||
name: GORM Security Scanner (Manual)
|
||||
entry: scripts/pre-commit-hooks/gorm-security-check.sh
|
||||
language: script
|
||||
files: '\.go$'
|
||||
pass_filenames: false
|
||||
stages: [manual] # Manual stage initially (soft launch)
|
||||
verbose: true
|
||||
description: "Detects GORM ID leaks and common GORM security mistakes"
|
||||
|
||||
- id: semgrep-scan
|
||||
name: Semgrep Security Scan (Manual)
|
||||
entry: scripts/pre-commit-hooks/semgrep-scan.sh
|
||||
language: script
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
stages: [manual] # Manual stage initially (reversible rollout)
|
||||
|
||||
- id: gitleaks-tuned-scan
|
||||
name: Gitleaks Security Scan (Tuned, Manual)
|
||||
entry: scripts/pre-commit-hooks/gitleaks-tuned-scan.sh
|
||||
language: script
|
||||
pass_filenames: false
|
||||
verbose: true
|
||||
stages: [manual] # Manual stage initially (reversible rollout)
|
||||
|
||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||
rev: v0.47.0
|
||||
hooks:
|
||||
- id: markdownlint
|
||||
args: ["--fix"]
|
||||
exclude: '^(node_modules|\.venv|test-results|codeql-db|codeql-agent-results)/'
|
||||
stages: [manual]
|
||||
@@ -7,3 +7,10 @@ playwright/.auth/
|
||||
# Charon does not use Nebula VPN PKI by default. Review by: 2026-03-05
|
||||
# See also: .grype.yaml for full justification
|
||||
CVE-2026-25793
|
||||
|
||||
# CVE-2026-22184: zlib Global Buffer Overflow in untgz utility
|
||||
# Severity: CRITICAL (CVSS 9.8) — Package: zlib 1.3.1-r2 in Alpine base image
|
||||
# No upstream fix available: Alpine 3.23 (including edge) still ships zlib 1.3.1-r2.
|
||||
# Charon does not use untgz or process untrusted tar archives. Review by: 2026-03-14
|
||||
# See also: .grype.yaml for full justification
|
||||
CVE-2026-22184
|
||||
|
||||
12
.vscode/tasks.json
vendored
12
.vscode/tasks.json
vendored
@@ -371,9 +371,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Lint: Pre-commit (All Files)",
|
||||
"label": "Lint: Lefthook Pre-commit (All Files)",
|
||||
"type": "shell",
|
||||
"command": ".github/skills/scripts/skill-runner.sh qa-precommit-all",
|
||||
"command": "lefthook run pre-commit",
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
@@ -466,9 +466,9 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Security: Semgrep Scan (Manual Hook)",
|
||||
"label": "Security: Semgrep Scan (Lefthook Pre-push)",
|
||||
"type": "shell",
|
||||
"command": "pre-commit run --hook-stage manual semgrep-scan --all-files",
|
||||
"command": "lefthook run pre-push",
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
@@ -480,9 +480,9 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Security: Gitleaks Scan (Tuned Manual Hook)",
|
||||
"label": "Security: Gitleaks Scan (Lefthook Pre-push)",
|
||||
"type": "shell",
|
||||
"command": "pre-commit run --hook-stage manual gitleaks-tuned-scan --all-files",
|
||||
"command": "lefthook run pre-push",
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
|
||||
@@ -33,7 +33,19 @@ This project follows a Code of Conduct that all contributors are expected to adh
|
||||
|
||||
### Development Tools
|
||||
|
||||
Install golangci-lint for pre-commit hooks (required for Go development):
|
||||
Install golangci-lint for lefthook pre-commit-phase hooks (required for Go development):
|
||||
|
||||
Also install lefthook itself so the git hooks work:
|
||||
|
||||
```bash
|
||||
# Option 1: Homebrew (macOS/Linux)
|
||||
brew install lefthook
|
||||
|
||||
# Option 2: Go install
|
||||
go install github.com/evilmartians/lefthook@latest
|
||||
```
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
# Option 1: Homebrew (macOS/Linux)
|
||||
@@ -59,7 +71,7 @@ golangci-lint --version
|
||||
# Should output: golangci-lint has version 1.xx.x ...
|
||||
```
|
||||
|
||||
**Note:** Pre-commit hooks will **BLOCK commits** if golangci-lint finds issues. This is intentional - fix the issues before committing.
|
||||
**Note:** Lefthook pre-commit-phase hooks will **BLOCK commits** if golangci-lint finds issues. This is intentional - fix the issues before committing.
|
||||
|
||||
### CI/CD Go Version Management
|
||||
|
||||
@@ -84,7 +96,7 @@ When the project's Go version is updated (usually by Renovate):
|
||||
|
||||
3. **Rebuild your development tools**
|
||||
```bash
|
||||
# This fixes pre-commit hook errors and IDE issues
|
||||
# This fixes lefthook hook errors and IDE issues
|
||||
./scripts/rebuild-go-tools.sh
|
||||
```
|
||||
|
||||
@@ -104,7 +116,7 @@ Rebuilding tools with `./scripts/rebuild-go-tools.sh` fixes this by compiling th
|
||||
|
||||
**What if I forget?**
|
||||
|
||||
Don't worry! The pre-commit hook will detect the version mismatch and automatically rebuild tools for you. You'll see:
|
||||
Don't worry! The lefthook pre-commit hook will detect the version mismatch and automatically rebuild tools for you. You'll see:
|
||||
|
||||
```
|
||||
⚠️ golangci-lint Go version mismatch:
|
||||
|
||||
80
Dockerfile
80
Dockerfile
@@ -8,6 +8,25 @@ ARG VCS_REF
|
||||
# Set BUILD_DEBUG=1 to build with debug symbols (required for Delve debugging)
|
||||
ARG BUILD_DEBUG=0
|
||||
|
||||
# ---- Pinned Toolchain Versions ----
|
||||
# renovate: datasource=docker depName=golang versioning=docker
|
||||
ARG GO_VERSION=1.26.1
|
||||
|
||||
# renovate: datasource=docker depName=alpine versioning=docker
|
||||
ARG ALPINE_IMAGE=alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||
|
||||
# ---- Shared CrowdSec Version ----
|
||||
# renovate: datasource=github-releases depName=crowdsecurity/crowdsec
|
||||
ARG CROWDSEC_VERSION=1.7.6
|
||||
# CrowdSec fallback tarball checksum (v${CROWDSEC_VERSION})
|
||||
ARG CROWDSEC_RELEASE_SHA256=704e37121e7ac215991441cef0d8732e33fa3b1a2b2b88b53a0bfe5e38f863bd
|
||||
|
||||
# ---- Shared Go Security Patches ----
|
||||
# renovate: datasource=go depName=github.com/expr-lang/expr
|
||||
ARG EXPR_LANG_VERSION=1.17.7
|
||||
# renovate: datasource=go depName=golang.org/x/net
|
||||
ARG XNET_VERSION=0.51.0
|
||||
|
||||
# Allow pinning Caddy version - Renovate will update this
|
||||
# Build the most recent Caddy 2.x release (keeps major pinned under v3).
|
||||
# Setting this to '2' tells xcaddy to resolve the latest v2.x tag so we
|
||||
@@ -20,14 +39,14 @@ ARG CADDY_CANDIDATE_VERSION=2.11.2
|
||||
ARG CADDY_USE_CANDIDATE=0
|
||||
ARG CADDY_PATCH_SCENARIO=B
|
||||
# renovate: datasource=go depName=github.com/greenpau/caddy-security
|
||||
ARG CADDY_SECURITY_VERSION=1.1.36
|
||||
ARG CADDY_SECURITY_VERSION=1.1.45
|
||||
# renovate: datasource=go depName=github.com/corazawaf/coraza-caddy
|
||||
ARG CORAZA_CADDY_VERSION=2.2.0
|
||||
## 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
|
||||
## upstream caddy image tags while still shipping a pinned caddy binary.
|
||||
## Alpine 3.23 base to reduce glibc CVE exposure and image size.
|
||||
# renovate: datasource=docker depName=alpine versioning=docker
|
||||
ARG CADDY_IMAGE=alpine:3.23.3
|
||||
|
||||
# ---- Cross-Compilation Helpers ----
|
||||
# renovate: datasource=docker depName=tonistiigi/xx
|
||||
@@ -38,8 +57,7 @@ FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0@sha256:c64defb9ed5a91eacb37f9
|
||||
# This fixes 22 HIGH/CRITICAL CVEs in stdlib embedded in Debian's gosu package
|
||||
# CVEs fixed: CVE-2023-24531, CVE-2023-24540, CVE-2023-29402, CVE-2023-29404,
|
||||
# CVE-2023-29405, CVE-2024-24790, CVE-2025-22871, and 15 more
|
||||
# renovate: datasource=docker depName=golang
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS gosu-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS gosu-builder
|
||||
COPY --from=xx / /
|
||||
|
||||
WORKDIR /tmp/gosu
|
||||
@@ -70,7 +88,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# ---- Frontend Builder ----
|
||||
# Build the frontend using the BUILDPLATFORM to avoid arm64 musl Rollup native issues
|
||||
# renovate: datasource=docker depName=node
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine@sha256:7fddd9ddeae8196abf4a3ef2de34e11f7b1a722119f91f28ddf1e99dcafdf114 AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy frontend package files
|
||||
@@ -93,8 +111,7 @@ RUN --mount=type=cache,target=/app/frontend/node_modules/.cache \
|
||||
npm run build
|
||||
|
||||
# ---- Backend Builder ----
|
||||
# renovate: datasource=docker depName=golang
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS backend-builder
|
||||
# Copy xx helpers for cross-compilation
|
||||
COPY --from=xx / /
|
||||
|
||||
@@ -196,8 +213,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# ---- Caddy Builder ----
|
||||
# Build Caddy from source to ensure we use the latest Go version and dependencies
|
||||
# This fixes vulnerabilities found in the pre-built Caddy images (e.g. CVE-2025-59530, stdlib issues)
|
||||
# renovate: datasource=docker depName=golang
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS caddy-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS caddy-builder
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG CADDY_VERSION
|
||||
@@ -205,11 +221,14 @@ ARG CADDY_CANDIDATE_VERSION
|
||||
ARG CADDY_USE_CANDIDATE
|
||||
ARG CADDY_PATCH_SCENARIO
|
||||
ARG CADDY_SECURITY_VERSION
|
||||
ARG CORAZA_CADDY_VERSION
|
||||
# renovate: datasource=go depName=github.com/caddyserver/xcaddy
|
||||
ARG XCADDY_VERSION=0.4.5
|
||||
ARG EXPR_LANG_VERSION
|
||||
ARG XNET_VERSION
|
||||
|
||||
# hadolint ignore=DL3018
|
||||
RUN apk add --no-cache git
|
||||
RUN apk add --no-cache bash git
|
||||
# hadolint ignore=DL3062
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go install github.com/caddyserver/xcaddy/cmd/xcaddy@v${XCADDY_VERSION}
|
||||
@@ -221,7 +240,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
# hadolint ignore=SC2016
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
sh -c 'set -e; \
|
||||
bash -c 'set -e; \
|
||||
CADDY_TARGET_VERSION="${CADDY_VERSION}"; \
|
||||
if [ "${CADDY_USE_CANDIDATE}" = "1" ]; then \
|
||||
CADDY_TARGET_VERSION="${CADDY_CANDIDATE_VERSION}"; \
|
||||
@@ -234,7 +253,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_TARGET_VERSION} \
|
||||
--with github.com/caddyserver/caddy/v2@v${CADDY_TARGET_VERSION} \
|
||||
--with github.com/greenpau/caddy-security@v${CADDY_SECURITY_VERSION} \
|
||||
--with github.com/corazawaf/coraza-caddy/v2 \
|
||||
--with github.com/corazawaf/coraza-caddy/v2@v${CORAZA_CADDY_VERSION} \
|
||||
--with github.com/hslatman/caddy-crowdsec-bouncer@v0.10.0 \
|
||||
--with github.com/zhangjiayin/caddy-geoip2 \
|
||||
--with github.com/mholt/caddy-ratelimit \
|
||||
@@ -251,10 +270,10 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# Patch ALL dependencies BEFORE building the final binary
|
||||
# These patches fix CVEs in transitive dependencies
|
||||
# Renovate tracks these via regex manager in renovate.json
|
||||
# renovate: datasource=go depName=github.com/expr-lang/expr
|
||||
go get github.com/expr-lang/expr@v1.17.7; \
|
||||
go get github.com/expr-lang/expr@v${EXPR_LANG_VERSION}; \
|
||||
# renovate: datasource=go depName=github.com/hslatman/ipstore
|
||||
go get github.com/hslatman/ipstore@v0.4.0; \
|
||||
go get golang.org/x/net@v${XNET_VERSION}; \
|
||||
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
|
||||
@@ -288,10 +307,9 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
rm -rf /tmp/buildenv_* /tmp/caddy-initial'
|
||||
|
||||
# ---- CrowdSec Builder ----
|
||||
# Build CrowdSec from source to ensure we use Go 1.26.0+ and avoid stdlib vulnerabilities
|
||||
# Build CrowdSec from source to ensure we use Go 1.26.1+ and avoid stdlib vulnerabilities
|
||||
# (CVE-2025-58183, CVE-2025-58186, CVE-2025-58187, CVE-2025-61729)
|
||||
# renovate: datasource=docker depName=golang versioning=docker
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.0-alpine AS crowdsec-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS crowdsec-builder
|
||||
COPY --from=xx / /
|
||||
|
||||
WORKDIR /tmp/crowdsec
|
||||
@@ -299,11 +317,10 @@ WORKDIR /tmp/crowdsec
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
# CrowdSec version - Renovate can update this
|
||||
# renovate: datasource=github-releases depName=crowdsecurity/crowdsec
|
||||
ARG CROWDSEC_VERSION=1.7.6
|
||||
# CrowdSec fallback tarball checksum (v${CROWDSEC_VERSION})
|
||||
ARG CROWDSEC_RELEASE_SHA256=704e37121e7ac215991441cef0d8732e33fa3b1a2b2b88b53a0bfe5e38f863bd
|
||||
ARG CROWDSEC_VERSION
|
||||
ARG CROWDSEC_RELEASE_SHA256
|
||||
ARG EXPR_LANG_VERSION
|
||||
ARG XNET_VERSION
|
||||
|
||||
# hadolint ignore=DL3018
|
||||
RUN apk add --no-cache git clang lld
|
||||
@@ -317,10 +334,10 @@ RUN git clone --depth 1 --branch "v${CROWDSEC_VERSION}" https://github.com/crowd
|
||||
|
||||
# Patch dependencies to fix CVEs in transitive dependencies
|
||||
# This follows the same pattern as Caddy's dependency patches
|
||||
# renovate: datasource=go depName=github.com/expr-lang/expr
|
||||
# renovate: datasource=go depName=golang.org/x/crypto
|
||||
RUN go get github.com/expr-lang/expr@v1.17.7 && \
|
||||
RUN go get github.com/expr-lang/expr@v${EXPR_LANG_VERSION} && \
|
||||
go get golang.org/x/crypto@v0.46.0 && \
|
||||
go get golang.org/x/net@v${XNET_VERSION} && \
|
||||
go mod tidy
|
||||
|
||||
# Fix compatibility issues with expr-lang v1.17.7
|
||||
@@ -350,18 +367,15 @@ RUN mkdir -p /crowdsec-out/config && \
|
||||
cp -r config/* /crowdsec-out/config/ || true
|
||||
|
||||
# ---- CrowdSec Fallback (for architectures where build fails) ----
|
||||
# renovate: datasource=docker depName=alpine versioning=docker
|
||||
FROM alpine:3.23.3 AS crowdsec-fallback
|
||||
FROM ${ALPINE_IMAGE} AS crowdsec-fallback
|
||||
|
||||
SHELL ["/bin/ash", "-o", "pipefail", "-c"]
|
||||
|
||||
WORKDIR /tmp/crowdsec
|
||||
|
||||
ARG TARGETARCH
|
||||
# CrowdSec version - Renovate can update this
|
||||
# renovate: datasource=github-releases depName=crowdsecurity/crowdsec
|
||||
ARG CROWDSEC_VERSION=1.7.6
|
||||
ARG CROWDSEC_RELEASE_SHA256=704e37121e7ac215991441cef0d8732e33fa3b1a2b2b88b53a0bfe5e38f863bd
|
||||
ARG CROWDSEC_VERSION
|
||||
ARG CROWDSEC_RELEASE_SHA256
|
||||
|
||||
# hadolint ignore=DL3018
|
||||
RUN apk add --no-cache curl ca-certificates
|
||||
@@ -390,7 +404,7 @@ RUN set -eux; \
|
||||
fi
|
||||
|
||||
# ---- Final Runtime with Caddy ----
|
||||
FROM ${CADDY_IMAGE}
|
||||
FROM ${ALPINE_IMAGE}
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies for Charon, including bash for maintenance scripts
|
||||
@@ -450,7 +464,7 @@ COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
|
||||
# Allow non-root to bind privileged ports (80/443) securely
|
||||
RUN setcap 'cap_net_bind_service=+ep' /usr/bin/caddy
|
||||
|
||||
# Copy CrowdSec binaries from the crowdsec-builder stage (built with Go 1.26.0+)
|
||||
# Copy CrowdSec binaries from the crowdsec-builder stage (built with Go 1.26.1+)
|
||||
# This ensures we don't have stdlib vulnerabilities from older Go versions
|
||||
COPY --from=crowdsec-builder /crowdsec-out/crowdsec /usr/local/bin/crowdsec
|
||||
COPY --from=crowdsec-builder /crowdsec-out/cscli /usr/local/bin/cscli
|
||||
|
||||
9
Makefile
9
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install test build run clean docker-build docker-run release go-check gopls-logs lint-fast lint-staticcheck-only
|
||||
.PHONY: help install test build run clean docker-build docker-run release go-check gopls-logs lint-fast lint-staticcheck-only security-local
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -22,6 +22,7 @@ help:
|
||||
@echo ""
|
||||
@echo "Security targets:"
|
||||
@echo " security-scan - Quick security scan (govulncheck on Go deps)"
|
||||
@echo " security-local - Run govulncheck + semgrep (p/golang) locally before push"
|
||||
@echo " security-scan-full - Full container scan with Trivy"
|
||||
@echo " security-scan-deps - Check for outdated Go dependencies"
|
||||
|
||||
@@ -145,6 +146,12 @@ security-scan:
|
||||
@echo "Running security scan (govulncheck)..."
|
||||
@./scripts/security-scan.sh
|
||||
|
||||
security-local: ## Run govulncheck + semgrep (p/golang) before push — fast local gate
|
||||
@echo "[1/2] Running govulncheck..."
|
||||
@./scripts/security-scan.sh
|
||||
@echo "[2/2] Running Semgrep (p/golang, ERROR+WARNING)..."
|
||||
@SEMGREP_CONFIG=p/golang ./scripts/pre-commit-hooks/semgrep-scan.sh
|
||||
|
||||
security-scan-full:
|
||||
@echo "Building local Docker image for security scan..."
|
||||
docker build --build-arg VCS_REF=$(shell git rev-parse HEAD) -t charon:local .
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Wikid82/charon/backend
|
||||
|
||||
go 1.26
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
@@ -18,8 +18,8 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -84,18 +84,18 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/sys v0.42.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.69.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
|
||||
@@ -176,49 +176,49 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
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.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
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.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=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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=
|
||||
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=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
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=
|
||||
@@ -243,8 +243,8 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
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/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
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=
|
||||
@@ -253,8 +253,8 @@ 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/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
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=
|
||||
|
||||
@@ -63,7 +63,10 @@ func (h *AuditLogHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Calculate pagination metadata
|
||||
totalPages := (int(total) + limit - 1) / limit
|
||||
var totalPages int
|
||||
if limit > 0 {
|
||||
totalPages = (int(total) + limit - 1) / limit
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"audit_logs": audits,
|
||||
@@ -127,7 +130,10 @@ func (h *AuditLogHandler) ListByProvider(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Calculate pagination metadata
|
||||
totalPages := (int(total) + limit - 1) / limit
|
||||
var totalPages int
|
||||
if limit > 0 {
|
||||
totalPages = (int(total) + limit - 1) / limit
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"audit_logs": audits,
|
||||
|
||||
@@ -77,12 +77,12 @@ func originHost(rawURL string) string {
|
||||
return normalizeHost(parsedURL.Host)
|
||||
}
|
||||
|
||||
func isLocalHost(host string) bool {
|
||||
func isLocalOrPrivateHost(host string) bool {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
||||
if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate()) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func isLocalRequest(c *gin.Context) bool {
|
||||
continue
|
||||
}
|
||||
|
||||
if isLocalHost(host) {
|
||||
if isLocalOrPrivateHost(host) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -127,8 +127,9 @@ func isLocalRequest(c *gin.Context) bool {
|
||||
|
||||
// setSecureCookie sets an auth cookie with security best practices
|
||||
// - HttpOnly: prevents JavaScript access (XSS protection)
|
||||
// - 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
|
||||
// - Secure: true for HTTPS; false for local/private network HTTP requests
|
||||
// - SameSite: Lax for any local/private-network request (regardless of scheme),
|
||||
// Strict otherwise (public HTTPS only)
|
||||
func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
|
||||
scheme := requestScheme(c)
|
||||
secure := true
|
||||
@@ -148,13 +149,14 @@ func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
|
||||
domain := ""
|
||||
|
||||
c.SetSameSite(sameSite)
|
||||
c.SetCookie(
|
||||
// secure is intentionally false for local/private network HTTP requests; always true for external or HTTPS requests.
|
||||
c.SetCookie( // codeql[go/cookie-secure-not-set]
|
||||
name, // name
|
||||
value, // value
|
||||
maxAge, // maxAge in seconds
|
||||
"/", // path
|
||||
domain, // domain (empty = current host)
|
||||
secure, // secure (always true)
|
||||
secure, // secure
|
||||
true, // httpOnly (no JS access)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,6 +202,114 @@ func TestSetSecureCookie_OriginLoopbackForcesInsecure(t *testing.T) {
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
func TestSetSecureCookie_HTTP_PrivateIP_Insecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest("POST", "http://192.168.1.50:8080/login", http.NoBody)
|
||||
req.Host = "192.168.1.50: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_HTTP_10Network_Insecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest("POST", "http://10.0.0.5:8080/login", http.NoBody)
|
||||
req.Host = "10.0.0.5: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_HTTP_172Network_Insecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest("POST", "http://172.16.0.1:8080/login", http.NoBody)
|
||||
req.Host = "172.16.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_HTTPS_PrivateIP_Secure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest("POST", "https://192.168.1.50:8080/login", http.NoBody)
|
||||
req.Host = "192.168.1.50:8080"
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
ctx.Request = req
|
||||
|
||||
setSecureCookie(ctx, "auth_token", "abc", 60)
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
func TestSetSecureCookie_HTTP_IPv6ULA_Insecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest("POST", "http://[fd12::1]:8080/login", http.NoBody)
|
||||
req.Host = "[fd12::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_HTTP_PublicIP_Secure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest("POST", "http://203.0.113.5:8080/login", http.NoBody)
|
||||
req.Host = "203.0.113.5: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.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
func TestIsProduction(t *testing.T) {
|
||||
t.Setenv("CHARON_ENV", "production")
|
||||
assert.True(t, isProduction())
|
||||
@@ -271,11 +379,16 @@ func TestHostHelpers(t *testing.T) {
|
||||
assert.Equal(t, "localhost", originHost("http://localhost:8080/path"))
|
||||
})
|
||||
|
||||
t.Run("isLocalHost", func(t *testing.T) {
|
||||
assert.True(t, isLocalHost("localhost"))
|
||||
assert.True(t, isLocalHost("127.0.0.1"))
|
||||
assert.True(t, isLocalHost("::1"))
|
||||
assert.False(t, isLocalHost("example.com"))
|
||||
t.Run("isLocalOrPrivateHost", func(t *testing.T) {
|
||||
assert.True(t, isLocalOrPrivateHost("localhost"))
|
||||
assert.True(t, isLocalOrPrivateHost("127.0.0.1"))
|
||||
assert.True(t, isLocalOrPrivateHost("::1"))
|
||||
assert.True(t, isLocalOrPrivateHost("192.168.1.50"))
|
||||
assert.True(t, isLocalOrPrivateHost("10.0.0.1"))
|
||||
assert.True(t, isLocalOrPrivateHost("172.16.0.1"))
|
||||
assert.True(t, isLocalOrPrivateHost("fd12::1"))
|
||||
assert.False(t, isLocalOrPrivateHost("203.0.113.5"))
|
||||
assert.False(t, isLocalOrPrivateHost("example.com"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1222,10 +1335,10 @@ func TestAuthHandler_HelperFunctions(t *testing.T) {
|
||||
assert.Equal(t, "example.com", originHost("https://example.com/path"))
|
||||
})
|
||||
|
||||
t.Run("isLocalHost and isLocalRequest", func(t *testing.T) {
|
||||
assert.True(t, isLocalHost("localhost"))
|
||||
assert.True(t, isLocalHost("127.0.0.1"))
|
||||
assert.False(t, isLocalHost("example.com"))
|
||||
t.Run("isLocalOrPrivateHost and isLocalRequest", func(t *testing.T) {
|
||||
assert.True(t, isLocalOrPrivateHost("localhost"))
|
||||
assert.True(t, isLocalOrPrivateHost("127.0.0.1"))
|
||||
assert.False(t, isLocalOrPrivateHost("example.com"))
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
@@ -41,7 +41,8 @@ func (h *CerberusLogsHandler) LiveLogs(c *gin.Context) {
|
||||
logger.Log().Info("Cerberus logs WebSocket connection attempt")
|
||||
|
||||
// Upgrade HTTP connection to WebSocket
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
// CheckOrigin is enforced on the shared upgrader in logs_ws.go (same package).
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) // nosemgrep: go.gorilla.security.audit.websocket-missing-origin-check.websocket-missing-origin-check
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).Error("Failed to upgrade Cerberus logs WebSocket")
|
||||
return
|
||||
|
||||
@@ -125,7 +125,7 @@ func (h *CertificateHandler) Upload(c *gin.Context) {
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"cert",
|
||||
"Certificate Uploaded",
|
||||
fmt.Sprintf("Certificate %s uploaded", util.SanitizeForLog(cert.Name)),
|
||||
"A new custom certificate was successfully uploaded.",
|
||||
map[string]any{
|
||||
"Name": util.SanitizeForLog(cert.Name),
|
||||
"Domains": util.SanitizeForLog(cert.Domains),
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -516,6 +518,42 @@ func generateSelfSignedCertPEM() (certPEM, keyPEM string, err error) {
|
||||
|
||||
// Note: mockCertificateService removed — helper tests now use real service instances or testify mocks inlined where required.
|
||||
|
||||
// TestCertificateHandler_Upload_WithNotificationService verifies that the notification
|
||||
// path is exercised when a non-nil NotificationService is provided.
|
||||
func TestCertificateHandler_Upload_WithNotificationService(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.NotificationProvider{}))
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
svc := services.NewCertificateService(tmpDir, db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewCertificateHandler(svc, nil, ns)
|
||||
r.POST("/api/certificates", h.Upload)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
_ = writer.WriteField("name", "cert-with-ns")
|
||||
certPEM, keyPEM, err := generateSelfSignedCertPEM()
|
||||
require.NoError(t, err)
|
||||
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
|
||||
_, _ = part.Write([]byte(certPEM))
|
||||
part2, _ := writer.CreateFormFile("key_file", "key.pem")
|
||||
_, _ = part2.Write([]byte(keyPEM))
|
||||
_ = writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
|
||||
// Test Delete with invalid ID format
|
||||
func TestDeleteCertificate_InvalidID(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
@@ -721,7 +759,7 @@ func TestDeleteCertificate_NotificationRateLimit(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
|
||||
mockBackupService := &mockBackupService{
|
||||
createFunc: func() (string, error) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
@@ -56,7 +55,7 @@ func (h *DomainHandler) Create(c *gin.Context) {
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"domain",
|
||||
"Domain Added",
|
||||
fmt.Sprintf("Domain %s added", util.SanitizeForLog(domain.Name)),
|
||||
"A new domain was successfully added.",
|
||||
map[string]any{
|
||||
"Name": util.SanitizeForLog(domain.Name),
|
||||
"Action": "created",
|
||||
@@ -76,7 +75,7 @@ func (h *DomainHandler) Delete(c *gin.Context) {
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"domain",
|
||||
"Domain Deleted",
|
||||
fmt.Sprintf("Domain %s deleted", util.SanitizeForLog(domain.Name)),
|
||||
"A domain was successfully deleted.",
|
||||
map[string]any{
|
||||
"Name": util.SanitizeForLog(domain.Name),
|
||||
"Action": "deleted",
|
||||
|
||||
@@ -24,7 +24,7 @@ func setupDomainTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.Domain{}, &models.Notification{}, &models.NotificationProvider{}))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewDomainHandler(db, ns)
|
||||
r := gin.New()
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -127,179 +126,3 @@ func TestBlocker3_SecurityProviderEventsFlagCanBeEnabled(t *testing.T) {
|
||||
assert.True(t, response["feature.notifications.security_provider_events.enabled"],
|
||||
"security_provider_events flag should be true when enabled in DB")
|
||||
}
|
||||
|
||||
// TestLegacyFallbackRemoved_UpdateFlagsRejectsTrue tests that attempting to set legacy fallback to true returns error code LEGACY_FALLBACK_REMOVED.
|
||||
func TestLegacyFallbackRemoved_UpdateFlagsRejectsTrue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
handler := NewFeatureFlagsHandler(db)
|
||||
|
||||
// Attempt to set legacy fallback to true
|
||||
payload := map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": true,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
assert.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("PUT", "/api/v1/feature-flags", bytes.NewBuffer(jsonPayload))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateFlags(c)
|
||||
|
||||
// Must return 400 with code LEGACY_FALLBACK_REMOVED
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, response["error"], "retired")
|
||||
assert.Equal(t, "LEGACY_FALLBACK_REMOVED", response["code"])
|
||||
}
|
||||
|
||||
// TestLegacyFallbackRemoved_UpdateFlagsAcceptsFalse tests that setting legacy fallback to false is allowed (forced false).
|
||||
func TestLegacyFallbackRemoved_UpdateFlagsAcceptsFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
handler := NewFeatureFlagsHandler(db)
|
||||
|
||||
// Set legacy fallback to false (should be accepted and forced)
|
||||
payload := map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": false,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
assert.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("PUT", "/api/v1/feature-flags", bytes.NewBuffer(jsonPayload))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify in DB that it's false
|
||||
var setting models.Setting
|
||||
db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting)
|
||||
assert.Equal(t, "false", setting.Value)
|
||||
}
|
||||
|
||||
// TestLegacyFallbackRemoved_GetFlagsReturnsHardFalse tests that GET always returns false for legacy fallback.
|
||||
func TestLegacyFallbackRemoved_GetFlagsReturnsHardFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
handler := NewFeatureFlagsHandler(db)
|
||||
|
||||
// Scenario 1: No DB entry
|
||||
t.Run("no_db_entry", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil)
|
||||
|
||||
handler.GetFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false when no DB entry")
|
||||
})
|
||||
|
||||
// Scenario 2: DB entry says true (invalid, forced false)
|
||||
t.Run("db_entry_true", func(t *testing.T) {
|
||||
// Force a true value in DB (simulating legacy state)
|
||||
setting := models.Setting{
|
||||
Key: "feature.notifications.legacy.fallback_enabled",
|
||||
Value: "true",
|
||||
Type: "bool",
|
||||
Category: "feature",
|
||||
}
|
||||
db.Create(&setting)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil)
|
||||
|
||||
handler.GetFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false even when DB says true")
|
||||
|
||||
// Clean up
|
||||
db.Unscoped().Delete(&setting)
|
||||
})
|
||||
|
||||
// Scenario 3: DB entry says false
|
||||
t.Run("db_entry_false", func(t *testing.T) {
|
||||
setting := models.Setting{
|
||||
Key: "feature.notifications.legacy.fallback_enabled",
|
||||
Value: "false",
|
||||
Type: "bool",
|
||||
Category: "feature",
|
||||
}
|
||||
db.Create(&setting)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil)
|
||||
|
||||
handler.GetFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false when DB says false")
|
||||
|
||||
// Clean up
|
||||
db.Unscoped().Delete(&setting)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLegacyFallbackRemoved_InvalidEnvValue tests that invalid environment variable values are handled (lines 157-158)
|
||||
func TestLegacyFallbackRemoved_InvalidEnvValue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
// Set invalid environment variable value
|
||||
t.Setenv("CHARON_NOTIFICATIONS_LEGACY_FALLBACK", "invalid-value")
|
||||
|
||||
handler := NewFeatureFlagsHandler(db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/api/v1/feature-flags", nil)
|
||||
|
||||
// Lines 157-158: Should log warning for invalid env value and return hard-false
|
||||
handler.GetFlags(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, response["feature.notifications.legacy.fallback_enabled"], "Must return hard-false even with invalid env value")
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestResolveRetiredLegacyFallback_InvalidPersistedValue covers lines 139-140
|
||||
func TestResolveRetiredLegacyFallback_InvalidPersistedValue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
// Create setting with invalid value for retired fallback flag
|
||||
db.Create(&models.Setting{
|
||||
Key: "feature.notifications.legacy.fallback_enabled",
|
||||
Value: "invalid_value_not_bool",
|
||||
Type: "bool",
|
||||
Category: "feature",
|
||||
})
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Should log warning and return false (lines 139-140)
|
||||
var flags map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, flags["feature.notifications.legacy.fallback_enabled"])
|
||||
}
|
||||
|
||||
// TestResolveRetiredLegacyFallback_InvalidEnvValue covers lines 149-150
|
||||
func TestResolveRetiredLegacyFallback_InvalidEnvValue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
// Set invalid env var for retired fallback flag
|
||||
t.Setenv("CHARON_LEGACY_FALLBACK_ENABLED", "not_a_boolean")
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Should log warning and return false (lines 149-150)
|
||||
var flags map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, flags["feature.notifications.legacy.fallback_enabled"])
|
||||
}
|
||||
|
||||
// TestResolveRetiredLegacyFallback_DefaultFalse covers lines 157-158
|
||||
func TestResolveRetiredLegacyFallback_DefaultFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
|
||||
// No DB value, no env vars - should default to false
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Should return false (lines 157-158)
|
||||
var flags map[string]bool
|
||||
err = json.Unmarshal(w.Body.Bytes(), &flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, flags["feature.notifications.legacy.fallback_enabled"])
|
||||
}
|
||||
@@ -30,9 +30,10 @@ var defaultFlags = []string{
|
||||
"feature.crowdsec.console_enrollment",
|
||||
"feature.notifications.engine.notify_v1.enabled",
|
||||
"feature.notifications.service.discord.enabled",
|
||||
"feature.notifications.service.email.enabled",
|
||||
"feature.notifications.service.gotify.enabled",
|
||||
"feature.notifications.service.webhook.enabled",
|
||||
"feature.notifications.legacy.fallback_enabled",
|
||||
"feature.notifications.service.telegram.enabled",
|
||||
"feature.notifications.security_provider_events.enabled", // Blocker 3: Add security_provider_events gate
|
||||
}
|
||||
|
||||
@@ -42,17 +43,13 @@ var defaultFlagValues = map[string]bool{
|
||||
"feature.crowdsec.console_enrollment": false,
|
||||
"feature.notifications.engine.notify_v1.enabled": false,
|
||||
"feature.notifications.service.discord.enabled": false,
|
||||
"feature.notifications.service.email.enabled": false,
|
||||
"feature.notifications.service.gotify.enabled": false,
|
||||
"feature.notifications.service.webhook.enabled": false,
|
||||
"feature.notifications.legacy.fallback_enabled": false,
|
||||
"feature.notifications.service.telegram.enabled": false,
|
||||
"feature.notifications.security_provider_events.enabled": false, // Blocker 3: Default disabled for this stage
|
||||
}
|
||||
|
||||
var retiredLegacyFallbackEnvAliases = []string{
|
||||
"FEATURE_NOTIFICATIONS_LEGACY_FALLBACK_ENABLED",
|
||||
"NOTIFICATIONS_LEGACY_FALLBACK_ENABLED",
|
||||
}
|
||||
|
||||
// GetFlags returns a map of feature flag -> bool. DB setting takes precedence
|
||||
// and falls back to environment variables if present.
|
||||
func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
|
||||
@@ -86,11 +83,6 @@ func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
|
||||
defaultVal = v
|
||||
}
|
||||
|
||||
if key == "feature.notifications.legacy.fallback_enabled" {
|
||||
result[key] = h.resolveRetiredLegacyFallback(settingsMap)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if flag exists in DB
|
||||
if s, exists := settingsMap[key]; exists {
|
||||
v := strings.ToLower(strings.TrimSpace(s.Value))
|
||||
@@ -131,40 +123,6 @@ func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func parseFlagBool(raw string) (bool, bool) {
|
||||
v := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch v {
|
||||
case "1", "true", "yes":
|
||||
return true, true
|
||||
case "0", "false", "no":
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FeatureFlagsHandler) resolveRetiredLegacyFallback(settingsMap map[string]models.Setting) bool {
|
||||
const retiredKey = "feature.notifications.legacy.fallback_enabled"
|
||||
|
||||
if s, exists := settingsMap[retiredKey]; exists {
|
||||
if _, ok := parseFlagBool(s.Value); !ok {
|
||||
log.Printf("[WARN] Invalid persisted retired fallback flag value, forcing disabled: key=%s value=%q", retiredKey, s.Value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for _, alias := range retiredLegacyFallbackEnvAliases {
|
||||
if ev, ok := os.LookupEnv(alias); ok {
|
||||
if _, parsed := parseFlagBool(ev); !parsed {
|
||||
log.Printf("[WARN] Invalid environment retired fallback flag value, forcing disabled: key=%s value=%q", alias, ev)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateFlags accepts a JSON object map[string]bool and upserts settings.
|
||||
func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
|
||||
// Phase 0: Performance instrumentation
|
||||
@@ -180,14 +138,6 @@ func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if v, exists := payload["feature.notifications.legacy.fallback_enabled"]; exists && v {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "feature.notifications.legacy.fallback_enabled is retired and can only be false",
|
||||
"code": "LEGACY_FALLBACK_REMOVED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1: Transaction wrapping - all updates in single atomic transaction
|
||||
if err := h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for k, v := range payload {
|
||||
@@ -203,10 +153,6 @@ func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
if k == "feature.notifications.legacy.fallback_enabled" {
|
||||
v = false
|
||||
}
|
||||
|
||||
s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"}
|
||||
if err := tx.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&s).Error; err != nil {
|
||||
return err // Rollback on error
|
||||
|
||||
@@ -460,3 +460,24 @@ func TestFeatureFlagsHandler_NewFeatureFlagsHandler(t *testing.T) {
|
||||
assert.NotNil(t, h.DB)
|
||||
assert.Equal(t, db, h.DB)
|
||||
}
|
||||
|
||||
func TestFeatureFlagsHandler_GetFlags_EmailFlagDefaultFalse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupFlagsDB(t)
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var flags map[string]bool
|
||||
err := json.Unmarshal(w.Body.Bytes(), &flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, flags["feature.notifications.service.email.enabled"])
|
||||
}
|
||||
|
||||
@@ -100,147 +100,6 @@ func TestFeatureFlags_EnvFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_RetiredFallback_DenyByDefault(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var flags map[string]bool
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
|
||||
if flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
t.Fatalf("expected retired fallback flag to be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_RetiredFallback_PersistedAndEnvStillResolveFalse(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
|
||||
if err := db.Create(&models.Setting{
|
||||
Key: "feature.notifications.legacy.fallback_enabled",
|
||||
Value: "true",
|
||||
Type: "bool",
|
||||
Category: "feature",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed setting: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("FEATURE_NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "true")
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var flags map[string]bool
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
|
||||
if flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
t.Fatalf("expected retired fallback flag to remain false even when persisted/env are true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_RetiredFallback_EnvAliasResolvesFalse(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
t.Setenv("NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "true")
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var flags map[string]bool
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
|
||||
if flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
t.Fatalf("expected retired fallback flag to remain false for env alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_UpdateRejectsLegacyFallbackTrue(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
|
||||
|
||||
payload := map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": true,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlags_UpdatePersistsLegacyFallbackFalse(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.PUT("/api/v1/feature-flags", h.UpdateFlags)
|
||||
|
||||
payload := map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": false,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var s models.Setting
|
||||
if err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&s).Error; err != nil {
|
||||
t.Fatalf("expected setting persisted: %v", err)
|
||||
}
|
||||
if s.Value != "false" {
|
||||
t.Fatalf("expected persisted fallback value false, got %s", s.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// setupBenchmarkFlagsDB creates an in-memory SQLite database for feature flags benchmarks
|
||||
func setupBenchmarkFlagsDB(b *testing.B) *gorm.DB {
|
||||
b.Helper()
|
||||
@@ -428,32 +287,3 @@ func TestUpdateFlags_TransactionAtomic(t *testing.T) {
|
||||
t.Errorf("expected crowdsec.console_enrollment to be true, got %s", s3.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeatureFlags_InvalidRetiredEnvAlias covers lines 157-158 (invalid env var warning)
|
||||
func TestFeatureFlags_InvalidRetiredEnvAlias(t *testing.T) {
|
||||
db := setupFlagsDB(t)
|
||||
t.Setenv("NOTIFICATIONS_LEGACY_FALLBACK_ENABLED", "invalid-value")
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/feature-flags", h.GetFlags)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var flags map[string]bool
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
|
||||
// Should force disabled due to invalid value (lines 157-158)
|
||||
if flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
t.Fatalf("expected retired fallback flag to be false for invalid env value")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestRemoteServerHandler_List(t *testing.T) {
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
@@ -74,7 +74,7 @@ func TestRemoteServerHandler_Create(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
@@ -119,7 +119,7 @@ func TestRemoteServerHandler_TestConnection(t *testing.T) {
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
@@ -154,7 +154,7 @@ func TestRemoteServerHandler_Get(t *testing.T) {
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
@@ -188,7 +188,7 @@ func TestRemoteServerHandler_Update(t *testing.T) {
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
@@ -234,7 +234,7 @@ func TestRemoteServerHandler_Delete(t *testing.T) {
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
@@ -271,7 +271,7 @@ func TestProxyHostHandler_List(t *testing.T) {
|
||||
}
|
||||
db.Create(host)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
@@ -295,7 +295,7 @@ func TestProxyHostHandler_Create(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
@@ -343,7 +343,7 @@ func TestProxyHostHandler_PartialUpdate_DoesNotWipeFields(t *testing.T) {
|
||||
}
|
||||
db.Create(original)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
@@ -408,7 +408,7 @@ func TestRemoteServerHandler_Errors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,13 +15,24 @@ import (
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// Allow all origins for development. In production, this should check
|
||||
// against a whitelist of allowed origins.
|
||||
return true
|
||||
},
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// No Origin header — non-browser client or same-origin request.
|
||||
return true
|
||||
}
|
||||
originURL, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
requestHost := r.Host
|
||||
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
requestHost = forwardedHost
|
||||
}
|
||||
return originURL.Host == requestHost
|
||||
},
|
||||
}
|
||||
|
||||
// LogEntry represents a structured log entry sent over WebSocket.
|
||||
|
||||
@@ -33,6 +33,43 @@ func waitFor(t *testing.T, timeout time.Duration, condition func() bool) {
|
||||
t.Fatalf("condition not met within %s", timeout)
|
||||
}
|
||||
|
||||
func TestUpgraderCheckOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
host string
|
||||
xForwardedHost string
|
||||
want bool
|
||||
}{
|
||||
{"empty origin allows request", "", "example.com", "", true},
|
||||
{"invalid URL origin rejects", "://bad-url", "example.com", "", false},
|
||||
{"matching host allows", "http://example.com", "example.com", "", true},
|
||||
{"non-matching host rejects", "http://evil.com", "example.com", "", false},
|
||||
{"X-Forwarded-Host matching allows", "http://proxy.example.com", "backend.internal", "proxy.example.com", true},
|
||||
{"X-Forwarded-Host non-matching rejects", "http://evil.com", "backend.internal", "proxy.example.com", false},
|
||||
{"origin with port matching", "http://example.com:8080", "example.com:8080", "", true},
|
||||
{"origin with port non-matching", "http://example.com:9090", "example.com:8080", "", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ws", http.NoBody)
|
||||
if tc.origin != "" {
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
}
|
||||
req.Host = tc.host
|
||||
if tc.xForwardedHost != "" {
|
||||
req.Header.Set("X-Forwarded-Host", tc.xForwardedHost)
|
||||
}
|
||||
got := upgrader.CheckOrigin(req)
|
||||
assert.Equal(t, tc.want, got, "origin=%q host=%q xfh=%q", tc.origin, tc.host, tc.xForwardedHost)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsWebSocketHandler_DeprecatedWrapperUpgradeFailure(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
charonlogger.Init(false, io.Discard)
|
||||
|
||||
@@ -35,7 +35,7 @@ func setAdminContext(c *gin.Context) {
|
||||
func TestNotificationHandler_List_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationHandler(svc)
|
||||
|
||||
// Drop the table to cause error
|
||||
@@ -57,7 +57,7 @@ func TestNotificationHandler_List_Error(t *testing.T) {
|
||||
func TestNotificationHandler_List_UnreadOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationHandler(svc)
|
||||
|
||||
// Create some notifications
|
||||
@@ -77,7 +77,7 @@ func TestNotificationHandler_List_UnreadOnly(t *testing.T) {
|
||||
func TestNotificationHandler_MarkAsRead_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -97,7 +97,7 @@ func TestNotificationHandler_MarkAsRead_Error(t *testing.T) {
|
||||
func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -118,7 +118,7 @@ func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
|
||||
func TestNotificationProviderHandler_List_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -137,7 +137,7 @@ func TestNotificationProviderHandler_List_Error(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Create_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -154,7 +154,7 @@ func TestNotificationProviderHandler_Create_InvalidJSON(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Create_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -182,7 +182,7 @@ func TestNotificationProviderHandler_Create_DBError(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Create_InvalidTemplate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
@@ -208,7 +208,7 @@ func TestNotificationProviderHandler_Create_InvalidTemplate(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Update_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -226,7 +226,7 @@ func TestNotificationProviderHandler_Update_InvalidJSON(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Update_InvalidTemplate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
// Create a provider first
|
||||
@@ -258,7 +258,7 @@ func TestNotificationProviderHandler_Update_InvalidTemplate(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Update_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -287,7 +287,7 @@ func TestNotificationProviderHandler_Update_DBError(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Delete_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -307,7 +307,7 @@ func TestNotificationProviderHandler_Delete_Error(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -324,7 +324,7 @@ func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -356,7 +356,7 @@ func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *te
|
||||
func TestNotificationProviderHandler_Test_RejectsGotifyTokenWithWhitespace(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -477,7 +477,7 @@ func TestClassifyProviderTestFailure_TLSHandshakeFailed(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Templates(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -495,7 +495,7 @@ func TestNotificationProviderHandler_Templates(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Preview_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -512,7 +512,7 @@ func TestNotificationProviderHandler_Preview_InvalidJSON(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Preview_WithData(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -538,7 +538,7 @@ func TestNotificationProviderHandler_Preview_WithData(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Preview_InvalidTemplate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -563,7 +563,7 @@ func TestNotificationProviderHandler_Preview_InvalidTemplate(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_List_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -582,7 +582,7 @@ func TestNotificationTemplateHandler_List_Error(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_Create_BadJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -599,7 +599,7 @@ func TestNotificationTemplateHandler_Create_BadJSON(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_Create_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -625,7 +625,7 @@ func TestNotificationTemplateHandler_Create_DBError(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_Update_BadJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -643,7 +643,7 @@ func TestNotificationTemplateHandler_Update_BadJSON(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_Update_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -670,7 +670,7 @@ func TestNotificationTemplateHandler_Update_DBError(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_Delete_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
// Drop table to cause error
|
||||
@@ -690,7 +690,7 @@ func TestNotificationTemplateHandler_Delete_Error(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_Preview_BadJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -707,7 +707,7 @@ func TestNotificationTemplateHandler_Preview_BadJSON(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_Preview_TemplateNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -730,7 +730,7 @@ func TestNotificationTemplateHandler_Preview_TemplateNotFound(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_Preview_WithStoredTemplate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
// Create a template
|
||||
@@ -762,7 +762,7 @@ func TestNotificationTemplateHandler_Preview_WithStoredTemplate(t *testing.T) {
|
||||
func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -784,7 +784,7 @@ func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -808,7 +808,7 @@ func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
@@ -842,7 +842,7 @@ func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -865,7 +865,7 @@ func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -889,7 +889,7 @@ func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Test_EmptyProviderURL(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
@@ -942,7 +942,7 @@ func TestIsProviderValidationError_Comprehensive(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
@@ -975,7 +975,7 @@ func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) {
|
||||
func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
@@ -1013,7 +1013,7 @@ func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.
|
||||
func TestNotificationProviderHandler_Test_ReadDBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
_ = db.Migrator().DropTable(&models.NotificationProvider{})
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestNotificationHandler_List(t *testing.T) {
|
||||
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
|
||||
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: true})
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
router := gin.New()
|
||||
router.GET("/notifications", handler.List)
|
||||
@@ -72,7 +72,7 @@ func TestNotificationHandler_MarkAsRead(t *testing.T) {
|
||||
notif := &models.Notification{Title: "Test 1", Message: "Msg 1", Read: false}
|
||||
db.Create(notif)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
router := gin.New()
|
||||
router.POST("/notifications/:id/read", handler.MarkAsRead)
|
||||
@@ -96,7 +96,7 @@ func TestNotificationHandler_MarkAllAsRead(t *testing.T) {
|
||||
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
|
||||
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: false})
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
router := gin.New()
|
||||
router.POST("/notifications/read-all", handler.MarkAllAsRead)
|
||||
@@ -115,7 +115,7 @@ func TestNotificationHandler_MarkAllAsRead(t *testing.T) {
|
||||
func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationTestDB(t)
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
|
||||
r := gin.New()
|
||||
@@ -134,7 +134,7 @@ func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
|
||||
func TestNotificationHandler_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationTestDB(t)
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
|
||||
r := gin.New()
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create handler
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Test cases: provider types with security events enabled
|
||||
@@ -40,7 +40,7 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
|
||||
{"webhook", "webhook", http.StatusCreated},
|
||||
{"gotify", "gotify", http.StatusCreated},
|
||||
{"slack", "slack", http.StatusBadRequest},
|
||||
{"email", "email", http.StatusBadRequest},
|
||||
{"email", "email", http.StatusCreated},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -96,7 +96,7 @@ func TestBlocker3_CreateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create handler
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Create request payload with Discord provider and security events
|
||||
@@ -144,7 +144,7 @@ func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testin
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create handler
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Create request payload with webhook provider but no security events
|
||||
@@ -200,7 +200,7 @@ func TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
|
||||
assert.NoError(t, db.Create(&existingProvider).Error)
|
||||
|
||||
// Create handler
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Try to update to enable security events (should be rejected)
|
||||
@@ -256,7 +256,7 @@ func TestBlocker3_UpdateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
|
||||
assert.NoError(t, db.Create(&existingProvider).Error)
|
||||
|
||||
// Create handler
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Update to enable security events
|
||||
@@ -302,7 +302,7 @@ func TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create handler
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Test each security event field individually
|
||||
@@ -359,7 +359,7 @@ func TestBlocker3_UpdateProvider_DatabaseError(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create handler
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Update payload
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{}))
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
testCases := []struct {
|
||||
@@ -36,9 +36,9 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
|
||||
{"webhook", "webhook", http.StatusCreated, ""},
|
||||
{"gotify", "gotify", http.StatusCreated, ""},
|
||||
{"slack", "slack", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"telegram", "telegram", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"telegram", "telegram", http.StatusCreated, ""},
|
||||
{"generic", "generic", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"email", "email", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"email", "email", http.StatusCreated, ""},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -83,7 +83,7 @@ func TestDiscordOnly_CreateAcceptsDiscord(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{}))
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
@@ -129,7 +129,7 @@ func TestDiscordOnly_UpdateRejectsTypeMutation(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(&deprecatedProvider).Error)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Try to change type to discord
|
||||
@@ -183,7 +183,7 @@ func TestDiscordOnly_UpdateRejectsEnable(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(&deprecatedProvider).Error)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Try to enable the deprecated provider
|
||||
@@ -231,7 +231,7 @@ func TestDiscordOnly_UpdateAllowsDisabledDeprecated(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(&deprecatedProvider).Error)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Update name (keeping type and enabled unchanged)
|
||||
@@ -279,7 +279,7 @@ func TestDiscordOnly_UpdateAcceptsDiscord(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(&discordProvider).Error)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Update to enable security notifications
|
||||
@@ -327,7 +327,7 @@ func TestDiscordOnly_DeleteAllowsDeprecated(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(&deprecatedProvider).Error)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -409,7 +409,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
|
||||
|
||||
id := tc.setupFunc(db)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
req, params := tc.requestFunc(id)
|
||||
|
||||
@@ -92,7 +92,7 @@ func respondSanitizedProviderError(c *gin.Context, status int, code, category, m
|
||||
c.JSON(status, response)
|
||||
}
|
||||
|
||||
var providerStatusCodePattern = regexp.MustCompile(`provider returned status\s+(\d{3})`)
|
||||
var providerStatusCodePattern = regexp.MustCompile(`provider returned status\s+(\d{3})(?::\s*(.+))?`)
|
||||
|
||||
func classifyProviderTestFailure(err error) (code string, category string, message string) {
|
||||
if err == nil {
|
||||
@@ -107,14 +107,18 @@ func classifyProviderTestFailure(err error) (code string, category string, messa
|
||||
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 {
|
||||
if statusMatch := providerStatusCodePattern.FindStringSubmatch(errText); len(statusMatch) >= 2 {
|
||||
hint := ""
|
||||
if len(statusMatch) >= 3 && strings.TrimSpace(statusMatch[2]) != "" {
|
||||
hint = ": " + strings.TrimSpace(statusMatch[2])
|
||||
}
|
||||
switch statusMatch[1] {
|
||||
case "401", "403":
|
||||
return "PROVIDER_TEST_AUTH_REJECTED", "dispatch", "Provider rejected authentication. Verify your Gotify token"
|
||||
return "PROVIDER_TEST_AUTH_REJECTED", "dispatch", "Provider rejected authentication. Verify your credentials"
|
||||
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])
|
||||
return "PROVIDER_TEST_REMOTE_REJECTED", "dispatch", fmt.Sprintf("Provider rejected the test request (HTTP %s)%s", statusMatch[1], hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +172,7 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" {
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
@@ -228,12 +232,12 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(existing.Type))
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" {
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
|
||||
if providerType == "gotify" && strings.TrimSpace(req.Token) == "" {
|
||||
if (providerType == "gotify" || providerType == "telegram") && strings.TrimSpace(req.Token) == "" {
|
||||
// Keep existing token if update payload omits token
|
||||
req.Token = existing.Token
|
||||
}
|
||||
@@ -306,6 +310,23 @@ func (h *NotificationProviderHandler) Test(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Email providers use global SMTP + recipients from the URL field; they don't require a saved provider ID.
|
||||
if providerType == "email" {
|
||||
provider := models.NotificationProvider{
|
||||
ID: strings.TrimSpace(req.ID),
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
URL: req.URL,
|
||||
}
|
||||
if err := h.service.TestEmailProvider(provider); err != nil {
|
||||
code, category, message := classifyProviderTestFailure(err)
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, code, category, message)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(req.ID)
|
||||
if providerID == "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "MISSING_PROVIDER_ID", "validation", "Trusted provider ID is required for test dispatch")
|
||||
|
||||
@@ -23,7 +23,7 @@ func setupNotificationProviderTest(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
db := handlers.OpenTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}, &models.Notification{}))
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewNotificationProviderHandler(service)
|
||||
|
||||
r := gin.Default()
|
||||
@@ -510,3 +510,161 @@ func TestNotificationProviderHandler_Create_ResponseHasHasToken(t *testing.T) {
|
||||
assert.Equal(t, true, raw["has_token"])
|
||||
assert.NotContains(t, w.Body.String(), "app-token-123")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_Email_NoMailService_Returns400(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
// mailService is nil in test setup — email test should return 400 (not MISSING_PROVIDER_ID)
|
||||
payload := map[string]interface{}{
|
||||
"type": "email",
|
||||
"url": "user@example.com",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_Email_EmptyURL_Returns400(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"type": "email",
|
||||
"url": "",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_Email_DoesNotRequireProviderID(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
// No ID field — email path must not return MISSING_PROVIDER_ID
|
||||
payload := map[string]interface{}{
|
||||
"type": "email",
|
||||
"url": "user@example.com",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NotEqual(t, "MISSING_PROVIDER_ID", resp["code"])
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_NonEmail_StillRequiresProviderID(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"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))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Equal(t, "MISSING_PROVIDER_ID", resp["code"])
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Create_Telegram(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "My Telegram Bot",
|
||||
"type": "telegram",
|
||||
"url": "123456789",
|
||||
"token": "bot123456789:ABCdefGHIjklMNOpqrSTUvwxYZ",
|
||||
"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, "telegram", raw["type"])
|
||||
assert.Equal(t, true, raw["has_token"])
|
||||
// Token must never appear in response
|
||||
assert.NotContains(t, w.Body.String(), "bot123456789:ABCdefGHIjklMNOpqrSTUvwxYZ")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Update_TelegramTokenPreservation(t *testing.T) {
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
p := models.NotificationProvider{
|
||||
ID: "tg-preserve",
|
||||
Name: "Telegram Bot",
|
||||
Type: "telegram",
|
||||
URL: "123456789",
|
||||
Token: "original-bot-token",
|
||||
}
|
||||
require.NoError(t, db.Create(&p).Error)
|
||||
|
||||
// Update without token — token should be preserved
|
||||
payload := map[string]interface{}{
|
||||
"name": "Updated Telegram Bot",
|
||||
"type": "telegram",
|
||||
"url": "987654321",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/notifications/providers/tg-preserve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify token was preserved in DB
|
||||
var dbProvider models.NotificationProvider
|
||||
require.NoError(t, db.Where("id = ?", "tg-preserve").First(&dbProvider).Error)
|
||||
assert.Equal(t, "original-bot-token", dbProvider.Token)
|
||||
assert.Equal(t, "987654321", dbProvider.URL)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_List_TelegramNeverExposesBotToken(t *testing.T) {
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
p := models.NotificationProvider{
|
||||
ID: "tg-secret",
|
||||
Name: "Secret Telegram",
|
||||
Type: "telegram",
|
||||
URL: "123456789",
|
||||
Token: "bot999:SECRETTOKEN",
|
||||
}
|
||||
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(), "bot999:SECRETTOKEN")
|
||||
assert.NotContains(t, w.Body.String(), "api.telegram.org")
|
||||
|
||||
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"])
|
||||
_, hasTokenField := raw[0]["token"]
|
||||
assert.False(t, hasTokenField, "raw token field must not appear in JSON response")
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestUpdate_BlockTypeMutationForNonDiscord(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(existing).Error)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -85,7 +85,7 @@ func TestUpdate_AllowTypeMutationForDiscord(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(existing).Error)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestNotificationTemplateHandler_CRUDAndPreview(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}, &models.Notification{}, &models.NotificationProvider{}))
|
||||
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
r := gin.New()
|
||||
@@ -92,7 +92,7 @@ func TestNotificationTemplateHandler_Create_InvalidJSON(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
@@ -113,7 +113,7 @@ func TestNotificationTemplateHandler_Update_InvalidJSON(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
@@ -134,7 +134,7 @@ func TestNotificationTemplateHandler_Preview_InvalidJSON(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
@@ -155,7 +155,7 @@ func TestNotificationTemplateHandler_AdminRequired(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
r := gin.New()
|
||||
@@ -185,7 +185,7 @@ func TestNotificationTemplateHandler_List_DBError(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
r := gin.New()
|
||||
@@ -205,7 +205,7 @@ func TestNotificationTemplateHandler_WriteOps_DBError(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{}))
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
r := gin.New()
|
||||
@@ -264,7 +264,7 @@ func TestNotificationTemplateHandler_WriteOps_PermissionErrorResponse(t *testing
|
||||
_ = db.Callback().Delete().Remove(deleteHook)
|
||||
})
|
||||
|
||||
svc := services.NewNotificationService(db)
|
||||
svc := services.NewNotificationService(db, nil)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
r := gin.New()
|
||||
|
||||
@@ -404,7 +404,7 @@ func (h *ProxyHostHandler) Create(c *gin.Context) {
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"proxy_host",
|
||||
"Proxy Host Created",
|
||||
fmt.Sprintf("Proxy Host %s (%s) created", util.SanitizeForLog(host.Name), util.SanitizeForLog(host.DomainNames)),
|
||||
"A new proxy host was successfully created.",
|
||||
map[string]any{
|
||||
"Name": util.SanitizeForLog(host.Name),
|
||||
"Domains": util.SanitizeForLog(host.DomainNames),
|
||||
@@ -679,7 +679,7 @@ func (h *ProxyHostHandler) Delete(c *gin.Context) {
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"proxy_host",
|
||||
"Proxy Host Deleted",
|
||||
fmt.Sprintf("Proxy Host %s deleted", host.Name),
|
||||
"A proxy host was successfully deleted.",
|
||||
map[string]any{
|
||||
"Name": host.Name,
|
||||
"Action": "deleted",
|
||||
|
||||
@@ -32,7 +32,7 @@ func setupTestRouterForSecurityHeaders(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
&models.NotificationProvider{},
|
||||
))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewProxyHostHandler(db, nil, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
@@ -36,7 +36,7 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
&models.NotificationProvider{},
|
||||
))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewProxyHostHandler(db, nil, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
@@ -60,7 +60,7 @@ func setupTestRouterWithReferenceTables(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
&models.NotificationProvider{},
|
||||
))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewProxyHostHandler(db, nil, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
@@ -86,7 +86,7 @@ func setupTestRouterWithUptime(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
&models.Setting{},
|
||||
))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
us := services.NewUptimeService(db, ns)
|
||||
h := NewProxyHostHandler(db, nil, ns, us)
|
||||
r := gin.New()
|
||||
@@ -100,7 +100,7 @@ func TestProxyHostHandler_ResolveAccessListReference_TargetedBranches(t *testing
|
||||
t.Parallel()
|
||||
|
||||
_, db := setupTestRouterWithReferenceTables(t)
|
||||
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
|
||||
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db, nil), nil)
|
||||
|
||||
resolved, err := h.resolveAccessListReference(true)
|
||||
require.Error(t, err)
|
||||
@@ -124,7 +124,7 @@ func TestProxyHostHandler_ResolveSecurityHeaderReference_TargetedBranches(t *tes
|
||||
t.Parallel()
|
||||
|
||||
_, db := setupTestRouterWithReferenceTables(t)
|
||||
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
|
||||
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db, nil), nil)
|
||||
|
||||
resolved, err := h.resolveSecurityHeaderProfileReference(" ")
|
||||
require.NoError(t, err)
|
||||
@@ -327,7 +327,7 @@ func TestProxyHostDelete_WithUptimeCleanup(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.UptimeMonitor{}, &models.UptimeHeartbeat{}))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
us := services.NewUptimeService(db, ns)
|
||||
h := NewProxyHostHandler(db, nil, ns, us)
|
||||
|
||||
@@ -381,7 +381,7 @@ func TestProxyHostErrors(t *testing.T) {
|
||||
manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
|
||||
|
||||
// Setup Handler
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewProxyHostHandler(db, manager, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
@@ -661,7 +661,7 @@ func TestProxyHostWithCaddyIntegration(t *testing.T) {
|
||||
manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
|
||||
|
||||
// Setup Handler
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewProxyHostHandler(db, manager, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
@@ -1894,7 +1894,7 @@ func TestUpdate_IntegrationCaddyConfig(t *testing.T) {
|
||||
client := caddy.NewClientWithExpectedPort(caddyServer.URL, expectedPortFromURL(t, caddyServer.URL))
|
||||
manager := caddy.NewManager(client, db, tmpDir, "", false, config.SecurityConfig{})
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewProxyHostHandler(db, manager, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
@@ -36,7 +36,7 @@ func setupUpdateTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
&models.NotificationProvider{},
|
||||
))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewProxyHostHandler(db, nil, ns, nil)
|
||||
|
||||
r := gin.New()
|
||||
@@ -933,7 +933,7 @@ func TestBulkUpdateSecurityHeaders_DBError_NonNotFound(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(&host).Error)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewProxyHostHandler(db, nil, ns, nil)
|
||||
|
||||
r := gin.New()
|
||||
|
||||
@@ -73,7 +73,7 @@ func (h *RemoteServerHandler) Create(c *gin.Context) {
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"remote_server",
|
||||
"Remote Server Added",
|
||||
fmt.Sprintf("Remote Server %s (%s:%d) added", util.SanitizeForLog(server.Name), util.SanitizeForLog(server.Host), server.Port),
|
||||
"A new remote server was successfully added.",
|
||||
map[string]any{
|
||||
"Name": util.SanitizeForLog(server.Name),
|
||||
"Host": util.SanitizeForLog(server.Host),
|
||||
@@ -142,7 +142,7 @@ func (h *RemoteServerHandler) Delete(c *gin.Context) {
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"remote_server",
|
||||
"Remote Server Deleted",
|
||||
fmt.Sprintf("Remote Server %s deleted", util.SanitizeForLog(server.Name)),
|
||||
"A remote server was successfully deleted.",
|
||||
map[string]any{
|
||||
"Name": util.SanitizeForLog(server.Name),
|
||||
"Action": "deleted",
|
||||
|
||||
@@ -22,7 +22,7 @@ func setupRemoteServerTest_New(t *testing.T) (*gin.Engine, *handlers.RemoteServe
|
||||
// Ensure RemoteServer table exists
|
||||
_ = db.AutoMigrate(&models.RemoteServer{})
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestSecurityEventIntakeCompileSuccess(t *testing.T) {
|
||||
db := SetupCompatibilityTestDB(t)
|
||||
|
||||
// This test validates that the handler can be instantiated with all required dependencies
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
service := services.NewEnhancedSecurityNotificationService(db)
|
||||
securityService := services.NewSecurityService(db)
|
||||
managementCIDRs := []string{"127.0.0.0/8"}
|
||||
@@ -47,7 +47,7 @@ func TestSecurityEventIntakeCompileSuccess(t *testing.T) {
|
||||
func TestSecurityEventIntakeAuthLocalhost(t *testing.T) {
|
||||
db := SetupCompatibilityTestDB(t)
|
||||
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
service := services.NewEnhancedSecurityNotificationService(db)
|
||||
managementCIDRs := []string{"10.0.0.0/8"}
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestSecurityEventIntakeAuthLocalhost(t *testing.T) {
|
||||
func TestSecurityEventIntakeAuthManagementCIDR(t *testing.T) {
|
||||
db := SetupCompatibilityTestDB(t)
|
||||
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
service := services.NewEnhancedSecurityNotificationService(db)
|
||||
managementCIDRs := []string{"192.168.1.0/24", "10.0.0.0/8"}
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestSecurityEventIntakeAuthManagementCIDR(t *testing.T) {
|
||||
func TestSecurityEventIntakeAuthUnauthorizedIP(t *testing.T) {
|
||||
db := SetupCompatibilityTestDB(t)
|
||||
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
service := services.NewEnhancedSecurityNotificationService(db)
|
||||
managementCIDRs := []string{"192.168.1.0/24"}
|
||||
|
||||
@@ -175,7 +175,7 @@ func TestSecurityEventIntakeAuthUnauthorizedIP(t *testing.T) {
|
||||
func TestSecurityEventIntakeAuthInvalidIP(t *testing.T) {
|
||||
db := SetupCompatibilityTestDB(t)
|
||||
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
service := services.NewEnhancedSecurityNotificationService(db)
|
||||
managementCIDRs := []string{"192.168.1.0/24"}
|
||||
|
||||
@@ -234,7 +234,7 @@ func TestSecurityEventIntakeDispatchInvoked(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(provider).Error)
|
||||
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
service := services.NewEnhancedSecurityNotificationService(db)
|
||||
managementCIDRs := []string{"127.0.0.0/8"}
|
||||
|
||||
@@ -374,7 +374,7 @@ func TestSecurityEventIntakeDiscordOnly(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, db.Create(webhookProvider).Error)
|
||||
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
service := services.NewEnhancedSecurityNotificationService(db)
|
||||
managementCIDRs := []string{"127.0.0.0/8"}
|
||||
|
||||
@@ -419,7 +419,7 @@ func TestSecurityEventIntakeDiscordOnly(t *testing.T) {
|
||||
func TestSecurityEventIntakeMalformedPayload(t *testing.T) {
|
||||
db := SetupCompatibilityTestDB(t)
|
||||
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
service := services.NewEnhancedSecurityNotificationService(db)
|
||||
managementCIDRs := []string{"127.0.0.0/8"}
|
||||
|
||||
@@ -454,7 +454,7 @@ func TestSecurityEventIntakeMalformedPayload(t *testing.T) {
|
||||
func TestSecurityEventIntakeIPv6Localhost(t *testing.T) {
|
||||
db := SetupCompatibilityTestDB(t)
|
||||
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
service := services.NewEnhancedSecurityNotificationService(db)
|
||||
managementCIDRs := []string{"10.0.0.0/8"}
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ func TestFinalBlocker3_SupportedProviderTypes_UnsupportedTypesIgnored(t *testing
|
||||
db := SetupCompatibilityTestDB(t)
|
||||
|
||||
// Create ONLY unsupported providers
|
||||
unsupportedTypes := []string{"telegram", "generic"}
|
||||
unsupportedTypes := []string{"pushover", "generic"}
|
||||
|
||||
for _, providerType := range unsupportedTypes {
|
||||
provider := &models.NotificationProvider{
|
||||
|
||||
@@ -238,7 +238,7 @@ func TestR6_LegacyWrite410GoneNoMutation(t *testing.T) {
|
||||
func TestProviderCRUD_SecurityEventsIncludeCrowdSec(t *testing.T) {
|
||||
db := setupSingleSourceTestDB(t)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
service := services.NewNotificationService(db, nil)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestHandleSecurityEvent_TimestampZero(t *testing.T) {
|
||||
|
||||
enhancedService := services.NewEnhancedSecurityNotificationService(db)
|
||||
securityService := services.NewSecurityService(db)
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
h := NewSecurityNotificationHandlerWithDeps(enhancedService, securityService, "/tmp", notificationService, []string{"127.0.0.0/8"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -85,7 +85,7 @@ func TestHandleSecurityEvent_SendViaProvidersError(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
securityService := services.NewSecurityService(db)
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
mockService := &mockFailingService{}
|
||||
h := NewSecurityNotificationHandlerWithDeps(mockService, securityService, "/tmp", notificationService, []string{"127.0.0.0/8"})
|
||||
|
||||
|
||||
@@ -1,681 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// mockSecurityNotificationService implements the service interface for controlled testing.
|
||||
type mockSecurityNotificationService struct {
|
||||
getSettingsFunc func() (*models.NotificationConfig, error)
|
||||
updateSettingsFunc func(*models.NotificationConfig) error
|
||||
}
|
||||
|
||||
func (m *mockSecurityNotificationService) GetSettings() (*models.NotificationConfig, error) {
|
||||
if m.getSettingsFunc != nil {
|
||||
return m.getSettingsFunc()
|
||||
}
|
||||
return &models.NotificationConfig{}, nil
|
||||
}
|
||||
|
||||
func (m *mockSecurityNotificationService) UpdateSettings(c *models.NotificationConfig) error {
|
||||
if m.updateSettingsFunc != nil {
|
||||
return m.updateSettingsFunc(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupSecNotifTestDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationConfig{}))
|
||||
return db
|
||||
}
|
||||
|
||||
// TestNewSecurityNotificationHandler verifies constructor returns non-nil handler.
|
||||
func TestNewSecurityNotificationHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := setupSecNotifTestDB(t)
|
||||
svc := services.NewSecurityNotificationService(db)
|
||||
handler := NewSecurityNotificationHandler(svc)
|
||||
|
||||
assert.NotNil(t, handler, "Handler should not be nil")
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_GetSettings_Success tests successful settings retrieval.
|
||||
func TestSecurityNotificationHandler_GetSettings_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectedConfig := &models.NotificationConfig{
|
||||
ID: "test-id",
|
||||
Enabled: true,
|
||||
MinLogLevel: "warn",
|
||||
WebhookURL: "https://example.com/webhook",
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: false,
|
||||
}
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
getSettingsFunc: func() (*models.NotificationConfig, error) {
|
||||
return expectedConfig, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/security/notifications/settings", http.NoBody)
|
||||
|
||||
handler.GetSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var config models.NotificationConfig
|
||||
err := json.Unmarshal(w.Body.Bytes(), &config)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expectedConfig.ID, config.ID)
|
||||
assert.Equal(t, expectedConfig.Enabled, config.Enabled)
|
||||
assert.Equal(t, expectedConfig.MinLogLevel, config.MinLogLevel)
|
||||
assert.Equal(t, expectedConfig.WebhookURL, config.WebhookURL)
|
||||
assert.Equal(t, expectedConfig.NotifyWAFBlocks, config.NotifyWAFBlocks)
|
||||
assert.Equal(t, expectedConfig.NotifyACLDenies, config.NotifyACLDenies)
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_GetSettings_ServiceError tests service error handling.
|
||||
func TestSecurityNotificationHandler_GetSettings_ServiceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
getSettingsFunc: func() (*models.NotificationConfig, error) {
|
||||
return nil, errors.New("database connection failed")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/security/notifications/settings", http.NoBody)
|
||||
|
||||
handler.GetSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Failed to retrieve settings")
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_InvalidJSON tests malformed JSON handling.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_InvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
malformedJSON := []byte(`{enabled: true, "min_log_level": "error"`)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(malformedJSON))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Invalid request body")
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_InvalidMinLogLevel tests invalid log level rejection.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_InvalidMinLogLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
invalidLevels := []struct {
|
||||
name string
|
||||
level string
|
||||
}{
|
||||
{"trace", "trace"},
|
||||
{"critical", "critical"},
|
||||
{"fatal", "fatal"},
|
||||
{"unknown", "unknown"},
|
||||
}
|
||||
|
||||
for _, tc := range invalidLevels {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockService := &mockSecurityNotificationService{}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: tc.level,
|
||||
NotifyWAFBlocks: true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Invalid min_log_level")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_InvalidWebhookURL_SSRF tests SSRF protection.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_InvalidWebhookURL_SSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ssrfURLs := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"AWS Metadata", "http://169.254.169.254/latest/meta-data/"},
|
||||
{"GCP Metadata", "http://metadata.google.internal/computeMetadata/v1/"},
|
||||
{"Azure Metadata", "http://169.254.169.254/metadata/instance"},
|
||||
{"Private IP 10.x", "http://10.0.0.1/admin"},
|
||||
{"Private IP 172.16.x", "http://172.16.0.1/config"},
|
||||
{"Private IP 192.168.x", "http://192.168.1.1/api"},
|
||||
{"Link-local", "http://169.254.1.1/"},
|
||||
}
|
||||
|
||||
for _, tc := range ssrfURLs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mockService := &mockSecurityNotificationService{}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "error",
|
||||
WebhookURL: tc.url,
|
||||
NotifyWAFBlocks: true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Invalid webhook URL")
|
||||
if help, ok := response["help"]; ok {
|
||||
assert.Contains(t, help, "private networks")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_PrivateIPWebhook tests private IP handling.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_PrivateIPWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Note: localhost is allowed by WithAllowLocalhost() option
|
||||
localhostURLs := []string{
|
||||
"http://127.0.0.1/hook",
|
||||
"http://localhost/webhook",
|
||||
"http://[::1]/api",
|
||||
}
|
||||
|
||||
for _, url := range localhostURLs {
|
||||
t.Run(url, func(t *testing.T) {
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "warn",
|
||||
WebhookURL: url,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
// Localhost should be allowed with AllowLocalhost option
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Localhost should be allowed: %s", url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_ServiceError tests database error handling.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_ServiceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
return errors.New("database write failed")
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "error",
|
||||
WebhookURL: "http://localhost:9090/webhook", // Use localhost
|
||||
NotifyWAFBlocks: true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response["error"], "Failed to update settings")
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_Success tests successful settings update.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var capturedConfig *models.NotificationConfig
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
capturedConfig = c
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "warn",
|
||||
WebhookURL: "http://localhost:8080/security", // Use localhost which is allowed
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: false,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Settings updated successfully", response["message"])
|
||||
|
||||
// Verify the service was called with the correct config
|
||||
require.NotNil(t, capturedConfig)
|
||||
assert.Equal(t, config.Enabled, capturedConfig.Enabled)
|
||||
assert.Equal(t, config.MinLogLevel, capturedConfig.MinLogLevel)
|
||||
assert.Equal(t, config.WebhookURL, capturedConfig.WebhookURL)
|
||||
assert.Equal(t, config.NotifyWAFBlocks, capturedConfig.NotifyWAFBlocks)
|
||||
assert.Equal(t, config.NotifyACLDenies, capturedConfig.NotifyACLDenies)
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_UpdateSettings_EmptyWebhookURL tests empty webhook is valid.
|
||||
func TestSecurityNotificationHandler_UpdateSettings_EmptyWebhookURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "info",
|
||||
WebhookURL: "",
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Settings updated successfully", response["message"])
|
||||
}
|
||||
|
||||
func TestSecurityNotificationHandler_RouteAliasGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectedConfig := &models.NotificationConfig{
|
||||
ID: "alias-test-id",
|
||||
Enabled: true,
|
||||
MinLogLevel: "info",
|
||||
WebhookURL: "https://example.com/webhook",
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: true,
|
||||
}
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
getSettingsFunc: func() (*models.NotificationConfig, error) {
|
||||
return expectedConfig, nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/security/notifications/settings", handler.GetSettings)
|
||||
router.GET("/api/v1/notifications/settings/security", handler.GetSettings)
|
||||
|
||||
originalWriter := httptest.NewRecorder()
|
||||
originalRequest := httptest.NewRequest(http.MethodGet, "/api/v1/security/notifications/settings", http.NoBody)
|
||||
router.ServeHTTP(originalWriter, originalRequest)
|
||||
|
||||
aliasWriter := httptest.NewRecorder()
|
||||
aliasRequest := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/settings/security", http.NoBody)
|
||||
router.ServeHTTP(aliasWriter, aliasRequest)
|
||||
|
||||
assert.Equal(t, http.StatusOK, originalWriter.Code)
|
||||
assert.Equal(t, originalWriter.Code, aliasWriter.Code)
|
||||
assert.Equal(t, originalWriter.Body.String(), aliasWriter.Body.String())
|
||||
}
|
||||
|
||||
func TestSecurityNotificationHandler_RouteAliasUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
legacyUpdates := 0
|
||||
canonicalUpdates := 0
|
||||
mockService := &mockSecurityNotificationService{
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
if c.WebhookURL == "http://localhost:8080/security" {
|
||||
canonicalUpdates++
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
config := models.NotificationConfig{
|
||||
Enabled: true,
|
||||
MinLogLevel: "warn",
|
||||
WebhookURL: "http://localhost:8080/security",
|
||||
NotifyWAFBlocks: true,
|
||||
NotifyACLDenies: false,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
setAdminContext(c)
|
||||
c.Next()
|
||||
})
|
||||
router.PUT("/api/v1/security/notifications/settings", handler.DeprecatedUpdateSettings)
|
||||
router.PUT("/api/v1/notifications/settings/security", handler.UpdateSettings)
|
||||
|
||||
originalWriter := httptest.NewRecorder()
|
||||
originalRequest := httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body))
|
||||
originalRequest.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(originalWriter, originalRequest)
|
||||
|
||||
aliasWriter := httptest.NewRecorder()
|
||||
aliasRequest := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
|
||||
aliasRequest.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(aliasWriter, aliasRequest)
|
||||
|
||||
assert.Equal(t, http.StatusGone, originalWriter.Code)
|
||||
assert.Equal(t, "true", originalWriter.Header().Get("X-Charon-Deprecated"))
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", originalWriter.Header().Get("X-Charon-Canonical-Endpoint"))
|
||||
|
||||
assert.Equal(t, http.StatusOK, aliasWriter.Code)
|
||||
assert.Equal(t, 0, legacyUpdates)
|
||||
assert.Equal(t, 1, canonicalUpdates)
|
||||
}
|
||||
|
||||
func TestSecurityNotificationHandler_DeprecatedRouteHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{
|
||||
getSettingsFunc: func() (*models.NotificationConfig, error) {
|
||||
return &models.NotificationConfig{Enabled: true, MinLogLevel: "warn"}, nil
|
||||
},
|
||||
updateSettingsFunc: func(c *models.NotificationConfig) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
setAdminContext(c)
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/api/v1/security/notifications/settings", handler.DeprecatedGetSettings)
|
||||
router.PUT("/api/v1/security/notifications/settings", handler.DeprecatedUpdateSettings)
|
||||
router.GET("/api/v1/notifications/settings/security", handler.GetSettings)
|
||||
router.PUT("/api/v1/notifications/settings/security", handler.UpdateSettings)
|
||||
|
||||
legacyGet := httptest.NewRecorder()
|
||||
legacyGetReq := httptest.NewRequest(http.MethodGet, "/api/v1/security/notifications/settings", http.NoBody)
|
||||
router.ServeHTTP(legacyGet, legacyGetReq)
|
||||
require.Equal(t, http.StatusOK, legacyGet.Code)
|
||||
assert.Equal(t, "true", legacyGet.Header().Get("X-Charon-Deprecated"))
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", legacyGet.Header().Get("X-Charon-Canonical-Endpoint"))
|
||||
|
||||
canonicalGet := httptest.NewRecorder()
|
||||
canonicalGetReq := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/settings/security", http.NoBody)
|
||||
router.ServeHTTP(canonicalGet, canonicalGetReq)
|
||||
require.Equal(t, http.StatusOK, canonicalGet.Code)
|
||||
assert.Empty(t, canonicalGet.Header().Get("X-Charon-Deprecated"))
|
||||
|
||||
body, err := json.Marshal(models.NotificationConfig{Enabled: true, MinLogLevel: "warn"})
|
||||
require.NoError(t, err)
|
||||
|
||||
legacyPut := httptest.NewRecorder()
|
||||
legacyPutReq := httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body))
|
||||
legacyPutReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(legacyPut, legacyPutReq)
|
||||
require.Equal(t, http.StatusGone, legacyPut.Code)
|
||||
assert.Equal(t, "true", legacyPut.Header().Get("X-Charon-Deprecated"))
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", legacyPut.Header().Get("X-Charon-Canonical-Endpoint"))
|
||||
|
||||
var legacyBody map[string]string
|
||||
err = json.Unmarshal(legacyPut.Body.Bytes(), &legacyBody)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, legacyBody, 2)
|
||||
assert.Equal(t, "This endpoint is deprecated and no longer accepts updates", legacyBody["error"])
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", legacyBody["canonical_endpoint"])
|
||||
|
||||
canonicalPut := httptest.NewRecorder()
|
||||
canonicalPutReq := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/settings/security", bytes.NewBuffer(body))
|
||||
canonicalPutReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(canonicalPut, canonicalPutReq)
|
||||
require.Equal(t, http.StatusOK, canonicalPut.Code)
|
||||
}
|
||||
|
||||
func TestNormalizeEmailRecipients(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: " ",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "single valid",
|
||||
input: "admin@example.com",
|
||||
want: "admin@example.com",
|
||||
},
|
||||
{
|
||||
name: "multiple valid with spaces and blanks",
|
||||
input: " admin@example.com, , ops@example.com ,security@example.com ",
|
||||
want: "admin@example.com, ops@example.com, security@example.com",
|
||||
},
|
||||
{
|
||||
name: "duplicates and mixed case preserved",
|
||||
input: "Admin@Example.com, admin@example.com, Admin@Example.com",
|
||||
want: "Admin@Example.com, admin@example.com, Admin@Example.com",
|
||||
},
|
||||
{
|
||||
name: "invalid only",
|
||||
input: "not-an-email",
|
||||
wantErr: "invalid email recipients: not-an-email",
|
||||
},
|
||||
{
|
||||
name: "mixed invalid and valid",
|
||||
input: "admin@example.com, bad-address,ops@example.com",
|
||||
wantErr: "invalid email recipients: bad-address",
|
||||
},
|
||||
{
|
||||
name: "multiple invalids",
|
||||
input: "bad-address,also-bad",
|
||||
wantErr: "invalid email recipients: bad-address, also-bad",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeEmailRecipients(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tt.wantErr, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityNotificationHandler_DeprecatedUpdateSettings_AllFields tests that all JSON fields are returned
|
||||
func TestSecurityNotificationHandler_DeprecatedUpdateSettings_AllFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockService := &mockSecurityNotificationService{}
|
||||
handler := NewSecurityNotificationHandler(mockService)
|
||||
|
||||
body, err := json.Marshal(models.NotificationConfig{Enabled: true, MinLogLevel: "warn"})
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/security/notifications/settings", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.DeprecatedUpdateSettings(c)
|
||||
|
||||
assert.Equal(t, http.StatusGone, w.Code)
|
||||
assert.Equal(t, "true", w.Header().Get("X-Charon-Deprecated"))
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", w.Header().Get("X-Charon-Canonical-Endpoint"))
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both JSON fields are present with exact values
|
||||
assert.Equal(t, "This endpoint is deprecated and no longer accepts updates", response["error"])
|
||||
assert.Equal(t, "/api/v1/notifications/settings/security", response["canonical_endpoint"])
|
||||
assert.Len(t, response, 2, "Should have exactly 2 fields in JSON response")
|
||||
}
|
||||
@@ -131,16 +131,6 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Block legacy fallback flag writes (LEGACY_FALLBACK_REMOVED)
|
||||
if req.Key == "feature.notifications.legacy.fallback_enabled" &&
|
||||
strings.EqualFold(strings.TrimSpace(req.Value), "true") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Legacy fallback has been removed and cannot be re-enabled",
|
||||
"code": "LEGACY_FALLBACK_REMOVED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Key == "security.admin_whitelist" {
|
||||
if err := validateAdminWhitelist(req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid admin_whitelist: %v", err)})
|
||||
@@ -279,12 +269,6 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
|
||||
|
||||
if err := h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for key, value := range updates {
|
||||
// Block legacy fallback flag writes (LEGACY_FALLBACK_REMOVED)
|
||||
if key == "feature.notifications.legacy.fallback_enabled" &&
|
||||
strings.EqualFold(strings.TrimSpace(value), "true") {
|
||||
return fmt.Errorf("legacy fallback has been removed and cannot be re-enabled")
|
||||
}
|
||||
|
||||
if key == "security.admin_whitelist" {
|
||||
if err := validateAdminWhitelist(value); err != nil {
|
||||
return fmt.Errorf("invalid admin_whitelist: %w", err)
|
||||
@@ -321,13 +305,6 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
if strings.Contains(err.Error(), "legacy fallback has been removed") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Legacy fallback has been removed and cannot be re-enabled",
|
||||
"code": "LEGACY_FALLBACK_REMOVED",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrInvalidAdminCIDR) || strings.Contains(err.Error(), "invalid admin_whitelist") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"})
|
||||
return
|
||||
@@ -657,7 +634,10 @@ func (h *SettingsHandler) SendTestEmail(c *gin.Context) {
|
||||
</html>
|
||||
`
|
||||
|
||||
if err := h.MailService.SendEmail(req.To, "Charon - Test Email", htmlBody); err != nil {
|
||||
// req.To is validated as RFC 5321 email via gin binding:"required,email".
|
||||
// SendEmail enforces validateEmailRecipients + net/mail.ParseAddress + rejectCRLF as defence-in-depth.
|
||||
// Suppression annotations are on the SMTP sinks in mail_service.go.
|
||||
if err := h.MailService.SendEmail(c.Request.Context(), []string{req.To}, "Charon - Test Email", htmlBody); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
|
||||
@@ -516,81 +516,6 @@ func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T)
|
||||
assert.Equal(t, 1, mgr.calls)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_BlocksLegacyFallbackFlag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.POST("/settings", handler.UpdateSetting)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"true lowercase", "true"},
|
||||
{"true uppercase", "TRUE"},
|
||||
{"true mixed case", "True"},
|
||||
{"true with whitespace", " true "},
|
||||
{"true with tabs", "\ttrue\t"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"key": "feature.notifications.legacy.fallback_enabled",
|
||||
"value": tc.value,
|
||||
}
|
||||
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)
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, resp["error"], "Legacy fallback has been removed")
|
||||
assert.Equal(t, "LEGACY_FALLBACK_REMOVED", resp["code"])
|
||||
|
||||
// Verify flag was not saved to database
|
||||
var setting models.Setting
|
||||
err = db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error
|
||||
assert.Error(t, err) // Should not exist
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_AllowsLegacyFallbackFlagFalse(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": "feature.notifications.legacy.fallback_enabled",
|
||||
"value": "false",
|
||||
}
|
||||
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)
|
||||
|
||||
// Verify flag was saved to database with false value
|
||||
var setting models.Setting
|
||||
err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "false", setting.Value)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
@@ -774,98 +699,6 @@ func TestSettingsHandler_PatchConfig_EnablesCerberusWhenACLEnabled(t *testing.T)
|
||||
assert.True(t, cfg.Enabled)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_BlocksLegacyFallbackFlag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.PATCH("/config", handler.PatchConfig)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
}{
|
||||
{"nested true", map[string]any{
|
||||
"feature": map[string]any{
|
||||
"notifications": map[string]any{
|
||||
"legacy": map[string]any{
|
||||
"fallback_enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
{"flat key true", map[string]any{
|
||||
"feature.notifications.legacy.fallback_enabled": "true",
|
||||
}},
|
||||
{"nested string true", map[string]any{
|
||||
"feature": map[string]any{
|
||||
"notifications": map[string]any{
|
||||
"legacy": map[string]any{
|
||||
"fallback_enabled": "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(tc.payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, resp["error"], "Legacy fallback has been removed")
|
||||
assert.Equal(t, "LEGACY_FALLBACK_REMOVED", resp["code"])
|
||||
|
||||
// Verify flag was not saved to database
|
||||
var setting models.Setting
|
||||
err = db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error
|
||||
assert.Error(t, err) // Should not exist
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_AllowsLegacyFallbackFlagFalse(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{
|
||||
"feature": map[string]any{
|
||||
"notifications": map[string]any{
|
||||
"legacy": map[string]any{
|
||||
"fallback_enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PATCH", "/config", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify flag was saved to database with false value
|
||||
var setting models.Setting
|
||||
err := db.Where("key = ?", "feature.notifications.legacy.fallback_enabled").First(&setting).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "false", setting.Value)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_DatabaseError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
@@ -23,7 +23,7 @@ func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
db := handlers.OpenTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}, &models.RemoteServer{}, &models.NotificationProvider{}, &models.Notification{}, &models.ProxyHost{}))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
service := services.NewUptimeService(db, ns)
|
||||
handler := handlers.NewUptimeHandler(service)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestUptimeMonitorInitialStatePending(t *testing.T) {
|
||||
_ = db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHost{})
|
||||
|
||||
// Create handler with service
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, nil)
|
||||
uptimeService := services.NewUptimeService(db, notificationService)
|
||||
|
||||
// Test: Create a monitor via service
|
||||
|
||||
@@ -594,6 +594,7 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
appName := getAppName(h.DB)
|
||||
|
||||
go func() {
|
||||
// userEmail validated as RFC 5321 format; rejectCRLF + net/mail.ParseAddress in mail_service.go cover this path.
|
||||
if err := h.MailService.SendInvite(userEmail, userToken, appName, baseURL); err != nil {
|
||||
// Log failure but don't block response
|
||||
middleware.GetRequestLogger(c).WithField("user_email", sanitizeForLog(userEmail)).WithField("error", sanitizeForLog(err.Error())).Error("Failed to send invite email")
|
||||
@@ -1012,6 +1013,7 @@ func (h *UserHandler) ResendInvite(c *gin.Context) {
|
||||
baseURL, ok := utils.GetConfiguredPublicURL(h.DB)
|
||||
if ok {
|
||||
appName := getAppName(h.DB)
|
||||
// userEmail validated as RFC 5321 format; rejectCRLF + net/mail.ParseAddress in mail_service.go cover this path.
|
||||
if err := h.MailService.SendInvite(user.Email, inviteToken, appName, baseURL); err == nil {
|
||||
emailSent = true
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
wsStatusHandler := handlers.NewWebSocketStatusHandler(wsTracker)
|
||||
|
||||
// Notification Service (needed for multiple handlers)
|
||||
notificationService := services.NewNotificationService(db)
|
||||
notificationService := services.NewNotificationService(db, services.NewMailService(db))
|
||||
|
||||
// Ensure notify-only provider migration reconciliation at boot
|
||||
if err := notificationService.EnsureNotifyOnlyProviderMigration(context.Background()); err != nil {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -96,7 +98,6 @@ func Load() (Config, error) {
|
||||
CaddyBinary: getEnvAny("caddy", "CHARON_CADDY_BINARY", "CPM_CADDY_BINARY"),
|
||||
ImportCaddyfile: getEnvAny("/import/Caddyfile", "CHARON_IMPORT_CADDYFILE", "CPM_IMPORT_CADDYFILE"),
|
||||
ImportDir: getEnvAny(filepath.Join("data", "imports"), "CHARON_IMPORT_DIR", "CPM_IMPORT_DIR"),
|
||||
JWTSecret: getEnvAny("change-me-in-production", "CHARON_JWT_SECRET", "CPM_JWT_SECRET"),
|
||||
EncryptionKey: getEnvAny("", "CHARON_ENCRYPTION_KEY"),
|
||||
ACMEStaging: getEnvAny("", "CHARON_ACME_STAGING", "CPM_ACME_STAGING") == "true",
|
||||
SingleContainer: strings.EqualFold(getEnvAny("true", "CHARON_SINGLE_CONTAINER_MODE"), "true"),
|
||||
@@ -108,6 +109,13 @@ func Load() (Config, error) {
|
||||
Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true",
|
||||
}
|
||||
|
||||
// Set JWTSecret using os.Getenv directly so no string literal flows into the
|
||||
// field — prevents CodeQL go/parse-jwt-with-hardcoded-key taint from any fallback.
|
||||
cfg.JWTSecret = os.Getenv("CHARON_JWT_SECRET")
|
||||
if cfg.JWTSecret == "" {
|
||||
cfg.JWTSecret = os.Getenv("CPM_JWT_SECRET")
|
||||
}
|
||||
|
||||
allowedInternalHosts := security.InternalServiceHostAllowlist()
|
||||
normalizedCaddyAdminURL, err := security.ValidateInternalServiceBaseURL(
|
||||
cfg.CaddyAdminAPI,
|
||||
@@ -131,6 +139,14 @@ func Load() (Config, error) {
|
||||
return Config{}, fmt.Errorf("ensure import directory: %w", err)
|
||||
}
|
||||
|
||||
if cfg.JWTSecret == "" {
|
||||
b := make([]byte, 32)
|
||||
if _, err := crand.Read(b); err != nil {
|
||||
return Config{}, fmt.Errorf("generate fallback jwt secret: %w", err)
|
||||
}
|
||||
cfg.JWTSecret = hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,24 @@ func TestGetEnvIntAny(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoad_JWTSecretFallbackGeneration(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"))
|
||||
|
||||
// Clear both JWT secret env vars to trigger fallback generation
|
||||
t.Setenv("CHARON_JWT_SECRET", "")
|
||||
t.Setenv("CPM_JWT_SECRET", "")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fallback generates 32 random bytes → 64-char hex string
|
||||
assert.NotEmpty(t, cfg.JWTSecret)
|
||||
assert.Len(t, cfg.JWTSecret, 64)
|
||||
}
|
||||
|
||||
func TestLoad_SecurityConfig(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("CHARON_DB_PATH", filepath.Join(tempDir, "test.db"))
|
||||
|
||||
@@ -172,7 +172,9 @@ func TestApplyRepullsOnCacheMissAfterCSCLIFailure(t *testing.T) {
|
||||
func TestApplyRepullsOnCacheExpired(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
dataDir := filepath.Join(t.TempDir(), "data")
|
||||
cache, err := NewHubCache(cacheDir, 5*time.Millisecond)
|
||||
// Use a long TTL; expiry is simulated via nowFn injection to avoid wall-clock races on
|
||||
// loaded CI runners where 5ms can elapse between Store and Load, causing a second expiry.
|
||||
cache, err := NewHubCache(cacheDir, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
archive := makeTestArchive(t, map[string]string{"config.yaml": "test: expired"})
|
||||
@@ -180,8 +182,9 @@ func TestApplyRepullsOnCacheExpired(t *testing.T) {
|
||||
_, err = cache.Store(ctx, "expired/preset", "etag-old", "hub", "old", archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
// wait for expiration
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
// Advance the cache clock 2 hours past TTL so Apply sees the entry as expired,
|
||||
// while the freshly re-stored entry (retrieved_at ≈ now+2h, TTL=1h) remains valid.
|
||||
cache.nowFn = func() time.Time { return time.Now().Add(2 * time.Hour) }
|
||||
|
||||
hub := NewHubService(nil, cache, dataDir)
|
||||
hub.HubBaseURL = "http://test.example.com"
|
||||
|
||||
@@ -134,6 +134,7 @@ func TestManualChallenge_StructFields(t *testing.T) {
|
||||
assert.Empty(t, challenge.ErrorMessage)
|
||||
assert.False(t, challenge.DNSPropagated)
|
||||
assert.Equal(t, now, challenge.CreatedAt)
|
||||
assert.Equal(t, now.Add(10*time.Minute), challenge.ExpiresAt)
|
||||
assert.NotNil(t, challenge.LastCheckAt)
|
||||
assert.NotNil(t, challenge.VerifiedAt)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,10 @@ type NotificationConfig struct {
|
||||
MinLogLevel string `json:"min_log_level"` // error, warn, info, debug
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
// Blocker 2 Fix: API surface uses security_* field names per spec (internal fields remain notify_*)
|
||||
NotifyWAFBlocks bool `json:"security_waf_enabled"`
|
||||
NotifyACLDenies bool `json:"security_acl_enabled"`
|
||||
NotifyRateLimitHits bool `json:"security_rate_limit_enabled"`
|
||||
NotifyCrowdSecDecisions bool `json:"security_crowdsec_enabled"`
|
||||
EmailRecipients string `json:"email_recipients"`
|
||||
NotifyWAFBlocks bool `json:"security_waf_enabled"`
|
||||
NotifyACLDenies bool `json:"security_acl_enabled"`
|
||||
NotifyRateLimitHits bool `json:"security_rate_limit_enabled"`
|
||||
NotifyCrowdSecDecisions bool `json:"security_crowdsec_enabled"`
|
||||
|
||||
// Legacy destination fields (compatibility, not stored in DB)
|
||||
DiscordWebhookURL string `gorm:"-" json:"discord_webhook_url,omitempty"`
|
||||
|
||||
@@ -3,7 +3,6 @@ package notifications
|
||||
import "context"
|
||||
|
||||
const (
|
||||
EngineLegacy = "legacy"
|
||||
EngineNotifyV1 = "notify_v1"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package notifications
|
||||
const (
|
||||
FlagNotifyEngineEnabled = "feature.notifications.engine.notify_v1.enabled"
|
||||
FlagDiscordServiceEnabled = "feature.notifications.service.discord.enabled"
|
||||
FlagEmailServiceEnabled = "feature.notifications.service.email.enabled"
|
||||
FlagGotifyServiceEnabled = "feature.notifications.service.gotify.enabled"
|
||||
FlagWebhookServiceEnabled = "feature.notifications.service.webhook.enabled"
|
||||
FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled"
|
||||
FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled"
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -157,6 +158,9 @@ func (w *HTTPWrapper) Send(ctx context.Context, request HTTPWrapperRequest) (*HT
|
||||
}
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
if hint := extractProviderErrorHint(body); hint != "" {
|
||||
return nil, fmt.Errorf("provider returned status %d: %s", resp.StatusCode, hint)
|
||||
}
|
||||
return nil, fmt.Errorf("provider returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -410,6 +414,34 @@ func shouldRetry(resp *http.Response, err error) bool {
|
||||
return resp.StatusCode >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// extractProviderErrorHint attempts to extract a short, human-readable error description
|
||||
// from a JSON error response body. Only well-known fields are extracted to avoid
|
||||
// accidentally surfacing sensitive or overlong content from arbitrary providers.
|
||||
func extractProviderErrorHint(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
var errResp map[string]any
|
||||
if err := json.Unmarshal(body, &errResp); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"description", "message", "error", "error_description"} {
|
||||
v, ok := errResp[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
if len(s) > 100 {
|
||||
s = s[:100] + "..."
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readCappedResponseBody(body io.Reader) ([]byte, error) {
|
||||
limited := io.LimitReader(body, MaxNotifyResponseBodyBytes+1)
|
||||
content, err := io.ReadAll(limited)
|
||||
|
||||
@@ -921,3 +921,81 @@ func TestAllowNotifyHTTPOverride(t *testing.T) {
|
||||
t.Fatal("expected allowHTTP to be true in test binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractProviderErrorHint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "description field",
|
||||
body: []byte(`{"description":"Not Found: chat not found"}`),
|
||||
expected: "Not Found: chat not found",
|
||||
},
|
||||
{
|
||||
name: "message field",
|
||||
body: []byte(`{"message":"Unauthorized"}`),
|
||||
expected: "Unauthorized",
|
||||
},
|
||||
{
|
||||
name: "error field",
|
||||
body: []byte(`{"error":"rate limited"}`),
|
||||
expected: "rate limited",
|
||||
},
|
||||
{
|
||||
name: "error_description field",
|
||||
body: []byte(`{"error_description":"invalid token"}`),
|
||||
expected: "invalid token",
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: []byte{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "non-JSON body",
|
||||
body: []byte(`<html>Server Error</html>`),
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "string over 100 chars truncated",
|
||||
body: []byte(`{"description":"` + strings.Repeat("x", 120) + `"}`),
|
||||
expected: strings.Repeat("x", 100) + "...",
|
||||
},
|
||||
{
|
||||
name: "empty string value ignored",
|
||||
body: []byte(`{"description":"","message":"fallback hint"}`),
|
||||
expected: "fallback hint",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only value ignored",
|
||||
body: []byte(`{"description":" ","message":"real hint"}`),
|
||||
expected: "real hint",
|
||||
},
|
||||
{
|
||||
name: "non-string value ignored",
|
||||
body: []byte(`{"description":42,"message":"string hint"}`),
|
||||
expected: "string hint",
|
||||
},
|
||||
{
|
||||
name: "priority order: description before message",
|
||||
body: []byte(`{"message":"second","description":"first"}`),
|
||||
expected: "first",
|
||||
},
|
||||
{
|
||||
name: "no recognized fields",
|
||||
body: []byte(`{"status":"error","code":500}`),
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := extractProviderErrorHint(tt.body)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractProviderErrorHint(%q) = %q, want %q", string(tt.body), result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,36 +2,30 @@ package notifications
|
||||
|
||||
import "strings"
|
||||
|
||||
// NOTE: used only in tests
|
||||
type Router struct{}
|
||||
|
||||
func NewRouter() *Router {
|
||||
return &Router{}
|
||||
}
|
||||
|
||||
func (r *Router) ShouldUseNotify(providerType, providerEngine string, flags map[string]bool) bool {
|
||||
func (r *Router) ShouldUseNotify(providerType string, flags map[string]bool) bool {
|
||||
if !flags[FlagNotifyEngineEnabled] {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.EqualFold(providerEngine, EngineLegacy) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToLower(providerType) {
|
||||
case "discord":
|
||||
return flags[FlagDiscordServiceEnabled]
|
||||
case "email":
|
||||
return flags[FlagEmailServiceEnabled]
|
||||
case "gotify":
|
||||
return flags[FlagGotifyServiceEnabled]
|
||||
case "webhook":
|
||||
return flags[FlagWebhookServiceEnabled]
|
||||
case "telegram":
|
||||
return flags[FlagTelegramServiceEnabled]
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) ShouldUseLegacyFallback(flags map[string]bool) bool {
|
||||
// Hard-disabled: Legacy fallback has been permanently removed.
|
||||
// This function exists only for interface compatibility and always returns false.
|
||||
_ = flags // Explicitly ignore flags to prevent accidental re-introduction
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -10,37 +10,15 @@ func TestRouter_ShouldUseNotify(t *testing.T) {
|
||||
FlagDiscordServiceEnabled: true,
|
||||
}
|
||||
|
||||
if !router.ShouldUseNotify("discord", EngineNotifyV1, flags) {
|
||||
if !router.ShouldUseNotify("discord", flags) {
|
||||
t.Fatalf("expected notify routing for discord when enabled")
|
||||
}
|
||||
|
||||
if router.ShouldUseNotify("discord", EngineLegacy, flags) {
|
||||
t.Fatalf("expected legacy engine to stay on legacy path")
|
||||
}
|
||||
|
||||
if router.ShouldUseNotify("telegram", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("telegram", flags) {
|
||||
t.Fatalf("expected unsupported service to remain legacy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_ShouldUseLegacyFallback(t *testing.T) {
|
||||
router := NewRouter()
|
||||
|
||||
if router.ShouldUseLegacyFallback(map[string]bool{}) {
|
||||
t.Fatalf("expected fallback disabled by default")
|
||||
}
|
||||
|
||||
// Note: FlagLegacyFallbackEnabled constant has been removed as part of hard-disable
|
||||
// Using string literal for test completeness
|
||||
if router.ShouldUseLegacyFallback(map[string]bool{"feature.notifications.legacy.fallback_enabled": false}) {
|
||||
t.Fatalf("expected fallback disabled when flag is false")
|
||||
}
|
||||
|
||||
if router.ShouldUseLegacyFallback(map[string]bool{"feature.notifications.legacy.fallback_enabled": true}) {
|
||||
t.Fatalf("expected fallback disabled even when flag is true (hard-disabled)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouter_ShouldUseNotify_EngineDisabled covers lines 13-14
|
||||
func TestRouter_ShouldUseNotify_EngineDisabled(t *testing.T) {
|
||||
router := NewRouter()
|
||||
@@ -50,7 +28,7 @@ func TestRouter_ShouldUseNotify_EngineDisabled(t *testing.T) {
|
||||
FlagDiscordServiceEnabled: true,
|
||||
}
|
||||
|
||||
if router.ShouldUseNotify("discord", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("discord", flags) {
|
||||
t.Fatalf("expected notify routing disabled when FlagNotifyEngineEnabled is false")
|
||||
}
|
||||
}
|
||||
@@ -64,7 +42,7 @@ func TestRouter_ShouldUseNotify_DiscordServiceFlag(t *testing.T) {
|
||||
FlagDiscordServiceEnabled: false,
|
||||
}
|
||||
|
||||
if router.ShouldUseNotify("discord", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("discord", flags) {
|
||||
t.Fatalf("expected notify routing disabled for discord when FlagDiscordServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
@@ -79,14 +57,14 @@ func TestRouter_ShouldUseNotify_GotifyServiceFlag(t *testing.T) {
|
||||
FlagGotifyServiceEnabled: true,
|
||||
}
|
||||
|
||||
if !router.ShouldUseNotify("gotify", EngineNotifyV1, flags) {
|
||||
if !router.ShouldUseNotify("gotify", flags) {
|
||||
t.Fatalf("expected notify routing enabled for gotify when FlagGotifyServiceEnabled is true")
|
||||
}
|
||||
|
||||
// Test with gotify disabled
|
||||
flags[FlagGotifyServiceEnabled] = false
|
||||
|
||||
if router.ShouldUseNotify("gotify", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("gotify", flags) {
|
||||
t.Fatalf("expected notify routing disabled for gotify when FlagGotifyServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
@@ -99,12 +77,12 @@ func TestRouter_ShouldUseNotify_WebhookServiceFlag(t *testing.T) {
|
||||
FlagWebhookServiceEnabled: true,
|
||||
}
|
||||
|
||||
if !router.ShouldUseNotify("webhook", EngineNotifyV1, flags) {
|
||||
if !router.ShouldUseNotify("webhook", flags) {
|
||||
t.Fatalf("expected notify routing enabled for webhook when FlagWebhookServiceEnabled is true")
|
||||
}
|
||||
|
||||
flags[FlagWebhookServiceEnabled] = false
|
||||
if router.ShouldUseNotify("webhook", EngineNotifyV1, flags) {
|
||||
if router.ShouldUseNotify("webhook", flags) {
|
||||
t.Fatalf("expected notify routing disabled for webhook when FlagWebhookServiceEnabled is false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,12 +652,13 @@ func (s *BackupService) extractDatabaseFromBackup(zipPath string) (string, error
|
||||
}()
|
||||
|
||||
const maxDecompressedSize = 100 * 1024 * 1024 // 100MB
|
||||
limitedReader := io.LimitReader(rc, maxDecompressedSize+1)
|
||||
written, err := io.Copy(outFile, limitedReader)
|
||||
lr := &io.LimitedReader{R: rc, N: maxDecompressedSize}
|
||||
written, err := io.Copy(outFile, lr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy archive entry: %w", err)
|
||||
}
|
||||
if written > maxDecompressedSize {
|
||||
_ = written
|
||||
if lr.N == 0 {
|
||||
return fmt.Errorf("archive entry %s exceeded decompression limit (%d bytes), potential decompression bomb", file.Name, maxDecompressedSize)
|
||||
}
|
||||
if err := outFile.Sync(); err != nil {
|
||||
@@ -749,13 +750,14 @@ func (s *BackupService) unzipWithSkip(src, dest string, skipEntries map[string]s
|
||||
return err
|
||||
}
|
||||
|
||||
// Limit decompressed size to prevent decompression bombs (100MB limit)
|
||||
// Limit decompressed size to prevent decompression bombs (100MB limit).
|
||||
// Use max+1 so lr.N == 0 only when a byte beyond the limit was consumed,
|
||||
// avoiding a false positive for files that are exactly maxDecompressedSize.
|
||||
const maxDecompressedSize = 100 * 1024 * 1024 // 100MB
|
||||
limitedReader := io.LimitReader(rc, maxDecompressedSize)
|
||||
written, err := io.Copy(outFile, limitedReader)
|
||||
lr := &io.LimitedReader{R: rc, N: maxDecompressedSize + 1}
|
||||
_, err = io.Copy(outFile, lr)
|
||||
|
||||
// Verify we didn't hit the limit (potential attack)
|
||||
if err == nil && written >= maxDecompressedSize {
|
||||
if err == nil && lr.N == 0 {
|
||||
err = fmt.Errorf("file %s exceeded decompression limit (%d bytes), potential decompression bomb", f.Name, maxDecompressedSize)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
@@ -120,7 +121,7 @@ func TestCoverageBoost_ErrorPaths(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("NotificationService_ListTemplates_EmptyDB", func(t *testing.T) {
|
||||
svc := NewNotificationService(db)
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
// Should not error with empty db
|
||||
templates, err := svc.ListTemplates()
|
||||
@@ -130,7 +131,7 @@ func TestCoverageBoost_ErrorPaths(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("NotificationService_GetTemplate_NotFound", func(t *testing.T) {
|
||||
svc := NewNotificationService(db)
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
// Test with non-existent ID
|
||||
_, err := svc.GetTemplate("nonexistent")
|
||||
@@ -227,7 +228,7 @@ func TestCoverageBoost_MailService_ErrorPaths(t *testing.T) {
|
||||
|
||||
t.Run("SendEmail_NoConfig", func(t *testing.T) {
|
||||
// With empty config, should error
|
||||
err := svc.SendEmail("test@example.com", "Subject", "Body")
|
||||
err := svc.SendEmail(context.Background(), []string{"test@example.com"}, "Subject", "Body")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -426,7 +427,7 @@ func TestCoverageBoost_MailService_SendSSL(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to send - should fail with connection error
|
||||
err = svc.SendEmail("test@example.com", "Test", "Body")
|
||||
err = svc.SendEmail(context.Background(), []string{"test@example.com"}, "Test", "Body")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
@@ -444,7 +445,7 @@ func TestCoverageBoost_MailService_SendSSL(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to send - should fail with connection error
|
||||
err = svc.SendEmail("test@example.com", "Test", "Body")
|
||||
err = svc.SendEmail(context.Background(), []string{"test@example.com"}, "Test", "Body")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -523,7 +524,7 @@ func TestCoverageBoost_NotificationService_Providers(t *testing.T) {
|
||||
err = db.AutoMigrate(&models.NotificationProvider{})
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewNotificationService(db)
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
t.Run("ListProviders_EmptyDB", func(t *testing.T) {
|
||||
providers, err := svc.ListProviders()
|
||||
@@ -591,7 +592,7 @@ func TestCoverageBoost_NotificationService_CRUD(t *testing.T) {
|
||||
err = db.AutoMigrate(&models.Notification{})
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewNotificationService(db)
|
||||
svc := NewNotificationService(db, nil)
|
||||
|
||||
t.Run("List_EmptyDB", func(t *testing.T) {
|
||||
notifs, err := svc.List(false)
|
||||
|
||||
@@ -84,10 +84,11 @@ func (s *EnhancedSecurityNotificationService) getProviderAggregatedConfig() (*mo
|
||||
// Blocker 3: Filter for supported notify-only provider types (PR-1 scope)
|
||||
// All supported types are included in GET aggregation for configuration visibility
|
||||
supportedTypes := map[string]bool{
|
||||
"webhook": true,
|
||||
"discord": true,
|
||||
"slack": true,
|
||||
"gotify": true,
|
||||
"webhook": true,
|
||||
"discord": true,
|
||||
"slack": true,
|
||||
"gotify": true,
|
||||
"telegram": true,
|
||||
}
|
||||
filteredProviders := []models.NotificationProvider{}
|
||||
for _, p := range providers {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/notifications"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -170,41 +169,6 @@ func TestDiscordOnly_SendViaProvidersFiltersNonDiscord(t *testing.T) {
|
||||
_ = originalDispatch // Suppress unused warning
|
||||
}
|
||||
|
||||
// TestNoFallbackPath_RouterAlwaysReturnsFalse tests that the router never enables legacy fallback.
|
||||
func TestNoFallbackPath_RouterAlwaysReturnsFalse(t *testing.T) {
|
||||
// Import router to test actual routing behavior
|
||||
router := notifications.NewRouter()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
flags map[string]bool
|
||||
}{
|
||||
{"no_flags", map[string]bool{}},
|
||||
{"fallback_false", map[string]bool{"feature.notifications.legacy.fallback_enabled": false}},
|
||||
{"fallback_true", map[string]bool{"feature.notifications.legacy.fallback_enabled": true}},
|
||||
{"all_enabled", map[string]bool{
|
||||
"feature.notifications.legacy.fallback_enabled": true,
|
||||
"feature.notifications.engine.notify_v1.enabled": true,
|
||||
"feature.notifications.service.discord.enabled": true,
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Concrete assertion: Router always returns false regardless of flag state
|
||||
shouldFallback := router.ShouldUseLegacyFallback(tc.flags)
|
||||
assert.False(t, shouldFallback,
|
||||
"Router must return false for all flag combinations - legacy fallback is permanently disabled")
|
||||
|
||||
// Proof: Even when flag is explicitly true, router returns false
|
||||
if tc.flags["feature.notifications.legacy.fallback_enabled"] {
|
||||
assert.False(t, shouldFallback,
|
||||
"Router ignores legacy fallback flag and always returns false")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoFallbackPath_ServiceHasNoLegacyDispatchHooks tests that the service has no legacy dispatch hooks.
|
||||
func TestNoFallbackPath_ServiceHasNoLegacyDispatchHooks(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user