Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
56 changes: 56 additions & 0 deletions audit/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package audit

import (
"log/slog"
"net/http"
)

// Request represents information about an HTTP request for auditing
type Request struct {
Method string
URL string
Allowed bool
Rule string // The rule that matched (if any)
Reason string // Reason for the action (e.g., "no matching allow rules")
}

// Auditor handles audit logging for HTTP requests
type Auditor interface {
// AuditRequest logs information about an HTTP request and the action taken
AuditRequest(req *Request)
}

// LoggingAuditor implements Auditor by logging to slog
type LoggingAuditor struct {
logger *slog.Logger
}

// NewLoggingAuditor creates a new LoggingAuditor
func NewLoggingAuditor(logger *slog.Logger) *LoggingAuditor {
return &LoggingAuditor{
logger: logger,
}
}

// AuditRequest logs the request using structured logging
func (a *LoggingAuditor) AuditRequest(req *Request) {
if req.Allowed {
a.logger.Info("ALLOW",
"method", req.Method,
"url", req.URL,
"rule", req.Rule)
} else {
a.logger.Warn("DENY",
"method", req.Method,
"url", req.URL,
"reason", req.Reason)
}
}

// HTTPRequestToAuditRequest converts an http.Request to an audit.Request
func HTTPRequestToAuditRequest(httpReq *http.Request) *Request {
return &Request{
Method: httpReq.Method,
URL: httpReq.URL.String(),
}
}
65 changes: 65 additions & 0 deletions audit/audit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package audit

import (
"log/slog"
"net/http"
"testing"
)

func TestLoggingAuditor(t *testing.T) {
// Create a logger that discards output during tests
logger := slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{
Level: slog.LevelError + 1, // Higher than any level to suppress all logs
}))

auditor := NewLoggingAuditor(logger)

tests := []struct {
name string
request *Request
}{
{
name: "allow request",
request: &Request{
Method: "GET",
URL: "https://github.com",
Allowed: true,
Rule: "allow github.com",
},
},
{
name: "deny request",
request: &Request{
Method: "POST",
URL: "https://example.com",
Allowed: false,
Reason: "no matching allow rules",
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Should not panic
auditor.AuditRequest(tt.request)
})
}
}

func TestHTTPRequestToAuditRequest(t *testing.T) {
req, err := http.NewRequest("GET", "https://example.com/path?query=value", nil)
if err != nil {
t.Fatalf("failed to create HTTP request: %v", err)
}

auditReq := HTTPRequestToAuditRequest(req)

if auditReq.Method != "GET" {
t.Errorf("expected method GET, got %s", auditReq.Method)
}

expectedURL := "https://example.com/path?query=value"
if auditReq.URL != expectedURL {
t.Errorf("expected URL %s, got %s", expectedURL, auditReq.URL)
}
}
5 changes: 5 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"syscall"
"time"

"github.com/coder/jail/audit"
"github.com/coder/jail/network"
"github.com/coder/jail/proxy"
"github.com/coder/jail/rules"
Expand Down Expand Up @@ -225,11 +226,15 @@ func runJail(inv *serpent.Invocation) error {
return fmt.Errorf("failed to setup network jail: %v", err)
}

// Create auditor
auditor := audit.NewLoggingAuditor(logger)

// Create proxy server
proxyConfig := proxy.Config{
HTTPPort: networkConfig.HTTPPort,
HTTPSPort: networkConfig.HTTPSPort,
RuleEngine: ruleEngine,
Auditor: auditor,
Logger: logger,
TLSConfig: tlsConfig,
}
Expand Down
37 changes: 33 additions & 4 deletions proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"net/url"
"time"

"github.com/coder/jail/audit"
"github.com/coder/jail/rules"
)

