fix(t233): add redacted ERP diagnostics
This commit is contained in:
@@ -25,6 +25,7 @@ const (
|
||||
defaultCaptchaTTL = 5 * time.Minute
|
||||
maxCaptchaBytes = 2 << 20
|
||||
maxERPResponseBytes = 4 << 20
|
||||
maxDiagnosticBytes = 4 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -40,12 +41,17 @@ type SessionConfig struct {
|
||||
CaptchaTTL time.Duration
|
||||
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
|
||||
CaptchaRecognizer CaptchaRecognizer
|
||||
DiagnosticLogger DiagnosticLogger
|
||||
}
|
||||
|
||||
type CaptchaRecognizer interface {
|
||||
Recognize(context.Context, []byte, string) (string, error)
|
||||
}
|
||||
|
||||
// DiagnosticLogger receives only redacted request/response summaries when
|
||||
// explicitly enabled by the API composition root.
|
||||
type DiagnosticLogger func(string)
|
||||
|
||||
type SessionStatus struct {
|
||||
Configured bool
|
||||
Authenticated bool
|
||||
@@ -69,6 +75,8 @@ type SessionManager struct {
|
||||
headers http.Header
|
||||
http *http.Client
|
||||
recognizer CaptchaRecognizer
|
||||
diagnosticLog DiagnosticLogger
|
||||
diagnosticsOn bool
|
||||
authenticated bool
|
||||
captchaTicket string
|
||||
captchaContent []byte
|
||||
@@ -118,7 +126,9 @@ func NewSessionManager(config SessionConfig) (*SessionManager, error) {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
recognizer: config.CaptchaRecognizer,
|
||||
recognizer: config.CaptchaRecognizer,
|
||||
diagnosticLog: config.DiagnosticLogger,
|
||||
diagnosticsOn: config.DiagnosticLogger != nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -179,12 +189,15 @@ func (manager *SessionManager) FetchCaptcha(
|
||||
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
manager.applyHeaders(request)
|
||||
manager.logERPRequest(request)
|
||||
response, err := manager.http.Do(request)
|
||||
if err != nil {
|
||||
manager.logERPTransportFailure(request)
|
||||
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
manager.logERPResponsePreview(request, response)
|
||||
return manager.statusLocked(), manager.responseErrorLocked(response.StatusCode)
|
||||
}
|
||||
contentType := strings.TrimSpace(
|
||||
@@ -195,8 +208,10 @@ func (manager *SessionManager) FetchCaptcha(
|
||||
}
|
||||
content, err := readBounded(response.Body, maxCaptchaBytes)
|
||||
if err != nil || len(content) == 0 {
|
||||
manager.logERPResponseReadFailure(request, response)
|
||||
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
manager.logERPResponse(request, response, content, false)
|
||||
ticket, err := newCaptchaTicket()
|
||||
if err != nil {
|
||||
return manager.statusLocked(), domain.ErrFreightSourceUnavailable
|
||||
@@ -327,12 +342,15 @@ func (manager *SessionManager) requestJSONLocked(
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
manager.logERPRequest(request)
|
||||
response, err := manager.http.Do(request)
|
||||
if err != nil {
|
||||
manager.logERPTransportFailure(request)
|
||||
return nil, domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
manager.logERPResponsePreview(request, response)
|
||||
if loginRequest {
|
||||
return nil, ErrLoginRejected
|
||||
}
|
||||
@@ -340,8 +358,10 @@ func (manager *SessionManager) requestJSONLocked(
|
||||
}
|
||||
contentBytes, err := readBounded(response.Body, maxERPResponseBytes)
|
||||
if err != nil {
|
||||
manager.logERPResponseReadFailure(request, response)
|
||||
return nil, domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
manager.logERPResponse(request, response, contentBytes, false)
|
||||
var envelope struct {
|
||||
Status *bool `json:"status"`
|
||||
Code json.RawMessage `json:"code"`
|
||||
@@ -439,6 +459,209 @@ func (manager *SessionManager) clearCaptchaLocked() {
|
||||
manager.captchaExpires = time.Time{}
|
||||
}
|
||||
|
||||
func (manager *SessionManager) logERPRequest(request *http.Request) {
|
||||
if !manager.diagnosticsOn {
|
||||
return
|
||||
}
|
||||
manager.diagnosticLog(
|
||||
"erp_request method=" + request.Method +
|
||||
" path=" + request.URL.EscapedPath(),
|
||||
)
|
||||
}
|
||||
|
||||
func (manager *SessionManager) logERPTransportFailure(request *http.Request) {
|
||||
if !manager.diagnosticsOn {
|
||||
return
|
||||
}
|
||||
manager.diagnosticLog(
|
||||
"erp_transport_failed method=" + request.Method +
|
||||
" path=" + request.URL.EscapedPath() + " class=transport",
|
||||
)
|
||||
}
|
||||
|
||||
func (manager *SessionManager) logERPResponsePreview(
|
||||
request *http.Request,
|
||||
response *http.Response,
|
||||
) {
|
||||
if !manager.diagnosticsOn {
|
||||
return
|
||||
}
|
||||
content, truncated, readable := readDiagnosticPreview(response.Body)
|
||||
if !readable {
|
||||
manager.logERPResponseReadFailure(request, response)
|
||||
return
|
||||
}
|
||||
manager.logERPResponse(request, response, content, truncated)
|
||||
}
|
||||
|
||||
func (manager *SessionManager) logERPResponseReadFailure(
|
||||
request *http.Request,
|
||||
response *http.Response,
|
||||
) {
|
||||
if !manager.diagnosticsOn {
|
||||
return
|
||||
}
|
||||
manager.diagnosticLog(
|
||||
"erp_response method=" + request.Method +
|
||||
" path=" + request.URL.EscapedPath() +
|
||||
" status=" + strconv.Itoa(response.StatusCode) +
|
||||
" content_type=" + diagnosticContentType(response.Header.Get("Content-Type")) +
|
||||
" body=unavailable",
|
||||
)
|
||||
}
|
||||
|
||||
func (manager *SessionManager) logERPResponse(
|
||||
request *http.Request,
|
||||
response *http.Response,
|
||||
content []byte,
|
||||
truncated bool,
|
||||
) {
|
||||
if !manager.diagnosticsOn {
|
||||
return
|
||||
}
|
||||
byteCount := "bytes=" + strconv.Itoa(len(content))
|
||||
body := "body=empty"
|
||||
if truncated {
|
||||
byteCount = "bytes_at_least=" + strconv.Itoa(len(content))
|
||||
body = "body=omitted_truncated"
|
||||
} else if len(content) > 0 {
|
||||
body = "body=omitted_non_json"
|
||||
if summary, ok := redactedDiagnosticJSON(content); ok {
|
||||
body = "json=" + summary
|
||||
}
|
||||
}
|
||||
manager.diagnosticLog(
|
||||
"erp_response method=" + request.Method +
|
||||
" path=" + request.URL.EscapedPath() +
|
||||
" status=" + strconv.Itoa(response.StatusCode) +
|
||||
" content_type=" + diagnosticContentType(response.Header.Get("Content-Type")) +
|
||||
" " + byteCount + " " + body,
|
||||
)
|
||||
}
|
||||
|
||||
func readDiagnosticPreview(reader io.Reader) ([]byte, bool, bool) {
|
||||
content, err := io.ReadAll(io.LimitReader(reader, maxDiagnosticBytes+1))
|
||||
if err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
if len(content) > maxDiagnosticBytes {
|
||||
return content[:maxDiagnosticBytes], true, true
|
||||
}
|
||||
return content, false, true
|
||||
}
|
||||
|
||||
func diagnosticContentType(value string) string {
|
||||
value = strings.TrimSpace(strings.Split(value, ";")[0])
|
||||
if value == "" || len(value) > 128 || !utf8.ValidString(value) || hasControl(value) {
|
||||
return "unknown"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func redactedDiagnosticJSON(content []byte) (string, bool) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return "", false
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return "", false
|
||||
}
|
||||
encoded, err := json.Marshal(redactDiagnosticValue(value))
|
||||
if err != nil || len(encoded) > maxDiagnosticBytes {
|
||||
return `{"summary":"omitted_large_json"}`, true
|
||||
}
|
||||
return string(encoded), true
|
||||
}
|
||||
|
||||
func redactDiagnosticValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
redacted := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
if sensitiveDiagnosticKey(key) {
|
||||
redacted[key] = "[REDACTED]"
|
||||
continue
|
||||
}
|
||||
redacted[key] = redactDiagnosticValue(item)
|
||||
}
|
||||
return redacted
|
||||
case []any:
|
||||
limit := len(typed)
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
redacted := make([]any, 0, limit+1)
|
||||
for _, item := range typed[:limit] {
|
||||
redacted = append(redacted, redactDiagnosticValue(item))
|
||||
}
|
||||
if len(typed) > limit {
|
||||
redacted = append(redacted, "[TRUNCATED]")
|
||||
}
|
||||
return redacted
|
||||
case string:
|
||||
return redactDiagnosticString(typed)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func sensitiveDiagnosticKey(value string) bool {
|
||||
var normalized strings.Builder
|
||||
for _, character := range strings.ToLower(value) {
|
||||
if (character >= 'a' && character <= 'z') ||
|
||||
(character >= '0' && character <= '9') {
|
||||
normalized.WriteRune(character)
|
||||
}
|
||||
}
|
||||
key := normalized.String()
|
||||
for _, marker := range []string{
|
||||
"password", "passwd", "pwd", "username", "user", "captcha", "token",
|
||||
"cookie", "authorization", "auth", "receiver", "recipient", "phone",
|
||||
"mobile", "tel", "address", "email", "order", "stock", "tracking",
|
||||
"track", "express", "shipment", "shop", "name", "id", "code", "remark",
|
||||
"note", "detail",
|
||||
} {
|
||||
if strings.Contains(key, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redactDiagnosticString(value string) string {
|
||||
value = strings.ToValidUTF8(strings.TrimSpace(value), "?")
|
||||
value = strings.NewReplacer("\r", " ", "\n", " ", "\t", " ").Replace(value)
|
||||
if strings.Contains(value, "@") {
|
||||
return "[REDACTED]"
|
||||
}
|
||||
var result strings.Builder
|
||||
for index := 0; index < len(value); {
|
||||
if value[index] < '0' || value[index] > '9' {
|
||||
result.WriteByte(value[index])
|
||||
index++
|
||||
continue
|
||||
}
|
||||
end := index
|
||||
for end < len(value) && value[end] >= '0' && value[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
if end-index >= 6 {
|
||||
result.WriteString("[REDACTED]")
|
||||
} else {
|
||||
result.WriteString(value[index:end])
|
||||
}
|
||||
index = end
|
||||
}
|
||||
characters := []rune(result.String())
|
||||
if len(characters) > 512 {
|
||||
return string(characters[:512]) + "[TRUNCATED]"
|
||||
}
|
||||
return string(characters)
|
||||
}
|
||||
|
||||
func hasUser(value any) bool {
|
||||
data, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
|
||||
Reference in New Issue
Block a user