blob: 327c959166ac3975d8fb54baff1cf08e92ae4dec [file] [log] [blame]
// audits package checks for audit issues and automatically files bugs.
package audits
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path"
"time"
"go.skia.org/infra/go/executil"
"go.skia.org/infra/go/gitiles"
"go.skia.org/infra/go/metrics2"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"go.skia.org/infra/npm-audit-mirror/go/config"
"go.skia.org/infra/npm-audit-mirror/go/issues"
"go.skia.org/infra/npm-audit-mirror/go/types"
)
const (
packageFileName = "package.json"
packageLockFileName = "package-lock.json"
issueTitleTmpl = "npm audit found high severity issues in %s’s package.json file"
issueBodyTmpl = `npm audit found high severity issues in %s’s [package.json](%s) file.
This issue was automatically filed by the npm-audit-mirror framework (see [go/sk-npm-audit-mirror](http://go/sk-npm-audit-mirror) for more information).
`
)
// File new audit issues 1 hour after the last one was closed.
var fileAuditIssueAfterThreshold = time.Hour
// NpmProjectAudit implements the types.ProjectAudit interface.
type NpmProjectAudit struct {
projectName string
repoURL string
gitBranch string
packageFilesDir string
workDir string
gitilesRepo gitiles.GitilesRepo
dbClient types.NpmDB
issueTrackerConfig *config.IssueTrackerConfig
issueTrackerService types.IIssueTrackerService
auditDevDependencies bool
}
// NewNpmProjectAudit periodically downloads package.json/package-lock.json from gitiles
// and runs audit on it.
func NewNpmProjectAudit(ctx context.Context, projectName, repoURL, gitBranch, packageFilesDir, workDir string, httpClient *http.Client, dbClient types.NpmDB, issueTrackerConfig *config.IssueTrackerConfig, auditDevDependencies bool) (types.ProjectAudit, error) {
gitilesRepo := gitiles.NewRepo(repoURL, httpClient)
// Instantiate issueTrackerService only if we have a issueTrackerConfig.
var issueTrackerService *issues.IssueTrackerService
var err error
if issueTrackerConfig != nil {
issueTrackerService, err = issues.NewIssueTrackerService(ctx)
if err != nil {
return nil, skerr.Wrap(err)
}
}
return &NpmProjectAudit{
projectName: projectName,
repoURL: repoURL,
gitBranch: gitBranch,
packageFilesDir: packageFilesDir,
workDir: workDir,
gitilesRepo: gitilesRepo,
dbClient: dbClient,
issueTrackerConfig: issueTrackerConfig,
issueTrackerService: issueTrackerService,
auditDevDependencies: auditDevDependencies,
}, nil
}
// StartAudit runs `npm audit` on the project's package.json/package-lock.json
// files. If there are any high severity issues reported then the following
// algorithm will be used:
// * Check in the DB to see if an audit issue has been filed.
// * If issue has not been filed:
// - File a new issue and add it to the DB.
//
// * Else if issue has been filed:
// - Check to see if the issue has been closed.
// - If issue is closed:
// - Check to see if the issue is closed more than fileAuditIssueAfterThreshold duration ago.
// - If it is older then file a new issue and add it to the DB.
// - Else do nothing.
// - Else if issue is still open then do nothing.
//
// StartAudit implements the types.ProjectAudit interface.
func (a *NpmProjectAudit) StartAudit(ctx context.Context, pollInterval time.Duration) {
liveness := metrics2.NewLiveness("npm_audit", map[string]string{
"project": a.projectName,
})
go util.RepeatCtx(ctx, pollInterval, func(ctx context.Context) {
a.oneAuditCycle(ctx, liveness)
})
}
func (a *NpmProjectAudit) oneAuditCycle(ctx context.Context, liveness metrics2.Liveness) {
sklog.Infof("Starting audit of %s", a.projectName)
// Download package.json and package-lock.json
for _, packageFile := range []string{packageFileName, packageLockFileName} {
packageFilePath := path.Join(a.packageFilesDir, packageFile)
dest := path.Join(a.workDir, packageFile)
err := a.gitilesRepo.DownloadFileAtRef(ctx, packageFilePath, a.gitBranch, dest)
if err != nil {
sklog.Errorf("Could not download %s from %s/%s/%s: %s", packageFile, a.repoURL, a.gitBranch, a.packageFilesDir, err)
return // return so that the liveness is not updated
}
}
auditCmd := executil.CommandContext(ctx, "npm", "audit", "--json", "--audit-level=high")
if !a.auditDevDependencies {
auditCmd.Args = append(auditCmd.Args, "--omit=dev")
}
auditCmd.Dir = a.workDir
sklog.Info(auditCmd.String())
b, err := auditCmd.Output()
if err != nil {
// Ignore error here because "npm audit" exits with a non-0 code if any vulnerability is found.
}
var ao types.NpmAuditOutput
if err := json.Unmarshal(b, &ao); err != nil {
sklog.Errorf("Could not parse npm audit output for %s: %s", a.projectName, err)
return // return so that the liveness is not updated
}
// Keep track of how many audit issues have high severity.
highSeverityCounter := 0
if issues, ok := ao.Metadata.Vulnerabilities["high"]; ok {
highSeverityCounter = highSeverityCounter + issues
}
if issues, ok := ao.Metadata.Vulnerabilities["critical"]; ok {
highSeverityCounter = highSeverityCounter + issues
}
sklog.Infof("Done with audit of %s. Found %d high severity issues.", a.projectName, highSeverityCounter)
if highSeverityCounter > 0 && a.issueTrackerConfig != nil {
// Check in the DB to see if an audit issue has been filed.
ad, err := a.dbClient.GetFromDB(ctx, a.projectName)
if err != nil {
sklog.Errorf("Could not get audit data for %s from the DB: %s", a.projectName, err)
return // return so that the liveness is not updated
}
if ad == nil {
// Issue has not been filed yet. File one and add it to the DB.
sklog.Infof("There is no audit data for project %s in firestore. File a new issue.", a.projectName)
if err := a.fileAndPersistAuditIssue(ctx); err != nil {
sklog.Errorf("Could not file and persist audit issue for %s: %s", a.projectName, err)
return // return so that the liveness is not updated
}
} else {
sklog.Infof("Found audit data in firestore for project %s: %+v", a.projectName, ad)
// Query issue tracker to see if the issue is closed.
existingIssue, err := a.issueTrackerService.GetIssue(ad.IssueId)
if err != nil {
sklog.Errorf("Could not query issue tracker for %d: %s", ad.IssueId, err)
return // return so that the liveness is not updated
}
if existingIssue.ResolvedTime != "" {
// Check to see when the issue was closed.
closedTime, err := time.Parse(time.RFC3339, existingIssue.ResolvedTime)
if err != nil {
sklog.Errorf("Could not parse resolved time %s", existingIssue.ResolvedTime)
return // return so that liveness is not updated
}
closedDuration := time.Now().UTC().Sub(closedTime)
sklog.Infof("Previously filed audit issue %d was closed at %s which is %s ago.", existingIssue.IssueId, closedTime, closedDuration)
if closedDuration > fileAuditIssueAfterThreshold {
sklog.Infof("Filing new audit issue since audit issue %d was closed longer than the threshold %s.", existingIssue.IssueId, fileAuditIssueAfterThreshold)
if err := a.fileAndPersistAuditIssue(ctx); err != nil {
sklog.Errorf("Could not file and persist audit issue for %s: %s", a.projectName, err)
return // return so that the liveness is not updated
}
}
} else {
sklog.Infof("Previously filed audit issue %d is still open. Do nothing.", existingIssue.IssueId)
}
}
}
liveness.Reset()
}
// fileAndPersistAuditIssue calls the issue tracker service to file a new issue
// and then adds that issue to the DB.
func (a *NpmProjectAudit) fileAndPersistAuditIssue(ctx context.Context) error {
itc := a.issueTrackerConfig
// Create a new issue.
title := fmt.Sprintf(issueTitleTmpl, a.projectName)
body := fmt.Sprintf(issueBodyTmpl, a.projectName, path.Join(a.gitilesRepo.URL(), "+show", "refs/heads/"+a.gitBranch, a.packageFilesDir, packageFileName))
newIssue, err := a.issueTrackerService.MakeIssue(itc.Owner, title, body)
if err != nil {
return skerr.Wrapf(err, "Could not create an audit issue for project %s", a.projectName)
}
// Add new issue data to firestore.
createdTime, err := time.Parse(time.RFC3339, newIssue.CreatedTime)
if err != nil {
return skerr.Wrapf(err, "could not parse %s", newIssue.CreatedTime)
}
if err := a.dbClient.PutInDB(ctx, a.projectName, newIssue.IssueId, createdTime.UTC()); err != nil {
return skerr.Wrapf(err, "Could not put issue data into firestore for project %s", a.projectName)
}
sklog.Infof("Filed new audit issue %d for project %s and put it in DB.", newIssue.IssueId, a.projectName)
return nil
}