Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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**

---
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions cmd/egressor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 29 additions & 15 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 │
Expand Down Expand Up @@ -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
Expand All @@ -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`

Expand Down Expand Up @@ -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`)
Expand All @@ -160,22 +172,24 @@ 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

```
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
Expand All @@ -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
Expand All @@ -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/
Expand Down
6 changes: 5 additions & 1 deletion internal/audit/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
Expand Down Expand Up @@ -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')
Expand All @@ -74,7 +76,9 @@ func (l *Logger) rotate() {

// Rename current log to audit.log.<unix_epoch>
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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/audit/session.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Package audit provides session logging, storage, and observation
// for intercepted proxy traffic.
package audit

import (
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Package config handles YAML configuration loading, defaults, and persistence.
package config

import (
Expand Down
2 changes: 2 additions & 0 deletions internal/extract/files.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Package extract detects file references in HTTP request bodies
// sent to LLM APIs.
package extract

import (
Expand Down
8 changes: 6 additions & 2 deletions internal/policy/policy.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Package policy implements directory scope and file pattern enforcement
// for intercepted requests.
package policy

import (
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Package proxy implements an HTTPS intercepting proxy with TLS MITM
// for monitoring and controlling outbound traffic.
package proxy

import (
Expand Down
Loading