diff --git a/README.md b/README.md index b8f5ba0..f22129a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Egressor +[![CI](https://github.com/ehsaniara/egressor/actions/workflows/ci.yml/badge.svg)](https://github.com/ehsaniara/egressor/actions/workflows/ci.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/ehsaniara/egressor)](https://goreportcard.com/report/github.com/ehsaniara/egressor) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + **Local-first egress monitoring and control for developer tools** --- @@ -10,6 +14,7 @@ Egressor is a local HTTPS proxy that intercepts outbound traffic from developer - **TLS interception** — decrypts and inspects HTTPS payloads via a local CA - **File detection** — identifies file paths and contents in API request bodies +- **Directory scope enforcement** — blocks requests referencing files outside allowed project directories - **File blocking** — prevents sensitive files (`.env`, `.pem`, secrets) from being sent - **Desktop UI** — real-time session inspector with request/response viewer - **Audit logging** — structured JSON logs with automatic rotation @@ -102,6 +107,11 @@ egressor --version listen_address: "127.0.0.1:8080" policy: + # Block requests that reference files outside these directories. + # If empty, no directory scope is enforced. + allowed_directories: + # - "~/Projects/my-app" + deny_file_patterns: - "*.env" - "*.pem" @@ -122,6 +132,15 @@ intercept: max_body_size: 1048576 # 1MB ``` +### Allowed directories + +When `allowed_directories` is set, any request containing file references outside those directories is blocked. This prevents LLM tools from accessing files beyond the intended project scope (e.g. `~/.ssh`, `/etc/passwd`, `~/.aws/credentials`). + +- Relative paths are resolved against the current working directory +- Path traversals (`../`) are cleaned before evaluation +- Multiple directories can be specified +- Leave empty to allow all directories (default) + ### Deny file patterns Glob patterns that block requests containing matching file references: @@ -144,7 +163,7 @@ The default mode opens a native desktop window (built with Wails + React): - **Sessions tab** — live table of intercepted connections with method, host, status, file count - **Detail panel** — click a session to see full request/response headers, body (JSON-formatted), and detected files -- **Policy tab** — edit deny file patterns, add/remove patterns, save to config +- **Policy tab** — manage allowed directories and deny file patterns, save to config - **Bottom bar** — proxy start/stop, pause/resume policy, session stats Blocked requests are highlighted in red with the matching deny pattern shown. @@ -165,9 +184,10 @@ Client ──TLS(egressor cert)──► Egressor ──TLS(real cert)──► 4. Egressor opens its own TLS connection to the real server 5. Sitting between two decrypted streams, it reads the plaintext HTTP request 6. Extracts file references from the JSON payload -7. Checks file paths against `deny_file_patterns` -8. If blocked: returns `403`, logs the attempt, never forwards to the server -9. If allowed: forwards the request, captures the response, logs everything +7. Checks file paths against `allowed_directories` — blocks if out of scope +8. Checks file paths against `deny_file_patterns` — blocks if matched +9. If blocked: returns `403`, logs the attempt, never forwards to the server +10. If allowed: forwards the request, captures the response, logs everything ### File detection diff --git a/cmd/egressor/main.go b/cmd/egressor/main.go index d97b48e..215653b 100644 --- a/cmd/egressor/main.go +++ b/cmd/egressor/main.go @@ -112,6 +112,11 @@ func fileExists(path string) bool { const defaultConfig = `listen_address: "127.0.0.1:8080" policy: + # Block requests that reference files outside these directories. + # If empty, no directory scope is enforced. + allowed_directories: + # - "~/Projects/my-app" + deny_file_patterns: - "*.env" - "*.pem" diff --git a/docs/design.md b/docs/design.md index 0e6f9d1..b5f7644 100644 --- a/docs/design.md +++ b/docs/design.md @@ -26,6 +26,8 @@ Egressor is a local HTTPS intercepting proxy that monitors and controls outbound │ └────┬─────────┘ │ │ │ │ │ ├──▶ Extract file references │ +│ ├──▶ Check allowed_directories │ +│ │ └─ OUT OF SCOPE → 403 │ │ ├──▶ Check deny_file_patterns │ │ │ ├─ BLOCKED → 403 to client │ │ │ └─ ALLOWED → forward upstream │ @@ -68,7 +70,8 @@ For each connection: 3. HTTP/1.1 relay loop: - Read full request body into buffer - Extract file references from the body - - Evaluate file paths against `deny_file_patterns` + - Evaluate file paths against `allowed_directories` — block if out of scope + - Evaluate file paths against `deny_file_patterns` — block if matched - If blocked: send 403 back to client, log, stop - If allowed: forward request to upstream, relay response back 4. Record exchange in session @@ -87,11 +90,20 @@ Returns `[]FileRef{Path, Source}` where Source is `"json_field"` or `"text_patte ### Policy Engine (`internal/policy/policy.go`) -File-pattern-based policy enforcement: +Two-layer policy enforcement: -- `EvaluateFiles(paths []string) Decision` — checks paths against `deny_file_patterns` +**Directory scope** — `EvaluateScope(paths []string) Decision`: +- Checks if file paths fall within `allowed_directories` +- Resolves relative paths against cwd, cleans `../` traversals +- If no directories configured, all paths are allowed (default) +- Runtime mutation: `GetAllowedDirectories()`, `SetAllowedDirectories()` + +**File pattern deny** — `EvaluateFiles(paths []string) Decision`: +- Checks paths against `deny_file_patterns` - Pattern matching: `filepath.Match` for globs, `**/` prefix for recursive matching, basename fallback - Runtime mutation: `GetDenyPatterns()`, `SetDenyPatterns()`, `AddDenyPattern()`, `RemoveDenyPattern()` + +Both layers: - Pause/bypass via atomic bool (for UI toggle) - Thread-safe with `sync.RWMutex` @@ -138,7 +150,7 @@ File-pattern-based policy enforcement: **React frontend** (`internal/ui/frontend/`): - Sessions tab: live table with real-time updates via `EventsOn("session:new")` - Detail panel: request/response inspector with JSON viewer, detected files, blocked indicator -- Policy tab: editable deny patterns with save-to-config +- Policy tab: allowed directories and deny patterns with save-to-config - Bottom bar: proxy controls, policy pause/resume, stats ### Configuration (`internal/config/config.go`) @@ -160,11 +172,12 @@ File-pattern-based policy enforcement: 5. Interceptor: TLS handshake with upstream (real cert) 6. Interceptor: read HTTP request, buffer body 7. Extract: scan body → detected_files: ["src/main.go"] -8. Policy: EvaluateFiles(["src/main.go"]) → allowed -9. Interceptor: forward request to upstream -10. Interceptor: read response, forward to client -11. Logger: write session JSON to audit.log -12. Store: add session, emit "session:new" event → UI +8. Policy: EvaluateScope(["src/main.go"]) → in scope +9. Policy: EvaluateFiles(["src/main.go"]) → allowed +10. Interceptor: forward request to upstream +11. Interceptor: read response, forward to client +12. Logger: write session JSON to audit.log +13. Store: add session, emit "session:new" event → UI ``` ### Blocked request @@ -172,10 +185,11 @@ File-pattern-based policy enforcement: ``` 1-6. Same as above 7. Extract: scan body → detected_files: [".env"] -8. Policy: EvaluateFiles([".env"]) → denied (matches "*.env") -9. Interceptor: send 403 back to client over TLS -10. Logger: write session with blocked=true, block_reason -11. Store: add session → UI shows red row +8. Policy: EvaluateScope([".env"]) → in scope (or blocked if outside allowed dirs) +9. Policy: EvaluateFiles([".env"]) → denied (matches "*.env") +10. Interceptor: send 403 back to client over TLS +11. Logger: write session with blocked=true, block_reason +12. Store: add session → UI shows red row ``` ## Security Considerations @@ -195,7 +209,7 @@ internal/ proxy.go TCP listener, CONNECT handler, lifecycle intercept.go TLS MITM, HTTP relay, file extraction, blocking policy/ - policy.go deny_file_patterns matching engine + policy.go Directory scope + deny pattern engine audit/ session.go Session, InterceptedExchange, FileRef models logger.go JSON file logger with rotation @@ -220,7 +234,7 @@ internal/ SessionDetail.tsx Exchange inspector RequestPane.tsx Request headers + body + files ResponsePane.tsx Response headers + body - PolicyEditor.tsx Deny pattern CRUD + PolicyEditor.tsx Allowed dirs + deny pattern CRUD ProxyControls.tsx Start/stop/pause + stats JsonViewer.tsx Formatted JSON display hooks/ diff --git a/internal/audit/logger.go b/internal/audit/logger.go index 91dc849..091511d 100644 --- a/internal/audit/logger.go +++ b/internal/audit/logger.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "os" "path/filepath" "sync" @@ -54,6 +55,7 @@ func NewLogger(format, file string, maxSize int64) (*Logger, error) { func (l *Logger) Log(s *Session) { data, err := json.Marshal(s) if err != nil { + slog.Error("failed to marshal session", "err", err) return } data = append(data, '\n') @@ -74,7 +76,9 @@ func (l *Logger) rotate() { // Rename current log to audit.log. rotated := fmt.Sprintf("%s.%d", l.file, time.Now().Unix()) - os.Rename(l.file, rotated) + if err := os.Rename(l.file, rotated); err != nil { + slog.Error("failed to rotate log file", "err", err) + } f, err := os.OpenFile(l.file, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { diff --git a/internal/audit/session.go b/internal/audit/session.go index 66190c3..4c61b75 100644 --- a/internal/audit/session.go +++ b/internal/audit/session.go @@ -1,3 +1,5 @@ +// Package audit provides session logging, storage, and observation +// for intercepted proxy traffic. package audit import ( diff --git a/internal/config/config.go b/internal/config/config.go index a8b8885..d6a5f3c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,3 +1,4 @@ +// Package config handles YAML configuration loading, defaults, and persistence. package config import ( diff --git a/internal/extract/files.go b/internal/extract/files.go index 5bfcbc1..d822f51 100644 --- a/internal/extract/files.go +++ b/internal/extract/files.go @@ -1,3 +1,5 @@ +// Package extract detects file references in HTTP request bodies +// sent to LLM APIs. package extract import ( diff --git a/internal/policy/policy.go b/internal/policy/policy.go index bc32d54..c665cae 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -1,3 +1,5 @@ +// Package policy implements directory scope and file pattern enforcement +// for intercepted requests. package policy import ( @@ -106,7 +108,8 @@ func (e *Engine) EvaluateScope(paths []string) Decision { } e.mu.RLock() - allowedDirs := e.cfg.AllowedDirectories + allowedDirs := make([]string, len(e.cfg.AllowedDirectories)) + copy(allowedDirs, e.cfg.AllowedDirectories) e.mu.RUnlock() if len(allowedDirs) == 0 { @@ -160,7 +163,8 @@ func (e *Engine) EvaluateFiles(paths []string) Decision { } e.mu.RLock() - patterns := e.cfg.DenyFilePatterns + patterns := make([]string, len(e.cfg.DenyFilePatterns)) + copy(patterns, e.cfg.DenyFilePatterns) e.mu.RUnlock() if len(patterns) == 0 { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 0e3367e..71c9327 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -1,3 +1,5 @@ +// Package proxy implements an HTTPS intercepting proxy with TLS MITM +// for monitoring and controlling outbound traffic. package proxy import (