github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/githubactions/parse_workflow.go (about)

     1  package githubactions
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"regexp"
     8  
     9  	"go.yaml.in/yaml/v3"
    10  
    11  	"github.com/anchore/syft/internal/unknown"
    12  	"github.com/anchore/syft/syft/artifact"
    13  	"github.com/anchore/syft/syft/file"
    14  	"github.com/anchore/syft/syft/pkg"
    15  	"github.com/anchore/syft/syft/pkg/cataloger/generic"
    16  )
    17  
    18  var (
    19  	_ generic.Parser = parseWorkflowForActionUsage
    20  	_ generic.Parser = parseWorkflowForWorkflowUsage
    21  )
    22  
    23  type workflowDef struct {
    24  	Jobs map[string]workflowJobDef `yaml:"jobs"`
    25  }
    26  
    27  type workflowJobDef struct {
    28  	Uses        string    `yaml:"uses"`
    29  	UsesComment string    `yaml:"-"`
    30  	Steps       []stepDef `yaml:"steps"`
    31  }
    32  
    33  type stepDef struct {
    34  	Name        string `yaml:"name"`
    35  	Uses        string `yaml:"uses"`
    36  	UsesComment string `yaml:"-"`
    37  	With        struct {
    38  		Path string `yaml:"path"`
    39  		Key  string `yaml:"key"`
    40  	} `yaml:"with"`
    41  }
    42  
    43  func parseWorkflowForWorkflowUsage(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
    44  	contents, errs := io.ReadAll(reader)
    45  	if errs != nil {
    46  		return nil, nil, fmt.Errorf("unable to read yaml workflow file: %w", errs)
    47  	}
    48  
    49  	// parse the yaml file into a generic node to preserve comments
    50  	var node yaml.Node
    51  	if errs = yaml.Unmarshal(contents, &node); errs != nil {
    52  		return nil, nil, fmt.Errorf("unable to parse yaml workflow file: %w", errs)
    53  	}
    54  
    55  	// unmarshal the node into a workflowDef struct
    56  	var wf workflowDef
    57  	if errs = node.Decode(&wf); errs != nil {
    58  		return nil, nil, fmt.Errorf("unable to decode workflow: %w", errs)
    59  	}
    60  
    61  	attachUsageComments(&node, &wf)
    62  
    63  	// we use a collection to help with deduplication before raising to higher level processing
    64  	pkgs := pkg.NewCollection()
    65  
    66  	for _, job := range wf.Jobs {
    67  		if job.Uses != "" {
    68  			p, err := newPackageFromUsageStatement(job.Uses, job.UsesComment, reader.Location)
    69  			if err != nil {
    70  				errs = unknown.Append(errs, reader, err)
    71  			}
    72  			if p != nil {
    73  				pkgs.Add(*p)
    74  			}
    75  		}
    76  	}
    77  
    78  	return pkgs.Sorted(), nil, errs
    79  }
    80  
    81  func parseWorkflowForActionUsage(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
    82  	contents, errs := io.ReadAll(reader)
    83  	if errs != nil {
    84  		return nil, nil, fmt.Errorf("unable to read yaml workflow file: %w", errs)
    85  	}
    86  
    87  	// parse the yaml file into a generic node to preserve comments
    88  	var node yaml.Node
    89  	if errs = yaml.Unmarshal(contents, &node); errs != nil {
    90  		return nil, nil, fmt.Errorf("unable to parse yaml workflow file: %w", errs)
    91  	}
    92  
    93  	// unmarshal the node into a workflowDef struct
    94  	var wf workflowDef
    95  	if errs = node.Decode(&wf); errs != nil {
    96  		return nil, nil, fmt.Errorf("unable to decode workflow: %w", errs)
    97  	}
    98  
    99  	attachUsageComments(&node, &wf)
   100  
   101  	// we use a collection to help with deduplication before raising to higher level processing
   102  	pkgs := pkg.NewCollection()
   103  
   104  	for _, job := range wf.Jobs {
   105  		for _, step := range job.Steps {
   106  			if step.Uses == "" {
   107  				continue
   108  			}
   109  			p, err := newPackageFromUsageStatement(step.Uses, step.UsesComment, reader.Location)
   110  			if err != nil {
   111  				errs = unknown.Append(errs, reader, err)
   112  			}
   113  			if p != nil {
   114  				pkgs.Add(*p)
   115  			}
   116  		}
   117  	}
   118  
   119  	return pkgs.Sorted(), nil, errs
   120  }
   121  
   122  // attachUsageComments traverses the yaml node tree and attaches usage comments to the workflowDef job strcuts and step structs.
   123  // This is a best-effort approach to attach comments to the correct job or step.
   124  func attachUsageComments(node *yaml.Node, wf *workflowDef) {
   125  	// for a document node, process its content (usually a single mapping node)
   126  	if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
   127  		processNode(node.Content[0], wf, nil, nil, nil)
   128  	} else {
   129  		processNode(node, wf, nil, nil, nil)
   130  	}
   131  }
   132  
   133  func processNode(node *yaml.Node, wf *workflowDef, currentJob *string, currentStep *int, inJobsSection *bool) {
   134  	switch node.Kind {
   135  	case yaml.MappingNode:
   136  		for i := 0; i < len(node.Content); i += 2 {
   137  			key := node.Content[i]
   138  			value := node.Content[i+1]
   139  
   140  			// track if we're in the jobs section...
   141  			if key.Value == "jobs" && inJobsSection == nil {
   142  				inJobs := true
   143  				inJobsSection = &inJobs
   144  				processNode(value, wf, nil, nil, inJobsSection)
   145  				continue
   146  			}
   147  
   148  			// if we're in jobs section, and this is a job key...
   149  			if inJobsSection != nil && *inJobsSection && currentJob == nil {
   150  				job := key.Value
   151  				currentJob = &job
   152  				processNode(value, wf, currentJob, nil, inJobsSection)
   153  				currentJob = nil
   154  				continue
   155  			}
   156  
   157  			// if this is a "uses" key...
   158  			if key.Value == "uses" {
   159  				processUsesNode(value, wf, currentJob, currentStep)
   160  			}
   161  
   162  			// if this is a "steps" key inside a job...
   163  			if key.Value == "steps" && currentJob != nil {
   164  				for j, stepNode := range value.Content {
   165  					stepIndex := j
   166  					processNode(stepNode, wf, currentJob, &stepIndex, inJobsSection)
   167  				}
   168  				continue
   169  			}
   170  
   171  			processNode(key, wf, currentJob, currentStep, inJobsSection)
   172  			processNode(value, wf, currentJob, currentStep, inJobsSection)
   173  		}
   174  
   175  	case yaml.SequenceNode:
   176  		for i, item := range node.Content {
   177  			idx := i
   178  			processNode(item, wf, currentJob, &idx, inJobsSection)
   179  		}
   180  	}
   181  }
   182  
   183  func processUsesNode(node *yaml.Node, wf *workflowDef, currentJob *string, currentStep *int) {
   184  	if node.Kind != yaml.ScalarNode {
   185  		return
   186  	}
   187  
   188  	comment := node.LineComment
   189  	if comment == "" {
   190  		comment = node.HeadComment
   191  	}
   192  	if comment == "" {
   193  		comment = node.FootComment
   194  	}
   195  
   196  	if comment != "" {
   197  		versionRegex := regexp.MustCompile(`v?\d+(\.\d+)*`)
   198  		versionMatch := versionRegex.FindString(comment)
   199  
   200  		if versionMatch != "" {
   201  			if currentJob != nil && currentStep == nil {
   202  				// this is a job level "uses"
   203  				if job, ok := wf.Jobs[*currentJob]; ok {
   204  					job.UsesComment = versionMatch
   205  					wf.Jobs[*currentJob] = job
   206  				}
   207  			} else if currentJob != nil && currentStep != nil {
   208  				// this is a step level "uses"
   209  				if job, ok := wf.Jobs[*currentJob]; ok {
   210  					if *currentStep < len(job.Steps) {
   211  						job.Steps[*currentStep].UsesComment = versionMatch
   212  						wf.Jobs[*currentJob] = job
   213  					}
   214  				}
   215  			}
   216  		}
   217  	}
   218  }