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
11 changes: 8 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,17 @@ FROM alpine:latest

RUN apk --no-cache add ca-certificates

WORKDIR /root/
# Create non-root user for security
RUN adduser -D -u 1000 -h /home/appuser appuser

# Switch to non-root user
USER appuser
WORKDIR /home/appuser

# Copy the binary from builder
COPY --from=builder /app/apicli .

# Create data directory
RUN mkdir -p /root/.apicli
# Create data directory with correct ownership (already owned by appuser due to USER directive)
RUN mkdir -p /home/appuser/.apicli

ENTRYPOINT ["./apicli"]
5 changes: 4 additions & 1 deletion cmd/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ func runCollectionAdd(cmd *cobra.Command, args []string) {

headerMap := parseHeaders(headers)

// Filter sensitive headers before storing in collection
filteredHeaders := filterSensitiveHeaders(headerMap)

store, err := storage.NewStorage()
if err != nil {
format.PrintError(fmt.Sprintf("Failed to add request: %v", err))
Expand All @@ -160,7 +163,7 @@ func runCollectionAdd(cmd *cobra.Command, args []string) {
Name: requestName,
Method: method,
URL: url,
Headers: headerMap,
Headers: filteredHeaders,
Body: data,
}

Expand Down
72 changes: 65 additions & 7 deletions cmd/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,41 @@ import (

// sensitiveHeaders is a list of headers that should be redacted before storing in history
var sensitiveHeaders = map[string]bool{
// Standard authentication headers
"authorization": true,
"cookie": true,
"set-cookie": true,
"x-api-key": true,
"api-key": true,
"x-auth-token": true,
"proxy-authorization": true,
"x-csrf-token": true,
"x-xsrf-token": true,
"www-authenticate": true,

// Session and token headers
"cookie": true,
"set-cookie": true,
"x-api-key": true,
"api-key": true,
"x-auth-token": true,
"x-csrf-token": true,
"x-xsrf-token": true,

// AWS credentials
"x-amz-security-token": true,
"x-amz-credential": true,
"x-amz-signature": true,

// GCP credentials
"x-goog-authenticated-user-email": true,
"x-goog-authenticated-user-id": true,
"x-goog-iap-jwt-assertion": true,

// Azure credentials
"x-ms-client-principal": true,
"x-ms-client-principal-id": true,
"x-ms-token-aad-id-token": true,

// Other common auth headers
"x-access-token": true,
"x-refresh-token": true,
"x-session-token": true,
"x-secret-key": true,
"x-private-key": true,
}

var (
Expand Down Expand Up @@ -117,6 +143,11 @@ func runRequest(method string) func(cmd *cobra.Command, args []string) {
body = content
}

// Warn if body contains potentially sensitive data
if !noHistory {
warnIfSensitiveBody(body)
}

// Create HTTP client and make request
client := httpclient.NewClient()
resp, err := client.Do(method, url, headerMap, body)
Expand Down Expand Up @@ -315,3 +346,30 @@ func filterSensitiveHeaders(headers map[string]string) map[string]string {
}
return filtered
}

// sensitiveBodyPatterns contains patterns that suggest sensitive data in request bodies
var sensitiveBodyPatterns = []string{
"password", "passwd", "pwd",
"secret", "token", "api_key", "apikey",
"private_key", "privatekey",
"credit_card", "creditcard", "card_number",
"ssn", "social_security",
"access_token", "refresh_token",
"client_secret", "auth",
}

// warnIfSensitiveBody checks if the request body might contain sensitive data and warns the user
func warnIfSensitiveBody(body string) {
if body == "" {
return
}

lowerBody := strings.ToLower(body)
for _, pattern := range sensitiveBodyPatterns {
if strings.Contains(lowerBody, pattern) {
fmt.Fprintln(os.Stderr, "WARNING: Request body may contain sensitive data (e.g., passwords, tokens). This will be stored in history.")
fmt.Fprintln(os.Stderr, " Use --no-history flag to skip storing this request.")
return
}
}
}
23 changes: 23 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/sqlite v1.29.0/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
69 changes: 49 additions & 20 deletions internal/format/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,40 @@ import (
"fmt"
"sort"
"strings"
"unicode"

"github.com/fatih/color"
"api/internal/model"
)

// sanitizeOutput removes or escapes potentially dangerous control characters
// that could manipulate terminal display or execute commands
func sanitizeOutput(s string) string {
var result strings.Builder
result.Grow(len(s))

for _, r := range s {
switch {
case r == '\n' || r == '\r' || r == '\t':
// Allow common whitespace characters
result.WriteRune(r)
case r == '\x1b':
// Escape ANSI escape sequences - replace ESC with visible representation
result.WriteString("\\x1b")
case unicode.IsControl(r) && r < 0x20:
// Replace other control characters (0x00-0x1F except allowed whitespace)
result.WriteString(fmt.Sprintf("\\x%02x", r))
case r == 0x7F:
// DEL character
result.WriteString("\\x7f")
default:
result.WriteRune(r)
}
}

return result.String()
}

var (
successColor = color.New(color.FgGreen, color.Bold)
redirectColor = color.New(color.FgYellow, color.Bold)
Expand Down Expand Up @@ -41,7 +70,7 @@ func PrintResponse(resp *model.Response, showHeaders bool) {

func printStatusLine(resp *model.Response) {
statusColor := getStatusColor(resp.StatusCode)
statusColor.Printf("%s\n", resp.Status)
statusColor.Printf("%s\n", sanitizeOutput(resp.Status))
}

func getStatusColor(code int) *color.Color {
Expand Down Expand Up @@ -72,8 +101,8 @@ func printHeaders(headers map[string]string) {
sort.Strings(keys)

for _, key := range keys {
headerKeyColor.Printf(" %s: ", key)
fmt.Println(headers[key])
headerKeyColor.Printf(" %s: ", sanitizeOutput(key))
fmt.Println(sanitizeOutput(headers[key]))
}
fmt.Println()
}
Expand All @@ -84,9 +113,9 @@ func printBody(body string) {
return
}

// Try to pretty-print JSON
// Try to pretty-print JSON, then sanitize output for terminal safety
prettyBody := prettyJSON(body)
fmt.Println(prettyBody)
fmt.Println(sanitizeOutput(prettyBody))
}

func prettyJSON(s string) string {
Expand All @@ -102,14 +131,14 @@ func prettyJSON(s string) string {
// PrintRequest prints a formatted HTTP request summary
func PrintRequest(req *model.Request) {
methodColor.Printf("%s ", req.Method)
urlColor.Println(req.URL)
urlColor.Println(sanitizeOutput(req.URL))
dimColor.Printf(" ID: %s\n", req.ID)
dimColor.Printf(" Time: %s\n", req.Timestamp.Format("2006-01-02 15:04:05"))

if req.Response != nil {
fmt.Print(" Status: ")
statusColor := getStatusColor(req.Response.StatusCode)
statusColor.Println(req.Response.Status)
statusColor.Println(sanitizeOutput(req.Response.Status))
}
}

Expand All @@ -118,7 +147,7 @@ func PrintRequestDetail(req *model.Request) {
fmt.Println("Request:")
fmt.Println(strings.Repeat("-", 40))
methodColor.Printf("%s ", req.Method)
urlColor.Println(req.URL)
urlColor.Println(sanitizeOutput(req.URL))
dimColor.Printf("ID: %s\n", req.ID)
dimColor.Printf("Time: %s\n\n", req.Timestamp.Format("2006-01-02 15:04:05"))

Expand All @@ -128,7 +157,7 @@ func PrintRequestDetail(req *model.Request) {

if req.Body != "" {
fmt.Println("Body:")
fmt.Println(prettyJSON(req.Body))
fmt.Println(sanitizeOutput(prettyJSON(req.Body)))
fmt.Println()
}

Expand Down Expand Up @@ -156,12 +185,12 @@ func PrintHistoryList(requests []model.Request, limit int) {
dimColor.Printf("[%d] ", i+1)
methodColor.Printf("%-7s ", req.Method)

// Truncate URL if too long
// Truncate URL if too long, then sanitize
url := req.URL
if len(url) > 60 {
url = url[:57] + "..."
}
urlColor.Printf("%-60s ", url)
urlColor.Printf("%-60s ", sanitizeOutput(url))

if req.Response != nil {
statusColor := getStatusColor(req.Response.StatusCode)
Expand All @@ -185,28 +214,28 @@ func PrintCollectionList(collections *model.Collections) {

fmt.Println("Collections:")
for name, col := range collections.Collections {
headerKeyColor.Printf(" %s ", name)
headerKeyColor.Printf(" %s ", sanitizeOutput(name))
dimColor.Printf("(%d requests)\n", len(col.Requests))
}
}

// PrintCollectionRequests prints requests in a collection
func PrintCollectionRequests(col *model.Collection) {
if len(col.Requests) == 0 {
dimColor.Printf("Collection '%s' is empty\n", col.Name)
dimColor.Printf("Collection '%s' is empty\n", sanitizeOutput(col.Name))
return
}

headerKeyColor.Printf("Collection: %s\n", col.Name)
headerKeyColor.Printf("Collection: %s\n", sanitizeOutput(col.Name))
fmt.Println(strings.Repeat("-", 40))

for i, req := range col.Requests {
dimColor.Printf("[%d] ", i+1)
if req.Name != "" {
fmt.Printf("%s: ", req.Name)
fmt.Printf("%s: ", sanitizeOutput(req.Name))
}
methodColor.Printf("%s ", req.Method)
urlColor.Println(req.URL)
urlColor.Println(sanitizeOutput(req.URL))
}
}

Expand All @@ -229,15 +258,15 @@ func PrintAliasList(aliases *model.Aliases) {

fmt.Println("Aliases:")
for name, url := range aliases.Aliases {
headerKeyColor.Printf(" %s ", name)
headerKeyColor.Printf(" %s ", sanitizeOutput(name))
dimColor.Print("→ ")
urlColor.Println(url)
urlColor.Println(sanitizeOutput(url))
}
}

// PrintAlias prints a single alias
func PrintAlias(name, url string) {
headerKeyColor.Printf("%s ", name)
headerKeyColor.Printf("%s ", sanitizeOutput(name))
dimColor.Print("→ ")
urlColor.Println(url)
urlColor.Println(sanitizeOutput(url))
}
Loading