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