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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ bin/
*.so
*.dylib
.DS_Store
.agent-logs/
4 changes: 4 additions & 0 deletions cmd/probe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ func main() {
fmt.Fprintln(os.Stderr, "Error: timeout must be greater than 0")
os.Exit(1)
}
if cfg.Timeout > 300 {
fmt.Fprintln(os.Stderr, "Error: timeout must be 300 seconds or less")
os.Exit(1)
}

tgt, err := core.ParseTarget(cfg.Target)
if err != nil {
Expand Down
73 changes: 71 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import (
"strings"
)

const (
minTimeoutSeconds = 1
maxTimeoutSeconds = 300
)

// Config holds parsed CLI options.
type Config struct {
Target string
Expand Down Expand Up @@ -37,7 +42,7 @@ OPTIONS:
--json Output as JSON (default: table)
--method METHOD HTTP method (default: GET)
--header KEY:VALUE HTTP header (repeatable)
--timeout N Timeout in seconds (default: 10)
--timeout N Timeout in seconds (1-300, default: 10)
--insecure Skip TLS certificate verification
--no-redact Show sensitive header values (default: redacted)
--version Show version
Expand Down Expand Up @@ -127,17 +132,35 @@ func ParseArgs(args []string) (*Config, error) {
return cfg, nil
}

cfg.Method = strings.ToUpper(strings.TrimSpace(cfg.Method))
if cfg.Method == "" {
return nil, fmt.Errorf("method must not be empty")
}
if !isValidMethod(cfg.Method) {
return nil, fmt.Errorf("invalid HTTP method %q: use ASCII letters only", cfg.Method)
}
if cfg.Timeout < minTimeoutSeconds || cfg.Timeout > maxTimeoutSeconds {
return nil, fmt.Errorf("timeout must be between %d and %d seconds", minTimeoutSeconds, maxTimeoutSeconds)
}

// Parse header values into map.
for _, h := range headers {
k, v, ok := strings.Cut(h, ":")
if !ok {
return nil, fmt.Errorf("invalid header format: %q (expected Key: Value)", h)
}
cfg.Headers[strings.TrimSpace(k)] = strings.TrimSpace(v)
if containsForbiddenControl(k) {
return nil, fmt.Errorf("invalid header name %q: contains forbidden control characters", strings.TrimSpace(k))
}
if containsForbiddenControl(v) {
return nil, fmt.Errorf("invalid value for header %q: contains forbidden control characters", strings.TrimSpace(k))
}
key := strings.TrimSpace(k)
value := strings.TrimSpace(v)
if err := validateHeaderKV(key, value); err != nil {
return nil, err
}
cfg.Headers[key] = value
}

if len(positional) == 0 {
Expand All @@ -159,3 +182,49 @@ func needsValue(name string) bool {
}
return false
}

func isValidMethod(method string) bool {
for i := 0; i < len(method); i++ {
c := method[i]
if c < 'A' || c > 'Z' {
return false
}
}
return true
}

func validateHeaderKV(key, value string) error {
if key == "" {
return fmt.Errorf("header name must not be empty")
}
if !isValidHeaderName(key) {
return fmt.Errorf("invalid header name %q", key)
}
if containsForbiddenControl(key) {
return fmt.Errorf("invalid header name %q: contains forbidden control characters", key)
}
if containsForbiddenControl(value) {
return fmt.Errorf("invalid value for header %q: contains forbidden control characters", key)
}
return nil
}

func containsForbiddenControl(s string) bool {
return strings.ContainsAny(s, "\r\n\x00")
}

func isValidHeaderName(name string) bool {
for i := 0; i < len(name); i++ {
c := name[i]
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' {
continue
}
switch c {
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~':
continue
default:
return false
}
}
return true
}
54 changes: 54 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,57 @@ func TestParseArgsErrorMessages(t *testing.T) {
})
}
}

