Skip to content
Open
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
50 changes: 44 additions & 6 deletions agent/server/snykbroker/acceptfile/accept_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ func (w acceptFileWrapper) AddRule(routeType string, entry acceptFileRule) accep
}
existingRoutes := w.dict[routeType].([]any)
w.dict[routeType] = append([]any{routeDict}, existingRoutes...)
return acceptFileRuleWrapper{dict: routeDict}
// without the back-reference every accessor that logs would nil-deref
return acceptFileRuleWrapper{dict: routeDict, acceptFile: w.acceptFile}
}

func (w acceptFileWrapper) toJSON() ([]byte, error) {
Expand Down Expand Up @@ -239,6 +240,42 @@ func (r acceptFileRuleWrapper) SetOrigin(origin string) {
r.dict["origin"] = origin
}

// RuleKeyDynamicTargetHosts is an Axon extension to the Snyk Broker rule
// format. Its value is a list of host patterns the rule may be retargeted to
// per request, each either an exact host or a leading wildcard such as
// "*.googleapis.com". A rule that omits it cannot be retargeted at all.
//
// Like "headers", the broker has no knowledge of this key and ignores it:
// rules round-trip through map[string]any and the broker applies no schema
// validation.
const RuleKeyDynamicTargetHosts = "dynamicTargetHosts"

// DynamicTargetHosts returns the rule's retargeting allowlist, or nil if it
// declares none. A malformed value panics rather than silently disabling
// retargeting, which would otherwise surface as an unexplained 403 per
// request.
func (r acceptFileRuleWrapper) DynamicTargetHosts() []string {
value, present := r.dict[RuleKeyDynamicTargetHosts]
if !present {
return nil
}
entries, ok := value.([]any)
if !ok {
r.acceptFile.logger.Panic("accept file rule has a non-list "+RuleKeyDynamicTargetHosts,
zap.Any("value", value))
}
hosts := make([]string, 0, len(entries))
for _, entry := range entries {
host, ok := entry.(string)
if !ok || host == "" {
r.acceptFile.logger.Panic("accept file rule has an empty or non-string "+RuleKeyDynamicTargetHosts+" entry",
zap.Any("entry", entry))
}
hosts = append(hosts, host)
}
return hosts
}

func (r acceptFileRuleWrapper) Headers() ResolverMap {
headers, ok := r.dict["headers"].(map[string]any)
if !ok {
Expand All @@ -259,11 +296,12 @@ func (r acceptFileRuleWrapper) Headers() ResolverMap {
// about additional fields that might be in the accept file that we don't know about.

type acceptFileRule struct {
Method string `json:"method"`
Path string `json:"path"`
Origin string `json:"origin"`
Auth *acceptFileRuleAuth `json:"auth,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Method string `json:"method"`
Path string `json:"path"`
Origin string `json:"origin"`
Auth *acceptFileRuleAuth `json:"auth,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
DynamicTargetHosts []string `json:"dynamicTargetHosts,omitempty"`
}

type acceptFileRuleAuth struct {
Expand Down
100 changes: 100 additions & 0 deletions agent/server/snykbroker/acceptfile/accept_file_dynamic_targets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package acceptfile

import (
"encoding/json"
"testing"

axonConfig "github.com/cortexapps/axon/config"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)

func privateRulesOf(t *testing.T, content string) []acceptFileRuleWrapper {
t.Helper()
cfg := axonConfig.NewAgentEnvConfig()
af, err := NewAcceptFile([]byte(content), cfg, nil)
require.NoError(t, err)
return newAcceptFileWrapper(af.content, af).PrivateRules()
}

func TestDynamicTargetHostsParsed(t *testing.T) {
rules := privateRulesOf(t, `{"private": [
{"method": "any", "origin": "https://a.googleapis.com", "path": "/*",
"dynamicTargetHosts": ["*.googleapis.com", "oauth2.googleapis.com"]}
]}`)
require.Len(t, rules, 1)
require.Equal(t, []string{"*.googleapis.com", "oauth2.googleapis.com"}, rules[0].DynamicTargetHosts())
}

func TestDynamicTargetHostsAbsent(t *testing.T) {
rules := privateRulesOf(t, `{"private": [
{"method": "any", "origin": "https://api.example.com", "path": "/*"}
]}`)
require.Len(t, rules, 1)
require.Nil(t, rules[0].DynamicTargetHosts())
}

// A typo here would otherwise disable retargeting silently, which surfaces as
// an unexplained 403 on every request rather than as a config error.
func TestDynamicTargetHostsMalformedPanics(t *testing.T) {
cases := map[string]string{
"not a list": `{"private": [{"method": "any", "origin": "https://a.com", "path": "/*", "dynamicTargetHosts": "*.googleapis.com"}]}`,
"non-string entry": `{"private": [{"method": "any", "origin": "https://a.com", "path": "/*", "dynamicTargetHosts": [42]}]}`,
"empty entry": `{"private": [{"method": "any", "origin": "https://a.com", "path": "/*", "dynamicTargetHosts": [""]}]}`,
}
for name, content := range cases {
t.Run(name, func(t *testing.T) {
cfg := axonConfig.NewAgentEnvConfig()
af, err := NewAcceptFile([]byte(content), cfg, zap.NewNop())
require.NoError(t, err)
rules := newAcceptFileWrapper(af.content, af).PrivateRules()
require.Len(t, rules, 1)
require.Panics(t, func() { rules[0].DynamicTargetHosts() })
})
}
}

// The broker has no knowledge of this key, so it must survive rendering
// untouched - the same contract "headers" already relies on.
func TestDynamicTargetHostsRoundTripsToBroker(t *testing.T) {
cfg := axonConfig.NewAgentEnvConfig()
af, err := NewAcceptFile([]byte(`{"private": [
{"method": "any", "origin": "https://a.googleapis.com", "path": "/*",
"dynamicTargetHosts": ["*.googleapis.com"]}
]}`), cfg, nil)
require.NoError(t, err)

rendered, err := af.Render(zap.NewNop())
require.NoError(t, err)

var out struct {
Private []map[string]any `json:"private"`
}
require.NoError(t, json.Unmarshal(rendered, &out))

var found bool
for _, rule := range out.Private {
if hosts, ok := rule[RuleKeyDynamicTargetHosts]; ok {
require.Equal(t, []any{"*.googleapis.com"}, hosts)
found = true
}
}
require.True(t, found, "dynamicTargetHosts did not survive rendering")
}

// AddRule builds rules from the typed struct rather than a raw dict, so the
// field has to be on the struct too or generated rules can never opt in.
func TestDynamicTargetHostsOnGeneratedRule(t *testing.T) {
cfg := axonConfig.NewAgentEnvConfig()
af, err := NewAcceptFile([]byte(`{"private": []}`), cfg, nil)
require.NoError(t, err)

wrapper := newAcceptFileWrapper(af.content, af)
added := wrapper.AddRule(RULES_PRIVATE, acceptFileRule{
Method: "any",
Path: "/*",
Origin: "https://a.googleapis.com",
DynamicTargetHosts: []string{"*.googleapis.com"},
})
require.Equal(t, []string{"*.googleapis.com"}, added.DynamicTargetHosts())
}
Loading
Loading