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 }