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
24 changes: 24 additions & 0 deletions crane-check/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
PROJECT = crane-check
MELANGE_CONTEXTDIR ?= /tmp/melange-context/$(PROJECT)
MELANGE_INSTALL_PATH = $(MELANGE_CONTEXTDIR)/usr/bin

.PHONY: build tidy vet test clean melange-install

build:
go build -o crane-check

test:
go test -v ./...

tidy:
go mod tidy

vet:
go vet ./...

clean:
rm -f crane-check

melange-install: build
mkdir -p $(MELANGE_INSTALL_PATH)
install -Dm755 crane-check $(MELANGE_INSTALL_PATH)/crane-check
27 changes: 27 additions & 0 deletions crane-check/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module chainguard.dev/tw/crane-check

go 1.25

require (
github.com/chainguard-dev/clog v1.7.0
github.com/google/go-containerregistry v0.20.6
github.com/spf13/cobra v1.10.1
)

require (
github.com/containerd/stargz-snapshotter/estargz v0.18.0 // indirect
github.com/docker/cli v28.5.2+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/vbatts/tar-split v0.12.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
)
57 changes: 57 additions & 0 deletions crane-check/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
github.com/chainguard-dev/clog v1.7.0 h1:guPznsK8vLHvzz1QJe2yU6MFeYaiSOFOQBYw4OXu+g8=
github.com/chainguard-dev/clog v1.7.0/go.mod h1:4+WFhRMsGH79etYXY3plYdp+tCz/KCkU8fAr0HoaPvs=
github.com/containerd/stargz-snapshotter/estargz v0.18.0 h1:Ny5yptQgEXSkDFKvlKJGTvf1YJ+4xD8V+hXqoRG0n74=
github.com/containerd/stargz-snapshotter/estargz v0.18.0/go.mod h1:7hfU1BO2KB3axZl0dRQCdnHrIWw7TRDdK6L44Rdeuo0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/cli v28.5.2+incompatible h1:XmG99IHcBmIAoC1PPg9eLBZPlTrNUAijsHLm8PjhBlg=
github.com/docker/cli v28.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker-credential-helpers v0.9.4 h1:76ItO69/AP/V4yT9V4uuuItG0B1N8hvt0T0c0NN/DzI=
github.com/docker/docker-credential-helpers v0.9.4/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
234 changes: 234 additions & 0 deletions crane-check/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package main

import (
"context"
"encoding/json"
"fmt"
"os"
"strings"

"github.com/chainguard-dev/clog"
"github.com/google/go-containerregistry/pkg/crane"
"github.com/spf13/cobra"
)

type cfg struct {
Image string
Environment string
MatchMode string
}

type ImageConfig struct {
Config struct {
Env []string `json:"Env"`
} `json:"config"`
}

var validMatchModes = []string{"exact", "prefix", "relative", "contains"}

func main() {
cmd := Command()
if err := cmd.Execute(); err != nil {
clog.ErrorContextf(cmd.Context(), "failed to execute command: %v", err)
os.Exit(1)
}
}

func isValidMatchMode(mode string) bool {
for _, valid := range validMatchModes {
if mode == valid {
return true
}
}
return false
}

func Command() *cobra.Command {
cfg := &cfg{}

cmd := &cobra.Command{
Use: "crane-check",
Short: "Check image config using crane",
Long: `Tool that evaluates image configuration using crane.

The image to evaluate is simply passed via the '--image' argument (required)

Environment variables (required, --env) can be specified in two formats:
- Space-separated: ENV_VAR1=value1 ENV_VAR2=value2
- Newline-separated (useful for multi-line input)

Match modes (--match):
- exact: Provided value and image value must match exactly (default)
- prefix: Provided value is a prefix of the image value
- relative: Either value is a prefix of the other
- contains: The provided value is a substring within the image value

Usage:
crane-check --image=cgr.dev/chainguard/go:latest --env="GOPATH=/go GOROOT=/usr/lib/go"
crane-check --image=alpine:latest --env="PATH=/usr/lib" --match=relative`,
// We don't want to print usage every single time there's an error
SilenceUsage: true,
// We don't want duplicate error output
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
// Throw an error early if we provide an invalid match mode
if !isValidMatchMode(cfg.MatchMode) {
return fmt.Errorf("invalid match-mode: %s (must be one of: %s)", cfg.MatchMode, strings.Join(validMatchModes, ", "))
}
return cfg.Run(cmd.Context())
},
}

