Skip to content

Commit d4178ac

Browse files
f0sselclaude
andcommitted
Add audit package for request access logging
- Create audit.Auditor interface for pluggable audit implementations - Add audit.LoggingAuditor that logs to slog (replaces rules engine logging) - Integrate auditor into proxy request handling pipeline - Update rules engine with EvaluateWithRule() to return rule details - Remove logging responsibility from rules engine (separation of concerns) - Add comprehensive tests for audit package All HTTP requests (allow/deny) now go through consistent audit pipeline 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 7b60fd0 commit d4178ac

File tree

5 files changed

+189
-6
lines changed

5 files changed

+189
-6
lines changed

audit/audit.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package audit
2+
3+
import (
4+
"log/slog"
5+
"net/http"
6+
7+
"github.com/coder/jail/rules"
8+
)
9+
10+
// Request represents information about an HTTP request for auditing
11+
type Request struct {
12+
Method string
13+
URL string
14+
Action rules.Action
15+
Rule string // The rule that matched (if any)
16+
Reason string // Reason for the action (e.g., "no matching allow rules")
17+
}
18+
19+
// Auditor handles audit logging for HTTP requests
20+
type Auditor interface {
21+
// AuditRequest logs information about an HTTP request and the action taken
22+
AuditRequest(req *Request)
23+
}
24+
25+
// LoggingAuditor implements Auditor by logging to slog
26+
type LoggingAuditor struct {
27+
logger *slog.Logger
28+
}
29+
30+
// NewLoggingAuditor creates a new LoggingAuditor
31+
func NewLoggingAuditor(logger *slog.Logger) *LoggingAuditor {
32+
return &LoggingAuditor{
33+
logger: logger,
34+
}
35+
}
36+
37+
// AuditRequest logs the request using structured logging
38+
func (a *LoggingAuditor) AuditRequest(req *Request) {
39+
switch req.Action {
40+
case rules.Allow:
41+
a.logger.Info("ALLOW",
42+
"method", req.Method,
43+
"url", req.URL,
44+
"rule", req.Rule)
45+
case rules.Deny:
46+
a.logger.Warn("DENY",
47+
"method", req.Method,
48+
"url", req.URL,
49+
"reason", req.Reason)
50+
}
51+
}
52+
53+
// HTTPRequestToAuditRequest converts an http.Request to an audit.Request
54+
func HTTPRequestToAuditRequest(httpReq *http.Request) *Request {
55+
return &Request{
56+
Method: httpReq.Method,
57+
URL: httpReq.URL.String(),
58+
}
59+
}

audit/audit_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package audit
2+
3+
import (
4+
"log/slog"
5+
"net/http"
6+
"testing"
7+
8+
"github.com/coder/jail/rules"
9+
)
10+
11+
func TestLoggingAuditor(t *testing.T) {
12+
// Create a logger that discards output during tests
13+
logger := slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{
14+
Level: slog.LevelError + 1, // Higher than any level to suppress all logs
15+
}))
16+
17+
auditor := NewLoggingAuditor(logger)
18+
19+
tests := []struct {
20+
name string
21+
request *Request
22+
}{
23+
{
24+
name: "allow request",
25+
request: &Request{
26+
Method: "GET",
27+
URL: "https://github.com",
28+
Action: rules.Allow,
29+
Rule: "allow github.com",
30+
},
31+
},
32+
{
33+
name: "deny request",
34+
request: &Request{
35+
Method: "POST",
36+
URL: "https://example.com",
37+
Action: rules.Deny,
38+
Reason: "no matching allow rules",
39+
},
40+
},
41+
}
42+
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
// Should not panic
46+
auditor.AuditRequest(tt.request)
47+
})
48+
}
49+
}
50+
51+
func TestHTTPRequestToAuditRequest(t *testing.T) {
52+
req, err := http.NewRequest("GET", "https://example.com/path?query=value", nil)
53+
if err != nil {
54+
t.Fatalf("failed to create HTTP request: %v", err)
55+
}
56+
57+
auditReq := HTTPRequestToAuditRequest(req)
58+
59+
if auditReq.Method != "GET" {
60+
t.Errorf("expected method GET, got %s", auditReq.Method)
61+
}
62+
63+
expectedURL := "https://example.com/path?query=value"
64+
if auditReq.URL != expectedURL {
65+
t.Errorf("expected URL %s, got %s", expectedURL, auditReq.URL)
66+
}
67+
}

main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"syscall"
1313
"time"
1414

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

229+
// Create auditor
230+
auditor := audit.NewLoggingAuditor(logger)
231+
228232
// Create proxy server
229233
proxyConfig := proxy.Config{
230234
HTTPPort: networkConfig.HTTPPort,
231235
HTTPSPort: networkConfig.HTTPSPort,
232236
RuleEngine: ruleEngine,
237+
Auditor: auditor,
233238
Logger: logger,
234239
TLSConfig: tlsConfig,
235240
}

