From 0de540184851d954b509c6f88baae9689cfc0b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 09:24:49 +0000 Subject: [PATCH] fix(rules): read securityOpt entries and capabilities the way Docker does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the reading of a "securityOpt" entry and of a capability name into dockerargs, so the rules share one answer to how Docker splits and matches them. securityOpt entries were split on "=" only, and only against a fixed set of literal spellings, which both missed and misreported configurations: - "seccomp:unconfined" went unreported. Docker falls back to ":" as the separator in an entry holding no "=". - "no-new-privileges=1" was read as unset. The value is a boolean, which Docker reads with strconv.ParseBool. - "seccomp=builtin" was reported as overriding the default profile. Docker passes the name straight through and the daemon loads the same profile it would with no seccomp option at all, so it is the default, not an override. The match is exact, so "seccomp=BUILTIN" still names a file. Capability names now go through one normalization — upper-cased, "CAP_" prefixed, with "ALL" standing on its own — which also reaches the "capAdd" property, where "all" previously went unreported. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JMeKPpNA7DkazGxjXRrk3q --- dockerargs/capability.go | 17 ++++++++ dockerargs/capability_test.go | 32 ++++++++++++++ dockerargs/dockerargs.go | 12 ++++-- dockerargs/securityopt.go | 50 ++++++++++++++++++++++ dockerargs/securityopt_test.go | 55 +++++++++++++++++++++++++ rules/no_cap_add_all.go | 2 +- rules/no_cap_add_all_test.go | 10 +++++ rules/no_seccomp_override.go | 11 ++--- rules/no_seccomp_override_test.go | 15 +++++++ rules/no_seccomp_unconfined.go | 12 +++++- rules/no_seccomp_unconfined_test.go | 13 ++++++ rules/require_cap_drop_all_test.go | 5 +++ rules/require_no_new_privileges.go | 14 +++---- rules/require_no_new_privileges_test.go | 16 +++++++ rules/util.go | 17 ++++++-- 15 files changed, 258 insertions(+), 23 deletions(-) create mode 100644 dockerargs/capability.go create mode 100644 dockerargs/capability_test.go create mode 100644 dockerargs/securityopt.go create mode 100644 dockerargs/securityopt_test.go diff --git a/dockerargs/capability.go b/dockerargs/capability.go new file mode 100644 index 0000000..4e0db1c --- /dev/null +++ b/dockerargs/capability.go @@ -0,0 +1,17 @@ +package dockerargs + +import "strings" + +// AllCapabilities is the pseudo-capability standing for every Linux capability. +const AllCapabilities = "ALL" + +// Capability returns name — a capability written in "capAdd" or given to "--cap-add"/"--cap-drop" — +// as Docker matches it: upper-cased and prefixed with "CAP_", except for [AllCapabilities], which +// takes no prefix. +func Capability(name string) string { + c := strings.ToUpper(name) + if c == AllCapabilities || strings.HasPrefix(c, "CAP_") { + return c + } + return "CAP_" + c +} diff --git a/dockerargs/capability_test.go b/dockerargs/capability_test.go new file mode 100644 index 0000000..932dce0 --- /dev/null +++ b/dockerargs/capability_test.go @@ -0,0 +1,32 @@ +package dockerargs + +import "testing" + +func TestCapability(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + want string + }{ + {"SYS_PTRACE", "CAP_SYS_PTRACE"}, + {"sys_ptrace", "CAP_SYS_PTRACE"}, + {"CAP_SYS_PTRACE", "CAP_SYS_PTRACE"}, + {"cap_sys_ptrace", "CAP_SYS_PTRACE"}, + {"", "CAP_"}, + + {"ALL", "ALL"}, + {"all", "ALL"}, + {"All", "ALL"}, + // "ALL" is a name of its own, so prefixing it names an ordinary — and unknown — capability. + {"CAP_ALL", "CAP_ALL"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := Capability(tt.name); got != tt.want { + t.Errorf("Capability(%q) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} diff --git a/dockerargs/dockerargs.go b/dockerargs/dockerargs.go index 1ab5f07..33e0687 100644 --- a/dockerargs/dockerargs.go +++ b/dockerargs/dockerargs.go @@ -1,7 +1,11 @@ -// Package dockerargs reads a devcontainer.json "runArgs" array as what it becomes: the argv of the -// "docker run" command the devcontainer tooling builds. It is the single place that knows where a -// flag's value can be written, so a rule only has to know the values it cares about — a capability -// name, a mount, a "securityOpt" entry — and never which entry of the array holds one. +// Package dockerargs reads the parts of a devcontainer.json that Docker, rather than the +// devcontainer tooling, gives meaning to: +// +// - "runArgs", which becomes the argv of the "docker run" command the tooling builds. [Parse] is +// the single place that knows where a flag's value can be written, so a rule only has to know +// the values it cares about and never which entry of the array holds one. +// - the values themselves, whose syntax is Docker's wherever they are written: a "securityOpt" +// entry ([ParseSecurityOpt]), a capability name ([Capability]), a boolean ([IsTrue]). package dockerargs import ( diff --git a/dockerargs/securityopt.go b/dockerargs/securityopt.go new file mode 100644 index 0000000..609b97b --- /dev/null +++ b/dockerargs/securityopt.go @@ -0,0 +1,50 @@ +package dockerargs + +import "strings" + +// Seccomp profile names that stand for a built-in profile rather than for the path of one to load. +// Docker matches them exactly, so a differently cased spelling names a file. +const ( + // SeccompProfileDefault is the runtime's own default profile — the one a container that is + // given no seccomp option at all runs under. + SeccompProfileDefault = "builtin" + // SeccompProfileUnconfined turns syscall filtering off. + SeccompProfileUnconfined = "unconfined" +) + +// noNewPrivileges is the one security option that may be written without a value. +const noNewPrivileges = "no-new-privileges" + +// SecurityOpt is one "securityOpt" entry, equivalently one value given to "--security-opt". +type SecurityOpt struct { + // Key names the option, e.g. "seccomp" or "no-new-privileges". Docker matches it + // case-sensitively, so it is kept as written. + Key string + // Value is what the entry gives the option, which for a bare "no-new-privileges" is the "true" + // it stands for on its own. + Value string +} + +// ParseSecurityOpt reads s the way Docker splits it: +// +// - the key and the value are separated by the first "=", or, in an entry holding none, by the +// first ":"; +// - "no-new-privileges" is the one option that may be written bare, standing for "true". +// +// ok is false for an entry Docker rejects outright, which is any other one left without a value. +func ParseSecurityOpt(s string) (opt SecurityOpt, ok bool) { + key, value, split := strings.Cut(s, "=") + if !split && key != noNewPrivileges { + key, value, split = strings.Cut(s, ":") + } + if key == noNewPrivileges { + if !split { + value = "true" + } + return SecurityOpt{Key: key, Value: value}, true + } + if !split || value == "" { + return SecurityOpt{}, false + } + return SecurityOpt{Key: key, Value: value}, true +} diff --git a/dockerargs/securityopt_test.go b/dockerargs/securityopt_test.go new file mode 100644 index 0000000..94d405d --- /dev/null +++ b/dockerargs/securityopt_test.go @@ -0,0 +1,55 @@ +package dockerargs + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestParseSecurityOpt(t *testing.T) { + t.Parallel() + + tests := []struct { + s string + want SecurityOpt + wantO bool + }{ + {"seccomp=unconfined", SecurityOpt{Key: "seccomp", Value: "unconfined"}, true}, + {"seccomp=builtin", SecurityOpt{Key: "seccomp", Value: "builtin"}, true}, + {"seccomp=./profile.json", SecurityOpt{Key: "seccomp", Value: "./profile.json"}, true}, + {"apparmor=unconfined", SecurityOpt{Key: "apparmor", Value: "unconfined"}, true}, + + // A ":" separates the key from the value only in an entry holding no "=". + {"seccomp:unconfined", SecurityOpt{Key: "seccomp", Value: "unconfined"}, true}, + {"seccomp:builtin", SecurityOpt{Key: "seccomp", Value: "builtin"}, true}, + {"label=user:USER", SecurityOpt{Key: "label", Value: "user:USER"}, true}, + + // The key is matched case-sensitively, so it is kept as written. + {"SECCOMP=unconfined", SecurityOpt{Key: "SECCOMP", Value: "unconfined"}, true}, + {"seccomp=BUILTIN", SecurityOpt{Key: "seccomp", Value: "BUILTIN"}, true}, + + {"no-new-privileges", SecurityOpt{Key: "no-new-privileges", Value: "true"}, true}, + {"no-new-privileges=true", SecurityOpt{Key: "no-new-privileges", Value: "true"}, true}, + {"no-new-privileges:true", SecurityOpt{Key: "no-new-privileges", Value: "true"}, true}, + {"no-new-privileges=1", SecurityOpt{Key: "no-new-privileges", Value: "1"}, true}, + {"no-new-privileges=false", SecurityOpt{Key: "no-new-privileges", Value: "false"}, true}, + {"no-new-privileges=", SecurityOpt{Key: "no-new-privileges"}, true}, + {"no-new-privileges:", SecurityOpt{Key: "no-new-privileges"}, true}, + {"NO-NEW-PRIVILEGES", SecurityOpt{}, false}, + + // Every other option needs a value. + {"seccomp", SecurityOpt{}, false}, + {"seccomp=", SecurityOpt{}, false}, + {"seccomp:", SecurityOpt{}, false}, + {"", SecurityOpt{}, false}, + } + for _, tt := range tests { + t.Run(tt.s, func(t *testing.T) { + t.Parallel() + got, gotOK := ParseSecurityOpt(tt.s) + if diff := cmp.Diff(tt.want, got); diff != "" || gotOK != tt.wantO { + t.Errorf("ParseSecurityOpt(%q) ok = %v, want %v; mismatch (-want +got):\n%s", tt.s, gotOK, tt.wantO, diff) + } + }) + } +} diff --git a/rules/no_cap_add_all.go b/rules/no_cap_add_all.go index 823d4b9..215f82e 100644 --- a/rules/no_cap_add_all.go +++ b/rules/no_cap_add_all.go @@ -63,7 +63,7 @@ func checkNoCapAddAll(ctx *linter.Context, node *linter.Node) []linter.Finding { } lit, ok := node.Value.Value.(hujson.Literal) - if !ok || lit.Kind() != '"' || lit.String() != "ALL" { + if !ok || lit.Kind() != '"' || !isAllCapability(lit.String()) { return nil } return []linter.Finding{{ diff --git a/rules/no_cap_add_all_test.go b/rules/no_cap_add_all_test.go index 9b15975..730cc34 100644 --- a/rules/no_cap_add_all_test.go +++ b/rules/no_cap_add_all_test.go @@ -21,6 +21,12 @@ func TestNoCapAddAll(t *testing.T) { {Path: "devcontainer.json", Line: 1, Col: 27, RuleID: "no-cap-add-all", Message: `"capAdd" contains "ALL", granting every Linux capability to the container`}, }}, + {"capAdd with lower-case all", `{"capAdd": ["all"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 13, RuleID: "no-cap-add-all", + Message: `"capAdd" contains "ALL", granting every Linux capability to the container`}, + }}, + // "ALL" takes no "CAP_" prefix, so a prefixed one is an ordinary capability name. + {"capAdd with CAP_ALL", `{"capAdd": ["CAP_ALL"]}`, nil}, {"no runArgs", `{"runArgs": ["--init"]}`, nil}, {"runArgs without cap-add=ALL", `{"runArgs": ["--init", "--cap-add=SYS_PTRACE"]}`, nil}, {"runArgs with cap-add=ALL", `{"runArgs": ["--init", "--cap-add=ALL"]}`, []linter.Issue{ @@ -70,6 +76,10 @@ func TestNoCapAddAll_Feature(t *testing.T) { {Path: "devcontainer-feature.json", Line: 1, Col: 27, RuleID: "no-cap-add-all", Message: `"capAdd" contains "ALL", granting every Linux capability to the container`}, }}, + {"capAdd with lower-case all", `{"id": "test", "capAdd": ["all"]}`, []linter.Issue{ + {Path: "devcontainer-feature.json", Line: 1, Col: 27, RuleID: "no-cap-add-all", + Message: `"capAdd" contains "ALL", granting every Linux capability to the container`}, + }}, // "runArgs" has no meaning in a Feature, so it's not flagged there. {"runArgs with cap-add=ALL is ignored", `{"id": "test", "runArgs": ["--cap-add=ALL"]}`, nil}, } diff --git a/rules/no_seccomp_override.go b/rules/no_seccomp_override.go index 400ba29..9e5332c 100644 --- a/rules/no_seccomp_override.go +++ b/rules/no_seccomp_override.go @@ -1,8 +1,7 @@ package rules import ( - "strings" - + "github.com/bare-devcontainer/decolint/dockerargs" "github.com/bare-devcontainer/decolint/linter" "github.com/tailscale/hujson" ) @@ -79,8 +78,10 @@ func checkNoSeccompOverride(ctx *linter.Context, node *linter.Node) []linter.Fin }} } -// securityOptOverridesSeccomp reports whether s, a single "securityOpt" entry, points seccomp at a -// profile of its own. +// securityOptOverridesSeccomp reports whether s, a single "securityOpt" entry, points seccomp at +// anything other than the runtime's own default profile. Naming that default explicitly leaves the +// container exactly where it started, so it is not an override. func securityOptOverridesSeccomp(s string) bool { - return strings.HasPrefix(s, "seccomp=") + profile, ok := securityOptSeccompProfile(s) + return ok && profile != dockerargs.SeccompProfileDefault } diff --git a/rules/no_seccomp_override_test.go b/rules/no_seccomp_override_test.go index b96347e..ab1f9d2 100644 --- a/rules/no_seccomp_override_test.go +++ b/rules/no_seccomp_override_test.go @@ -28,6 +28,20 @@ func TestNoSeccompOverride(t *testing.T) { {Path: "devcontainer.json", Line: 1, Col: 18, RuleID: "no-seccomp-override", Message: `"securityOpt" overrides the default seccomp profile`}, }}, + {"securityOpt with custom seccomp profile separated by a colon", `{"securityOpt": ["seccomp:/path/to/profile.json"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 18, RuleID: "no-seccomp-override", + Message: `"securityOpt" overrides the default seccomp profile`}, + }}, + // "builtin" names the runtime's own profile, the one an unset "seccomp" already selects. + {"securityOpt seccomp builtin", `{"securityOpt": ["seccomp=builtin"]}`, nil}, + {"securityOpt seccomp builtin separated by a colon", `{"securityOpt": ["seccomp:builtin"]}`, nil}, + {"securityOpt seccomp builtin upper-cased names a file", `{"securityOpt": ["seccomp=BUILTIN"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 18, RuleID: "no-seccomp-override", + Message: `"securityOpt" overrides the default seccomp profile`}, + }}, + {"securityOpt seccomp key upper-cased", `{"securityOpt": ["SECCOMP=/path/to/profile.json"]}`, nil}, + {"securityOpt seccomp without a value", `{"securityOpt": ["seccomp"]}`, nil}, + {"securityOpt seccomp with an empty value", `{"securityOpt": ["seccomp="]}`, nil}, // "runArgs" {"runArgs without security-opt", `{"runArgs": ["--init", "--cap-add=SYS_PTRACE"]}`, nil}, @@ -46,6 +60,7 @@ func TestNoSeccompOverride(t *testing.T) { {Path: "devcontainer.json", Line: 1, Col: 32, RuleID: "no-seccomp-override", Message: `"runArgs" overrides the default seccomp profile via "--security-opt"`}, }}, + {"runArgs seccomp builtin", `{"runArgs": ["--security-opt", "seccomp=builtin"]}`, nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/rules/no_seccomp_unconfined.go b/rules/no_seccomp_unconfined.go index a9a2a8e..b7b3791 100644 --- a/rules/no_seccomp_unconfined.go +++ b/rules/no_seccomp_unconfined.go @@ -1,6 +1,7 @@ package rules import ( + "github.com/bare-devcontainer/decolint/dockerargs" "github.com/bare-devcontainer/decolint/linter" "github.com/tailscale/hujson" ) @@ -52,7 +53,7 @@ func checkNoSeccompUnconfined(ctx *linter.Context, node *linter.Node) []linter.F if !ok || !runArgsApplicable(ctx) { return nil } - v := runArgsFindFlagValue(arr, "security-opt", func(s string) bool { return s == "seccomp=unconfined" }) + v := runArgsFindFlagValue(arr, "security-opt", securityOptDisablesSeccomp) if v == nil { return nil } @@ -63,7 +64,7 @@ func checkNoSeccompUnconfined(ctx *linter.Context, node *linter.Node) []linter.F } lit, ok := node.Value.Value.(hujson.Literal) - if !ok || lit.Kind() != '"' || lit.String() != "seccomp=unconfined" { + if !ok || lit.Kind() != '"' || !securityOptDisablesSeccomp(lit.String()) { return nil } return []linter.Finding{{ @@ -71,3 +72,10 @@ func checkNoSeccompUnconfined(ctx *linter.Context, node *linter.Node) []linter.F Offset: node.Value.StartOffset, }} } + +// securityOptDisablesSeccomp reports whether s, a single "securityOpt" entry, turns syscall +// filtering off. +func securityOptDisablesSeccomp(s string) bool { + profile, ok := securityOptSeccompProfile(s) + return ok && profile == dockerargs.SeccompProfileUnconfined +} diff --git a/rules/no_seccomp_unconfined_test.go b/rules/no_seccomp_unconfined_test.go index 9d06623..1e9cbb9 100644 --- a/rules/no_seccomp_unconfined_test.go +++ b/rules/no_seccomp_unconfined_test.go @@ -24,6 +24,14 @@ func TestNoSeccompUnconfined(t *testing.T) { {Path: "devcontainer.json", Line: 1, Col: 18, RuleID: "no-seccomp-unconfined", Message: `"securityOpt" contains "seccomp=unconfined", disabling the container's syscall filtering`}, }}, + {"securityOpt seccomp unconfined separated by a colon", `{"securityOpt": ["seccomp:unconfined"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 18, RuleID: "no-seccomp-unconfined", + Message: `"securityOpt" contains "seccomp=unconfined", disabling the container's syscall filtering`}, + }}, + {"securityOpt seccomp builtin", `{"securityOpt": ["seccomp=builtin"]}`, nil}, + {"securityOpt seccomp key upper-cased", `{"securityOpt": ["SECCOMP=unconfined"]}`, nil}, + {"securityOpt seccomp profile upper-cased", `{"securityOpt": ["seccomp=UNCONFINED"]}`, nil}, + {"securityOpt seccomp without a value", `{"securityOpt": ["seccomp"]}`, nil}, // "runArgs" {"runArgs without security-opt", `{"runArgs": ["--init", "--cap-add=SYS_PTRACE"]}`, nil}, @@ -33,6 +41,11 @@ func TestNoSeccompUnconfined(t *testing.T) { {Path: "devcontainer.json", Line: 1, Col: 32, RuleID: "no-seccomp-unconfined", Message: `"runArgs" contains "--security-opt seccomp=unconfined", disabling the container's syscall filtering`}, }}, + {"runArgs seccomp unconfined separated by a colon", `{"runArgs": ["--security-opt", "seccomp:unconfined"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 32, RuleID: "no-seccomp-unconfined", + Message: `"runArgs" contains "--security-opt seccomp=unconfined", disabling the container's syscall filtering`}, + }}, + {"runArgs seccomp builtin", `{"runArgs": ["--security-opt", "seccomp=builtin"]}`, nil}, {"runArgs security-opt consumed as another flag's value", `{"runArgs": ["--label", "--security-opt=seccomp=unconfined"]}`, nil}, {"runArgs bare seccomp entry names no flag", `{"runArgs": ["seccomp=unconfined"]}`, nil}, {"runArgs seccomp unconfined combined", `{"runArgs": ["--security-opt=seccomp=unconfined"]}`, []linter.Issue{ diff --git a/rules/require_cap_drop_all_test.go b/rules/require_cap_drop_all_test.go index 3268a09..2441260 100644 --- a/rules/require_cap_drop_all_test.go +++ b/rules/require_cap_drop_all_test.go @@ -22,6 +22,11 @@ func TestRequireCapDropAll(t *testing.T) { {"runArgs with cap-drop=ALL", `{"runArgs": ["--cap-drop=ALL"]}`, nil}, {"runArgs with cap-drop ALL two tokens", `{"runArgs": ["--cap-drop", "ALL"]}`, nil}, {"runArgs with lower-case all", `{"runArgs": ["--cap-drop=all"]}`, nil}, + // "ALL" takes no "CAP_" prefix, so a prefixed one is an ordinary capability name. + {"runArgs with cap-drop=CAP_ALL", `{"runArgs": ["--cap-drop=CAP_ALL"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-cap-drop-all", + Message: `"ALL" is not set via "runArgs", leaving the container with its default Linux capabilities`}, + }}, {"runArgs with cap-drop consumed as another flag's value", `{"runArgs": ["--label", "--cap-drop=ALL"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-cap-drop-all", Message: `"ALL" is not set via "runArgs", leaving the container with its default Linux capabilities`}, diff --git a/rules/require_no_new_privileges.go b/rules/require_no_new_privileges.go index 39b70ec..29f58a8 100644 --- a/rules/require_no_new_privileges.go +++ b/rules/require_no_new_privileges.go @@ -1,6 +1,7 @@ package rules import ( + "github.com/bare-devcontainer/decolint/dockerargs" "github.com/bare-devcontainer/decolint/linter" "github.com/tailscale/hujson" ) @@ -67,13 +68,10 @@ func checkRequireNoNewPrivileges(_ *linter.Context, node *linter.Node) []linter. }} } -// securityOptIsNoNewPrivileges reports whether s, a single "securityOpt" entry, sets -// "no-new-privileges". Docker treats the bare keyword as well as an explicit "=true" or ":true" -// value as enabling it; "=false" or ":false" leaves it disabled. +// securityOptIsNoNewPrivileges reports whether s, a single "securityOpt" entry, turns on +// "no-new-privileges". Its value is a boolean, read as [dockerargs.IsTrue] describes, and the +// option is the one that may also be written bare. func securityOptIsNoNewPrivileges(s string) bool { - switch s { - case "no-new-privileges", "no-new-privileges=true", "no-new-privileges:true": - return true - } - return false + opt, ok := dockerargs.ParseSecurityOpt(s) + return ok && opt.Key == "no-new-privileges" && dockerargs.IsTrue(opt.Value) } diff --git a/rules/require_no_new_privileges_test.go b/rules/require_no_new_privileges_test.go index a6776c5..a700e56 100644 --- a/rules/require_no_new_privileges_test.go +++ b/rules/require_no_new_privileges_test.go @@ -24,10 +24,25 @@ func TestRequireNoNewPrivileges(t *testing.T) { {"securityOpt bare", `{"securityOpt": ["no-new-privileges"]}`, nil}, {"securityOpt equals true", `{"securityOpt": ["no-new-privileges=true"]}`, nil}, {"securityOpt colon true", `{"securityOpt": ["no-new-privileges:true"]}`, nil}, + // The value is a boolean, not the literal "true": Docker reads it with strconv.ParseBool. + {"securityOpt equals 1", `{"securityOpt": ["no-new-privileges=1"]}`, nil}, + {"securityOpt colon T", `{"securityOpt": ["no-new-privileges:T"]}`, nil}, {"securityOpt equals false", `{"securityOpt": ["no-new-privileges=false"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-no-new-privileges", Message: `"no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges`}, }}, + {"securityOpt equals 0", `{"securityOpt": ["no-new-privileges=0"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-no-new-privileges", + Message: `"no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges`}, + }}, + {"securityOpt key upper-cased", `{"securityOpt": ["NO-NEW-PRIVILEGES=true"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-no-new-privileges", + Message: `"no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges`}, + }}, + {"securityOpt key upper-cased and bare", `{"securityOpt": ["NO-NEW-PRIVILEGES"]}`, []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-no-new-privileges", + Message: `"no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges`}, + }}, {"securityOpt with unrelated option", `{"securityOpt": ["seccomp=unconfined"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-no-new-privileges", Message: `"no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges`}, @@ -44,6 +59,7 @@ func TestRequireNoNewPrivileges(t *testing.T) { }}, {"runArgs two tokens", `{"runArgs": ["--security-opt", "no-new-privileges"]}`, nil}, {"runArgs combined", `{"runArgs": ["--security-opt=no-new-privileges=true"]}`, nil}, + {"runArgs combined 1", `{"runArgs": ["--security-opt=no-new-privileges=1"]}`, nil}, {"runArgs security-opt consumed as another flag's value", `{"runArgs": ["--label", "--security-opt=no-new-privileges"]}`, []linter.Issue{ {Path: "devcontainer.json", Line: 1, Col: 1, RuleID: "require-no-new-privileges", Message: `"no-new-privileges" is not set via "securityOpt" or "runArgs", allowing container processes to gain additional privileges`}, diff --git a/rules/util.go b/rules/util.go index 6041fce..86a06d4 100644 --- a/rules/util.go +++ b/rules/util.go @@ -26,10 +26,21 @@ func isDockerSocketSource(source string) bool { return path.Clean(source) == dockerSocketPath } -// isAllCapability reports whether s names the "ALL" pseudo-capability, which stands for every Linux -// capability. Docker upper-cases a capability name before matching it, so "all" names it too. +// isAllCapability reports whether s names the pseudo-capability standing for every Linux +// capability. See [dockerargs.Capability] for the spellings that reach it. func isAllCapability(s string) bool { - return strings.EqualFold(s, "ALL") + return dockerargs.Capability(s) == dockerargs.AllCapabilities +} + +// securityOptSeccompProfile returns the seccomp profile s, a "securityOpt" entry, selects, and +// reports whether it selects one at all. See [dockerargs.ParseSecurityOpt] for the forms an entry +// can be written in. +func securityOptSeccompProfile(s string) (profile string, ok bool) { + opt, ok := dockerargs.ParseSecurityOpt(s) + if !ok || opt.Key != "seccomp" { + return "", false + } + return opt.Value, true } // hasMember reports whether obj has a member named name.