mirror of
https://github.com/aquasecurity/trivy.git
synced 2025-12-12 07:40:48 -08:00
520 lines
14 KiB
Go
520 lines
14 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/google/go-containerregistry/pkg/v1/types"
|
|
|
|
"github.com/aquasecurity/trivy/pkg/fanal/secret"
|
|
"github.com/aquasecurity/trivy/pkg/fanal/utils"
|
|
"github.com/aquasecurity/trivy/pkg/log"
|
|
)
|
|
|
|
const (
|
|
redactedText = "<redacted>"
|
|
binaryRedactText = "<binary data redacted>"
|
|
maxBodySize = 1024 * 1024 // 1MB
|
|
|
|
// MIME types
|
|
mimeApplicationJSON = "application/json"
|
|
mimeApplicationXML = "application/xml"
|
|
mimeApplicationFormURLEncoded = "application/x-www-form-urlencoded"
|
|
mimeApplicationJavaScript = "application/javascript"
|
|
mimeApplicationOctetStream = "application/octet-stream"
|
|
mimeApplicationPDF = "application/pdf"
|
|
mimeApplicationZip = "application/zip"
|
|
mimeApplicationGzip = "application/gzip"
|
|
mimeApplicationXTar = "application/x-tar"
|
|
mimeApplicationXRar = "application/x-rar"
|
|
mimeMultipartFormData = "multipart/form-data"
|
|
mimeTextPrefix = "text/"
|
|
mimeImagePrefix = "image/"
|
|
mimeVideoPrefix = "video/"
|
|
mimeAudioPrefix = "audio/"
|
|
mimeApplicationVndPrefix = "application/vnd."
|
|
)
|
|
|
|
var (
|
|
// Colors for HTTP trace output
|
|
requestHeaderColor = color.New(color.FgCyan, color.Bold).SprintFunc()
|
|
responseHeaderColor = color.New(color.FgGreen, color.Bold).SprintFunc()
|
|
errorHeaderColor = color.New(color.FgRed, color.Bold).SprintFunc()
|
|
redactedColor = color.New(color.FgYellow).SprintFunc()
|
|
|
|
// HTTP method colors
|
|
methodColor = color.New(color.FgMagenta, color.Bold).SprintFunc()
|
|
urlColor = color.New(color.FgBlue).SprintFunc()
|
|
|
|
// Header colors
|
|
headerKeyColor = color.New(color.FgCyan).SprintFunc()
|
|
headerValueColor = color.New(color.FgWhite).SprintFunc()
|
|
|
|
// Status code colors
|
|
statusSuccessColor = color.New(color.FgGreen).SprintFunc() // 2xx
|
|
statusRedirectColor = color.New(color.FgYellow).SprintFunc() // 3xx
|
|
statusClientError = color.New(color.FgRed).SprintFunc() // 4xx
|
|
statusServerError = color.New(color.FgRed, color.Bold).SprintFunc() // 5xx
|
|
|
|
// Sensitive headers that should be redacted
|
|
sensitiveHeaders = []string{
|
|
"Authorization",
|
|
"Cookie",
|
|
"Set-Cookie",
|
|
"X-Auth-Token",
|
|
"X-API-Key",
|
|
"X-API-Secret",
|
|
"X-Access-Token",
|
|
"X-Secret-Key",
|
|
"API-Key",
|
|
"Access-Token",
|
|
"Proxy-Authorization",
|
|
"WWW-Authenticate",
|
|
"X-CSRF-Token",
|
|
"X-CSRFToken",
|
|
}
|
|
|
|
// Sensitive query parameters that should be redacted
|
|
sensitiveQueryParams = []string{
|
|
"token",
|
|
"api_key",
|
|
"apikey",
|
|
"access_token",
|
|
"client_secret",
|
|
"secret",
|
|
"password",
|
|
"auth",
|
|
"key",
|
|
"session",
|
|
"signature",
|
|
"oauth_token",
|
|
}
|
|
|
|
// Regex patterns for redaction
|
|
asteriskPattern = regexp.MustCompile(`\*+`)
|
|
)
|
|
|
|
type traceTransport struct {
|
|
inner http.RoundTripper
|
|
writer io.Writer
|
|
secretScanner secret.Scanner
|
|
}
|
|
|
|
// TraceOption is a functional option for traceTransport
|
|
type TraceOption func(*traceTransport)
|
|
|
|
// WithWriter sets the writer for trace output
|
|
func WithWriter(w io.Writer) TraceOption {
|
|
return func(tt *traceTransport) {
|
|
tt.writer = w
|
|
}
|
|
}
|
|
|
|
// NewTraceTransport returns an http.RoundTripper that logs HTTP requests and responses
|
|
func NewTraceTransport(inner http.RoundTripper, opts ...TraceOption) http.RoundTripper {
|
|
tt := &traceTransport{
|
|
inner: inner,
|
|
writer: os.Stderr, // default
|
|
secretScanner: secret.NewScanner(nil), // Use built-in rules
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(tt)
|
|
}
|
|
|
|
return tt
|
|
}
|
|
|
|
// RoundTrip implements http.RoundTripper with HTTP tracing
|
|
func (tt *traceTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
// Dump and redact request
|
|
reqDump, err := tt.dumpRequest(req)
|
|
if err != nil {
|
|
log.Debug("Failed to dump HTTP request", log.Err(err))
|
|
}
|
|
|
|
coloredDump := colorizeHTTPDump(reqDump, true)
|
|
fmt.Fprintf(tt.writer, "\n%s\n%s\n", requestHeaderColor("--- HTTP REQUEST ---"), coloredDump)
|
|
|
|
// Make the request
|
|
resp, err := tt.inner.RoundTrip(req)
|
|
if err != nil {
|
|
fmt.Fprintf(tt.writer, "\n%s\n%v\n", errorHeaderColor("--- HTTP ERROR ---"), err)
|
|
return nil, err
|
|
} else if resp == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Dump and redact response
|
|
respDump, err := tt.dumpResponse(resp)
|
|
if err != nil {
|
|
log.Debug("Failed to dump HTTP response", log.Err(err))
|
|
} else {
|
|
coloredDump = colorizeHTTPDump(respDump, false)
|
|
fmt.Fprintf(tt.writer, "\n%s\n%s\n", responseHeaderColor("--- HTTP RESPONSE ---"), coloredDump)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// dumpRequest dumps and redacts sensitive information from the request
|
|
func (tt *traceTransport) dumpRequest(req *http.Request) (string, error) {
|
|
// Clone request to avoid modifying the original
|
|
reqClone := req.Clone(req.Context())
|
|
|
|
// Redact sensitive headers
|
|
redactHeaders(reqClone.Header)
|
|
|
|
// Redact sensitive query parameters
|
|
if reqClone.URL != nil {
|
|
reqClone.URL = redactQueryParams(reqClone.URL)
|
|
}
|
|
|
|
// Handle body
|
|
if req.Body != nil && req.Body != http.NoBody {
|
|
bodyBytes, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Restore original body
|
|
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|
|
|
// Set redacted body on clone
|
|
redactedBody := tt.redactBody(bodyBytes, req.Header.Get("Content-Type"))
|
|
reqClone.Body = io.NopCloser(bytes.NewReader(redactedBody))
|
|
reqClone.ContentLength = int64(len(redactedBody))
|
|
}
|
|
|
|
// Dump the redacted request
|
|
dump, err := httputil.DumpRequestOut(reqClone, true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(dump), nil
|
|
}
|
|
|
|
// dumpResponse dumps and redacts sensitive information from the response
|
|
func (tt *traceTransport) dumpResponse(resp *http.Response) (string, error) {
|
|
// Read response body
|
|
var bodyBytes []byte
|
|
if resp.Body != nil {
|
|
var err error
|
|
bodyBytes, err = io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Restore original body
|
|
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|
}
|
|
|
|
// Clone response for redaction
|
|
respClone := &http.Response{
|
|
Status: resp.Status,
|
|
StatusCode: resp.StatusCode,
|
|
Proto: resp.Proto,
|
|
ProtoMajor: resp.ProtoMajor,
|
|
ProtoMinor: resp.ProtoMinor,
|
|
Header: resp.Header.Clone(),
|
|
ContentLength: resp.ContentLength,
|
|
TransferEncoding: resp.TransferEncoding,
|
|
Close: resp.Close,
|
|
Uncompressed: resp.Uncompressed,
|
|
Trailer: resp.Trailer,
|
|
Request: resp.Request,
|
|
}
|
|
|
|
// Redact sensitive headers
|
|
redactHeaders(respClone.Header)
|
|
|
|
// Set redacted body
|
|
if len(bodyBytes) > 0 {
|
|
redactedBody := tt.redactBody(bodyBytes, resp.Header.Get("Content-Type"))
|
|
respClone.Body = io.NopCloser(bytes.NewReader(redactedBody))
|
|
respClone.ContentLength = int64(len(redactedBody))
|
|
}
|
|
|
|
// Dump the redacted response
|
|
dump, err := httputil.DumpResponse(respClone, true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(dump), nil
|
|
}
|
|
|
|
// redactHeaders redacts sensitive headers
|
|
func redactHeaders(headers http.Header) {
|
|
for _, header := range sensitiveHeaders {
|
|
for k := range headers {
|
|
if strings.EqualFold(k, header) {
|
|
headers[k] = []string{redactedColor(redactedText)}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// redactQueryParams redacts sensitive query parameters
|
|
func redactQueryParams(u *url.URL) *url.URL {
|
|
// Clone URL to avoid modifying the original
|
|
cloned, _ := url.Parse(u.String())
|
|
|
|
values := cloned.Query()
|
|
for _, param := range sensitiveQueryParams {
|
|
for k := range values {
|
|
if strings.EqualFold(k, param) {
|
|
values[k] = []string{redactedColor(redactedText)}
|
|
}
|
|
}
|
|
}
|
|
cloned.RawQuery = values.Encode()
|
|
|
|
return cloned
|
|
}
|
|
|
|
// redactBody redacts sensitive information from request/response bodies using various methods including Trivy's secret scanner
|
|
func (tt *traceTransport) redactBody(body []byte, contentType string) []byte {
|
|
// Check if body is too large
|
|
if len(body) > maxBodySize {
|
|
return fmt.Appendf(nil, "<body too large: %d bytes>", len(body))
|
|
}
|
|
|
|
// Check if body is binary
|
|
if isBinaryContent(contentType) || isBinaryData(body) {
|
|
return []byte(redactedColor(binaryRedactText))
|
|
}
|
|
|
|
// Start with the original content
|
|
redacted := string(body)
|
|
|
|
// First, use Trivy's secret scanner for detection
|
|
scanResult := tt.secretScanner.Scan(secret.ScanArgs{
|
|
FilePath: "http-body.txt",
|
|
Content: bytes.NewReader(body),
|
|
Binary: false,
|
|
})
|
|
|
|
// If scanner found secrets, redact them
|
|
if len(scanResult.Findings) > 0 {
|
|
lines := strings.Split(redacted, "\n")
|
|
for _, finding := range scanResult.Findings {
|
|
for _, line := range finding.Code.Lines {
|
|
if line.IsCause && line.Number > 0 && line.Number <= len(lines) {
|
|
// Replace one or more * with <redacted> using regex
|
|
redactedLine := asteriskPattern.ReplaceAllString(line.Content, redactedColor(redactedText))
|
|
lines[line.Number-1] = redactedLine
|
|
}
|
|
}
|
|
}
|
|
redacted = strings.Join(lines, "\n")
|
|
}
|
|
|
|
// Apply additional pattern-based redactions
|
|
coloredRedactedText := redactedColor(redactedText)
|
|
|
|
// Handle JSON patterns
|
|
jsonPattern := regexp.MustCompile(`(?i)"(password|passwd|pwd|secret|token|api_key|apikey|access_token|client_secret|auth_token|private_key)"\s*:\s*"[^"]*"`)
|
|
redacted = jsonPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
|
colonIndex := strings.Index(match, ":")
|
|
if colonIndex != -1 {
|
|
key := match[:colonIndex+1]
|
|
return key + ` "` + coloredRedactedText + `"`
|
|
}
|
|
return coloredRedactedText
|
|
})
|
|
|
|
// Handle form data patterns
|
|
formPattern := regexp.MustCompile(`(?i)(password|passwd|pwd|secret|token|api_key|apikey|access_token|client_secret|auth_token|private_key)=[^&\s]+`)
|
|
redacted = formPattern.ReplaceAllStringFunc(redacted, func(match string) string {
|
|
equalIndex := strings.Index(match, "=")
|
|
if equalIndex != -1 {
|
|
key := match[:equalIndex+1]
|
|
return key + coloredRedactedText
|
|
}
|
|
return coloredRedactedText
|
|
})
|
|
|
|
// Handle Bearer tokens
|
|
bearerPattern := regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9\-._~+/]+=*`)
|
|
redacted = bearerPattern.ReplaceAllString(redacted, coloredRedactedText)
|
|
|
|
return []byte(redacted)
|
|
}
|
|
|
|
// isBinaryContent checks if the content type indicates binary data
|
|
func isBinaryContent(contentType string) bool {
|
|
if contentType == "" {
|
|
return false
|
|
}
|
|
|
|
contentType = strings.ToLower(contentType)
|
|
|
|
// Text-like content types (explicitly not binary)
|
|
textTypes := []string{
|
|
mimeTextPrefix,
|
|
mimeApplicationJSON,
|
|
mimeApplicationXML,
|
|
mimeApplicationFormURLEncoded,
|
|
mimeApplicationJavaScript,
|
|
string(types.OCIContentDescriptor),
|
|
string(types.OCIImageIndex),
|
|
string(types.OCIManifestSchema1),
|
|
string(types.OCIConfigJSON),
|
|
string(types.DockerManifestSchema1),
|
|
string(types.DockerManifestSchema1Signed),
|
|
string(types.DockerManifestSchema2),
|
|
string(types.DockerManifestList),
|
|
string(types.DockerConfigJSON),
|
|
string(types.DockerPluginConfig),
|
|
}
|
|
|
|
for _, textType := range textTypes {
|
|
if strings.HasPrefix(contentType, textType) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Common binary content types
|
|
binaryTypes := []string{
|
|
mimeApplicationOctetStream,
|
|
mimeApplicationPDF,
|
|
mimeApplicationZip,
|
|
mimeApplicationGzip,
|
|
mimeApplicationXTar,
|
|
mimeApplicationXRar,
|
|
mimeImagePrefix,
|
|
mimeVideoPrefix,
|
|
mimeAudioPrefix,
|
|
mimeApplicationVndPrefix,
|
|
mimeMultipartFormData,
|
|
}
|
|
|
|
for _, bType := range binaryTypes {
|
|
if strings.HasPrefix(contentType, bType) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isBinaryData checks if the data appears to be binary using utils.IsBinary
|
|
func isBinaryData(data []byte) bool {
|
|
if len(data) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Use bytes.Reader to implement ReadSeekerAt interface
|
|
reader := bytes.NewReader(data)
|
|
|
|
isBinary, _ := utils.IsBinary(reader, int64(len(data)))
|
|
return isBinary
|
|
}
|
|
|
|
// colorizeHTTPDump adds colors to HTTP request/response dumps
|
|
func colorizeHTTPDump(dump string, isRequest bool) string {
|
|
lines := strings.Split(dump, "\n")
|
|
if len(lines) == 0 {
|
|
return dump
|
|
}
|
|
|
|
var result []string
|
|
isBody := false
|
|
|
|
for i, line := range lines {
|
|
// Empty line indicates start of body
|
|
if line == "" || line == "\r" {
|
|
isBody = true
|
|
result = append(result, line)
|
|
continue
|
|
}
|
|
|
|
// First line processing
|
|
if i == 0 {
|
|
if isRequest {
|
|
// Colorize request line: METHOD URL HTTP/1.1
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 3 {
|
|
coloredLine := fmt.Sprintf("%s %s %s",
|
|
methodColor(parts[0]),
|
|
urlColor(parts[1]),
|
|
strings.Join(parts[2:], " "))
|
|
result = append(result, coloredLine)
|
|
} else {
|
|
result = append(result, line)
|
|
}
|
|
} else {
|
|
// Colorize response line: HTTP/1.1 STATUS_CODE STATUS_TEXT
|
|
parts := strings.SplitN(line, " ", 3)
|
|
if len(parts) >= 2 {
|
|
statusCode := parts[1]
|
|
statusColor := getStatusCodeColor(statusCode)
|
|
coloredLine := fmt.Sprintf("%s %s",
|
|
parts[0],
|
|
statusColor(strings.Join(parts[1:], " ")))
|
|
result = append(result, coloredLine)
|
|
} else {
|
|
result = append(result, line)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Header processing
|
|
if !isBody && strings.Contains(line, ":") {
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) == 2 {
|
|
key := parts[0]
|
|
value := strings.TrimSpace(parts[1])
|
|
// Don't color the value if it's already colored (e.g., redacted text)
|
|
if strings.Contains(value, "\x1b[") {
|
|
coloredLine := fmt.Sprintf("%s: %s",
|
|
headerKeyColor(key),
|
|
value)
|
|
result = append(result, coloredLine)
|
|
} else {
|
|
coloredLine := fmt.Sprintf("%s: %s",
|
|
headerKeyColor(key),
|
|
headerValueColor(value))
|
|
result = append(result, coloredLine)
|
|
}
|
|
} else {
|
|
result = append(result, line)
|
|
}
|
|
} else {
|
|
// Body content - no additional coloring
|
|
result = append(result, line)
|
|
}
|
|
}
|
|
|
|
return strings.Join(result, "\n")
|
|
}
|
|
|
|
// getStatusCodeColor returns the appropriate color function for a status code
|
|
func getStatusCodeColor(statusCode string) func(...any) string {
|
|
if statusCode == "" {
|
|
return headerValueColor
|
|
}
|
|
|
|
switch statusCode[0] {
|
|
case '2':
|
|
return statusSuccessColor
|
|
case '3':
|
|
return statusRedirectColor
|
|
case '4':
|
|
return statusClientError
|
|
case '5':
|
|
return statusServerError
|
|
default:
|
|
return headerValueColor
|
|
}
|
|
}
|