proxy/proxy.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/url"
1111
"time"
1212

13+
"github.com/coder/jail/audit"
1314
"github.com/coder/jail/rules"
1415
)
1516

@@ -18,6 +19,7 @@ type ProxyServer struct {
1819
httpServer *http.Server
1920
httpsServer *http.Server
2021
ruleEngine *rules.RuleEngine
22+
auditor audit.Auditor
2123
logger *slog.Logger
2224
tlsConfig *tls.Config
2325
httpPort int
@@ -29,6 +31,7 @@ type Config struct {
2931
HTTPPort int
3032
HTTPSPort int
3133
RuleEngine *rules.RuleEngine
34+
Auditor audit.Auditor
3235
Logger *slog.Logger
3336
TLSConfig *tls.Config
3437
}
@@ -37,6 +40,7 @@ type Config struct {
3740
func NewProxyServer(config Config) *ProxyServer {
3841
return &ProxyServer{
3942
ruleEngine: config.RuleEngine,
43+
auditor: config.Auditor,
4044
logger: config.Logger,
4145
tlsConfig: config.TLSConfig,
4246
httpPort: config.HTTPPort,
@@ -102,8 +106,15 @@ func (p *ProxyServer) Stop() error {
102106
// handleHTTP handles regular HTTP requests
103107
func (p *ProxyServer) handleHTTP(w http.ResponseWriter, r *http.Request) {
104108
// Check if request should be allowed
105-
action := p.ruleEngine.Evaluate(r.Method, r.URL.String())
106-
if action == rules.Deny {
109+
result := p.ruleEngine.EvaluateWithRule(r.Method, r.URL.String())
110+
111+
// Audit the request
112+
auditReq := audit.HTTPRequestToAuditRequest(r)
113+
auditReq.Action = result.Action
114+
auditReq.Rule = result.Rule
115+
p.auditRequest(auditReq)
116+
117+
if result.Action == rules.Deny {
107118
p.writeBlockedResponse(w, r)
108119
return
109120
}
@@ -121,8 +132,18 @@ func (p *ProxyServer) handleHTTPS(w http.ResponseWriter, r *http.Request) {
121132
}
122133

123134
// Check if request should be allowed
124-
action := p.ruleEngine.Evaluate(r.Method, fullURL)
125-
if action == rules.Deny {
135+
result := p.ruleEngine.EvaluateWithRule(r.Method, fullURL)
136+
137+
// Audit the request
138+
auditReq := &audit.Request{
139+
Method: r.Method,
140+
URL: fullURL,
141+
Action: result.Action,
142+
Rule: result.Rule,
143+
}
144+
p.auditRequest(auditReq)
145+
146+
if result.Action == rules.Deny {
126147
p.writeBlockedResponse(w, r)
127148
return
128149
}
@@ -268,3 +289,11 @@ For more help: https://github.com/coder/jail
268289
`,
269290
r.Method, r.URL.Path, host, host, r.Method, host, r.Method)
270291
}
292+
293+
// auditRequest handles auditing of requests
294+
func (p *ProxyServer) auditRequest(req *audit.Request) {
295+
if req.Action == rules.Deny {
296+
req.Reason = "no matching allow rules"
297+
}
298+
p.auditor.AuditRequest(req)
299+
}

rules/rules.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,21 +137,44 @@ func NewRuleEngine(rules []*Rule, logger *slog.Logger) *RuleEngine {
137137
}
138138
}
139139

140+
// EvaluationResult contains the result of rule evaluation
141+
type EvaluationResult struct {
142+
Action Action
143+
Rule string // The rule that matched (if any)
144+
}
145+
140146
// Evaluate evaluates a request against all allow rules and returns the action to take
141147
func (re *RuleEngine) Evaluate(method, url string) Action {
142148
// Check if any allow rule matches
143149
for _, rule := range re.rules {
144150
if rule.Matches(method, url) {
145-
re.logger.Info("ALLOW", "method", method, "url", url, "rule", rule.Raw)
146151
return Allow
147152
}
148153
}
149154

150155
// Default deny if no allow rules match
151-
re.logger.Warn("DENY", "method", method, "url", url, "reason", "no matching allow rules")
152156
return Deny
153157
}
154158

159+
// EvaluateWithRule evaluates a request and returns both action and matching rule
160+
func (re *RuleEngine) EvaluateWithRule(method, url string) EvaluationResult {
161+
// Check if any allow rule matches
162+
for _, rule := range re.rules {
163+
if rule.Matches(method, url) {
164+
return EvaluationResult{
165+
Action: Allow,
166+
Rule: rule.Raw,
167+
}
168+
}
169+
}
170+
171+
// Default deny if no allow rules match
172+
return EvaluationResult{
173+
Action: Deny,
174+
Rule: "",
175+
}
176+
}
177+
155178
// newAllowRule creates an allow Rule from a spec string used by --allow.
156179
// Supported formats:
157180
//

0 commit comments

Comments
 (0)