github.com/yogeshkumararora/slsa-github-generator@v1.10.1-0.20240520161934-11278bd5afb4/internal/builders/go/pkg/build.go (about)

     1  // Copyright 2022 SLSA Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package pkg
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"fmt"
    21  	"os"
    22  	"path/filepath"
    23  	"regexp"
    24  	"strings"
    25  
    26  	"github.com/yogeshkumararora/slsa-github-generator/github"
    27  	"github.com/yogeshkumararora/slsa-github-generator/internal/runner"
    28  	"github.com/yogeshkumararora/slsa-github-generator/internal/utils"
    29  )
    30  
    31  var unknownTag = "unknown"
    32  
    33  // See `go build help`.
    34  // `-asmflags`, `-n`, `-mod`, `-installsuffix`, `-modfile`,
    35  // `-workfile`, `-overlay`, `-pkgdir`, `-toolexec`, `-o`,
    36  // `-modcacherw`, `-work` not supported for now.
    37  
    38  var allowedBuildArgs = map[string]bool{
    39  	"-a": true, "-race": true, "-msan": true, "-asan": true,
    40  	"-v": true, "-x": true, "-buildinfo": true,
    41  	"-buildmode": true, "-buildvcs": true, "-compiler": true,
    42  	"-gccgoflags": true, "-gcflags": true,
    43  	"-ldflags": true, "-linkshared": true,
    44  	"-tags": true, "-trimpath": true,
    45  }
    46  
    47  var allowedEnvVariablePrefix = map[string]bool{
    48  	"GO": true, "CGO_": true,
    49  }
    50  
    51  var (
    52  	errEnvVariableNameEmpty      = errors.New("variable name empty")
    53  	errUnsupportedArguments      = errors.New("unsupported arguments")
    54  	errInvalidEnvArgument        = errors.New("invalid env argument")
    55  	errEnvVariableNameNotAllowed = errors.New("invalid variable name")
    56  	errInvalidFilename           = errors.New("invalid filename")
    57  )
    58  
    59  // GoBuild implements building a Go application.
    60  type GoBuild struct {
    61  	cfg *GoReleaserConfig
    62  	// Note: static env variables are contained in cfg.Env.
    63  	argEnv map[string]string
    64  	goc    string
    65  }
    66  
    67  // GoBuildNew returns a new GoBuild.
    68  func GoBuildNew(goc string, cfg *GoReleaserConfig) *GoBuild {
    69  	c := GoBuild{
    70  		cfg:    cfg,
    71  		goc:    goc,
    72  		argEnv: make(map[string]string),
    73  	}
    74  
    75  	return &c
    76  }
    77  
    78  // Run executes the build.
    79  func (b *GoBuild) Run(dry bool) error {
    80  	// Get directory.
    81  	dir, err := b.getDir()
    82  	if err != nil {
    83  		return err
    84  	}
    85  	// Set flags.
    86  	flags, err := b.generateFlags()
    87  	if err != nil {
    88  		return err
    89  	}
    90  
    91  	// Generate env variables.
    92  	envs, err := b.generateCommandEnvVariables()
    93  	if err != nil {
    94  		return err
    95  	}
    96  
    97  	// Generate ldflags.
    98  	ldflags, err := b.generateLdflags()
    99  	if err != nil {
   100  		return err
   101  	}
   102  
   103  	// Add ldflags.
   104  	if ldflags != "" {
   105  		flags = append(flags, fmt.Sprintf("-ldflags=%s", ldflags))
   106  	}
   107  
   108  	// A dry run prints the information that is trusted, before
   109  	// the compiler is invoked.
   110  	if dry {
   111  		// Generate filename.
   112  		// Note: the filename uses the config file and is resolved if it contains env variables.
   113  		// `OUTPUT_BINARY` is only used during the actual compilation, an is a trusted
   114  		// variable hardcoded in the reusable workflow, to avoid weird looking name
   115  		// that may interfere with the compilation.
   116  		filename, err := b.generateOutputFilename()
   117  		if err != nil {
   118  			return err
   119  		}
   120  
   121  		// Generate the command.
   122  		com := b.generateCommand(flags, filename)
   123  
   124  		env, err := b.generateCommandEnvVariables()
   125  		if err != nil {
   126  			return err
   127  		}
   128  
   129  		r := runner.CommandRunner{
   130  			Steps: []*runner.CommandStep{
   131  				{
   132  					Command:    com,
   133  					Env:        env,
   134  					WorkingDir: dir,
   135  				},
   136  			},
   137  		}
   138  
   139  		steps, err := r.Dry()
   140  		if err != nil {
   141  			return err
   142  		}
   143  
   144  		// There is a single command in steps given to the runner so we are
   145  		// assured to have only one step.
   146  		menv, err := utils.MarshalToString(steps[0].Env)
   147  		if err != nil {
   148  			return err
   149  		}
   150  		command, err := utils.MarshalToString(steps[0].Command)
   151  		if err != nil {
   152  			return err
   153  		}
   154  
   155  		// Share the resolved name of the binary.
   156  		if err := github.SetOutput("go-binary-name", filename); err != nil {
   157  			return err
   158  		}
   159  
   160  		// Share the command used.
   161  		if err := github.SetOutput("go-command", command); err != nil {
   162  			return err
   163  		}
   164  
   165  		// Share the env variables used.
   166  		if err := github.SetOutput("go-env", menv); err != nil {
   167  			return err
   168  		}
   169  
   170  		// Share working directory necessary for issuing the vendoring command.
   171  		return github.SetOutput("go-working-dir", dir)
   172  	}
   173  
   174  	binary, err := getOutputBinaryPath(os.Getenv("OUTPUT_BINARY"))
   175  	if err != nil {
   176  		return err
   177  	}
   178  
   179  	// Generate the command.
   180  	command := b.generateCommand(flags, binary)
   181  
   182  	fmt.Println("dir", dir)
   183  	fmt.Println("binary", binary)
   184  	fmt.Println("command", command)
   185  	fmt.Println("env", envs)
   186  
   187  	r := runner.CommandRunner{
   188  		Steps: []*runner.CommandStep{
   189  			{
   190  				Command:    command,
   191  				Env:        envs,
   192  				WorkingDir: dir,
   193  			},
   194  		},
   195  	}
   196  
   197  	// TODO: Add a timeout?
   198  	_, err = r.Run(context.Background())
   199  	return err
   200  }
   201  
   202  func getOutputBinaryPath(binary string) (string, error) {
   203  	// Use the name provider via env variable for the compilation.
   204  	// This variable is trusted and defined by the re-usable workflow.
   205  	// It should be set to an absolute path value.
   206  	abinary, err := filepath.Abs(binary)
   207  	if err != nil {
   208  		return "", fmt.Errorf("filepath.Abs: %w", err)
   209  	}
   210  
   211  	if binary == "" {
   212  		return "", fmt.Errorf("%w: OUTPUT_BINARY not defined", errInvalidFilename)
   213  	}
   214  
   215  	if binary != abinary {
   216  		return "", fmt.Errorf("%w: %v is not an absolute path", errInvalidFilename, binary)
   217  	}
   218  
   219  	return binary, nil
   220  }
   221  
   222  func (b *GoBuild) getDir() (string, error) {
   223  	if b.cfg.Dir == nil {
   224  		return os.Getenv("PWD"), nil
   225  	}
   226  
   227  	// Note: validation of the dir is done in config.go
   228  	fp, err := filepath.Abs(*b.cfg.Dir)
   229  	if err != nil {
   230  		return "", err
   231  	}
   232  
   233  	return fp, nil
   234  }
   235  
   236  func (b *GoBuild) generateCommand(flags []string, binary string) []string {
   237  	var command []string
   238  	command = append(command, flags...)
   239  	command = append(command, "-o", binary)
   240  
   241  	// Add the entry point.
   242  	if b.cfg.Main != nil {
   243  		command = append(command, *b.cfg.Main)
   244  	}
   245  	return command
   246  }
   247  
   248  func (b *GoBuild) generateCommandEnvVariables() ([]string, error) {
   249  	var env []string
   250  
   251  	if b.cfg.Goos == "" {
   252  		return nil, fmt.Errorf("%w: %s", errEnvVariableNameEmpty, "GOOS")
   253  	}
   254  	env = append(env, fmt.Sprintf("GOOS=%s", b.cfg.Goos))
   255  
   256  	if b.cfg.Goarch == "" {
   257  		return nil, fmt.Errorf("%w: %s", errEnvVariableNameEmpty, "GOARCH")
   258  	}
   259  	env = append(env, fmt.Sprintf("GOARCH=%s", b.cfg.Goarch))
   260  
   261  	// Set env variables from config file.
   262  	for k, v := range b.cfg.Env {
   263  		if !isAllowedEnvVariable(k) {
   264  			return env, fmt.Errorf("%w: %s", errEnvVariableNameNotAllowed, v)
   265  		}
   266  
   267  		env = append(env, fmt.Sprintf("%s=%s", k, v))
   268  	}
   269  
   270  	return env, nil
   271  }
   272  
   273  // SetArgEnvVariables sets static environment variables.
   274  func (b *GoBuild) SetArgEnvVariables(envs string) error {
   275  	// Notes:
   276  	// - I've tried running the re-usable workflow in a step
   277  	// and set the env variable in a previous step, but found that a re-usable workflow is not
   278  	// allowed to run in a step; they have to run as `job.uses`. Using `job.env` with `job.uses`
   279  	// is not allowed.
   280  	// - We don't want to allow env variables set in the workflow because of injections
   281  	// e.g. LD_PRELOAD, etc.
   282  	if envs == "" {
   283  		return nil
   284  	}
   285  
   286  	for _, e := range strings.Split(envs, ",") {
   287  		s := strings.Trim(e, " ")
   288  
   289  		sp := strings.Split(s, ":")
   290  		if len(sp) != 2 {
   291  			return fmt.Errorf("%w: %s", errInvalidEnvArgument, s)
   292  		}
   293  		name := strings.Trim(sp[0], " ")
   294  		value := strings.Trim(sp[1], " ")
   295  
   296  		fmt.Printf("arg env: %s:%s\n", name, value)
   297  		b.argEnv[name] = value
   298  	}
   299  	return nil
   300  }
   301  
   302  func (b *GoBuild) generateOutputFilename() (string, error) {
   303  	// Note: the `.` is needed to accommodate the semantic version
   304  	// as part of the name.
   305  	const alpha = ".abcdefghijklmnopqrstuvwxyz1234567890-_"
   306  
   307  	var name string
   308  
   309  	// Special variables.
   310  	name, err := b.resolveSpecialVariables(b.cfg.Binary)
   311  	if err != nil {
   312  		return "", err
   313  	}
   314  
   315  	// Dynamic env variables provided by caller.
   316  	name, err = b.resolveEnvVariables(name)
   317  	if err != nil {
   318  		return "", err
   319  	}
   320  
   321  	for _, char := range name {
   322  		if !strings.Contains(alpha, strings.ToLower(string(char))) {
   323  			return "", fmt.Errorf("%w: found character '%c'", errInvalidFilename, char)
   324  		}
   325  	}
   326  
   327  	if name == "" {
   328  		return "", fmt.Errorf("%w: filename is empty", errInvalidFilename)
   329  	}
   330  
   331  	// Validate the path.
   332  	if err := validatePath(name); err != nil {
   333  		return "", err
   334  	}
   335  
   336  	return name, nil
   337  }
   338  
   339  func (b *GoBuild) generateFlags() ([]string, error) {
   340  	// -x
   341  	flags := []string{b.goc, "build", "-mod=vendor"}
   342  
   343  	for _, v := range b.cfg.Flags {
   344  		if !isAllowedArg(v) {
   345  			return nil, fmt.Errorf("%w: %s", errUnsupportedArguments, v)
   346  		}
   347  		flags = append(flags, v)
   348  	}
   349  	return flags, nil
   350  }
   351  
   352  func isAllowedArg(arg string) bool {
   353  	for k := range allowedBuildArgs {
   354  		if strings.HasPrefix(arg, k) {
   355  			return true
   356  		}
   357  	}
   358  	return false
   359  }
   360  
   361  // Check if the env variable is allowed. We want to avoid
   362  // variable injection, e.g. LD_PRELOAD, etc.
   363  // See an overview in https://www.hale-legacy.com/class/security/s20/handout/slides-env-vars.pdf.
   364  func isAllowedEnvVariable(name string) bool {
   365  	for k := range allowedEnvVariablePrefix {
   366  		if strings.HasPrefix(name, k) {
   367  			return true
   368  		}
   369  	}
   370  	return false
   371  }
   372  
   373  // TODO: maybe not needed if handled directly by go compiler.
   374  func (b *GoBuild) generateLdflags() (string, error) {
   375  	var a []string
   376  
   377  	// Resolve variables.
   378  	for _, v := range b.cfg.Ldflags {
   379  		// Special variables.
   380  		v, err := b.resolveSpecialVariables(v)
   381  		if err != nil {
   382  			return "", err
   383  		}
   384  
   385  		// Dynamic env variables provided by caller.
   386  		v, err = b.resolveEnvVariables(v)
   387  		if err != nil {
   388  			return "", err
   389  		}
   390  		a = append(a, v)
   391  	}
   392  
   393  	if len(a) > 0 {
   394  		return strings.Join(a, " "), nil
   395  	}
   396  
   397  	return "", nil
   398  }
   399  
   400  func (b *GoBuild) resolveSpecialVariables(s string) (string, error) {
   401  	reVar := regexp.MustCompile(`{{ \.([A-Z][a-z]*) }}`)
   402  	names := reVar.FindAllString(s, -1)
   403  	for _, n := range names {
   404  		name := strings.ReplaceAll(n, "{{ .", "")
   405  		name = strings.ReplaceAll(name, " }}", "")
   406  
   407  		switch name {
   408  		case "Os":
   409  			if b.cfg.Goos == "" {
   410  				return "", fmt.Errorf("%w: {{ .Os }}", errEnvVariableNameEmpty)
   411  			}
   412  			s = strings.ReplaceAll(s, n, b.cfg.Goos)
   413  
   414  		case "Arch":
   415  			if b.cfg.Goarch == "" {
   416  				return "", fmt.Errorf("%w: {{ .Arch }}", errEnvVariableNameEmpty)
   417  			}
   418  			s = strings.ReplaceAll(s, n, b.cfg.Goarch)
   419  
   420  		case "Tag":
   421  			tag := getTag()
   422  			s = strings.ReplaceAll(s, n, tag)
   423  		default:
   424  			return "", fmt.Errorf("%w: %s", errInvalidEnvArgument, n)
   425  		}
   426  	}
   427  	return s, nil
   428  }
   429  
   430  func (b *GoBuild) resolveEnvVariables(s string) (string, error) {
   431  	reDyn := regexp.MustCompile(`{{ \.Env\.(\w+) }}`)
   432  	names := reDyn.FindAllString(s, -1)
   433  	for _, n := range names {
   434  		name := strings.ReplaceAll(n, "{{ .Env.", "")
   435  		name = strings.ReplaceAll(name, " }}", "")
   436  
   437  		val, exists := b.argEnv[name]
   438  		if !exists {
   439  			return "", fmt.Errorf("%w: %s", errEnvVariableNameEmpty, n)
   440  		}
   441  		s = strings.ReplaceAll(s, n, val)
   442  	}
   443  	return s, nil
   444  }
   445  
   446  func getTag() string {
   447  	tag := os.Getenv("GITHUB_REF_NAME")
   448  	if tag == "" {
   449  		return unknownTag
   450  	}
   451  	return tag
   452  }