sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/verify-owners/verify-owners.go (about) 1 /* 2 Copyright 2018 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package verifyowners 18 19 import ( 20 "fmt" 21 "os" 22 "path/filepath" 23 "reflect" 24 "regexp" 25 "strconv" 26 "strings" 27 28 "github.com/sirupsen/logrus" 29 30 "k8s.io/apimachinery/pkg/util/sets" 31 32 "sigs.k8s.io/prow/pkg/config" 33 "sigs.k8s.io/prow/pkg/git/types" 34 "sigs.k8s.io/prow/pkg/git/v2" 35 "sigs.k8s.io/prow/pkg/github" 36 "sigs.k8s.io/prow/pkg/labels" 37 "sigs.k8s.io/prow/pkg/pluginhelp" 38 "sigs.k8s.io/prow/pkg/plugins" 39 "sigs.k8s.io/prow/pkg/plugins/golint" 40 "sigs.k8s.io/prow/pkg/plugins/ownersconfig" 41 "sigs.k8s.io/prow/pkg/plugins/trigger" 42 "sigs.k8s.io/prow/pkg/repoowners" 43 ) 44 45 const ( 46 // PluginName defines this plugin's registered name. 47 PluginName = "verify-owners" 48 untrustedResponseFormat = `The following users are mentioned in %s file(s) but are untrusted for the following reasons. One way to make the user trusted is to add them as [members](%s) of the %s org. You can then trigger verification by writing ` + "`/verify-owners`" + ` in a comment.` 49 ) 50 51 type nonTrustedReasons struct { 52 // files is a list of files they are being added in 53 files []string 54 // triggerReason is the reason that trigger's TrustedUser responds with for a failed trust check 55 triggerReason string 56 } 57 58 var ( 59 verifyOwnersRe = regexp.MustCompile(`(?mi)^/verify-owners\s*$`) 60 ) 61 62 func init() { 63 plugins.RegisterPullRequestHandler(PluginName, handlePullRequest, helpProvider) 64 plugins.RegisterGenericCommentHandler(PluginName, handleGenericCommentEvent, helpProvider) 65 } 66 67 func helpProvider(c *plugins.Configuration, orgRepo []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 68 pluginHelp := &pluginhelp.PluginHelp{ 69 Description: fmt.Sprintf("The verify-owners plugin validates %s and %s files (by default) and ensures that they always contain collaborators of the org, if they are modified in a PR. On validation failure it automatically adds the '%s' label to the PR, and a review comment on the incriminating file(s). Per-repo configuration for filenames is possible.", ownersconfig.DefaultOwnersFile, ownersconfig.DefaultOwnersAliasesFile, labels.InvalidOwners), 70 Config: map[string]string{}, 71 } 72 defaultFilenames := c.OwnersFilenames("", "") 73 descriptionFor := func(filenames ownersconfig.Filenames) string { 74 description := fmt.Sprintf("%s and %s files are validated.", filenames.Owners, filenames.OwnersAliases) 75 if c.Owners.LabelsDenyList != nil { 76 description = fmt.Sprintf(`%s The verify-owners plugin will complain if %s files contain any of the following banned labels: %s.`, 77 description, 78 filenames.Owners, 79 strings.Join(c.Owners.LabelsDenyList, ", ")) 80 } 81 return description 82 } 83 pluginHelp.Config["default"] = descriptionFor(defaultFilenames) 84 for _, item := range orgRepo { 85 filenames := c.OwnersFilenames(item.Org, item.Repo) 86 if !reflect.DeepEqual(filenames, defaultFilenames) { 87 pluginHelp.Config[item.String()] = descriptionFor(filenames) 88 } 89 } 90 pluginHelp.AddCommand(pluginhelp.Command{ 91 Usage: "/verify-owners", 92 Description: labels.InvalidOwners, 93 Featured: false, 94 WhoCanUse: "Anyone", 95 Examples: []string{"/verify-owners"}, 96 }) 97 return pluginHelp, nil 98 } 99 100 type ownersClient interface { 101 ParseSimpleConfig(path string) (repoowners.SimpleConfig, error) 102 ParseFullConfig(path string) (repoowners.FullConfig, error) 103 } 104 105 type repoownersClient interface { 106 LoadRepoOwners(org, repo, base string) (repoowners.RepoOwner, error) 107 } 108 109 type githubClient interface { 110 IsCollaborator(owner, repo, login string) (bool, error) 111 IsMember(org, user string) (bool, error) 112 AddLabel(org, repo string, number int, label string) error 113 CreateComment(owner, repo string, number int, comment string) error 114 CreateReview(org, repo string, number int, r github.DraftReview) error 115 GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) 116 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 117 RemoveLabel(owner, repo string, number int, label string) error 118 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 119 BotUserChecker() (func(candidate string) bool, error) 120 } 121 122 type commentPruner interface { 123 PruneComments(shouldPrune func(github.IssueComment) bool) 124 } 125 126 type info struct { 127 org string 128 repo string 129 repoFullName string 130 number int 131 } 132 133 func handlePullRequest(pc plugins.Agent, pre github.PullRequestEvent) error { 134 if pre.Action != github.PullRequestActionOpened && pre.Action != github.PullRequestActionReopened && pre.Action != github.PullRequestActionSynchronize { 135 return nil 136 } 137 138 cp, err := pc.CommentPruner() 139 if err != nil { 140 return err 141 } 142 143 var skipTrustedUserCheck bool 144 for _, r := range pc.PluginConfig.Owners.SkipCollaborators { 145 if r == pre.Repo.FullName { 146 skipTrustedUserCheck = true 147 break 148 } 149 } 150 151 prInfo := info{ 152 org: pre.Repo.Owner.Login, 153 repo: pre.Repo.Name, 154 repoFullName: pre.Repo.FullName, 155 number: pre.Number, 156 } 157 158 return handle(pc.GitHubClient, pc.GitClient, pc.OwnersClient, pc.Logger, &pre.PullRequest, prInfo, pc.PluginConfig.Owners.LabelsDenyList, pc.PluginConfig.TriggerFor(pre.Repo.Owner.Login, pre.Repo.Name), skipTrustedUserCheck, cp, pc.PluginConfig.OwnersFilenames) 159 } 160 161 func handleGenericCommentEvent(pc plugins.Agent, e github.GenericCommentEvent) error { 162 cp, err := pc.CommentPruner() 163 if err != nil { 164 return err 165 } 166 167 var skipTrustedUserCheck bool 168 for _, r := range pc.PluginConfig.Owners.SkipCollaborators { 169 if r == e.Repo.FullName { 170 skipTrustedUserCheck = true 171 break 172 } 173 } 174 175 return handleGenericComment(pc.GitHubClient, pc.GitClient, pc.OwnersClient, pc.Logger, &e, pc.PluginConfig.Owners.LabelsDenyList, pc.PluginConfig.TriggerFor(e.Repo.Owner.Login, e.Repo.Name), skipTrustedUserCheck, cp, pc.PluginConfig.OwnersFilenames) 176 } 177 178 func handleGenericComment(ghc githubClient, gc git.ClientFactory, roc repoownersClient, log *logrus.Entry, ce *github.GenericCommentEvent, bannedLabels []string, triggerConfig plugins.Trigger, skipTrustedUserCheck bool, cp commentPruner, resolver ownersconfig.Resolver) error { 179 // Only consider open PRs and new comments. 180 if ce.IssueState != "open" || !ce.IsPR || ce.Action != github.GenericCommentActionCreated { 181 return nil 182 } 183 184 if !verifyOwnersRe.MatchString(ce.Body) { 185 return nil 186 } 187 188 prInfo := info{ 189 org: ce.Repo.Owner.Login, 190 repo: ce.Repo.Name, 191 repoFullName: ce.Repo.FullName, 192 number: ce.Number, 193 } 194 195 pr, err := ghc.GetPullRequest(ce.Repo.Owner.Login, ce.Repo.Name, ce.Number) 196 if err != nil { 197 return err 198 } 199 200 return handle(ghc, gc, roc, log, pr, prInfo, bannedLabels, triggerConfig, skipTrustedUserCheck, cp, resolver) 201 } 202 203 type messageWithLine struct { 204 line int 205 message string 206 } 207 208 func handle(ghc githubClient, gc git.ClientFactory, roc repoownersClient, log *logrus.Entry, pr *github.PullRequest, info info, bannedLabels []string, triggerConfig plugins.Trigger, skipTrustedUserCheck bool, cp commentPruner, resolver ownersconfig.Resolver) error { 209 org := info.org 210 repo := info.repo 211 number := info.number 212 filenames := resolver(org, repo) 213 wrongOwnersFiles := map[string]messageWithLine{} 214 215 // Get changes. 216 changes, err := ghc.GetPullRequestChanges(org, repo, number) 217 if err != nil { 218 return fmt.Errorf("error getting PR changes: %w", err) 219 } 220 221 // List modified OWNERS files. 222 var modifiedOwnersFiles []github.PullRequestChange 223 for _, change := range changes { 224 if filepath.Base(change.Filename) == filenames.Owners && change.Status != github.PullRequestFileRemoved { 225 modifiedOwnersFiles = append(modifiedOwnersFiles, change) 226 } 227 } 228 229 // Check if the OWNERS_ALIASES file was modified. 230 var modifiedOwnerAliasesFile github.PullRequestChange 231 var ownerAliasesModified bool 232 for _, change := range changes { 233 if change.Filename == filenames.OwnersAliases { 234 modifiedOwnerAliasesFile = change 235 ownerAliasesModified = true 236 break 237 } 238 } 239 240 issueLabels, err := ghc.GetIssueLabels(org, repo, number) 241 if err != nil { 242 return err 243 } 244 hasInvalidOwnersLabel := github.HasLabel(labels.InvalidOwners, issueLabels) 245 246 if len(modifiedOwnersFiles) == 0 && !ownerAliasesModified && !hasInvalidOwnersLabel { 247 return nil 248 } 249 250 // Clone the repo, checkout the PR. 251 r, err := gc.ClientFor(org, repo) 252 if err != nil { 253 return err 254 } 255 defer func() { 256 if err := r.Clean(); err != nil { 257 log.WithError(err).Error("Error cleaning up repo.") 258 } 259 }() 260 if err := r.Config("user.name", "prow"); err != nil { 261 return err 262 } 263 if err := r.Config("user.email", "prow@localhost"); err != nil { 264 return err 265 } 266 if err := r.Config("commit.gpgsign", "false"); err != nil { 267 log.WithError(err).Errorf("Cannot set gpgsign=false in gitconfig: %v", err) 268 } 269 if err := r.MergeAndCheckout(pr.Base.Ref, string(types.MergeMerge), pr.Head.SHA); err != nil { 270 return err 271 } 272 // If OWNERS_ALIASES file exists, get all aliases. 273 // If the file was modified, check for non trusted users in the newly added owners. 274 nonTrustedUsers, trustedUsers, repoAliases, err := nonTrustedUsersInOwnersAliases(ghc, log, triggerConfig, org, repo, r.Directory(), modifiedOwnerAliasesFile.Patch, ownerAliasesModified, skipTrustedUserCheck, filenames) 275 if err != nil { 276 return err 277 } 278 279 // Check if OWNERS files have the correct config and if they do, 280 // check if all newly added owners are trusted users. 281 oc, err := roc.LoadRepoOwners(org, repo, pr.Base.Ref) 282 if err != nil { 283 return fmt.Errorf("error loading RepoOwners: %w", err) 284 } 285 286 for _, c := range modifiedOwnersFiles { 287 path := filepath.Join(r.Directory(), c.Filename) 288 msg, owners := parseOwnersFile(oc, path, c, log, bannedLabels, filenames) 289 if msg != nil { 290 wrongOwnersFiles[c.Filename] = *msg 291 continue 292 } 293 294 if !skipTrustedUserCheck { 295 nonTrustedUsers, err = nonTrustedUsersInOwners(ghc, log, triggerConfig, org, repo, c.Patch, c.Filename, owners, nonTrustedUsers, trustedUsers, repoAliases) 296 if err != nil { 297 return err 298 } 299 } 300 } 301 302 if len(wrongOwnersFiles) > 0 { 303 s := "s" 304 if len(wrongOwnersFiles) == 1 { 305 s = "" 306 } 307 if !hasInvalidOwnersLabel { 308 if err := ghc.AddLabel(org, repo, number, labels.InvalidOwners); err != nil { 309 return err 310 } 311 } 312 log.Debugf("Creating a review for %d %s file%s.", len(wrongOwnersFiles), filenames.Owners, s) 313 var comments []github.DraftReviewComment 314 for errFile, err := range wrongOwnersFiles { 315 comments = append(comments, github.DraftReviewComment{ 316 Path: errFile, 317 Body: err.message, 318 Position: err.line, 319 }) 320 } 321 // Make the review body. 322 response := fmt.Sprintf("%d invalid %s file%s", len(wrongOwnersFiles), filenames.Owners, s) 323 draftReview := github.DraftReview{ 324 Body: plugins.FormatResponseRaw(pr.Body, pr.HTMLURL, pr.User.Login, response), 325 Action: github.Comment, 326 Comments: comments, 327 } 328 if pr.Head.SHA != "" { 329 draftReview.CommitSHA = pr.Head.SHA 330 } 331 err := ghc.CreateReview(org, repo, number, draftReview) 332 if err != nil { 333 return fmt.Errorf("error creating a review for invalid %s file%s: %w", filenames.Owners, s, err) 334 } 335 } 336 337 var joinOrgURL string 338 if triggerConfig.JoinOrgURL != "" { 339 joinOrgURL = triggerConfig.JoinOrgURL 340 } else { 341 joinOrgURL = fmt.Sprintf("https://github.com/orgs/%s/people", org) 342 } 343 344 if len(nonTrustedUsers) > 0 { 345 if !hasInvalidOwnersLabel { 346 if err := ghc.AddLabel(org, repo, number, labels.InvalidOwners); err != nil { 347 return err 348 } 349 } 350 351 // prune old comments before adding a new one 352 cp.PruneComments(func(comment github.IssueComment) bool { 353 return strings.Contains(comment.Body, fmt.Sprintf(untrustedResponseFormat, filenames.Owners, joinOrgURL, org)) 354 }) 355 if err := ghc.CreateComment(org, repo, number, markdownFriendlyComment(org, joinOrgURL, nonTrustedUsers, filenames)); err != nil { 356 log.WithError(err).Errorf("Could not create comment for listing non-collaborators in %s files", filenames.Owners) 357 } 358 } 359 360 if len(wrongOwnersFiles) == 0 && len(nonTrustedUsers) == 0 { 361 // Don't bother checking if it has the label...it's a race, and we'll have 362 // to handle failure due to not being labeled anyway. 363 if err := ghc.RemoveLabel(org, repo, number, labels.InvalidOwners); err != nil { 364 return fmt.Errorf("failed removing %s label: %w", labels.InvalidOwners, err) 365 } 366 cp.PruneComments(func(comment github.IssueComment) bool { 367 return strings.Contains(comment.Body, fmt.Sprintf(untrustedResponseFormat, filenames.Owners, joinOrgURL, org)) 368 }) 369 } 370 371 return nil 372 } 373 374 func parseOwnersFile(oc ownersClient, path string, c github.PullRequestChange, log *logrus.Entry, bannedLabels []string, filenames ownersconfig.Filenames) (*messageWithLine, []string) { 375 var reviewers []string 376 var approvers []string 377 var labels []string 378 379 // by default we bind errors to line 1 380 lineNumber := 1 381 simple, err := oc.ParseSimpleConfig(path) 382 if err == filepath.SkipDir { 383 return nil, nil 384 } 385 if err != nil || simple.Empty() { 386 full, err := oc.ParseFullConfig(path) 387 if err == filepath.SkipDir { 388 return nil, nil 389 } 390 if err != nil { 391 lineNumberRe, _ := regexp.Compile(`line (\d+)`) 392 lineNumberMatches := lineNumberRe.FindStringSubmatch(err.Error()) 393 // try to find a line number for the error 394 if len(lineNumberMatches) > 1 { 395 // we're sure it will convert as it passed the regexp already 396 absoluteLineNumber, _ := strconv.Atoi(lineNumberMatches[1]) 397 // we need to convert it to a line number relative to the patch 398 al, err := golint.AddedLines(c.Patch) 399 if err != nil { 400 log.WithError(err).Errorf("Failed to compute added lines in %s: %v", c.Filename, err) 401 } else if val, ok := al[absoluteLineNumber]; ok { 402 lineNumber = val 403 } 404 } 405 return &messageWithLine{ 406 lineNumber, 407 fmt.Sprintf("Cannot parse file: %v.", err), 408 }, nil 409 } 410 // it's a FullConfig 411 for _, config := range full.Filters { 412 reviewers = append(reviewers, config.Reviewers...) 413 approvers = append(approvers, config.Approvers...) 414 labels = append(labels, config.Labels...) 415 } 416 } else { 417 // it's a SimpleConfig 418 reviewers = simple.Config.Reviewers 419 approvers = simple.Config.Approvers 420 labels = simple.Config.Labels 421 } 422 // Check labels against ban list 423 if sets.New[string](labels...).HasAny(bannedLabels...) { 424 return &messageWithLine{ 425 lineNumber, 426 fmt.Sprintf("File contains banned labels: %s.", sets.List(sets.New[string](labels...).Intersection(sets.New[string](bannedLabels...)))), 427 }, nil 428 } 429 // Check approvers isn't empty 430 if filepath.Dir(c.Filename) == "." && len(approvers) == 0 { 431 return &messageWithLine{ 432 lineNumber, 433 fmt.Sprintf("No approvers defined in this root directory %s file.", filenames.Owners), 434 }, nil 435 } 436 owners := append(reviewers, approvers...) 437 return nil, owners 438 } 439 440 func markdownFriendlyComment(org, joinOrgURL string, nonTrustedUsers map[string]nonTrustedReasons, filenames ownersconfig.Filenames) string { 441 var commentLines []string 442 commentLines = append(commentLines, fmt.Sprintf(untrustedResponseFormat, filenames.Owners, joinOrgURL, org)) 443 444 for user, reasons := range nonTrustedUsers { 445 commentLines = append(commentLines, fmt.Sprintf("- %s", user)) 446 commentLines = append(commentLines, fmt.Sprintf(" - %s", reasons.triggerReason)) 447 for _, filename := range reasons.files { 448 commentLines = append(commentLines, fmt.Sprintf(" - %s", filename)) 449 } 450 } 451 return strings.Join(commentLines, "\n") 452 } 453 454 func nonTrustedUsersInOwnersAliases(ghc githubClient, log *logrus.Entry, triggerConfig plugins.Trigger, org, repo, dir, patch string, ownerAliasesModified, skipTrustedUserCheck bool, filenames ownersconfig.Filenames) (map[string]nonTrustedReasons, sets.Set[string], repoowners.RepoAliases, error) { 455 repoAliases := make(repoowners.RepoAliases) 456 // nonTrustedUsers is a map of non-trusted users to the reasons they were not trusted 457 nonTrustedUsers := map[string]nonTrustedReasons{} 458 trustedUsers := sets.Set[string]{} 459 var err error 460 461 // If OWNERS_ALIASES exists, get all aliases. 462 path := filepath.Join(dir, filenames.OwnersAliases) 463 if _, err := os.Stat(path); err == nil { 464 b, err := os.ReadFile(path) 465 if err != nil { 466 return nonTrustedUsers, trustedUsers, repoAliases, fmt.Errorf("Failed to read %s: %w", path, err) 467 } 468 repoAliases, err = repoowners.ParseAliasesConfig(b) 469 if err != nil { 470 return nonTrustedUsers, trustedUsers, repoAliases, fmt.Errorf("error parsing aliases config for %s file: %w", filenames.OwnersAliases, err) 471 } 472 } 473 474 // If OWNERS_ALIASES file was modified, check if newly added owners are trusted. 475 if ownerAliasesModified && !skipTrustedUserCheck { 476 allOwners := sets.List(repoAliases.ExpandAllAliases()) 477 for _, owner := range allOwners { 478 nonTrustedUsers, err = checkIfTrustedUser(ghc, log, triggerConfig, owner, patch, filenames.OwnersAliases, org, repo, nonTrustedUsers, trustedUsers, repoAliases) 479 if err != nil { 480 return nonTrustedUsers, trustedUsers, repoAliases, err 481 } 482 } 483 } 484 485 return nonTrustedUsers, trustedUsers, repoAliases, nil 486 } 487 488 func nonTrustedUsersInOwners(ghc githubClient, log *logrus.Entry, triggerConfig plugins.Trigger, org, repo, patch, fileName string, owners []string, nonTrustedUsers map[string]nonTrustedReasons, trustedUsers sets.Set[string], repoAliases repoowners.RepoAliases) (map[string]nonTrustedReasons, error) { 489 var err error 490 for _, owner := range owners { 491 // ignore if owner is an alias 492 if _, ok := repoAliases[owner]; ok { 493 continue 494 } 495 496 nonTrustedUsers, err = checkIfTrustedUser(ghc, log, triggerConfig, owner, patch, fileName, org, repo, nonTrustedUsers, trustedUsers, repoAliases) 497 if err != nil { 498 return nonTrustedUsers, err 499 } 500 } 501 return nonTrustedUsers, nil 502 } 503 504 // checkIfTrustedUser looks for newly addded owners by checking if they are in the patch 505 // and then checks if the owner is a trusted user. 506 // returns a map from user to reasons for not being trusted 507 func checkIfTrustedUser(ghc githubClient, log *logrus.Entry, triggerConfig plugins.Trigger, owner, patch, fileName, org, repo string, nonTrustedUsers map[string]nonTrustedReasons, trustedUsers sets.Set[string], repoAliases repoowners.RepoAliases) (map[string]nonTrustedReasons, error) { 508 // cap the number of checks to avoid exhausting tokens in case of large OWNERS refactors. 509 if len(nonTrustedUsers)+trustedUsers.Len() > 50 { 510 return nonTrustedUsers, nil 511 } 512 // only consider owners in the current patch 513 newOwnerRe, _ := regexp.Compile(fmt.Sprintf(`\+\s*-\s*\b%s\b`, owner)) 514 if !newOwnerRe.MatchString(patch) { 515 return nonTrustedUsers, nil 516 } 517 518 // if we already flagged the owner for the current file, return early 519 if reasons, ok := nonTrustedUsers[owner]; ok { 520 for _, file := range reasons.files { 521 if file == fileName { 522 return nonTrustedUsers, nil 523 } 524 } 525 // have to separate assignment from map update due to map implementation (see "index expressions") 526 reasons.files = append(reasons.files, fileName) 527 nonTrustedUsers[owner] = reasons 528 return nonTrustedUsers, nil 529 } 530 531 isAlreadyTrusted := trustedUsers.Has(owner) 532 var err error 533 var triggerTrustedResponse trigger.TrustedUserResponse 534 if !isAlreadyTrusted { 535 triggerTrustedResponse, err = trigger.TrustedUser(ghc, triggerConfig.OnlyOrgMembers, triggerConfig.TrustedApps, triggerConfig.TrustedOrg, owner, org, repo) 536 if err != nil { 537 return nonTrustedUsers, err 538 } 539 } 540 541 if !isAlreadyTrusted && triggerTrustedResponse.IsTrusted { 542 trustedUsers.Insert(owner) 543 } else if !isAlreadyTrusted && !triggerTrustedResponse.IsTrusted { 544 if reasons, ok := nonTrustedUsers[owner]; ok { 545 reasons.triggerReason = triggerTrustedResponse.Reason 546 nonTrustedUsers[owner] = reasons 547 } else { 548 nonTrustedUsers[owner] = nonTrustedReasons{ 549 // ensure that files is initialized to avoid nil pointer 550 files: []string{}, 551 triggerReason: triggerTrustedResponse.Reason, 552 } 553 } 554 } 555 556 return nonTrustedUsers, nil 557 }