github.com/w3security/vervet/v5@v5.3.1-0.20230618081846-5bd9b5d799dc/internal/linter/optic/linter.go (about)

     1  // Package optic supports linting OpenAPI specs with Optic CI and Sweater Comb.
     2  package optic
     3  
     4  import (
     5  	"bufio"
     6  	"context"
     7  	"crypto/sha256"
     8  	"encoding/hex"
     9  	"encoding/json"
    10  	"fmt"
    11  	"io"
    12  	"log"
    13  	"os"
    14  	"os/exec"
    15  	"path/filepath"
    16  	"regexp"
    17  	"sort"
    18  	"strings"
    19  	"time"
    20  
    21  	"github.com/ghodss/yaml"
    22  	"go.uber.org/multierr"
    23  
    24  	"github.com/w3security/vervet/v5"
    25  	"github.com/w3security/vervet/v5/config"
    26  	"github.com/w3security/vervet/v5/internal/files"
    27  	"github.com/w3security/vervet/v5/internal/linter"
    28  )
    29  
    30  // Optic runs a Docker image containing Optic CI and built-in rules.
    31  type Optic struct {
    32  	image      string
    33  	script     string
    34  	fromSource files.FileSource
    35  	toSource   files.FileSource
    36  	runner     commandRunner
    37  	timeNow    func() time.Time
    38  	debug      bool
    39  	extraArgs  []string
    40  	exceptions map[string][]string
    41  }
    42  
    43  type commandRunner interface {
    44  	run(cmd *exec.Cmd) error
    45  	bulkInput(interface{})
    46  }
    47  
    48  type execCommandRunner struct{}
    49  
    50  func (*execCommandRunner) bulkInput(interface{}) {}
    51  
    52  func (*execCommandRunner) run(cmd *exec.Cmd) error {
    53  	return cmd.Run()
    54  }
    55  
    56  // New returns a new Optic instance configured to run the given OCI image and
    57  // file sources. File sources may be a Git "treeish" (commit hash or anything
    58  // that resolves to one such as a branch or tag) where the current working
    59  // directory is a cloned git repository. If `from` is empty string, comparison
    60  // assumes all changes are new "from scratch" additions. If `to` is empty
    61  // string, spec files are assumed to be relative to the current working
    62  // directory.
    63  //
    64  // Temporary resources may be created by the linter, which are reclaimed when
    65  // the context cancels.
    66  func New(ctx context.Context, cfg *config.OpticCILinter) (*Optic, error) {
    67  	image, script, from, to := cfg.Image, cfg.Script, cfg.Original, cfg.Proposed
    68  	var fromSource, toSource files.FileSource
    69  	var err error
    70  
    71  	if !isDocker(script) {
    72  		image = ""
    73  	}
    74  
    75  	if from == "" {
    76  		fromSource = files.NilSource{}
    77  	} else {
    78  		fromSource, err = newGitRepoSource(".", from)
    79  		if err != nil {
    80  			return nil, err
    81  		}
    82  	}
    83  
    84  	if to == "" {
    85  		toSource = files.LocalFSSource{}
    86  	} else {
    87  		toSource, err = newGitRepoSource(".", to)
    88  		if err != nil {
    89  			return nil, err
    90  		}
    91  	}
    92  
    93  	go func() {
    94  		<-ctx.Done()
    95  		fromSource.Close()
    96  		toSource.Close()
    97  	}()
    98  	return &Optic{
    99  		image:      image,
   100  		script:     script,
   101  		fromSource: fromSource,
   102  		toSource:   toSource,
   103  		runner:     &execCommandRunner{},
   104  		timeNow:    time.Now,
   105  		debug:      cfg.Debug,
   106  		extraArgs:  cfg.ExtraArgs,
   107  		exceptions: cfg.Exceptions,
   108  	}, nil
   109  }
   110  
   111  func isDocker(script string) bool {
   112  	return script == ""
   113  }
   114  
   115  // Match implements linter.Linter.
   116  func (o *Optic) Match(rcConfig *config.ResourceSet) ([]string, error) {
   117  	fromFiles, err := o.fromSource.Match(rcConfig)
   118  	if err != nil {
   119  		return nil, err
   120  	}
   121  	toFiles, err := o.toSource.Match(rcConfig)
   122  	if err != nil {
   123  		return nil, err
   124  	}
   125  	// Unique set of files
   126  	// TODO: normalization needed? or if not needed, tested to prove it?
   127  	filesMap := map[string]struct{}{}
   128  	for i := range fromFiles {
   129  		filesMap[fromFiles[i]] = struct{}{}
   130  	}
   131  	for i := range toFiles {
   132  		filesMap[toFiles[i]] = struct{}{}
   133  	}
   134  	result := []string{}
   135  	for k := range filesMap {
   136  		result = append(result, k)
   137  	}
   138  	sort.Strings(result)
   139  	return result, nil
   140  }
   141  
   142  // WithOverride implements linter.Linter.
   143  func (*Optic) WithOverride(ctx context.Context, override *config.Linter) (linter.Linter, error) {
   144  	if override.OpticCI == nil {
   145  		return nil, fmt.Errorf("invalid linter override")
   146  	}
   147  	return New(ctx, override.OpticCI)
   148  }
   149  
   150  // Run runs Optic CI on the given paths. Linting output is written to standard
   151  // output by Optic CI. Returns an error when lint fails configured rules.
   152  func (o *Optic) Run(ctx context.Context, root string, paths ...string) error {
   153  	var errs error
   154  	var comparisons []comparison
   155  	localFrom, err := o.fromSource.Prefetch(root)
   156  	if err != nil {
   157  		return err
   158  	}
   159  	localTo, err := o.toSource.Prefetch(root)
   160  	if err != nil {
   161  		return err
   162  	}
   163  	var dockerArgs []string
   164  	var fromFilter, toFilter func(string) string
   165  	if localFrom != "" {
   166  		dockerArgs = append(dockerArgs, "-v", localFrom+":/from/"+root)
   167  		if o.isDocker() {
   168  			fromFilter = func(s string) string {
   169  				return strings.Replace(s, localFrom, "/from/"+root, 1)
   170  			}
   171  		}
   172  	}
   173  	if localTo != "" {
   174  		dockerArgs = append(dockerArgs, "-v", localTo+":/to/"+root)
   175  		if o.isDocker() {
   176  			toFilter = func(s string) string {
   177  				return strings.Replace(s, localTo, "/to/"+root, 1)
   178  			}
   179  		}
   180  	}
   181  	for i := range paths {
   182  		comparison, volumeArgs, err := o.newComparison(paths[i], fromFilter, toFilter)
   183  		if err == errHasException {
   184  			continue
   185  		} else if err != nil {
   186  			errs = multierr.Append(errs, err)
   187  		} else {
   188  			comparisons = append(comparisons, comparison)
   189  			dockerArgs = append(dockerArgs, volumeArgs...)
   190  		}
   191  	}
   192  	if o.isDocker() {
   193  		err = o.bulkCompareDocker(ctx, comparisons, dockerArgs)
   194  	} else {
   195  		err = o.bulkCompareScript(ctx, comparisons)
   196  	}
   197  	errs = multierr.Append(errs, err)
   198  	return errs
   199  }
   200  
   201  func (o *Optic) isDocker() bool {
   202  	return isDocker(o.script)
   203  }
   204  
   205  type comparison struct {
   206  	From    string  `json:"from,omitempty"`
   207  	To      string  `json:"to,omitempty"`
   208  	Context Context `json:"context,omitempty"`
   209  }
   210  
   211  type bulkCompareInput struct {
   212  	Comparisons []comparison `json:"comparisons,omitempty"`
   213  }
   214  
   215  func (o *Optic) newComparison(path string, fromFilter, toFilter func(string) string) (comparison, []string, error) {
   216  	var volumeArgs []string
   217  
   218  	// TODO: This assumes the file being linted is a resource version spec
   219  	// file, and not a compiled one. We don't yet have rules that support
   220  	// diffing _compiled_ specs; that will require a different context and rule
   221  	// set for Vervet Underground integration.
   222  	opticCtx, err := o.contextFromPath(path)
   223  	if err != nil {
   224  		return comparison{}, nil, fmt.Errorf("failed to get context from path %q: %w", path, err)
   225  	}
   226  
   227  	cmp := comparison{
   228  		Context: *opticCtx,
   229  	}
   230  
   231  	fromFile, err := o.fromSource.Fetch(path)
   232  	if err != nil {
   233  		return comparison{}, nil, err
   234  	}
   235  	if ok, err := o.hasException(path, fromFile); err != nil {
   236  		return comparison{}, nil, err
   237  	} else if ok {
   238  		return comparison{}, nil, errHasException
   239  	}
   240  	cmp.From = fromFile
   241  	if fromFilter != nil {
   242  		cmp.From = fromFilter(cmp.From)
   243  	}
   244  
   245  	toFile, err := o.toSource.Fetch(path)
   246  	if err != nil {
   247  		return comparison{}, nil, err
   248  	}
   249  	if ok, err := o.hasException(path, toFile); err != nil {
   250  		return comparison{}, nil, err
   251  	} else if ok {
   252  		return comparison{}, nil, errHasException
   253  	}
   254  	cmp.To = toFile
   255  	if toFilter != nil {
   256  		cmp.To = toFilter(cmp.To)
   257  	}
   258  
   259  	return cmp, volumeArgs, nil
   260  }
   261  
   262  var errHasException = fmt.Errorf("file is skipped due to lint exception")
   263  
   264  func (o *Optic) hasException(key, path string) (bool, error) {
   265  	if path == "" {
   266  		return false, nil
   267  	}
   268  	sum, ok := o.exceptions[key]
   269  	if !ok {
   270  		return false, nil
   271  	}
   272  	contents, err := os.ReadFile(path)
   273  	if err != nil {
   274  		return false, err
   275  	}
   276  	fileSum := sha256.Sum256(contents)
   277  	matchSum := hex.EncodeToString(fileSum[:])
   278  	for i := range sum {
   279  		if strings.ToLower(sum[i]) == matchSum {
   280  			return true, nil
   281  		}
   282  	}
   283  	return false, nil
   284  }
   285  
   286  func (o *Optic) bulkCompareScript(ctx context.Context, comparisons []comparison) error {
   287  	input := &bulkCompareInput{
   288  		Comparisons: comparisons,
   289  	}
   290  	o.runner.bulkInput(input)
   291  	inputFile, err := os.CreateTemp("", "*-input.json")
   292  	if err != nil {
   293  		return err
   294  	}
   295  	defer inputFile.Close()
   296  	err = json.NewEncoder(inputFile).Encode(&input)
   297  	if err != nil {
   298  		return err
   299  	}
   300  	if o.debug {
   301  		log.Println("input.json:")
   302  		err = json.NewEncoder(os.Stderr).Encode(&input)
   303  		if err != nil {
   304  			return err
   305  		}
   306  	}
   307  	if err := inputFile.Sync(); err != nil {
   308  		return err
   309  	}
   310  
   311  	if o.debug {
   312  		log.Print("bulk-compare input:")
   313  		if err := json.NewEncoder(os.Stdout).Encode(&input); err != nil {
   314  			log.Println("failed to encode input to stdout!")
   315  		}
   316  		log.Println()
   317  	}
   318  
   319  	extraArgs := o.extraArgs
   320  	if ok, reason := o.checkUploadEnabled(); ok {
   321  		extraArgs = append(extraArgs, "--upload-results")
   322  	} else {
   323  		log.Printf("not uploading to Optic Cloud: %s", reason)
   324  	}
   325  
   326  	args := append([]string{"bulk-compare", "--input", inputFile.Name()}, extraArgs...)
   327  	cmd := exec.CommandContext(ctx, o.script, args...)
   328  
   329  	pipeReader, pipeWriter := io.Pipe()
   330  	ch := make(chan struct{})
   331  	defer func() {
   332  		err := pipeWriter.Close()
   333  		if err != nil {
   334  			log.Printf("warning: failed to close output: %v", err)
   335  		}
   336  		select {
   337  		case <-ch:
   338  			return
   339  		case <-ctx.Done():
   340  			return
   341  		case <-time.After(cmdTimeout):
   342  			log.Printf("warning: timeout waiting for output to flush")
   343  			return
   344  		}
   345  	}()
   346  	go func() {
   347  		defer pipeReader.Close()
   348  		sc := bufio.NewScanner(pipeReader)
   349  		for sc.Scan() {
   350  			line := sc.Text()
   351  			// TODO: this wanton breakage of FileSource encapsulation indicates
   352  			// we probably need an abstraction if/when we support other
   353  			// sources. VU might be such a future source...
   354  			if fromGit, ok := o.fromSource.(*gitRepoSource); ok {
   355  				for root, tempDir := range fromGit.roots {
   356  					line = strings.ReplaceAll(line, tempDir, "("+fromGit.Name()+"):"+root)
   357  				}
   358  			}
   359  			if toGit, ok := o.toSource.(*gitRepoSource); ok {
   360  				for root, tempDir := range toGit.roots {
   361  					line = strings.ReplaceAll(line, tempDir, "("+toGit.Name()+"):"+root)
   362  				}
   363  			}
   364  			fmt.Println(line)
   365  		}
   366  		if err := sc.Err(); err != nil {
   367  			fmt.Fprintf(os.Stderr, "error reading stdout: %v", err)
   368  		}
   369  		close(ch)
   370  	}()
   371  	cmd.Stdin = os.Stdin
   372  	cmd.Stdout = pipeWriter
   373  	cmd.Stderr = os.Stderr
   374  	err = o.runner.run(cmd)
   375  	if err != nil {
   376  		return fmt.Errorf("lint failed: %w", err)
   377  	}
   378  	return nil
   379  }
   380  
   381  func (o *Optic) checkUploadEnabled() (bool, string) {
   382  	if os.Getenv("GITHUB_TOKEN") == "" {
   383  		return false, "GITHUB_TOKEN not set"
   384  	}
   385  	if os.Getenv("OPTIC_TOKEN") == "" {
   386  		return false, "OPTIC_TOKEN not set"
   387  	}
   388  	ciContextPath, err := filepath.Abs("ci-context.json")
   389  	if err != nil {
   390  		return false, err.Error()
   391  	}
   392  	if _, err := os.Stat(ciContextPath); err != nil {
   393  		return false, err.Error()
   394  	}
   395  	return true, ""
   396  }
   397  
   398  var fromDockerOutputRE = regexp.MustCompile(`/from/`)
   399  var toDockerOutputRE = regexp.MustCompile(`/to/`)
   400  
   401  func (o *Optic) bulkCompareDocker(ctx context.Context, comparisons []comparison, dockerArgs []string) error {
   402  	input := &bulkCompareInput{
   403  		Comparisons: comparisons,
   404  	}
   405  	o.runner.bulkInput(input)
   406  	inputFile, err := os.CreateTemp("", "*-input.json")
   407  	if err != nil {
   408  		return err
   409  	}
   410  	defer inputFile.Close()
   411  	err = json.NewEncoder(inputFile).Encode(&input)
   412  	if err != nil {
   413  		return err
   414  	}
   415  	if err := inputFile.Sync(); err != nil {
   416  		return err
   417  	}
   418  
   419  	if o.debug {
   420  		log.Print("bulk-compare input:")
   421  		if err := json.NewEncoder(os.Stdout).Encode(&input); err != nil {
   422  			log.Println("failed to encode input to stdout!")
   423  		}
   424  		log.Println()
   425  	}
   426  
   427  	// Pull latest image
   428  	cmd := exec.CommandContext(ctx, "docker", "pull", o.image)
   429  	cmd.Stdout = os.Stdout
   430  	cmd.Stderr = os.Stderr
   431  	err = o.runner.run(cmd)
   432  	if err != nil {
   433  		return err
   434  	}
   435  
   436  	extraArgs := o.extraArgs
   437  	if ok, reason := o.checkUploadEnabled(); ok {
   438  		extraArgs = append(extraArgs, "--upload-results")
   439  		dockerArgs = append(dockerArgs,
   440  			"-e", "GITHUB_TOKEN="+os.Getenv("GITHUB_TOKEN"),
   441  			"-e", "OPTIC_TOKEN="+os.Getenv("OPTIC_TOKEN"),
   442  		)
   443  	} else {
   444  		log.Printf("not uploading to Optic Cloud: %s", reason)
   445  	}
   446  
   447  	// Optic CI documentation: https://www.useoptic.com/docs/optic-ci
   448  	cmdline := append([]string{"run", "--rm", "-v", inputFile.Name() + ":/input.json"}, dockerArgs...)
   449  	cmdline = append(cmdline, o.image, "bulk-compare", "--input", "/input.json")
   450  	cmdline = append(cmdline, extraArgs...)
   451  	if o.debug {
   452  		log.Printf("running: docker %s", strings.Join(cmdline, " "))
   453  	}
   454  	cmd = exec.CommandContext(ctx, "docker", cmdline...)
   455  
   456  	pipeReader, pipeWriter := io.Pipe()
   457  	ch := make(chan struct{})
   458  	defer func() {
   459  		err := pipeWriter.Close()
   460  		if err != nil {
   461  			log.Printf("warning: failed to close output: %v", err)
   462  		}
   463  		select {
   464  		case <-ch:
   465  			return
   466  		case <-ctx.Done():
   467  			return
   468  		case <-time.After(cmdTimeout):
   469  			log.Printf("warning: timeout waiting for output to flush")
   470  			return
   471  		}
   472  	}()
   473  	go func() {
   474  		defer pipeReader.Close()
   475  		sc := bufio.NewScanner(pipeReader)
   476  		for sc.Scan() {
   477  			line := sc.Text()
   478  			line = fromDockerOutputRE.ReplaceAllString(line, "("+o.fromSource.Name()+"):")
   479  			line = toDockerOutputRE.ReplaceAllString(line, "("+o.toSource.Name()+"):")
   480  			fmt.Println(line)
   481  		}
   482  		if err := sc.Err(); err != nil {
   483  			fmt.Fprintf(os.Stderr, "error reading stdout: %v", err)
   484  		}
   485  		close(ch)
   486  	}()
   487  	cmd.Stdin = os.Stdin
   488  	cmd.Stdout = pipeWriter
   489  	cmd.Stderr = os.Stderr
   490  	err = o.runner.run(cmd)
   491  	if err != nil {
   492  		return fmt.Errorf("lint failed: %w", err)
   493  	}
   494  	return nil
   495  }
   496  
   497  func (o *Optic) contextFromPath(path string) (*Context, error) {
   498  	dateDir := filepath.Dir(path)
   499  	resourceDir := filepath.Dir(dateDir)
   500  	date, resource := filepath.Base(dateDir), filepath.Base(resourceDir)
   501  	if _, err := time.Parse("2006-01-02", date); err != nil {
   502  		return nil, err
   503  	}
   504  	stability, err := o.loadStability(path)
   505  	if err != nil {
   506  		return nil, err
   507  	}
   508  	if _, err := vervet.ParseStability(stability); err != nil {
   509  		return nil, err
   510  	}
   511  	return &Context{
   512  		ChangeDate:     o.timeNow().UTC().Format("2006-01-02"),
   513  		ChangeResource: resource,
   514  		ChangeVersion: Version{
   515  			Date:      date,
   516  			Stability: stability,
   517  		},
   518  	}, nil
   519  }
   520  
   521  func (o *Optic) loadStability(path string) (string, error) {
   522  	var (
   523  		doc struct {
   524  			Stability string `json:"x-w3security-api-stability"`
   525  		}
   526  		contentsFile string
   527  		err          error
   528  	)
   529  	contentsFile, err = o.fromSource.Fetch(path)
   530  	if err != nil {
   531  		return "", err
   532  	}
   533  	if contentsFile == "" {
   534  		contentsFile, err = o.toSource.Fetch(path)
   535  		if err != nil {
   536  			return "", err
   537  		}
   538  	}
   539  	contents, err := os.ReadFile(contentsFile)
   540  	if err != nil {
   541  		return "", err
   542  	}
   543  	err = yaml.Unmarshal(contents, &doc)
   544  	if err != nil {
   545  		return "", err
   546  	}
   547  	return doc.Stability, nil
   548  }
   549  
   550  const cmdTimeout = time.Second * 30