Expand All @@ -18,6 +19,7 @@ type ProxyServer struct {
httpServer *http.Server
httpsServer *http.Server
ruleEngine *rules.RuleEngine
auditor audit.Auditor
logger *slog.Logger
tlsConfig *tls.Config
httpPort int
Expand All @@ -29,6 +31,7 @@ type Config struct {
HTTPPort int
HTTPSPort int
RuleEngine *rules.RuleEngine
Auditor audit.Auditor
Logger *slog.Logger
TLSConfig *tls.Config
}
Expand All @@ -37,6 +40,7 @@ type Config struct {
func NewProxyServer(config Config) *ProxyServer {
return &ProxyServer{
ruleEngine: config.RuleEngine,
auditor: config.Auditor,
logger: config.Logger,
tlsConfig: config.TLSConfig,
httpPort: config.HTTPPort,
Expand Down Expand Up @@ -102,8 +106,15 @@ func (p *ProxyServer) Stop() error {
// handleHTTP handles regular HTTP requests
func (p *ProxyServer) handleHTTP(w http.ResponseWriter, r *http.Request) {
// Check if request should be allowed
action := p.ruleEngine.Evaluate(r.Method, r.URL.String())
if action == rules.Deny {
result := p.ruleEngine.EvaluateWithRule(r.Method, r.URL.String())

// Audit the request
auditReq := audit.HTTPRequestToAuditRequest(r)
auditReq.Allowed = result.Allowed
auditReq.Rule = result.Rule
p.auditRequest(auditReq)

if !result.Allowed {
p.writeBlockedResponse(w, r)
return
}
Expand All @@ -121,8 +132,18 @@ func (p *ProxyServer) handleHTTPS(w http.ResponseWriter, r *http.Request) {
}

// Check if request should be allowed
action := p.ruleEngine.Evaluate(r.Method, fullURL)
if action == rules.Deny {
result := p.ruleEngine.EvaluateWithRule(r.Method, fullURL)

// Audit the request
auditReq := &audit.Request{
Method: r.Method,
URL: fullURL,
Allowed: result.Allowed,
Rule: result.Rule,
}
p.auditRequest(auditReq)

if !result.Allowed {
p.writeBlockedResponse(w, r)
return
}
Expand Down Expand Up @@ -268,3 +289,11 @@ For more help: https://github.com/coder/jail
`,
r.Method, r.URL.Path, host, host, r.Method, host, r.Method)
}

// auditRequest handles auditing of requests
func (p *ProxyServer) auditRequest(req *audit.Request) {
if !req.Allowed {
req.Reason = "no matching allow rules"
}
p.auditor.AuditRequest(req)
}
51 changes: 29 additions & 22 deletions rules/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,6 @@ import (
"strings"
)

// Action represents whether to allow a request
type Action int

const (
Allow Action = iota
Deny // Default deny when no allow rules match
)

func (a Action) String() string {
switch a {
case Allow:
return "ALLOW"
default:
return "DENY"
}
}

// Rule represents an allow rule with optional HTTP method restrictions
type Rule struct {
Expand Down Expand Up @@ -137,19 +121,42 @@ func NewRuleEngine(rules []*Rule, logger *slog.Logger) *RuleEngine {
}
}

// Evaluate evaluates a request against all allow rules and returns the action to take
func (re *RuleEngine) Evaluate(method, url string) Action {
// EvaluationResult contains the result of rule evaluation
type EvaluationResult struct {
Allowed bool
Rule string // The rule that matched (if any)
}

// Evaluate evaluates a request against all allow rules and returns true if allowed
func (re *RuleEngine) Evaluate(method, url string) bool {
// Check if any allow rule matches
for _, rule := range re.rules {
if rule.Matches(method, url) {
re.logger.Info("ALLOW", "method", method, "url", url, "rule", rule.Raw)
return Allow
return true
}
}

// Default deny if no allow rules match
re.logger.Warn("DENY", "method", method, "url", url, "reason", "no matching allow rules")
return Deny
return false
}

// EvaluateWithRule evaluates a request and returns both result and matching rule
func (re *RuleEngine) EvaluateWithRule(method, url string) EvaluationResult {
// Check if any allow rule matches
for _, rule := range re.rules {
if rule.Matches(method, url) {
return EvaluationResult{
Allowed: true,
Rule: rule.Raw,
}
}
}

// Default deny if no allow rules match
return EvaluationResult{
Allowed: false,
Rule: "",
}
}

// newAllowRule creates an allow Rule from a spec string used by --allow.
Expand Down
22 changes: 11 additions & 11 deletions rules/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,12 @@ func TestRuleEngine(t *testing.T) {
name string
method string
url string
expected Action
expected bool
}{
{"allow github", "GET", "https://github.com/user/repo", Allow},
{"allow api GET", "GET", "https://api.example.com", Allow},
{"deny api POST", "POST", "https://api.example.com", Deny},
{"deny other", "GET", "https://example.com", Deny},
{"allow github", "GET", "https://github.com/user/repo", true},
{"allow api GET", "GET", "https://api.example.com", true},
{"deny api POST", "POST", "https://api.example.com", false},
{"deny other", "GET", "https://example.com", false},
}

for _, tt := range tests {
Expand Down Expand Up @@ -275,13 +275,13 @@ func TestRuleEngineWildcardRules(t *testing.T) {
name string
method string
url string
expected Action
expected bool
}{
{"allow github", "GET", "https://github.com", Allow},
{"allow github subdomain", "POST", "https://github.io", Allow},
{"allow api GET", "GET", "https://api.example.com", Allow},
{"deny api POST", "POST", "https://api.example.com", Deny},
{"deny unmatched", "GET", "https://example.org", Deny},
{"allow github", "GET", "https://github.com", true},
{"allow github subdomain", "POST", "https://github.io", true},
{"allow api GET", "GET", "https://api.example.com", true},
{"deny api POST", "POST", "https://api.example.com", false},
{"deny unmatched", "GET", "https://example.org", false},
}

for _, tt := range tests {
Expand Down
Loading