func TestParseArgsRejectsMethodWithNonLetters(t *testing.T) {
_, err := ParseArgs([]string{"--method", "POST1", "https://example.com"})
if err == nil {
t.Fatal("expected error for method containing non-letter")
}
if !strings.Contains(err.Error(), "invalid HTTP method") {
t.Fatalf("error = %q, want invalid HTTP method", err.Error())
}
}

func TestParseArgsRejectsHeaderControlCharacters(t *testing.T) {
tests := []struct {
name string
arg string
}{
{name: "value contains newline", arg: "X-Test: hello\nworld"},
{name: "name contains carriage return", arg: "X-Test\r: value"},
{name: "value contains NUL", arg: "X-Test: a\x00b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseArgs([]string{"--header", tt.arg, "https://example.com"})
if err == nil {
t.Fatal("expected header validation error")
}
})
}
}

func TestParseArgsTimeoutRange(t *testing.T) {
tests := []struct {
name string
timeout string
wantErr bool
}{
{name: "minimum", timeout: "1", wantErr: false},
{name: "maximum", timeout: "300", wantErr: false},
{name: "zero", timeout: "0", wantErr: true},
{name: "negative", timeout: "-1", wantErr: true},
{name: "too large", timeout: "301", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseArgs([]string{"--timeout", tt.timeout, "https://example.com"})
if tt.wantErr && err == nil {
t.Fatalf("expected timeout error for %s", tt.timeout)
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected timeout error for %s: %v", tt.timeout, err)
}
})
}
}
15 changes: 13 additions & 2 deletions internal/core/target.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ func ParseTarget(raw string) (Target, error) {
}

