Skip to content
Closed
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
38 changes: 37 additions & 1 deletion entity-service/internal/servicenow-integration-service/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,40 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"

"github.com/wso2-open-operations/cs-tools/entity-service/internal/apierror"
)

// sanitizeLog strips CR/LF characters from a string to prevent log injection.
// Apply to every downstream-derived operand before passing it to a log call.
var sanitizeLog = strings.NewReplacer("\n", `\n`, "\r", `\r`).Replace

// internalErrorTagPattern matches a leading bracketed all-caps tag (e.g.
// "[SERVICENOW_ERROR] ") that a downstream service prepends to its error
// messages to identify which internal layer raised the error. The tag is
// useful for correlating log lines with the originating layer, but it is an
// implementation detail that must never reach the client-facing message.
var internalErrorTagPattern = regexp.MustCompile(`^\[[A-Z][A-Z0-9_]*\]\s*`)

// stripInternalErrorTag splits msg into the client-safe message with any
// leading internal tag removed, and the tag itself (empty if msg carried no
// tag). Only the client-safe message should ever be placed in a field the
// caller can see; the tag, if present, belongs in logs only.
func stripInternalErrorTag(msg string) (clientMsg string, tag string) {
loc := internalErrorTagPattern.FindStringIndex(msg)
if loc == nil {
return msg, ""
}
return msg[loc[1]:], strings.TrimSpace(msg[loc[0]:loc[1]])
}

// ClientCredentialsConfig holds the OAuth2 client credentials used to obtain
// a bearer token for service-to-service calls to the Choreo API.
type ClientCredentialsConfig struct {
Expand Down Expand Up @@ -298,6 +323,13 @@ func (c *Client) Post(ctx context.Context, path string, userIDToken string, payl
// extractDownstreamMessage attempts to parse a "message" field from the JSON
// error body returned by the downstream service. Falls back to defaultMsg if
// the body is empty, not JSON, or has no "message" field.
//
// The downstream service sometimes prefixes its message with an internal
// bracketed tag identifying which layer raised it (e.g.
// "[SERVICENOW_ERROR] State transition rejected"). That tag is logged here
// for correlation but stripped from the returned string, since the returned
// string is used verbatim as the client-facing apierror Msg — it must never
// carry backend/vendor implementation detail.
func extractDownstreamMessage(body []byte, defaultMsg string) string {
if len(body) == 0 {
return defaultMsg
Expand All @@ -306,7 +338,11 @@ func extractDownstreamMessage(body []byte, defaultMsg string) string {
Message string `json:"message"`
}
if err := json.Unmarshal(body, &payload); err == nil && payload.Message != "" {
return payload.Message
clientMsg, tag := stripInternalErrorTag(payload.Message)
if tag != "" {
log.Printf("snclient: downstream error tagged %s: %s", sanitizeLog(tag), sanitizeLog(clientMsg)) // #nosec G706 -- tag and message sanitized
}
return clientMsg
}
return defaultMsg
}
Expand Down
117 changes: 117 additions & 0 deletions entity-service/internal/servicenow-integration-service/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package integrationservice

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/wso2-open-operations/cs-tools/entity-service/internal/apierror"
)

func TestStripInternalErrorTag(t *testing.T) {
tests := []struct {
name string
in string
wantClientMsg string
wantTag string
}{
{
name: "servicenow tag stripped",
in: "[SERVICENOW_ERROR] State transition rejected",
wantClientMsg: "State transition rejected",
wantTag: "[SERVICENOW_ERROR]",
},
{
name: "other bracket tag stripped",
in: "[ENTITY_SERVICE_ERROR] duplicate key",
wantClientMsg: "duplicate key",
wantTag: "[ENTITY_SERVICE_ERROR]",
},
{
name: "no tag left untouched",
in: "State transition rejected",
wantClientMsg: "State transition rejected",
wantTag: "",
},
{
name: "lowercase bracket content is not treated as a tag",
in: "[case 123] rejected",
wantClientMsg: "[case 123] rejected",
wantTag: "",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotMsg, gotTag := stripInternalErrorTag(tc.in)
if gotMsg != tc.wantClientMsg {
t.Errorf("clientMsg = %q, want %q", gotMsg, tc.wantClientMsg)
}
if gotTag != tc.wantTag {
t.Errorf("tag = %q, want %q", gotTag, tc.wantTag)
}
})
}
}

// TestClient_TaggedDownstreamMessage_NotLeakedToClient reproduces the
// production observation: the downstream service returns a 409 body whose
// "message" field carries a "[SERVICENOW_ERROR]" prefix. The error returned
// to the caller (and ultimately serialized into the HTTP response the FE
// sees) must have the tag stripped while keeping the rest of the message.
func TestClient_TaggedDownstreamMessage_NotLeakedToClient(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/oauth2/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "test-token", "expires_in": 3600})
})
mux.HandleFunc("/cases/abc", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(map[string]any{
"message": "[SERVICENOW_ERROR] State transition rejected",
})
})

srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)

client := New(srv.URL, ClientCredentialsConfig{
TokenURL: srv.URL + "/oauth2/token",
ClientID: "test-client",
ClientSecret: "test-secret",
})

_, err := client.Patch(context.Background(), "/cases/abc", "test-id-token", map[string]any{"workState": "ongoing"})
if err == nil {
t.Fatal("expected an error, got nil")
}

ce, ok := err.(*apierror.ConflictError)
if !ok {
t.Fatalf("expected *apierror.ConflictError, got %T: %v", err, err)
}

const wantMsg = "State transition rejected"
if ce.Msg != wantMsg {
t.Errorf("ConflictError.Msg = %q, want %q (internal tag must not reach the client-facing message)", ce.Msg, wantMsg)
}
}