diff --git a/src/nodejs/hooks/contrast_security.go b/src/nodejs/hooks/contrast_security.go index 6a375b7d2..1178fabe9 100644 --- a/src/nodejs/hooks/contrast_security.go +++ b/src/nodejs/hooks/contrast_security.go @@ -2,6 +2,7 @@ package hooks import ( "encoding/json" + "fmt" "os" "strings" @@ -15,6 +16,7 @@ type ContrastSecurityHook struct { type ContrastSecurityCredentials struct { ApiKey string + ApiToken string OrgUuid string ServiceKey string ContrastUrl string //formerly Teamserver URL @@ -29,6 +31,21 @@ func init() { }) } +// shellQuote wraps a value in single quotes and escapes any embedded single +// quotes so the result is safe to embed in a shell export statement. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// writeExport returns a shell export line for the given variable, or an empty +// string when value is empty (so the line is simply omitted from the script). +func writeExport(name, value string) string { + if value == "" { + return "" + } + return "export " + name + "=" + shellQuote(value) + "\n" +} + func (h ContrastSecurityHook) AfterCompile(stager *libbuildpack.Stager) error { h.Log.Debug("Contrast Security after compile hook") @@ -41,10 +58,20 @@ func (h ContrastSecurityHook) AfterCompile(stager *libbuildpack.Stager) error { h.Log.Info("Contrast Security credentials found. Configuring environment for [%s].", contrastSecurityCredentials.ContrastUrl) - var contrastSecurityScript = "export CONTRAST__API__API_KEY=" + contrastSecurityCredentials.ApiKey + "\n" + - "export CONTRAST__API__URL=" + contrastSecurityCredentials.ContrastUrl + "/Contrast/\n" + - "export CONTRAST__API__SERVICE_KEY=" + contrastSecurityCredentials.ServiceKey + "\n" + - "export CONTRAST__API__USER_NAME=" + contrastSecurityCredentials.Username + "\n" + url := "" + if contrastSecurityCredentials.ContrastUrl != "" { + url = strings.TrimRight(contrastSecurityCredentials.ContrastUrl, "/") + if !strings.HasSuffix(url, "/Contrast") { + url += "/Contrast" + } + } + + contrastSecurityScript := + writeExport("CONTRAST__API__URL", url) + + writeExport("CONTRAST__API__API_KEY", contrastSecurityCredentials.ApiKey) + + writeExport("CONTRAST__API__SERVICE_KEY", contrastSecurityCredentials.ServiceKey) + + writeExport("CONTRAST__API__USER_NAME", contrastSecurityCredentials.Username) + + writeExport("CONTRAST__API__TOKEN", contrastSecurityCredentials.ApiToken) stager.WriteProfileD("contrast_security", contrastSecurityScript) @@ -55,36 +82,30 @@ func (h ContrastSecurityHook) AfterCompile(stager *libbuildpack.Stager) error { func getContrastCredentialString(credentials map[string]interface{}, key string) string { if value, exists := credentials[key]; exists { - return value.(string) + if str, ok := value.(string); ok { + return str + } } return "" } -func containsContrastService(key string, services interface{}, query string) bool { - var serviceName string - var serviceLabel string - var serviceTags []interface{} - +// serviceMatchesContrast reports whether a single service entry (identified by +// its top-level VCAP_SERVICES category key plus its own fields) should be +// treated as a Contrast Security service. All type assertions use the comma-ok +// form so malformed entries are skipped rather than panicked on. +func serviceMatchesContrast(key string, svc map[string]interface{}, query string) bool { if strings.Contains(key, query) { return true } - val := services.([]interface{}) - for serviceIndex := range val { - service := val[serviceIndex].(map[string]interface{}) - if v, ok := service["name"]; ok { - serviceName = v.(string) - } - if v, ok := service["label"]; ok { - serviceLabel = v.(string) - } - if strings.Contains(serviceName, query) || strings.Contains(serviceLabel, query) { - return true - } - if v, ok := service["tags"]; ok { - serviceTags = v.([]interface{}) - } - for _, tagValue := range serviceTags { - if strings.Contains(tagValue.(string), query) { + if name, ok := svc["name"].(string); ok && strings.Contains(name, query) { + return true + } + if label, ok := svc["label"].(string); ok && strings.Contains(label, query) { + return true + } + if tags, ok := svc["tags"].([]interface{}); ok { + for _, t := range tags { + if tag, ok := t.(string); ok && strings.Contains(tag, query) { return true } } @@ -92,47 +113,72 @@ func containsContrastService(key string, services interface{}, query string) boo return false } +// loadVcapServicesJSON returns the raw VCAP_SERVICES JSON from whichever +// delivery mechanism is active. VCAP_SERVICES_FILE_PATH (file-based CF mode) +// takes priority; VCAP_SERVICES env var is the fallback (standard CF mode). +func (h ContrastSecurityHook) loadVcapServicesJSON() (string, error) { + fp := os.Getenv("VCAP_SERVICES_FILE_PATH") + if fp != "" { + data, err := os.ReadFile(fp) + if err != nil { + return "", fmt.Errorf("could not read VCAP_SERVICES_FILE_PATH %q: %w", fp, err) + } + if os.Getenv("VCAP_SERVICES") != "" { + h.Log.Warning("Contrast Security: both VCAP_SERVICES and VCAP_SERVICES_FILE_PATH are set; using VCAP_SERVICES_FILE_PATH.") + } + return string(data), nil + } + return os.Getenv("VCAP_SERVICES"), nil +} + func (h ContrastSecurityHook) GetCredentialsFromEnvironment() (bool, ContrastSecurityCredentials) { type rawVcapServicesJSONValue map[string]interface{} var vcapServices rawVcapServicesJSONValue - vcapServicesEnvironment := os.Getenv("VCAP_SERVICES") + vcapServicesEnvironment, err := h.loadVcapServicesJSON() + if err != nil { + h.Log.Warning("Contrast Security: %s", err.Error()) + return false, ContrastSecurityCredentials{} + } if vcapServicesEnvironment == "" { - h.Log.Debug("Contrast Security could not find VCAP_SERVICES in the environment") + h.Log.Debug("Contrast Security could not find VCAP_SERVICES or VCAP_SERVICES_FILE_PATH in the environment") return false, ContrastSecurityCredentials{} } - err := json.Unmarshal([]byte(vcapServicesEnvironment), &vcapServices) + err = json.Unmarshal([]byte(vcapServicesEnvironment), &vcapServices) if err != nil { h.Log.Warning("Contrast Security could not parse VCAP_SERVICES") return false, ContrastSecurityCredentials{} } - for key, services := range vcapServices { - if containsContrastService(key, services, "contrast-security") { + for key, rawServices := range vcapServices { + services, ok := rawServices.([]interface{}) + if !ok { + continue + } + for _, rawSvc := range services { + svc, ok := rawSvc.(map[string]interface{}) + if !ok { + continue + } + if !serviceMatchesContrast(key, svc, "contrast-security") { + continue + } + credentials, ok := svc["credentials"].(map[string]interface{}) + if !ok { + continue + } h.Log.Debug("Contrast Security found credentials in VCAP_SERVICES") - val := services.([]interface{}) - for serviceIndex := range val { - service := val[serviceIndex].(map[string]interface{}) - if credentials, exists := service["credentials"].(map[string]interface{}); exists { - apiKey := getContrastCredentialString(credentials, "api_key") - orgUuid := getContrastCredentialString(credentials, "org_uuid") - serviceKey := getContrastCredentialString(credentials, "service_key") - contrastUrl := getContrastCredentialString(credentials, "teamserver_url") - username := getContrastCredentialString(credentials, "username") - - contrastSecurityCredentials := ContrastSecurityCredentials{ - ApiKey: apiKey, - OrgUuid: orgUuid, - ServiceKey: serviceKey, - ContrastUrl: contrastUrl, - Username: username, - } - return true, contrastSecurityCredentials - } + return true, ContrastSecurityCredentials{ + ApiKey: getContrastCredentialString(credentials, "api_key"), + ApiToken: getContrastCredentialString(credentials, "api_token"), + OrgUuid: getContrastCredentialString(credentials, "org_uuid"), + ServiceKey: getContrastCredentialString(credentials, "service_key"), + ContrastUrl: getContrastCredentialString(credentials, "teamserver_url"), + Username: getContrastCredentialString(credentials, "username"), } } } diff --git a/src/nodejs/hooks/contrast_security_test.go b/src/nodejs/hooks/contrast_security_test.go index c92780c60..b853f6aed 100644 --- a/src/nodejs/hooks/contrast_security_test.go +++ b/src/nodejs/hooks/contrast_security_test.go @@ -92,10 +92,10 @@ var _ = Describe("contrastSecurityHook", func() { } fileContents := string(fileBytes) - var sampleExportList string = "export CONTRAST__API__API_KEY=sample_api_key\n" + - "export CONTRAST__API__URL=sample_teamserver_url/Contrast/\n" + - "export CONTRAST__API__SERVICE_KEY=sample_service_key\n" + - "export CONTRAST__API__USER_NAME=username@example.com\n" + var sampleExportList string = "export CONTRAST__API__URL='sample_teamserver_url/Contrast'\n" + + "export CONTRAST__API__API_KEY='sample_api_key'\n" + + "export CONTRAST__API__SERVICE_KEY='sample_service_key'\n" + + "export CONTRAST__API__USER_NAME='username@example.com'\n" Expect(fileContents).To(Equal(sampleExportList)) }) @@ -149,14 +149,194 @@ var _ = Describe("contrastSecurityHook", func() { } fileContents := string(fileBytes) - var sampleExportList string = "export CONTRAST__API__API_KEY=sample_api_key\n" + - "export CONTRAST__API__URL=sample_teamserver_url/Contrast/\n" + - "export CONTRAST__API__SERVICE_KEY=sample_service_key\n" + - "export CONTRAST__API__USER_NAME=username@example.com\n" + var sampleExportList string = "export CONTRAST__API__URL='sample_teamserver_url/Contrast'\n" + + "export CONTRAST__API__API_KEY='sample_api_key'\n" + + "export CONTRAST__API__SERVICE_KEY='sample_service_key'\n" + + "export CONTRAST__API__USER_NAME='username@example.com'\n" Expect(fileContents).To(Equal(sampleExportList)) }) }) + Context("teamserver_url already contains the /Contrast suffix", func() { + BeforeEach(func() { + os.Setenv("VCAP_APPLICATION", `{}`) + os.Setenv("VCAP_SERVICES", `{ + "contrast-security": [{ + "credentials": { + "api_key": "sample_api_key", + "service_key": "sample_service_key", + "teamserver_url": "https://app-agents.contrastsecurity.com/Contrast", + "username": "username@example.com" + } + }] + }`) + }) + + It("does not double the /Contrast suffix", func() { + err := contrast.AfterCompile(stager) + Expect(err).To(BeNil()) + + profileDir := filepath.Join(stager.DepDir(), "profile.d") + files, err := os.ReadDir(profileDir) + Expect(err).To(BeNil()) + + var fileIndex int + for index, file := range files { + if strings.Contains(file.Name(), "contrast_security") { + fileIndex = index + } + } + + fileBytes, err := os.ReadFile(filepath.Join(profileDir, files[fileIndex].Name())) + Expect(err).To(BeNil()) + + Expect(string(fileBytes)).To(ContainSubstring( + "export CONTRAST__API__URL='https://app-agents.contrastsecurity.com/Contrast'", + )) + }) + }) + + Context("api_token in credentials writes CONTRAST__API__TOKEN", func() { + BeforeEach(func() { + os.Setenv("VCAP_APPLICATION", `{}`) + os.Setenv("VCAP_SERVICES", `{ + "contrast-security": [{ + "credentials": { + "api_key": "sample_api_key", + "api_token": "sample_api_token", + "service_key": "sample_service_key", + "teamserver_url": "sample_teamserver_url", + "username": "username@example.com" + } + }] + }`) + }) + + It("writes both legacy credentials and the token", func() { + err := contrast.AfterCompile(stager) + Expect(err).To(BeNil()) + + profileDir := filepath.Join(stager.DepDir(), "profile.d") + files, err := os.ReadDir(profileDir) + Expect(err).To(BeNil()) + + var fileIndex int + for index, file := range files { + if strings.Contains(file.Name(), "contrast_security") { + fileIndex = index + } + } + + fileBytes, err := os.ReadFile(filepath.Join(profileDir, files[fileIndex].Name())) + Expect(err).To(BeNil()) + + fileContents := string(fileBytes) + Expect(fileContents).To(ContainSubstring("export CONTRAST__API__API_KEY='sample_api_key'")) + Expect(fileContents).To(ContainSubstring("export CONTRAST__API__TOKEN='sample_api_token'")) + }) + }) + + Context("api_token only (no legacy credentials) writes CONTRAST__API__TOKEN", func() { + BeforeEach(func() { + os.Setenv("VCAP_APPLICATION", `{}`) + os.Setenv("VCAP_SERVICES", `{ + "contrast-security": [{ + "credentials": { + "api_token": "sample_api_token", + "teamserver_url": "sample_teamserver_url" + } + }] + }`) + }) + + It("writes CONTRAST__API__TOKEN without legacy credential lines", func() { + err := contrast.AfterCompile(stager) + Expect(err).To(BeNil()) + + profileDir := filepath.Join(stager.DepDir(), "profile.d") + files, err := os.ReadDir(profileDir) + Expect(err).To(BeNil()) + + var fileIndex int + for index, file := range files { + if strings.Contains(file.Name(), "contrast_security") { + fileIndex = index + } + } + + fileBytes, err := os.ReadFile(filepath.Join(profileDir, files[fileIndex].Name())) + Expect(err).To(BeNil()) + + fileContents := string(fileBytes) + Expect(fileContents).To(ContainSubstring("export CONTRAST__API__TOKEN='sample_api_token'")) + Expect(fileContents).NotTo(ContainSubstring("CONTRAST__API__API_KEY")) + Expect(fileContents).NotTo(ContainSubstring("CONTRAST__API__SERVICE_KEY")) + }) + }) + + Context("Credentials containing special shell characters", func() { + BeforeEach(func() { + os.Setenv("VCAP_APPLICATION", `{}`) + os.Setenv("VCAP_SERVICES", `{ + "contrast-security": [{ + "binding_name": "CCC", + "credentials": { + "api_key": "malformedkey$with'special", + "service_key": "svc key", + "teamserver_url": "https://example.com", + "username": "user@example.com" + } + }] + }`) + }) + + It("single-quotes all values in the profile.d script", func() { + err := contrast.AfterCompile(stager) + Expect(err).To(BeNil()) + + profileDir := filepath.Join(stager.DepDir(), "profile.d") + files, err := os.ReadDir(profileDir) + Expect(err).To(BeNil()) + + var fileIndex int + for index, file := range files { + if strings.Contains(file.Name(), "contrast_security") { + fileIndex = index + } + } + + fileBytes, err := os.ReadFile(filepath.Join(profileDir, files[fileIndex].Name())) + Expect(err).To(BeNil()) + + fileContents := string(fileBytes) + // single quote escaping: ' → '\'' + Expect(fileContents).To(ContainSubstring("export CONTRAST__API__API_KEY='malformedkey$with'\\''special'")) + Expect(fileContents).To(ContainSubstring("export CONTRAST__API__SERVICE_KEY='svc key'")) + }) + }) + + Context("Non-string credential value does not panic", func() { + BeforeEach(func() { + os.Setenv("VCAP_APPLICATION", `{}`) + os.Setenv("VCAP_SERVICES", `{ + "contrast-security": [{ + "credentials": { + "api_key": 12345, + "service_key": "sample_service_key", + "teamserver_url": "sample_teamserver_url", + "username": "username@example.com" + } + }] + }`) + }) + It("returns empty string for the non-string field and does not panic", func() { + success, credentials := contrast.GetCredentialsFromEnvironment() + Expect(success).To(BeTrue()) + Expect(credentials.ApiKey).To(Equal("")) + Expect(credentials.ServiceKey).To(Equal("sample_service_key")) + }) + }) + Context("No Contrast Security credentials in VCAP_SERVICES", func() { BeforeEach(func() { os.Setenv("VCAP_APPLICATION", "{}") @@ -279,7 +459,7 @@ var _ = Describe("contrastSecurityHook", func() { }) }) - Context("Multiple user-provided services defined in VCAP_SERVICES", func() { + Context("Contrast Security found among multiple service categories in VCAP_SERVICES", func() { BeforeEach(func() { os.Setenv("VCAP_APPLICATION", `{}`) os.Setenv("VCAP_SERVICES", `{ @@ -330,6 +510,45 @@ var _ = Describe("contrastSecurityHook", func() { }) }) + Context("Two user-provided services where only the second matches contrast-security", func() { + BeforeEach(func() { + os.Setenv("VCAP_APPLICATION", `{}`) + os.Setenv("VCAP_SERVICES", `{ + "user-provided": [ + { + "label": "user-provided", + "name": "my-postgres", + "tags": [], + "credentials": { + "uri": "postgres://example.com" + } + }, + { + "label": "user-provided", + "name": "contrast-security-service", + "tags": [], + "credentials": { + "api_key": "correct_api_key", + "service_key": "correct_service_key", + "teamserver_url": "correct_teamserver_url", + "username": "correct@example.com" + } + } + ] + }`) + }) + It("returns credentials from the matching service, not the first in the category", func() { + success, credentials := contrast.GetCredentialsFromEnvironment() + Expect(success).To(BeTrue()) + Expect(credentials).To(BeEquivalentTo(hooks.ContrastSecurityCredentials{ + ApiKey: "correct_api_key", + ServiceKey: "correct_service_key", + ContrastUrl: "correct_teamserver_url", + Username: "correct@example.com", + })) + }) + }) + Context("Contrast Security undefined for user-defined service within VCAP_SERVICES", func() { BeforeEach(func() { os.Setenv("VCAP_APPLICATION", `{}`) @@ -433,5 +652,119 @@ var _ = Describe("contrastSecurityHook", func() { }) }) + + Context("api_token in credentials is extracted", func() { + BeforeEach(func() { + os.Setenv("VCAP_APPLICATION", `{}`) + os.Setenv("VCAP_SERVICES", `{ + "contrast-security": [{ + "credentials": { + "api_key": "sample_api_key", + "api_token": "sample_api_token", + "service_key": "sample_service_key", + "teamserver_url": "sample_teamserver_url", + "username": "username@example.com" + } + }] + }`) + }) + + It("returns ApiToken alongside legacy credentials", func() { + success, credentials := contrast.GetCredentialsFromEnvironment() + Expect(success).To(BeTrue()) + Expect(credentials.ApiKey).To(Equal("sample_api_key")) + Expect(credentials.ApiToken).To(Equal("sample_api_token")) + }) + }) + + Context("VCAP_SERVICES_FILE_PATH set to a valid file", func() { + var tmpFile *os.File + + BeforeEach(func() { + var err error + tmpFile, err = os.CreateTemp("", "vcap_services_*.json") + Expect(err).To(BeNil()) + _, err = tmpFile.WriteString(`{ + "contrast-security": [{ + "credentials": { + "api_token": "file_api_token" + } + }] + }`) + Expect(err).To(BeNil()) + Expect(tmpFile.Close()).To(BeNil()) + + os.Unsetenv("VCAP_SERVICES") + os.Setenv("VCAP_SERVICES_FILE_PATH", tmpFile.Name()) + }) + + AfterEach(func() { + os.Unsetenv("VCAP_SERVICES_FILE_PATH") + os.Remove(tmpFile.Name()) + }) + + It("returns credentials read from the file", func() { + success, credentials := contrast.GetCredentialsFromEnvironment() + Expect(success).To(BeTrue()) + Expect(credentials.ApiToken).To(Equal("file_api_token")) + }) + }) + + Context("VCAP_SERVICES_FILE_PATH points to a missing file", func() { + BeforeEach(func() { + os.Unsetenv("VCAP_SERVICES") + os.Setenv("VCAP_SERVICES_FILE_PATH", "/nonexistent/path/vcap.json") + }) + + AfterEach(func() { + os.Unsetenv("VCAP_SERVICES_FILE_PATH") + }) + + It("fails but continues", func() { + success, credentials := contrast.GetCredentialsFromEnvironment() + Expect(success).To(BeFalse()) + Expect(credentials).To(BeEquivalentTo(hooks.ContrastSecurityCredentials{})) + }) + }) + + Context("both VCAP_SERVICES and VCAP_SERVICES_FILE_PATH are set", func() { + var tmpFile *os.File + + BeforeEach(func() { + var err error + tmpFile, err = os.CreateTemp("", "vcap_services_*.json") + Expect(err).To(BeNil()) + _, err = tmpFile.WriteString(`{ + "contrast-security": [{ + "credentials": { + "api_token": "file_api_token" + } + }] + }`) + Expect(err).To(BeNil()) + Expect(tmpFile.Close()).To(BeNil()) + + os.Setenv("VCAP_SERVICES", `{ + "contrast-security": [{ + "credentials": { + "api_token": "env_api_token" + } + }] + }`) + os.Setenv("VCAP_SERVICES_FILE_PATH", tmpFile.Name()) + }) + + AfterEach(func() { + os.Unsetenv("VCAP_SERVICES_FILE_PATH") + os.Remove(tmpFile.Name()) + }) + + It("prefers VCAP_SERVICES_FILE_PATH and logs a warning", func() { + success, credentials := contrast.GetCredentialsFromEnvironment() + Expect(success).To(BeTrue()) + Expect(credentials.ApiToken).To(Equal("file_api_token")) + Expect(buffer.String()).To(ContainSubstring("VCAP_SERVICES_FILE_PATH")) + }) + }) }) })