// If no scheme, default to https.
hadScheme := strings.Contains(raw, "://")
normalized := raw
if !strings.Contains(normalized, "://") {
if !hadScheme {
normalized = "https://" + normalized
}

Expand Down Expand Up @@ -67,8 +68,18 @@ func ParseTarget(raw string) (Target, error) {
path = "/"
}

original := raw
if u.User != nil {
sanitized := *u
sanitized.User = nil
original = sanitized.String()
if !hadScheme {
original = strings.TrimPrefix(original, "https://")
}
}

return Target{
Original: raw,
Original: original,
Scheme: scheme,
Host: host,
Port: port,
Expand Down
10 changes: 10 additions & 0 deletions internal/core/target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,16 @@ func TestParseTargetURLWithFragment(t *testing.T) {
}
}

func TestParseTargetStripsUserInfoFromOriginal(t *testing.T) {
got, err := ParseTarget("https://alice:secret@example.com/private")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Original != "https://example.com/private" {
t.Fatalf("Original = %q, want %q", got.Original, "https://example.com/private")
}
}

func TestParseTargetBareHostnameWithPort(t *testing.T) {
got, err := ParseTarget("example.com:8443")
if err != nil {
Expand Down
14 changes: 11 additions & 3 deletions internal/layers/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type Layer struct {
client *http.Client
}

const defaultMaxResponseHeaderBytes int64 = 1 << 20 // 1 MiB

// New creates an HTTP Layer with the given http.Client.
func New(client *http.Client) *Layer {
return &Layer{client: client}
Expand All @@ -35,8 +37,9 @@ func NewDefault(insecure bool, serverName string) *Layer {
tlsCfg.ServerName = serverName
}
transport := &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: tlsCfg,
DisableKeepAlives: true,
TLSClientConfig: tlsCfg,
MaxResponseHeaderBytes: defaultMaxResponseHeaderBytes,
}
client := &http.Client{
Transport: transport,
Expand Down Expand Up @@ -178,7 +181,12 @@ func buildRequestHeaders(headers map[string]string, redact bool) map[string]stri
// isSensitiveHeader returns true for headers whose values should be redacted.
func isSensitiveHeader(name string) bool {
switch strings.ToLower(name) {
case "authorization", "cookie", "proxy-authorization":
case "authorization",
"cookie",
"proxy-authorization",
"set-cookie",
"x-api-key",
"x-auth-token":
return true
default:
return false
Expand Down
32 changes: 32 additions & 0 deletions internal/layers/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,27 @@ func TestHTTPRequestHeadersRedaction(t *testing.T) {
wantKey: "Proxy-Authorization",
wantVal: "[REDACTED]",
},
{
name: "X-API-Key redacted",
headers: map[string]string{"X-API-Key": "super-secret"},
redact: true,
wantKey: "X-API-Key",
wantVal: "[REDACTED]",
},
{
name: "X-Auth-Token redacted",
headers: map[string]string{"X-Auth-Token": "super-secret"},
redact: true,
wantKey: "X-Auth-Token",
wantVal: "[REDACTED]",
},
{
name: "Set-Cookie redacted",
headers: map[string]string{"Set-Cookie": "session=abc123"},
redact: true,
wantKey: "Set-Cookie",
wantVal: "[REDACTED]",
},
{
name: "case-insensitive authorization",
headers: map[string]string{"authorization": "Bearer token"},
Expand Down Expand Up @@ -562,6 +583,17 @@ func TestHTTPRequestHeadersRedaction(t *testing.T) {
}
}

func TestNewDefaultSetsResponseHeaderLimit(t *testing.T) {
layer := NewDefault(false, "example.com")
transport, ok := layer.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport type = %T, want *http.Transport", layer.client.Transport)
}
if transport.MaxResponseHeaderBytes != 1<<20 {
t.Fatalf("MaxResponseHeaderBytes = %d, want %d", transport.MaxResponseHeaderBytes, 1<<20)
}
}

func TestHTTPRequestHeadersMixedSensitive(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
Expand Down
27 changes: 27 additions & 0 deletions scripts/agent-log.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -lt 2 ]]; then
echo "usage: $0 <implementation|security-review|code-review> <message...>" >&2
exit 1
fi

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LATEST_FILE="${ROOT_DIR}/.agent-logs/latest-run"
ROLE="$1"
shift

if [[ ! -f "${LATEST_FILE}" ]]; then
echo "latest run file not found: ${LATEST_FILE}" >&2
exit 1
fi

RUN_DIR="$(cat "${LATEST_FILE}")"
LOG_FILE="${RUN_DIR}/${ROLE}.log"

if [[ ! -e "${LOG_FILE}" ]]; then
echo "unknown role or log file missing: ${ROLE}" >&2
exit 1
fi

printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"${LOG_FILE}"
30 changes: 30 additions & 0 deletions scripts/start-agent-dashboard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUN_ID="${1:-$(date +%Y%m%d-%H%M%S)}"
RUN_DIR="${ROOT_DIR}/.agent-logs/${RUN_ID}"
LATEST_FILE="${ROOT_DIR}/.agent-logs/latest-run"

mkdir -p "${RUN_DIR}"
touch "${RUN_DIR}/implementation.log" "${RUN_DIR}/security-review.log" "${RUN_DIR}/code-review.log"
printf '%s\n' "${RUN_DIR}" >"${LATEST_FILE}"

if command -v tmux >/dev/null 2>&1; then
if tmux has-session -t probe-agents 2>/dev/null; then
tmux kill-session -t probe-agents
fi
tmux new-session -d -s probe-agents "tail -F '${RUN_DIR}/implementation.log'"
tmux split-window -h -t probe-agents "tail -F '${RUN_DIR}/security-review.log'"
tmux split-window -v -t probe-agents "tail -F '${RUN_DIR}/code-review.log'"
tmux select-layout -t probe-agents tiled
echo "Dashboard started."
echo "Attach with: tmux attach -t probe-agents"
else
echo "tmux not found. Tail logs manually:"
echo "tail -F '${RUN_DIR}/implementation.log'"
echo "tail -F '${RUN_DIR}/security-review.log'"
echo "tail -F '${RUN_DIR}/code-review.log'"
fi

echo "Run directory: ${RUN_DIR}"
Loading