github.com/in-toto/in-toto-golang@v0.9.1-0.20240517212500-990269f763cf/in_toto/runlib.go (about)

     1  package in_toto
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"io"
     8  	"os"
     9  	"os/exec"
    10  	"path/filepath"
    11  	"reflect"
    12  	"strings"
    13  	"syscall"
    14  
    15  	"github.com/shibumi/go-pathspec"
    16  )
    17  
    18  // ErrSymCycle signals a detected symlink cycle in our RecordArtifacts() function.
    19  var ErrSymCycle = errors.New("symlink cycle detected")
    20  
    21  // ErrUnsupportedHashAlgorithm signals a missing hash mapping in getHashMapping
    22  var ErrUnsupportedHashAlgorithm = errors.New("unsupported hash algorithm detected")
    23  
    24  var ErrEmptyCommandArgs = errors.New("the command args are empty")
    25  
    26  // visitedSymlinks is a hashset that contains all paths that we have visited.
    27  var visitedSymlinks Set
    28  
    29  /*
    30  RecordArtifact reads and hashes the contents of the file at the passed path
    31  using sha256 and returns a map in the following format:
    32  
    33  	{
    34  		"<path>": {
    35  			"sha256": <hex representation of hash>
    36  		}
    37  	}
    38  
    39  If reading the file fails, the first return value is nil and the second return
    40  value is the error.
    41  NOTE: For cross-platform consistency Windows-style line separators (CRLF) are
    42  normalized to Unix-style line separators (LF) before hashing file contents.
    43  */
    44  func RecordArtifact(path string, hashAlgorithms []string, lineNormalization bool) (HashObj, error) {
    45  	supportedHashMappings := getHashMapping()
    46  	// Read file from passed path
    47  	contents, err := os.ReadFile(path)
    48  	hashedContentsMap := make(HashObj)
    49  	if err != nil {
    50  		return nil, err
    51  	}
    52  
    53  	if lineNormalization {
    54  		// "Normalize" file contents. We convert all line separators to '\n'
    55  		// for keeping operating system independence
    56  		contents = bytes.ReplaceAll(contents, []byte("\r\n"), []byte("\n"))
    57  		contents = bytes.ReplaceAll(contents, []byte("\r"), []byte("\n"))
    58  	}
    59  
    60  	// Create a map of all the hashes present in the hash_func list
    61  	for _, element := range hashAlgorithms {
    62  		if _, ok := supportedHashMappings[element]; !ok {
    63  			return nil, fmt.Errorf("%w: %s", ErrUnsupportedHashAlgorithm, element)
    64  		}
    65  		h := supportedHashMappings[element]
    66  		result := fmt.Sprintf("%x", hashToHex(h(), contents))
    67  		hashedContentsMap[element] = result
    68  	}
    69  
    70  	// Return it in a format that is conformant with link metadata artifacts
    71  	return hashedContentsMap, nil
    72  }
    73  
    74  /*
    75  RecordArtifacts is a wrapper around recordArtifacts.
    76  RecordArtifacts initializes a set for storing visited symlinks,
    77  calls recordArtifacts and deletes the set if no longer needed.
    78  recordArtifacts walks through the passed slice of paths, traversing
    79  subdirectories, and calls RecordArtifact for each file. It returns a map in
    80  the following format:
    81  
    82  	{
    83  		"<path>": {
    84  			"sha256": <hex representation of hash>
    85  		},
    86  		"<path>": {
    87  		"sha256": <hex representation of hash>
    88  		},
    89  		...
    90  	}
    91  
    92  If recording an artifact fails the first return value is nil and the second
    93  return value is the error.
    94  */
    95  func RecordArtifacts(paths []string, hashAlgorithms []string, gitignorePatterns []string, lStripPaths []string, lineNormalization bool, followSymlinkDirs bool) (evalArtifacts map[string]HashObj, err error) {
    96  	// Make sure to initialize a fresh hashset for every RecordArtifacts call
    97  	visitedSymlinks = NewSet()
    98  	evalArtifactsUnnormalized, err := recordArtifacts(paths, hashAlgorithms, gitignorePatterns, lStripPaths, lineNormalization, followSymlinkDirs)
    99  	if err != nil {
   100  		return nil, err
   101  	}
   102  
   103  	// Normalize all paths in evalArtifactsUnnormalized.
   104  	evalArtifacts = make(map[string]HashObj, len(evalArtifactsUnnormalized))
   105  	for key, value := range evalArtifactsUnnormalized {
   106  		// Convert windows filepath to unix filepath.
   107  		evalArtifacts[filepath.ToSlash(key)] = value
   108  	}
   109  
   110  	return evalArtifacts, nil
   111  }
   112  
   113  /*
   114  recordArtifacts walks through the passed slice of paths, traversing
   115  subdirectories, and calls RecordArtifact for each file. It returns a map in
   116  the following format:
   117  
   118  	{
   119  		"<path>": {
   120  			"sha256": <hex representation of hash>
   121  		},
   122  		"<path>": {
   123  		"sha256": <hex representation of hash>
   124  		},
   125  		...
   126  	}
   127  
   128  If recording an artifact fails the first return value is nil and the second
   129  return value is the error.
   130  */
   131  func recordArtifacts(paths []string, hashAlgorithms []string, gitignorePatterns []string, lStripPaths []string, lineNormalization bool, followSymlinkDirs bool) (map[string]HashObj, error) {
   132  	artifacts := make(map[string]HashObj)
   133  	for _, path := range paths {
   134  		err := filepath.Walk(path,
   135  			func(path string, info os.FileInfo, err error) error {
   136  				// Abort if Walk function has a problem,
   137  				// e.g. path does not exist
   138  				if err != nil {
   139  					return err
   140  				}
   141  				// We need to call pathspec.GitIgnore inside of our filepath.Walk, because otherwise
   142  				// we will not catch all paths. Just imagine a path like "." and a pattern like "*.pub".
   143  				// If we would call pathspec outside of the filepath.Walk this would not match.
   144  				ignore, err := pathspec.GitIgnore(gitignorePatterns, path)
   145  				if err != nil {
   146  					return err
   147  				}
   148  				if ignore {
   149  					return nil
   150  				}
   151  				// Don't hash directories
   152  				if info.IsDir() {
   153  					return nil
   154  				}
   155  
   156  				// check for symlink and evaluate the last element in a symlink
   157  				// chain via filepath.EvalSymlinks. We use EvalSymlinks here,
   158  				// because with os.Readlink() we would just read the next
   159  				// element in a possible symlink chain. This would mean more
   160  				// iterations. infoMode()&os.ModeSymlink uses the file
   161  				// type bitmask to check for a symlink.
   162  				if info.Mode()&os.ModeSymlink == os.ModeSymlink {
   163  					// return with error if we detect a symlink cycle
   164  					if ok := visitedSymlinks.Has(path); ok {
   165  						// this error will get passed through
   166  						// to RecordArtifacts()
   167  						return ErrSymCycle
   168  					}
   169  					evalSym, err := filepath.EvalSymlinks(path)
   170  					if err != nil {
   171  						return err
   172  					}
   173  					info, err := os.Stat(evalSym)
   174  					if err != nil {
   175  						return err
   176  					}
   177  					targetIsDir := false
   178  					if info.IsDir() {
   179  						if !followSymlinkDirs {
   180  							// We don't follow symlinked directories
   181  							return nil
   182  						}
   183  						targetIsDir = true
   184  					}
   185  					// add symlink to visitedSymlinks set
   186  					// this way, we know which link we have visited already
   187  					// if we visit a symlink twice, we have detected a symlink cycle
   188  					visitedSymlinks.Add(path)
   189  					// We recursively call recordArtifacts() to follow
   190  					// the new path.
   191  					evalArtifacts, evalErr := recordArtifacts([]string{evalSym}, hashAlgorithms, gitignorePatterns, lStripPaths, lineNormalization, followSymlinkDirs)
   192  					if evalErr != nil {
   193  						return evalErr
   194  					}
   195  					for key, value := range evalArtifacts {
   196  						if targetIsDir {
   197  							symlinkPath := filepath.Join(path, strings.TrimPrefix(key, evalSym))
   198  							artifacts[symlinkPath] = value
   199  						} else {
   200  							artifacts[path] = value
   201  						}
   202  					}
   203  					return nil
   204  				}
   205  				artifact, err := RecordArtifact(path, hashAlgorithms, lineNormalization)
   206  				// Abort if artifact can't be recorded, e.g.
   207  				// due to file permissions
   208  				if err != nil {
   209  					return err
   210  				}
   211  
   212  				for _, strip := range lStripPaths {
   213  					if strings.HasPrefix(path, strip) {
   214  						path = strings.TrimPrefix(path, strip)
   215  						break
   216  					}
   217  				}
   218  				// Check if path is unique
   219  				if _, exists := artifacts[path]; exists {
   220  					return fmt.Errorf("left stripping has resulted in non unique dictionary key: %s", path)
   221  				}
   222  				artifacts[path] = artifact
   223  				return nil
   224  			})
   225  
   226  		if err != nil {
   227  			return nil, err
   228  		}
   229  	}
   230  
   231  	return artifacts, nil
   232  }
   233  
   234  /*
   235  waitErrToExitCode converts an error returned by Cmd.wait() to an exit code.  It
   236  returns -1 if no exit code can be inferred.
   237  */
   238  func waitErrToExitCode(err error) int {
   239  	// If there's no exit code, we return -1
   240  	retVal := -1
   241  
   242  	// See https://stackoverflow.com/questions/10385551/get-exit-code-go
   243  	if err != nil {
   244  		if exiterr, ok := err.(*exec.ExitError); ok {
   245  			// The program has exited with an exit code != 0
   246  			// This works on both Unix and Windows. Although package
   247  			// syscall is generally platform dependent, WaitStatus is
   248  			// defined for both Unix and Windows and in both cases has
   249  			// an ExitStatus() method with the same signature.
   250  			if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
   251  				retVal = status.ExitStatus()
   252  			}
   253  		}
   254  	} else {
   255  		retVal = 0
   256  	}
   257  
   258  	return retVal
   259  }
   260  
   261  /*
   262  RunCommand executes the passed command in a subprocess.  The first element of
   263  cmdArgs is used as executable and the rest as command arguments.  It captures
   264  and returns stdout, stderr and exit code.  The format of the returned map is:
   265  
   266  	{
   267  		"return-value": <exit code>,
   268  		"stdout": "<standard output>",
   269  		"stderr": "<standard error>"
   270  	}
   271  
   272  If the command cannot be executed or no pipes for stdout or stderr can be
   273  created the first return value is nil and the second return value is the error.
   274  NOTE: Since stdout and stderr are captured, they cannot be seen during the
   275  command execution.
   276  */
   277  func RunCommand(cmdArgs []string, runDir string) (map[string]interface{}, error) {
   278  	if len(cmdArgs) == 0 {
   279  		return nil, ErrEmptyCommandArgs
   280  	}
   281  
   282  	cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...)
   283  
   284  	if runDir != "" {
   285  		cmd.Dir = runDir
   286  	}
   287  
   288  	stderrPipe, err := cmd.StderrPipe()
   289  	if err != nil {
   290  		return nil, err
   291  	}
   292  	stdoutPipe, err := cmd.StdoutPipe()
   293  	if err != nil {
   294  		return nil, err
   295  	}
   296  
   297  	if err := cmd.Start(); err != nil {
   298  		return nil, err
   299  	}
   300  
   301  	// TODO: duplicate stdout, stderr
   302  	stdout, _ := io.ReadAll(stdoutPipe)
   303  	stderr, _ := io.ReadAll(stderrPipe)
   304  
   305  	retVal := waitErrToExitCode(cmd.Wait())
   306  
   307  	return map[string]interface{}{
   308  		"return-value": float64(retVal),
   309  		"stdout":       string(stdout),
   310  		"stderr":       string(stderr),
   311  	}, nil
   312  }
   313  
   314  /*
   315  InTotoRun executes commands, e.g. for software supply chain steps or
   316  inspections of an in-toto layout, and creates and returns corresponding link
   317  metadata.  Link metadata contains recorded products at the passed productPaths
   318  and materials at the passed materialPaths.  The returned link is wrapped in a
   319  Metablock object.  If command execution or artifact recording fails the first
   320  return value is an empty Metablock and the second return value is the error.
   321  */
   322  func InTotoRun(name string, runDir string, materialPaths []string, productPaths []string, cmdArgs []string, key Key, hashAlgorithms []string, gitignorePatterns []string, lStripPaths []string, lineNormalization bool, followSymlinkDirs bool, useDSSE bool) (Metadata, error) {
   323  	materials, err := RecordArtifacts(materialPaths, hashAlgorithms, gitignorePatterns, lStripPaths, lineNormalization, followSymlinkDirs)
   324  	if err != nil {
   325  		return nil, err
   326  	}
   327  
   328  	// make sure that we only run RunCommand if cmdArgs is not nil or empty
   329  	byProducts := map[string]interface{}{}
   330  	if len(cmdArgs) != 0 {
   331  		byProducts, err = RunCommand(cmdArgs, runDir)
   332  		if err != nil {
   333  			return nil, err
   334  		}
   335  	}
   336  
   337  	products, err := RecordArtifacts(productPaths, hashAlgorithms, gitignorePatterns, lStripPaths, lineNormalization, followSymlinkDirs)
   338  	if err != nil {
   339  		return nil, err
   340  	}
   341  
   342  	link := Link{
   343  		Type:        "link",
   344  		Name:        name,
   345  		Materials:   materials,
   346  		Products:    products,
   347  		ByProducts:  byProducts,
   348  		Command:     cmdArgs,
   349  		Environment: map[string]interface{}{},
   350  	}
   351  
   352  	if useDSSE {
   353  		env := &Envelope{}
   354  		if err := env.SetPayload(link); err != nil {
   355  			return nil, err
   356  		}
   357  
   358  		if !reflect.ValueOf(key).IsZero() {
   359  			if err := env.Sign(key); err != nil {
   360  				return nil, err
   361  			}
   362  		}
   363  
   364  		return env, nil
   365  	}
   366  
   367  	linkMb := &Metablock{Signed: link, Signatures: []Signature{}}
   368  	if !reflect.ValueOf(key).IsZero() {
   369  		if err := linkMb.Sign(key); err != nil {
   370  			return nil, err
   371  		}
   372  	}
   373  
   374  	return linkMb, nil
   375  }
   376  
   377  /*
   378  InTotoRecordStart begins the creation of a link metablock file in two steps,
   379  in order to provide evidence for supply chain steps that cannot be carries out
   380  by a single command.  InTotoRecordStart collects the hashes of the materials
   381  before any commands are run, signs the unfinished link, and returns the link.
   382  */
   383  func InTotoRecordStart(name string, materialPaths []string, key Key, hashAlgorithms, gitignorePatterns []string, lStripPaths []string, lineNormalization bool, followSymlinkDirs bool, useDSSE bool) (Metadata, error) {
   384  	materials, err := RecordArtifacts(materialPaths, hashAlgorithms, gitignorePatterns, lStripPaths, lineNormalization, followSymlinkDirs)
   385  	if err != nil {
   386  		return nil, err
   387  	}
   388  
   389  	link := Link{
   390  		Type:        "link",
   391  		Name:        name,
   392  		Materials:   materials,
   393  		Products:    map[string]HashObj{},
   394  		ByProducts:  map[string]interface{}{},
   395  		Command:     []string{},
   396  		Environment: map[string]interface{}{},
   397  	}
   398  
   399  	if useDSSE {
   400  		env := &Envelope{}
   401  		if err := env.SetPayload(link); err != nil {
   402  			return nil, err
   403  		}
   404  
   405  		if !reflect.ValueOf(key).IsZero() {
   406  			if err := env.Sign(key); err != nil {
   407  				return nil, err
   408  			}
   409  		}
   410  
   411  		return env, nil
   412  	}
   413  
   414  	linkMb := &Metablock{Signed: link, Signatures: []Signature{}}
   415  	linkMb.Signatures = []Signature{}
   416  	if !reflect.ValueOf(key).IsZero() {
   417  		if err := linkMb.Sign(key); err != nil {
   418  			return nil, err
   419  		}
   420  	}
   421  
   422  	return linkMb, nil
   423  }
   424  
   425  /*
   426  InTotoRecordStop ends the creation of a metatadata link file created by
   427  InTotoRecordStart. InTotoRecordStop takes in a signed unfinished link metablock
   428  created by InTotoRecordStart and records the hashes of any products creted by
   429  commands run between InTotoRecordStart and InTotoRecordStop.  The resultant
   430  finished link metablock is then signed by the provided key and returned.
   431  */
   432  func InTotoRecordStop(prelimLinkEnv Metadata, productPaths []string, key Key, hashAlgorithms, gitignorePatterns []string, lStripPaths []string, lineNormalization bool, followSymlinkDirs bool, useDSSE bool) (Metadata, error) {
   433  	if err := prelimLinkEnv.VerifySignature(key); err != nil {
   434  		return nil, err
   435  	}
   436  
   437  	link, ok := prelimLinkEnv.GetPayload().(Link)
   438  	if !ok {
   439  		return nil, errors.New("invalid metadata block")
   440  	}
   441  
   442  	products, err := RecordArtifacts(productPaths, hashAlgorithms, gitignorePatterns, lStripPaths, lineNormalization, followSymlinkDirs)
   443  	if err != nil {
   444  		return nil, err
   445  	}
   446  
   447  	link.Products = products
   448  
   449  	if useDSSE {
   450  		env := &Envelope{}
   451  		if err := env.SetPayload(link); err != nil {
   452  			return nil, err
   453  		}
   454  
   455  		if !reflect.ValueOf(key).IsZero() {
   456  			if err := env.Sign(key); err != nil {
   457  				return nil, err
   458  			}
   459  		}
   460  
   461  		return env, nil
   462  	}
   463  
   464  	linkMb := &Metablock{Signed: link, Signatures: []Signature{}}
   465  	if !reflect.ValueOf(key).IsZero() {
   466  		if err := linkMb.Sign(key); err != nil {
   467  			return linkMb, err
   468  		}
   469  	}
   470  
   471  	return linkMb, nil
   472  }
   473  
   474  /*
   475  InTotoMatchProducts checks if local artifacts match products in passed link.
   476  
   477  NOTE: Does not check integrity or authenticity of passed link!
   478  */
   479  func InTotoMatchProducts(link *Link, paths []string, hashAlgorithms []string, excludePatterns []string, lstripPaths []string) ([]string, []string, []string, error) {
   480  	if len(paths) == 0 {
   481  		paths = append(paths, ".")
   482  	}
   483  
   484  	artifacts, err := RecordArtifacts(paths, hashAlgorithms, excludePatterns, lstripPaths, false, false)
   485  	if err != nil {
   486  		return nil, nil, nil, err
   487  	}
   488  
   489  	artifactNames := []string{}
   490  	for name := range artifacts {
   491  		artifactNames = append(artifactNames, name)
   492  	}
   493  	artifactsSet := NewSet(artifactNames...)
   494  
   495  	productNames := []string{}
   496  	for name := range link.Products {
   497  		productNames = append(productNames, name)
   498  	}
   499  	productsSet := NewSet(productNames...)
   500  
   501  	onlyInProductsSet := productsSet.Difference(artifactsSet)
   502  	onlyInProducts := []string{}
   503  	for name := range onlyInProductsSet {
   504  		onlyInProducts = append(onlyInProducts, name)
   505  	}
   506  
   507  	notInProductsSet := artifactsSet.Difference(productsSet)
   508  	notInProducts := []string{}
   509  	for name := range notInProductsSet {
   510  		notInProducts = append(notInProducts, name)
   511  	}
   512  
   513  	inBothSet := artifactsSet.Intersection(productsSet)
   514  	differ := []string{}
   515  	for name := range inBothSet {
   516  		linkHashes := HashObj{}
   517  		for alg, val := range link.Products[name] {
   518  			linkHashes[alg] = val
   519  		}
   520  
   521  		artifactHashes := HashObj{}
   522  		for alg, val := range artifacts[name] {
   523  			artifactHashes[alg] = val
   524  		}
   525  
   526  		if !reflect.DeepEqual(linkHashes, artifactHashes) {
   527  			differ = append(differ, name)
   528  		}
   529  	}
   530  
   531  	return onlyInProducts, notInProducts, differ, nil
   532  }