Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ coverage.out
*.crdownload
*.sarif

# -----------------------------------------------------------------------------
# SBOM artifacts
# -----------------------------------------------------------------------------
sbom*.json

# -----------------------------------------------------------------------------
# CodeQL & Security Scanning (large, not needed)
# -----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions .github/agents/Manegment.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can

7. **Phase 7: Closure**:
- **Docs**: Call `Docs_Writer`.
- **Manual Testing**: create a new test plan in `docs/issues/*.md` for tracking manual testing focused on finding potential bugs of the implemented features.
- **Final Report**: Summarize the successful subagent runs.
- **Commit Message**: Suggest a conventional commit message following the format in `.github/copilot-instructions.md`:
- Use `feat:` for new user-facing features
Expand Down
4 changes: 2 additions & 2 deletions .github/agents/QA_Security.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Your job is to act as an ADVERSARY. The Developer says "it works"; your job is t
3. **Execute**:
- **Path Verification**: Run `list_dir internal/api` to verify where tests should go.
- **Creation**: Write a new test file (e.g., `internal/api/tests/audit_test.go`) to test the *flow*.
- **Run**: Execute `go test ./internal/api/tests/...` (or specific path). Run local CodeQL and Trivy scans (they are built as VS Code Tasks so they just need to be triggered to run), pre-commit all files, and triage any findings.
- **Run**: Execute `.github/skills`, `go test ./internal/api/tests/...` (or specific path). Run local CodeQL and Trivy scans (they are built as VS Code Tasks so they just need to be triggered to run), pre-commit all files, and triage any findings.
- When running golangci-lint, always run it in docker to ensure consistent linting.
- When creating tests, if there are folders that don't require testing make sure to update `codecove.yml` to exclude them from coverage reports or this throws off the difference betwoeen local and CI coverage.
- **Cleanup**: If the test was temporary, delete it. If it's valuable, keep it.
Expand Down Expand Up @@ -85,7 +85,7 @@ The task is not complete until ALL of the following pass with zero issues:
4. **Security Scans**:
- CodeQL: Run as VS Code task or via GitHub Actions
- Trivy: Run as VS Code task or via Docker
- Zero Critical or High severity issues allowed
- Zero issues allowed

5. **Linting**: All language-specific linters must pass (Go vet, ESLint, markdownlint)

Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ jobs:
contents: read
packages: write
security-events: write
id-token: write # Required for SBOM attestation
attestations: write # Required for SBOM attestation

outputs:
skip_build: ${{ steps.skip.outputs.skip_build }}
Expand Down Expand Up @@ -231,6 +233,25 @@ jobs:
sarif_file: 'trivy-results.sarif'
token: ${{ secrets.GITHUB_TOKEN }}

# Generate SBOM (Software Bill of Materials) for supply chain security
- name: Generate SBOM
uses: anchore/sbom-action@61119d458adab75f756bc0b9e4bde25725f86a7a # v0.17.2
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cyclonedx.json

# Create verifiable attestation for the SBOM
- name: Attest SBOM
uses: actions/attest-sbom@115c3be05ff3974bcbd596578934b3f9ce39bf68 # v2.2.0
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build-and-push.outputs.digest }}
sbom-path: sbom.cyclonedx.json
push-to-registry: true

- name: Create summary
if: steps.skip.outputs.skip_build != 'true'
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs-to-issues.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ on:
dry_run:
description: 'Dry run (no issues created)'
required: false
default: 'false'
default: false
type: boolean
file_path:
description: 'Specific file to process (optional)'
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ test-results/local.har
# -----------------------------------------------------------------------------
/trivy-*.txt

# -----------------------------------------------------------------------------
# SBOM artifacts
# -----------------------------------------------------------------------------
sbom*.json

# -----------------------------------------------------------------------------
# Docker Overrides (new location)
# -----------------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions backend/internal/api/handlers/user_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)

type UserHandler struct {
Expand Down Expand Up @@ -793,6 +794,13 @@ func (h *UserHandler) AcceptInvite(c *gin.Context) {
return
}

// Verify token in constant time as defense-in-depth against timing attacks.
// The DB lookup itself has timing variance, but this prevents comparison timing leaks.
if !util.ConstantTimeCompare(user.InviteToken, req.Token) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid invite token"})
return
}

// Check if token is expired
if user.InviteExpires != nil && user.InviteExpires.Before(time.Now()) {
// Mark as expired
Expand Down
21 changes: 21 additions & 0 deletions backend/internal/util/crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package util

import (
"crypto/subtle"
)

// ConstantTimeCompare compares two strings in constant time to prevent timing attacks.
// Returns true if the strings are equal, false otherwise.
// This should be used when comparing sensitive values like tokens.
func ConstantTimeCompare(a, b string) bool {
aBytes := []byte(a)
bBytes := []byte(b)

// subtle.ConstantTimeCompare returns 1 if equal, 0 if not
return subtle.ConstantTimeCompare(aBytes, bBytes) == 1
}

// ConstantTimeCompareBytes compares two byte slices in constant time.
func ConstantTimeCompareBytes(a, b []byte) bool {
return subtle.ConstantTimeCompare(a, b) == 1
}
82 changes: 82 additions & 0 deletions backend/internal/util/crypto_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package util

import (
"testing"
)

func TestConstantTimeCompare(t *testing.T) {
tests := []struct {
name string
a string
b string
expected bool
}{
{"equal strings", "secret123", "secret123", true},
{"different strings", "secret123", "secret456", false},
{"different lengths", "short", "muchlonger", false},
{"empty strings", "", "", true},
{"one empty", "notempty", "", false},
{"unicode equal", "héllo", "héllo", true},
{"unicode different", "héllo", "hëllo", false},
{"special chars equal", "!@#$%^&*()", "!@#$%^&*()", true},
{"whitespace matters", "hello ", "hello", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConstantTimeCompare(tt.a, tt.b)
if result != tt.expected {
t.Errorf("ConstantTimeCompare(%q, %q) = %v, want %v", tt.a, tt.b, result, tt.expected)
}
})
}
}

