diff --git a/README.md b/README.md index e180617..989d1e2 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Axon Relay currently supports access to: * Jira * SonarQube * Prometheus +* Harness For details on Relay, see [here](README.relay.md). diff --git a/README.relay.md b/README.relay.md index eebadf1..89c38cb 100644 --- a/README.relay.md +++ b/README.relay.md @@ -85,6 +85,7 @@ Generally the naming works like: | **Bitbucket Hosted** | `BITBUCKET_API=https://bitbucket.mycompany.com`, `BITBUCKET_USERNAME`, `BITBUCKET_PASSWORD` | | **Jira** | `JIRA_API=https://jira.mycompany.com`, `JIRA_USERNAME`, `JIRA_TOKEN` | | **Jira Bearer/Cloud** | Arg `-s bearer`, `JIRA_API=https://mycompany.atlassian.com`, `JIRA_TOKEN` | +| **Harness** | `HARNESS_API=https://app.harness.io`, `HARNESS_TOKEN` | ## How it works diff --git a/agent/common/integration.go b/agent/common/integration.go index c27ed4b..cd1bfdf 100644 --- a/agent/common/integration.go +++ b/agent/common/integration.go @@ -27,6 +27,7 @@ const ( IntegrationSonarqube Integration = "sonarqube" IntegrationBitbucket Integration = "bitbucket" IntegrationPrometheus Integration = "prometheus" + IntegrationHarness Integration = "harness" ) var subtypes = map[Integration][]string{ @@ -59,7 +60,7 @@ func ParseIntegration(s string) (Integration, error) { } func ValidIntegrations() []Integration { - return []Integration{IntegrationCustom, IntegrationGithub, IntegrationJira, IntegrationGitlab, IntegrationBitbucket, IntegrationSonarqube, IntegrationPrometheus} + return []Integration{IntegrationCustom, IntegrationGithub, IntegrationJira, IntegrationGitlab, IntegrationBitbucket, IntegrationSonarqube, IntegrationPrometheus, IntegrationHarness} } type IntegrationInfo struct { diff --git a/agent/common/integration_test.go b/agent/common/integration_test.go index 58494da..eeb7ddf 100644 --- a/agent/common/integration_test.go +++ b/agent/common/integration_test.go @@ -140,6 +140,40 @@ func TestLoadIntegrationAcceptFilePoolVars(t *testing.T) { require.NotContains(t, string(contents), "GITHUB_TOKEN_POOL") } +func TestLoadIntegrationAcceptFileHarness(t *testing.T) { + os.Setenv("HARNESS_API", "https://app.harness.io") + os.Setenv("HARNESS_TOKEN", "the-harness-token") + + acceptFile, err := loadAcceptFile(t, IntegrationHarness) + require.NoError(t, err) + contents, err := acceptFile.Render(zap.NewNop()) + require.NoError(t, err) + require.Contains(t, string(contents), `"x-api-key":"${HARNESS_TOKEN}"`) + require.Contains(t, string(contents), `"origin":"${HARNESS_API}"`) +} + +func TestLoadIntegrationAcceptFileHarnessMissingVars(t *testing.T) { + os.Setenv("HARNESS_API", "") + os.Setenv("HARNESS_TOKEN", "") + + acceptFile, err := loadAcceptFile(t, IntegrationHarness) + require.Error(t, err) + require.Contains(t, err.Error(), "HARNESS_API") + require.Empty(t, acceptFile) +} + +func TestLoadValidationParamsHarness(t *testing.T) { + ii := IntegrationInfo{ + Integration: IntegrationHarness, + } + + validationParams := ii.GetValidationConfig() + require.NotNil(t, validationParams) + require.Equal(t, "$HARNESS_API/ng/api/user/currentUser", validationParams.URL) + require.Equal(t, "header", validationParams.Auth.Type) + require.Equal(t, "$HARNESS_TOKEN", validationParams.Auth.Value) +} + func TestGetOrigin(t *testing.T) { os.Setenv("USER", "testuser") diff --git a/agent/server/snykbroker/accept_files/accept.harness.json b/agent/server/snykbroker/accept_files/accept.harness.json new file mode 100644 index 0000000..d4cd278 --- /dev/null +++ b/agent/server/snykbroker/accept_files/accept.harness.json @@ -0,0 +1,12 @@ +{ + "private": [ + { + "method": "any", + "path": "/*", + "origin": "${HARNESS_API}", + "headers": { + "x-api-key": "${HARNESS_TOKEN}" + } + } + ] +} diff --git a/agent/server/snykbroker/accept_files/config.harness.json b/agent/server/snykbroker/accept_files/config.harness.json new file mode 100644 index 0000000..9663c66 --- /dev/null +++ b/agent/server/snykbroker/accept_files/config.harness.json @@ -0,0 +1,11 @@ +{ + "validation": [ + { + "url": "$HARNESS_API/ng/api/user/currentUser", + "auth": { + "type": "header", + "value": "$HARNESS_TOKEN" + } + } + ] +} diff --git a/agent/server/snykbroker/reflector_headers_harness_test.go b/agent/server/snykbroker/reflector_headers_harness_test.go new file mode 100644 index 0000000..e5c0c37 --- /dev/null +++ b/agent/server/snykbroker/reflector_headers_harness_test.go @@ -0,0 +1,96 @@ +package snykbroker + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/cortexapps/axon/config" + "github.com/cortexapps/axon/server/snykbroker/acceptfile" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// Tests the shipped Harness accept file end-to-end through the reflector: +// the x-api-key header is injected from HARNESS_TOKEN on proxied requests, +// and an inbound placeholder x-api-key is replaced (not duplicated). +func TestHarnessAcceptFileInjectsApiKeyHeader(t *testing.T) { + + content, err := os.ReadFile("accept_files/accept.harness.json") + require.NoError(t, err) + + var receivedApiKeys []string + backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedApiKeys = r.Header.Values("x-api-key") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "ok"}`)) + })) + defer backendServer.Close() + + os.Setenv("HARNESS_API", backendServer.URL) + os.Setenv("HARNESS_TOKEN", "real-harness-api-key") + defer func() { + os.Unsetenv("HARNESS_API") + os.Unsetenv("HARNESS_TOKEN") + }() + + logger := zap.NewNop() + cfg := config.AgentConfig{ + HttpRelayReflectorMode: config.RelayReflectorAllTraffic, + } + reflector := NewRegistrationReflector(RegistrationReflectorParams{ + Logger: logger, + Config: cfg, + }) + + _, err = reflector.Start() + require.NoError(t, err) + defer reflector.Stop() + + af, err := acceptfile.NewAcceptFile(content, cfg, nil) + require.NoError(t, err) + + var proxyURI string + _, err = af.Render( + logger, + func(renderContext acceptfile.RenderContext) error { + for _, entry := range renderContext.AcceptFile.PrivateRules() { + originalURI := entry.Origin() + if originalURI == cfg.HttpBaseUrl() { + continue + } + newURI := reflector.ProxyURI(originalURI, WithHeadersResolver(entry.Headers())) + entry.SetOrigin(newURI) + proxyURI = newURI + } + return nil + }, + ) + require.NoError(t, err) + require.NotEmpty(t, proxyURI) + + t.Run("injects x-api-key when absent", func(t *testing.T) { + resp, err := http.Get(fmt.Sprintf("%s/ng/api/projects", proxyURI)) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, []string{"real-harness-api-key"}, receivedApiKeys) + }) + + t.Run("replaces inbound placeholder x-api-key", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/ng/api/projects", proxyURI), nil) + require.NoError(t, err) + req.Header.Set("x-api-key", "placeholder-from-cortex") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + // exactly one value: the placeholder is replaced, not appended to + require.Equal(t, []string{"real-harness-api-key"}, receivedApiKeys) + }) +}