cmd.Flags().StringVar(&cfg.Image, "image", "", "Image to be assessed (e.g., cgr.dev/chainguard/crane:latest) [required]")
cmd.Flags().StringVar(&cfg.Environment, "env", "", "Environment variables to check (space or newline-separated, format: ENV_VAR=VALUE) [required]")
cmd.Flags().StringVar(&cfg.MatchMode, "match", "exact", "Match mode: 'exact', 'relative', or 'contains'")

cmd.MarkFlagRequired("image")
cmd.MarkFlagRequired("env")

return cmd
}

func (c *cfg) Run(ctx context.Context) error {
log := clog.FromContext(ctx).With("image", c.Image)

// Parse passed environment variables
envVars := parseEnvironmentVariables(c.Environment)
if envVars == nil {
return fmt.Errorf("no environment variables provided")
}
log.InfoContext(ctx, "retrieving image configuration")

// Get the image config with crane
imageConfig, err := getCraneConfig(ctx, c.Image)
if err != nil {
return fmt.Errorf("failed to get image configuration: %w", err)
}

// Parse the config
var imgCfg ImageConfig
if err := json.Unmarshal([]byte(imageConfig), &imgCfg); err != nil {
return fmt.Errorf("failed to parse image configuration: %w", err)
}

// Ensure environment exists in config
if imgCfg.Config.Env == nil {
return fmt.Errorf("failed to parse environment from image config")
}
log.InfoContext(ctx, "successfully parsed environment from image config")

// Check each environment variable
for _, envVar := range envVars {
if err := matchEnvironmentVariable(ctx, envVar, imgCfg.Config.Env, c.MatchMode); err != nil {
return err
}
}
log.InfoContext(ctx, "all environment variables match expected values")

return nil
}

func parseEnvironmentVariables(envStr string) []string {
envStr = strings.TrimSpace(envStr)
if envStr == "" {
return nil
}

// Attempt to split input via newlines first
// I want newline separation to "just" work in our pipelines
lines := strings.Split(envStr, "\n")
var envVars []string

for _, line := range lines {
line = strings.TrimSpace(line)

// Ignore commented out lines and skip empty ones
if line == "" || strings.HasPrefix(line, "#") {
continue
}

// Account for space separated env vars
if strings.Contains(line, " ") && strings.Count(line, "=") > 1 {
envs := strings.Fields(line)
envVars = append(envVars, envs...)
} else {
envVars = append(envVars, line)
}
}

return envVars
}

func getCraneConfig(ctx context.Context, image string) (string, error) {
log := clog.FromContext(ctx)

// Get image config with crane
configJSON, err := crane.Config(image, crane.WithContext(ctx))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will always be checking the amd64 image by default, which we might be okay with? We could plumb an arch flag down to this as well in case we want to check the arm64 variant?

if err != nil {
log.ErrorContextf(ctx, "crane config failed: %v", err)
return "", fmt.Errorf("crane config failed: %w", err)
}

return string(configJSON), nil
}

func matchEnvironmentVariable(ctx context.Context, envStr string, imageEnv []string, matchMode string) error {
log := clog.FromContext(ctx)

// Split env var and value
parts := strings.SplitN(envStr, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid environment variable format: %s (expected KEY=VALUE)", envStr)
}

envVar := parts[0]
envVal := parts[1]

// Make sure environment variable is in image config
var foundEnv string
found := false
for _, imageEnvVar := range imageEnv {
if strings.HasPrefix(imageEnvVar, envVar+"=") {
foundEnv = imageEnvVar
found = true
break
}
}

if !found {
return fmt.Errorf("failed to find %s in image config", envVar)
}
log.InfoContext(ctx, "found variable in image config", "var", envVar)

// Now split the env from the image config
imageParts := strings.SplitN(foundEnv, "=", 2)
imageVal := ""
if len(imageParts) == 2 {
imageVal = imageParts[1]
}

matched := false
switch matchMode {
case "exact":
matched = envVal == imageVal
case "prefix":
matched = strings.HasPrefix(imageVal, envVal)
case "relative":
matched = strings.HasPrefix(imageVal, envVal) || strings.HasPrefix(envVal, imageVal)
case "contains":
matched = strings.Contains(imageVal, envVal)
default:
// We should never ever reach this point, but if we do...
return fmt.Errorf("unsupported match mode: %s", matchMode)
}

if !matched {
return fmt.Errorf(`invalid value provided for %s:
Provided value: %s
Image value: %s
Match mode: %s`, envVar, envVal, imageVal, matchMode)
}
log.InfoContext(ctx, "environment variable successfully matched", "var", envVar, "value", envVal, "match", matchMode)

return nil
}
1 change: 1 addition & 0 deletions go.work
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
go 1.25

use (
./crane-check
./gosh
./no-docs-check
./package-type-check
Expand Down
Loading