Merge pull request #6135 from crazy-max/lint-expose-proto-casing

dockerfile: rules check for EXPOSE instruction
This commit is contained in:
Tõnis Tiigi authored and GitHub committed 2025-08-15 12:40:18 +03:00
commit f9b9b363d6
10 files changed
+374 -72

No files matched your search

@@ -971,7 +971,7 @@ func dispatch(d *dispatchState, cmd command, opt dispatchOpt) error {
case *instructions.HealthCheckCommand:
err = dispatchHealthcheck(d, c, opt.lint)
case *instructions.ExposeCommand:
err = dispatchExpose(d, c, opt.shlex)
err = dispatchExpose(d, c, &opt)
case *instructions.UserCommand:
err = dispatchUser(d, c, true)
case *instructions.VolumeCommand:
@@ -7,15 +7,16 @@ import (
"strings"
"github.com/moby/buildkit/frontend/dockerfile/instructions"
"github.com/moby/buildkit/frontend/dockerfile/shell"
"github.com/moby/buildkit/frontend/dockerfile/linter"
"github.com/moby/buildkit/frontend/dockerfile/parser"
"github.com/pkg/errors"
)
func dispatchExpose(d *dispatchState, c *instructions.ExposeCommand, shlex *shell.Lex) error {
func dispatchExpose(d *dispatchState, c *instructions.ExposeCommand, opt *dispatchOpt) error {
ports := []string{}
env := getEnv(d.state)
for _, p := range c.Ports {
ps, err := shlex.ProcessWords(p, env)
ps, err := opt.shlex.ProcessWords(p, env)
if err != nil {
return err
}
@@ -23,7 +24,12 @@ func dispatchExpose(d *dispatchState, c *instructions.ExposeCommand, shlex *shel
}
c.Ports = ports
ps, err := parsePortSpecs(c.Ports)
ps := newPortSpecs(
withLocation(c.Location()),
withLint(opt.lint),
)
psp, err := ps.parsePorts(c.Ports)
if err != nil {
return err
}
@@ -31,18 +37,45 @@ func dispatchExpose(d *dispatchState, c *instructions.ExposeCommand, shlex *shel
if d.image.Config.ExposedPorts == nil {
d.image.Config.ExposedPorts = make(map[string]struct{})
}
for _, p := range ps {
for _, p := range psp {
d.image.Config.ExposedPorts[p] = struct{}{}
}
return commitToHistory(&d.image, fmt.Sprintf("EXPOSE %v", ps), false, nil, d.epoch)
}
// parsePortSpecs receives port specs in the format of [ip:]public:private/proto
type portSpecs struct {
location []parser.Range
lint *linter.Linter
}
type portSpecsOption func(ps *portSpecs)
func withLocation(location []parser.Range) portSpecsOption {
return func(ps *portSpecs) {
ps.location = location
}
}
func withLint(lint *linter.Linter) portSpecsOption {
return func(ps *portSpecs) {
ps.lint = lint
}
}
func newPortSpecs(opts ...portSpecsOption) *portSpecs {
ps := &portSpecs{}
for _, opt := range opts {
opt(ps)
}
return ps
}
// parsePorts receives port specs in the format of [ip:]public:private/proto
// and returns them as a list of "port/proto".
func parsePortSpecs(ports []string) (exposedPorts []string, _ error) {
func (ps *portSpecs) parsePorts(ports []string) (exposedPorts []string, _ error) {
for _, p := range ports {
portProtos, err := parsePortSpec(p)
portProtos, err := ps.parsePort(p)
if err != nil {
return nil, err
}
@@ -51,49 +84,23 @@ func parsePortSpecs(ports []string) (exposedPorts []string, _ error) {
return exposedPorts, nil
}
// splitProtoPort splits a port(range) and protocol, formatted as "<portnum>/[<proto>]"
// "<startport-endport>/[<proto>]". It returns an error if no port(range) or
// an invalid proto is provided. If no protocol is provided, the default ("tcp")
// protocol is returned.
func splitProtoPort(rawPort string) (proto string, port string, _ error) {
port, proto, _ = strings.Cut(rawPort, "/")
if port == "" {
return "", "", errors.New("no port specified")
}
proto = strings.ToLower(proto)
switch proto {
case "":
return "tcp", port, nil
case "tcp", "udp", "sctp":
return proto, port, nil
default:
return "", "", errors.New("invalid proto: " + proto)
}
}
func splitParts(rawport string) (hostIP, hostPort, containerPort string) {
parts := strings.Split(rawport, ":")
switch len(parts) {
case 1:
return "", "", parts[0]
case 2:
return "", parts[0], parts[1]
case 3:
return parts[0], parts[1], parts[2]
default:
n := len(parts)
return strings.Join(parts[:n-2], ":"), parts[n-2], parts[n-1]
}
}
// parsePortSpec parses a port specification string into a slice of "<portnum>/[<proto>]"
func parsePortSpec(rawPort string) (portProto []string, _ error) {
ip, hostPort, containerPort := splitParts(rawPort)
proto, containerPort, err := splitProtoPort(containerPort)
// parsePort parses a port specification string into a slice of "<portnum>/[<proto>]"
func (ps *portSpecs) parsePort(rawPort string) (portProto []string, _ error) {
ip, hostPort, containerPort := ps.splitParts(rawPort)
proto, containerPort, err := ps.splitProtoPort(containerPort)
if err != nil {
return nil, errors.Wrapf(err, "invalid port: %q", rawPort)
}
if ps.lint != nil {
if proto != strings.ToLower(proto) {
msg := linter.RuleExposeProtoCasing.Format(rawPort)
ps.lint.Run(&linter.RuleExposeProtoCasing, ps.location, msg)
}
if ip != "" || hostPort != "" {
msg := linter.RuleExposeInvalidFormat.Format(rawPort)
ps.lint.Run(&linter.RuleExposeInvalidFormat, ps.location, msg)
}
}
// TODO(thaJeztah): mapping IP-addresses should not be allowed for EXPOSE; see https://github.com/moby/buildkit/issues/2173
if ip != "" && ip[0] == '[' {
@@ -108,14 +115,14 @@ func parsePortSpec(rawPort string) (portProto []string, _ error) {
return nil, errors.New("invalid IP address: " + ip)
}
startPort, endPort, err := parsePortRange(containerPort)
startPort, endPort, err := ps.parsePortRange(containerPort)
if err != nil {
return nil, errors.New("invalid containerPort: " + containerPort)
}
// TODO(thaJeztah): mapping host-ports should not be allowed for EXPOSE; see https://github.com/moby/buildkit/issues/2173
if hostPort != "" {
startHostPort, endHostPort, err := parsePortRange(hostPort)
startHostPort, endHostPort, err := ps.parsePortRange(hostPort)
if err != nil {
return nil, errors.New("invalid hostPort: " + hostPort)
}
@@ -133,19 +140,19 @@ func parsePortSpec(rawPort string) (portProto []string, _ error) {
ports := make([]string, 0, count)
for i := range count {
ports = append(ports, strconv.Itoa(startPort+i)+"/"+proto)
ports = append(ports, strconv.Itoa(startPort+i)+"/"+strings.ToLower(proto))
}
return ports, nil
}
// parsePortRange parses and validates the specified string as a port range (e.g., "8000-9000").
func parsePortRange(ports string) (startPort, endPort int, _ error) {
func (ps *portSpecs) parsePortRange(ports string) (startPort, endPort int, _ error) {
if ports == "" {
return 0, 0, errors.New("empty string specified for ports")
}
start, end, ok := strings.Cut(ports, "-")
startPort, err := parsePortNumber(start)
startPort, err := ps.parsePortNumber(start)
if err != nil {
return 0, 0, errors.Wrapf(err, "invalid start port '%s'", start)
}
@@ -153,7 +160,7 @@ func parsePortRange(ports string) (startPort, endPort int, _ error) {
return startPort, startPort, nil
}
endPort, err = parsePortNumber(end)
endPort, err = ps.parsePortNumber(end)
if err != nil {
return 0, 0, errors.Wrapf(err, "invalid end port '%s'", end)
}
@@ -165,7 +172,7 @@ func parsePortRange(ports string) (startPort, endPort int, _ error) {
// parsePortNumber parses rawPort into an int, unwrapping strconv errors
// and returning a single "out of range" error for any value outside 0–65535.
func parsePortNumber(rawPort string) (int, error) {
func (ps *portSpecs) parsePortNumber(rawPort string) (int, error) {
if rawPort == "" {
return 0, errors.New("value is empty")
}
@@ -183,3 +190,38 @@ func parsePortNumber(rawPort string) (int, error) {
return int(port), nil
}
// splitProtoPort splits a port(range) and protocol, formatted as "<portnum>/[<proto>]"
// "<startport-endport>/[<proto>]". It returns an error if no port(range) or
// an invalid proto is provided. If no protocol is provided, the default ("tcp")
// protocol is returned.
func (ps *portSpecs) splitProtoPort(rawPort string) (proto string, port string, _ error) {
port, proto, _ = strings.Cut(rawPort, "/")
if port == "" {
return "", "", errors.New("no port specified")
}
switch strings.ToLower(proto) {
case "":
return "tcp", port, nil
case "tcp", "udp", "sctp":
return proto, port, nil
default:
return "", "", errors.New("invalid proto: " + proto)
}
}
func (ps *portSpecs) splitParts(rawport string) (hostIP, hostPort, containerPort string) {
parts := strings.Split(rawport, ":")
switch len(parts) {
case 1:
return "", "", parts[0]
case 2:
return "", parts[0], parts[1]
case 3:
return parts[0], parts[1], parts[2]
default:
n := len(parts)
return strings.Join(parts[:n-2], ":"), parts[n-2], parts[n-1]
}
}
@@ -7,7 +7,9 @@ import (
"github.com/stretchr/testify/require"
)
func TestParsePortSpecEmptyContainerPort(t *testing.T) {
var ps = newPortSpecs()
func TestParsePortEmptyContainerPort(t *testing.T) {
tests := []struct {
name string
spec string
@@ -31,7 +33,7 @@ func TestParsePortSpecEmptyContainerPort(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := parsePortSpec(tc.spec)
_, err := ps.parsePort(tc.spec)
if tc.expError != "" {
assert.EqualError(t, err, tc.expError)
} else {
@@ -41,13 +43,13 @@ func TestParsePortSpecEmptyContainerPort(t *testing.T) {
}
}
func TestParsePortSpecFull(t *testing.T) {
exposedPorts, err := parsePortSpec("0.0.0.0:1234-1235:3333-3334/tcp")
func TestParsePortFull(t *testing.T) {
exposedPorts, err := ps.parsePort("0.0.0.0:1234-1235:3333-3334/tcp")
require.NoError(t, err)
assert.Equal(t, []string{"3333/tcp", "3334/tcp"}, exposedPorts)
}
func TestPartPortSpecIPV6(t *testing.T) {
func TestPartPortIPV6(t *testing.T) {
type test struct {
name string
spec string
@@ -82,43 +84,43 @@ func TestPartPortSpecIPV6(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
exposedPorts, err := parsePortSpec(c.spec)
exposedPorts, err := ps.parsePort(c.spec)
require.NoError(t, err)
assert.Equal(t, c.expected, exposedPorts)
})
}
}
func TestParsePortSpecs(t *testing.T) {
exposedPorts, err := parsePortSpecs([]string{"1234/tcp", "2345/udp", "3456/sctp"})
func TestParsePorts(t *testing.T) {
exposedPorts, err := ps.parsePorts([]string{"1234/tcp", "2345/udp", "3456/sctp"})
require.NoError(t, err)
assert.Equal(t, []string{"1234/tcp", "2345/udp", "3456/sctp"}, exposedPorts)
exposedPorts, err = parsePortSpecs([]string{"1234:1234/tcp", "2345:2345/udp", "3456:3456/sctp"})
exposedPorts, err = ps.parsePorts([]string{"1234:1234/tcp", "2345:2345/udp", "3456:3456/sctp"})
require.NoError(t, err)
assert.Equal(t, []string{"1234/tcp", "2345/udp", "3456/sctp"}, exposedPorts)
exposedPorts, err = parsePortSpecs([]string{"0.0.0.0:1234:1234/tcp", "0.0.0.0:2345:2345/udp", "0.0.0.0:3456:3456/sctp"})
exposedPorts, err = ps.parsePorts([]string{"0.0.0.0:1234:1234/tcp", "0.0.0.0:2345:2345/udp", "0.0.0.0:3456:3456/sctp"})
require.NoError(t, err)
assert.Equal(t, []string{"1234/tcp", "2345/udp", "3456/sctp"}, exposedPorts)
_, err = parsePortSpecs([]string{"localhost:1234:1234/tcp"})
_, err = ps.parsePorts([]string{"localhost:1234:1234/tcp"})
assert.Error(t, err, "Received no error while trying to parse a hostname instead of ip")
}
func TestParsePortSpecsWithRange(t *testing.T) {
exposedPorts, err := parsePortSpecs([]string{"1234-1236/tcp", "2345-2347/udp", "3456-3458/sctp"})
func TestParsePortsWithRange(t *testing.T) {
exposedPorts, err := ps.parsePorts([]string{"1234-1236/tcp", "2345-2347/udp", "3456-3458/sctp"})
require.NoError(t, err)
assert.Equal(t, []string{"1234/tcp", "1235/tcp", "1236/tcp", "2345/udp", "2346/udp", "2347/udp", "3456/sctp", "3457/sctp", "3458/sctp"}, exposedPorts)
exposedPorts, err = parsePortSpecs([]string{"1234-1236:1234-1236/tcp", "2345-2347:2345-2347/udp", "3456-3458:3456-3458/sctp"})
exposedPorts, err = ps.parsePorts([]string{"1234-1236:1234-1236/tcp", "2345-2347:2345-2347/udp", "3456-3458:3456-3458/sctp"})
require.NoError(t, err)
assert.Equal(t, []string{"1234/tcp", "1235/tcp", "1236/tcp", "2345/udp", "2346/udp", "2347/udp", "3456/sctp", "3457/sctp", "3458/sctp"}, exposedPorts)
exposedPorts, err = parsePortSpecs([]string{"0.0.0.0:1234-1236:1234-1236/tcp", "0.0.0.0:2345-2347:2345-2347/udp", "0.0.0.0:3456-3458:3456-3458/sctp"})
exposedPorts, err = ps.parsePorts([]string{"0.0.0.0:1234-1236:1234-1236/tcp", "0.0.0.0:2345-2347:2345-2347/udp", "0.0.0.0:3456-3458:3456-3458/sctp"})
require.NoError(t, err)
assert.Equal(t, []string{"1234/tcp", "1235/tcp", "1236/tcp", "2345/udp", "2346/udp", "2347/udp", "3456/sctp", "3457/sctp", "3458/sctp"}, exposedPorts)
_, err = parsePortSpecs([]string{"localhost:1234-1236:1234-1236/tcp"})
_, err = ps.parsePorts([]string{"localhost:1234-1236:1234-1236/tcp"})
assert.Error(t, err, "Received no error while trying to parse a hostname instead of ip")
}
@@ -49,6 +49,8 @@ var lintTests = integration.TestFuncs(
testFromPlatformFlagConstDisallowed,
testCopyIgnoredFiles,
testDefinitionDescription,
testExposeProtoCasing,
testExposeInvalidFormat,
)
func testDefinitionDescription(t *testing.T, sb integration.Sandbox) {
@@ -1435,6 +1437,70 @@ FROM --platform=linux/amd64 scratch AS linux
})
}
func testExposeProtoCasing(t *testing.T, sb integration.Sandbox) {
dockerfile := []byte(`
FROM scratch
EXPOSE 80/TcP 8080/TCP 8080/udp
`)
checkLinterWarnings(t, sb, &lintTestParams{
Dockerfile: dockerfile,
Warnings: []expectedLintWarning{
{
RuleName: "ExposeProtoCasing",
Description: "Protocol in EXPOSE instruction should be lowercase",
URL: "https://docs.docker.com/go/dockerfile/rule/expose-proto-casing/",
Detail: "Defined protocol '80/TcP' in EXPOSE instruction should be lowercase",
Level: 1,
Line: 3,
},
{
RuleName: "ExposeProtoCasing",
Description: "Protocol in EXPOSE instruction should be lowercase",
URL: "https://docs.docker.com/go/dockerfile/rule/expose-proto-casing/",
Detail: "Defined protocol '8080/TCP' in EXPOSE instruction should be lowercase",
Level: 1,
Line: 3,
},
},
})
}
func testExposeInvalidFormat(t *testing.T, sb integration.Sandbox) {
dockerfile := []byte(`
FROM scratch
EXPOSE 127.0.0.1:80:80 [::1]:8080:8080 5000:5000 8000
`)
checkLinterWarnings(t, sb, &lintTestParams{
Dockerfile: dockerfile,
Warnings: []expectedLintWarning{
{
RuleName: "ExposeInvalidFormat",
Description: "IP address and host-port mapping should not be used in EXPOSE instruction. This will become an error in a future release",
URL: "https://docs.docker.com/go/dockerfile/rule/expose-invalid-format/",
Detail: "EXPOSE instruction should not define an IP address or host-port mapping, found '127.0.0.1:80:80'",
Level: 1,
Line: 3,
},
{
RuleName: "ExposeInvalidFormat",
Description: "IP address and host-port mapping should not be used in EXPOSE instruction. This will become an error in a future release",
URL: "https://docs.docker.com/go/dockerfile/rule/expose-invalid-format/",
Detail: "EXPOSE instruction should not define an IP address or host-port mapping, found '[::1]:8080:8080'",
Level: 1,
Line: 3,
},
{
RuleName: "ExposeInvalidFormat",
Description: "IP address and host-port mapping should not be used in EXPOSE instruction. This will become an error in a future release",
URL: "https://docs.docker.com/go/dockerfile/rule/expose-invalid-format/",
Detail: "EXPOSE instruction should not define an IP address or host-port mapping, found '5000:5000'",
Level: 1,
Line: 3,
},
},
})
}
func checkUnmarshal(t *testing.T, sb integration.Sandbox, lintTest *lintTestParams) {
destDir, err := os.MkdirTemp("", "buildkit")
require.NoError(t, err)
+8
View File
@@ -107,5 +107,13 @@ To learn more about how to use build checks, see
<td><a href="./invalid-definition-description/">InvalidDefinitionDescription (experimental)</a></td>
<td>Comment for build stage or argument should follow the format: `# <arg/stage name> <description>`. If this is not intended to be a description comment, add an empty line or comment between the instruction and the comment.</td>
</tr>
<tr>
<td><a href="./expose-proto-casing/">ExposeProtoCasing</a></td>
<td>Protocol in EXPOSE instruction should be lowercase</td>
</tr>
<tr>
<td><a href="./expose-invalid-format/">ExposeInvalidFormat</a></td>
<td>IP address and host-port mapping should not be used in EXPOSE instruction. This will become an error in a future release</td>
</tr>
</tbody>
</table>
@@ -0,0 +1,55 @@
---
title: ExposeInvalidFormat
description: >-
IP address and host-port mapping should not be used in EXPOSE instruction. This will become an error in a future release
aliases:
- /go/dockerfile/rule/expose-invalid-format/
---
## Output
```text
EXPOSE instruction should not define an IP address or host-port mapping, found '127.0.0.1:80:80'
```
## Description
The [`EXPOSE`](https://docs.docker.com/reference/dockerfile/#expose) instruction
in a Dockerfile is used to indicate which ports the container listens on at
runtime. It should not include an IP address or host-port mapping, as this is
not the intended use of the `EXPOSE` instruction. Instead, it should only
specify the port number and optionally the protocol (TCP or UDP).
> [!IMPORTANT]
> This will become an error in a future release.
## Examples
❌ Bad: IP address and host-port mapping used.
```dockerfile
FROM alpine
EXPOSE 127.0.0.1:80:80
```
✅ Good: only the port number is specified.
```dockerfile
FROM alpine
EXPOSE 80
```
❌ Bad: Host-port mapping used.
```dockerfile
FROM alpine
EXPOSE 80:80
```
✅ Good: only the port number is specified.
```dockerfile
FROM alpine
EXPOSE 80
```
@@ -0,0 +1,37 @@
---
title: ExposeProtoCasing
description: >-
Protocol in EXPOSE instruction should be lowercase
aliases:
- /go/dockerfile/rule/expose-proto-casing/
---
## Output
```text
Defined protocol '80/TcP' in EXPOSE instruction should be lowercase
```
## Description
Protocol names in the [`EXPOSE`](https://docs.docker.com/reference/dockerfile/#expose)
instruction should be specified in lowercase to maintain consistency and
readability. This rule checks for protocols that are not in lowercase and
reports them.
## Examples
❌ Bad: protocol is not in lowercase.
```dockerfile
FROM alpine
EXPOSE 80/TcP
```
✅ Good: protocol is in lowercase.
```dockerfile
FROM alpine
EXPOSE 80/tcp
```
@@ -0,0 +1,46 @@
## Output
```text
EXPOSE instruction should not define an IP address or host-port mapping, found '127.0.0.1:80:80'
```
## Description
The [`EXPOSE`](https://docs.docker.com/reference/dockerfile/#expose) instruction
in a Dockerfile is used to indicate which ports the container listens on at
runtime. It should not include an IP address or host-port mapping, as this is
not the intended use of the `EXPOSE` instruction. Instead, it should only
specify the port number and optionally the protocol (TCP or UDP).
> [!IMPORTANT]
> This will become an error in a future release.
## Examples
❌ Bad: IP address and host-port mapping used.
```dockerfile
FROM alpine
EXPOSE 127.0.0.1:80:80
```
✅ Good: only the port number is specified.
```dockerfile
FROM alpine
EXPOSE 80
```
❌ Bad: Host-port mapping used.
```dockerfile
FROM alpine
EXPOSE 80:80
```
✅ Good: only the port number is specified.
```dockerfile
FROM alpine
EXPOSE 80
```
@@ -0,0 +1,28 @@
## Output
```text
Defined protocol '80/TcP' in EXPOSE instruction should be lowercase
```
## Description
Protocol names in the [`EXPOSE`](https://docs.docker.com/reference/dockerfile/#expose)
instruction should be specified in lowercase to maintain consistency and
readability. This rule checks for protocols that are not in lowercase and
reports them.
## Examples
❌ Bad: protocol is not in lowercase.
```dockerfile
FROM alpine
EXPOSE 80/TcP
```
✅ Good: protocol is in lowercase.
```dockerfile
FROM alpine
EXPOSE 80/tcp
```
+18
View File
@@ -174,4 +174,22 @@ var (
},
Experimental: true,
}
RuleExposeProtoCasing = LinterRule[func(string) string]{
Name: "ExposeProtoCasing",
Description: "Protocol in EXPOSE instruction should be lowercase",
URL: "https://docs.docker.com/go/dockerfile/rule/expose-proto-casing/",
Format: func(port string) string {
return fmt.Sprintf("Defined protocol '%s' in EXPOSE instruction should be lowercase", port)
},
}
RuleExposeInvalidFormat = LinterRule[func(string) string]{
Name: "ExposeInvalidFormat",
Description: "IP address and host-port mapping should not be used in EXPOSE instruction. This will become an error in a future release",
URL: "https://docs.docker.com/go/dockerfile/rule/expose-invalid-format/",
Format: func(port string) string {
return fmt.Sprintf("EXPOSE instruction should not define an IP address or host-port mapping, found '%s'", port)
},
// TODO(crazy-max): deprecate this rule in the future and error out instead
// Deprecated: true,
}
)