func TestConstantTimeCompareBytes(t *testing.T) {
tests := []struct {
name string
a []byte
b []byte
expected bool
}{
{"equal bytes", []byte{1, 2, 3}, []byte{1, 2, 3}, true},
{"different bytes", []byte{1, 2, 3}, []byte{1, 2, 4}, false},
{"different lengths", []byte{1, 2}, []byte{1, 2, 3}, false},
{"empty slices", []byte{}, []byte{}, true},
{"nil slices", nil, nil, true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConstantTimeCompareBytes(tt.a, tt.b)
if result != tt.expected {
t.Errorf("ConstantTimeCompareBytes(%v, %v) = %v, want %v", tt.a, tt.b, result, tt.expected)
}
})
}
}

// BenchmarkConstantTimeCompare ensures the function remains constant-time.
func BenchmarkConstantTimeCompare(b *testing.B) {
secret := "a]3kL9#mP2$vN7@qR5*wX1&yT4^uI8%oE0!"

b.Run("equal", func(b *testing.B) {
for i := 0; i < b.N; i++ {
ConstantTimeCompare(secret, secret)
}
})

b.Run("different_first_char", func(b *testing.B) {
different := "b]3kL9#mP2$vN7@qR5*wX1&yT4^uI8%oE0!"
for i := 0; i < b.N; i++ {
ConstantTimeCompare(secret, different)
}
})

b.Run("different_last_char", func(b *testing.B) {
different := "a]3kL9#mP2$vN7@qR5*wX1&yT4^uI8%oE0?"
for i := 0; i < b.N; i++ {
ConstantTimeCompare(secret, different)
}
})
}
36 changes: 36 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,42 @@ Now that you have the basics:

---

## Staying Updated

### Security Update Notifications

To receive notifications about security updates:

**1. GitHub Watch**

Click "Watch" → "Custom" → Select "Security advisories" on the [Charon repository](https://github.com/Wikid82/Charon)

**2. Automatic Updates with Watchtower**

```yaml
services:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_POLL_INTERVAL=86400 # Check daily
```

**3. Diun (Docker Image Update Notifier)**

For notification-only (no auto-update), use [Diun](https://crazymax.dev/diun/). This sends alerts when new images are available without automatically updating.

**Best Practices:**

- Subscribe to GitHub security advisories for early vulnerability warnings
- Review changelogs before updating production deployments
- Test updates in a staging environment first
- Keep backups before major version upgrades

---

## Stuck?

**[Ask for help](https://github.com/Wikid82/charon/discussions)** — The community is friendly!
Expand Down
110 changes: 110 additions & 0 deletions docs/issues/issue-365-manual-test-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
title: "Issue #365: Additional Security Enhancements - Manual Test Plan"
labels:
- manual-testing
- security
- testing
type: testing
priority: medium
parent_issue: 365
---

# Issue #365: Additional Security Enhancements - Manual Test Plan

**Issue**: https://github.com/Wikid82/Charon/issues/365
**PRs**: #436, #437
**Status**: Ready for Manual Testing

---

## Test Scenarios

### 1. Invite Token Security

**Objective**: Verify constant-time token comparison doesn't leak timing information.

**Steps**:
1. Create a new user invite via the admin UI
2. Copy the invite token from the generated link
3. Attempt to accept the invite with the correct token - should succeed
4. Attempt to accept with a token that differs only in the last character - should fail with same response time
5. Attempt to accept with a completely wrong token - should fail with same response time

**Expected**: Response times should be consistent regardless of where the token differs.

---

### 2. Security Headers Verification

**Objective**: Verify all security headers are present.

**Steps**:
1. Start Charon with HTTPS enabled
2. Use browser dev tools or curl to inspect response headers
3. Verify presence of:
- `Content-Security-Policy`
- `Strict-Transport-Security` (with preload)
- `X-Frame-Options: DENY`
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy`
- `Permissions-Policy`

**curl command**:
```bash
curl -I https://your-charon-instance.com/
```

---

### 3. Container Hardening (Optional - Production)

**Objective**: Verify documented container hardening works.

**Steps**:
1. Deploy Charon using the hardened docker-compose config from docs/security.md
2. Verify container starts successfully with `read_only: true`
3. Verify all functionality works (proxy hosts, certificates, etc.)
4. Verify logs are written to tmpfs mount

---

### 4. Documentation Review

**Objective**: Verify all documentation is accurate and complete.

**Pages to Review**:
- [ ] `docs/security.md` - TLS, DNS, Container Hardening sections
- [ ] `docs/security-incident-response.md` - SIRP document
- [ ] `docs/getting-started.md` - Security Update Notifications section

**Check for**:
- Correct code examples
- Working links
- No typos or formatting issues

---

### 5. SBOM Generation (CI/CD)

**Objective**: Verify SBOM is generated on release builds.

**Steps**:
1. Push a commit to trigger a non-PR build
2. Check GitHub Actions workflow run
3. Verify "Generate SBOM" step completes successfully
4. Verify "Attest SBOM" step completes successfully
5. Verify attestation is visible in GitHub container registry

---

## Acceptance Criteria

- [ ] All test scenarios pass
- [ ] No regressions in existing functionality
- [ ] Documentation is accurate and helpful

---

**Tester**: ________________
**Date**: ________________
**Result**: [ ] PASS / [ ] FAIL
Loading
Loading