* fix: Reduce the number of data to be fetched per page, when retrying after a timeout failure on Dependency Graph API * check rate limit on dependency graph API * comment
		
			
				
	
	
		
			396 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			396 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
//go:build !scanner
 | 
						||
// +build !scanner
 | 
						||
 | 
						||
package detector
 | 
						||
 | 
						||
import (
 | 
						||
	"bytes"
 | 
						||
	"context"
 | 
						||
	"encoding/json"
 | 
						||
	"fmt"
 | 
						||
	"io"
 | 
						||
	"net/http"
 | 
						||
	"strconv"
 | 
						||
	"time"
 | 
						||
 | 
						||
	"github.com/cenkalti/backoff"
 | 
						||
	"github.com/future-architect/vuls/errof"
 | 
						||
	"github.com/future-architect/vuls/logging"
 | 
						||
	"github.com/future-architect/vuls/models"
 | 
						||
	"golang.org/x/oauth2"
 | 
						||
)
 | 
						||
 | 
						||
// DetectGitHubSecurityAlerts access to owner/repo on GitHub and fetch security alerts of the repository via GitHub API v4 GraphQL and then set to the given ScanResult.
 | 
						||
// https://help.github.com/articles/about-security-alerts-for-vulnerable-dependencies/
 | 
						||
func DetectGitHubSecurityAlerts(r *models.ScanResult, owner, repo, token string, ignoreDismissed bool) (nCVEs int, err error) {
 | 
						||
	src := oauth2.StaticTokenSource(
 | 
						||
		&oauth2.Token{AccessToken: token},
 | 
						||
	)
 | 
						||
	//TODO Proxy
 | 
						||
	httpClient := oauth2.NewClient(context.Background(), src)
 | 
						||
 | 
						||
	// TODO Use `https://github.com/shurcooL/githubv4` if the tool supports vulnerabilityAlerts Endpoint
 | 
						||
	// Memo : https://developer.github.com/v4/explorer/
 | 
						||
	const jsonfmt = `{"query":
 | 
						||
	"query { repository(owner:\"%s\", name:\"%s\") { url vulnerabilityAlerts(first: %d, states:[OPEN], %s) { pageInfo { endCursor hasNextPage startCursor } edges { node { id dismissReason dismissedAt securityVulnerability{ package { name ecosystem } severity vulnerableVersionRange firstPatchedVersion { identifier } } vulnerableManifestFilename vulnerableManifestPath vulnerableRequirements securityAdvisory { description ghsaId permalink publishedAt summary updatedAt withdrawnAt origin severity references { url } identifiers { type value } } } } } } } "}`
 | 
						||
	after := ""
 | 
						||
 | 
						||
	for {
 | 
						||
		jsonStr := fmt.Sprintf(jsonfmt, owner, repo, 100, after)
 | 
						||
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 | 
						||
		req, err := http.NewRequestWithContext(ctx, http.MethodPost,
 | 
						||
			"https://api.github.com/graphql",
 | 
						||
			bytes.NewBuffer([]byte(jsonStr)),
 | 
						||
		)
 | 
						||
		defer cancel()
 | 
						||
		if err != nil {
 | 
						||
			return 0, err
 | 
						||
		}
 | 
						||
 | 
						||
		// https://developer.github.com/v4/previews/#repository-vulnerability-alerts
 | 
						||
		// To toggle this preview and access data, need to provide a custom media type in the Accept header:
 | 
						||
		// MEMO: I tried to get the affected version via GitHub API. Bit it seems difficult to determin the affected version if there are multiple dependency files such as package.json.
 | 
						||
		// TODO remove this header if it is no longer preview status in the future.
 | 
						||
		req.Header.Set("Accept", "application/vnd.github.package-deletes-preview+json")
 | 
						||
		req.Header.Set("Content-Type", "application/json")
 | 
						||
 | 
						||
		resp, err := httpClient.Do(req)
 | 
						||
		if err != nil {
 | 
						||
			return 0, err
 | 
						||
		}
 | 
						||
		defer resp.Body.Close()
 | 
						||
 | 
						||
		body, err := io.ReadAll(resp.Body)
 | 
						||
		if err != nil {
 | 
						||
			return 0, err
 | 
						||
		}
 | 
						||
 | 
						||
		alerts := SecurityAlerts{}
 | 
						||
		if err := json.Unmarshal(body, &alerts); err != nil {
 | 
						||
			return 0, err
 | 
						||
		}
 | 
						||
 | 
						||
		// util.Log.Debugf("%s", pp.Sprint(alerts))
 | 
						||
		// util.Log.Debugf("%s", string(body))
 | 
						||
		if alerts.Data.Repository.URL == "" {
 | 
						||
			return 0, errof.New(errof.ErrFailedToAccessGithubAPI,
 | 
						||
				fmt.Sprintf("Failed to access to GitHub API. Response: %s", string(body)))
 | 
						||
		}
 | 
						||
 | 
						||
		for _, v := range alerts.Data.Repository.VulnerabilityAlerts.Edges {
 | 
						||
			if ignoreDismissed && v.Node.DismissReason != "" {
 | 
						||
				continue
 | 
						||
			}
 | 
						||
 | 
						||
			m := models.GitHubSecurityAlert{
 | 
						||
				Repository: alerts.Data.Repository.URL,
 | 
						||
				Package: models.GSAVulnerablePackage{
 | 
						||
					Name:             v.Node.SecurityVulnerability.Package.Name,
 | 
						||
					Ecosystem:        v.Node.SecurityVulnerability.Package.Ecosystem,
 | 
						||
					ManifestFilename: v.Node.VulnerableManifestFilename,
 | 
						||
					ManifestPath:     v.Node.VulnerableManifestPath,
 | 
						||
					Requirements:     v.Node.VulnerableRequirements,
 | 
						||
				},
 | 
						||
				FixedIn:       v.Node.SecurityVulnerability.FirstPatchedVersion.Identifier,
 | 
						||
				AffectedRange: v.Node.SecurityVulnerability.VulnerableVersionRange,
 | 
						||
				Dismissed:     len(v.Node.DismissReason) != 0,
 | 
						||
				DismissedAt:   v.Node.DismissedAt,
 | 
						||
				DismissReason: v.Node.DismissReason,
 | 
						||
			}
 | 
						||
 | 
						||
			cveIDs, other := []string{}, []string{}
 | 
						||
			for _, identifier := range v.Node.SecurityAdvisory.Identifiers {
 | 
						||
				if identifier.Type == "CVE" {
 | 
						||
					cveIDs = append(cveIDs, identifier.Value)
 | 
						||
				} else {
 | 
						||
					other = append(other, identifier.Value)
 | 
						||
				}
 | 
						||
			}
 | 
						||
 | 
						||
			// If CVE-ID has not been assigned, use the GHSA ID etc as a ID.
 | 
						||
			if len(cveIDs) == 0 {
 | 
						||
				cveIDs = other
 | 
						||
			}
 | 
						||
 | 
						||
			refs := []models.Reference{}
 | 
						||
			for _, r := range v.Node.SecurityAdvisory.References {
 | 
						||
				refs = append(refs, models.Reference{Link: r.URL})
 | 
						||
			}
 | 
						||
 | 
						||
			for _, cveID := range cveIDs {
 | 
						||
				cveContent := models.CveContent{
 | 
						||
					Type:          models.GitHub,
 | 
						||
					CveID:         cveID,
 | 
						||
					Title:         v.Node.SecurityAdvisory.Summary,
 | 
						||
					Summary:       v.Node.SecurityAdvisory.Description,
 | 
						||
					Cvss2Severity: v.Node.SecurityVulnerability.Severity,
 | 
						||
					Cvss3Severity: v.Node.SecurityVulnerability.Severity,
 | 
						||
					SourceLink:    v.Node.SecurityAdvisory.Permalink,
 | 
						||
					References:    refs,
 | 
						||
					Published:     v.Node.SecurityAdvisory.PublishedAt,
 | 
						||
					LastModified:  v.Node.SecurityAdvisory.UpdatedAt,
 | 
						||
				}
 | 
						||
 | 
						||
				if val, ok := r.ScannedCves[cveID]; ok {
 | 
						||
					val.GitHubSecurityAlerts = val.GitHubSecurityAlerts.Add(m)
 | 
						||
					val.CveContents[models.GitHub] = []models.CveContent{cveContent}
 | 
						||
					r.ScannedCves[cveID] = val
 | 
						||
				} else {
 | 
						||
					v := models.VulnInfo{
 | 
						||
						CveID:                cveID,
 | 
						||
						Confidences:          models.Confidences{models.GitHubMatch},
 | 
						||
						GitHubSecurityAlerts: models.GitHubSecurityAlerts{m},
 | 
						||
						CveContents:          models.NewCveContents(cveContent),
 | 
						||
					}
 | 
						||
					r.ScannedCves[cveID] = v
 | 
						||
				}
 | 
						||
				nCVEs++
 | 
						||
			}
 | 
						||
		}
 | 
						||
		if !alerts.Data.Repository.VulnerabilityAlerts.PageInfo.HasNextPage {
 | 
						||
			break
 | 
						||
		}
 | 
						||
		after = fmt.Sprintf(`after: \"%s\"`, alerts.Data.Repository.VulnerabilityAlerts.PageInfo.EndCursor)
 | 
						||
	}
 | 
						||
	return nCVEs, err
 | 
						||
}
 | 
						||
 | 
						||
// SecurityAlerts has detected CVE-IDs, PackageNames, Refs
 | 
						||
type SecurityAlerts struct {
 | 
						||
	Data struct {
 | 
						||
		Repository struct {
 | 
						||
			URL                 string `json:"url"`
 | 
						||
			VulnerabilityAlerts struct {
 | 
						||
				PageInfo struct {
 | 
						||
					EndCursor   string `json:"endCursor"`
 | 
						||
					HasNextPage bool   `json:"hasNextPage"`
 | 
						||
					StartCursor string `json:"startCursor"`
 | 
						||
				} `json:"pageInfo"`
 | 
						||
				Edges []struct {
 | 
						||
					Node struct {
 | 
						||
						ID                    string    `json:"id"`
 | 
						||
						DismissReason         string    `json:"dismissReason"`
 | 
						||
						DismissedAt           time.Time `json:"dismissedAt"`
 | 
						||
						SecurityVulnerability struct {
 | 
						||
							Package struct {
 | 
						||
								Name      string `json:"name"`
 | 
						||
								Ecosystem string `json:"ecosystem"`
 | 
						||
							} `json:"package"`
 | 
						||
							Severity               string `json:"severity"`
 | 
						||
							VulnerableVersionRange string `json:"vulnerableVersionRange"`
 | 
						||
							FirstPatchedVersion    struct {
 | 
						||
								Identifier string `json:"identifier"`
 | 
						||
							} `json:"firstPatchedVersion"`
 | 
						||
						} `json:"securityVulnerability"`
 | 
						||
						VulnerableManifestFilename string `json:"vulnerableManifestFilename"`
 | 
						||
						VulnerableManifestPath     string `json:"vulnerableManifestPath"`
 | 
						||
						VulnerableRequirements     string `json:"vulnerableRequirements"`
 | 
						||
						SecurityAdvisory           struct {
 | 
						||
							Description string    `json:"description"`
 | 
						||
							GhsaID      string    `json:"ghsaId"`
 | 
						||
							Permalink   string    `json:"permalink"`
 | 
						||
							PublishedAt time.Time `json:"publishedAt"`
 | 
						||
							Summary     string    `json:"summary"`
 | 
						||
							UpdatedAt   time.Time `json:"updatedAt"`
 | 
						||
							WithdrawnAt time.Time `json:"withdrawnAt"`
 | 
						||
							Origin      string    `json:"origin"`
 | 
						||
							Severity    string    `json:"severity"`
 | 
						||
							References  []struct {
 | 
						||
								URL string `json:"url"`
 | 
						||
							} `json:"references"`
 | 
						||
							Identifiers []struct {
 | 
						||
								Type  string `json:"type"`
 | 
						||
								Value string `json:"value"`
 | 
						||
							} `json:"identifiers"`
 | 
						||
						} `json:"securityAdvisory"`
 | 
						||
					} `json:"node"`
 | 
						||
				} `json:"edges"`
 | 
						||
			} `json:"vulnerabilityAlerts"`
 | 
						||
		} `json:"repository"`
 | 
						||
	} `json:"data"`
 | 
						||
}
 | 
						||
 | 
						||
// DetectGitHubDependencyGraph access to owner/repo on GitHub and fetch dependency graph of the repository via GitHub API v4 GraphQL and then set to the given ScanResult.
 | 
						||
// https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-the-dependency-graph
 | 
						||
func DetectGitHubDependencyGraph(r *models.ScanResult, owner, repo, token string) (err error) {
 | 
						||
	src := oauth2.StaticTokenSource(
 | 
						||
		&oauth2.Token{AccessToken: token},
 | 
						||
	)
 | 
						||
	//TODO Proxy
 | 
						||
	httpClient := oauth2.NewClient(context.Background(), src)
 | 
						||
 | 
						||
	return fetchDependencyGraph(r, httpClient, owner, repo, "", "", 10, 100)
 | 
						||
}
 | 
						||
 | 
						||
// recursive function
 | 
						||
func fetchDependencyGraph(r *models.ScanResult, httpClient *http.Client, owner, repo, after, dependenciesAfter string, first, dependenciesFirst int) (err error) {
 | 
						||
	const queryFmt = `{"query":
 | 
						||
	"query { repository(owner:\"%s\", name:\"%s\") { url dependencyGraphManifests(first: %d, withDependencies: true%s) { pageInfo { endCursor hasNextPage } edges { node { blobPath filename repository { url } parseable exceedsMaxSize dependenciesCount dependencies(first: %d%s) { pageInfo { endCursor hasNextPage } edges { node { packageName packageManager repository { url } requirements hasDependencies } } } } } } } }"}`
 | 
						||
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
 | 
						||
	defer cancel()
 | 
						||
 | 
						||
	var graph DependencyGraph
 | 
						||
	rateLimitRemaining := 5000
 | 
						||
	count, retryMax := 0, 10
 | 
						||
	retryCheck := func(err error) error {
 | 
						||
		if count == retryMax {
 | 
						||
			return backoff.Permanent(err)
 | 
						||
		}
 | 
						||
		if rateLimitRemaining == 0 {
 | 
						||
			// The GraphQL API rate limit is 5,000 points per hour.
 | 
						||
			// Terminate with an error on rate limit reached.
 | 
						||
			return backoff.Permanent(errof.New(errof.ErrFailedToAccessGithubAPI,
 | 
						||
				fmt.Sprintf("rate limit exceeded. error: %s", err.Error())))
 | 
						||
		}
 | 
						||
		return err
 | 
						||
	}
 | 
						||
	operation := func() error {
 | 
						||
		count++
 | 
						||
		queryStr := fmt.Sprintf(queryFmt, owner, repo, first, after, dependenciesFirst, dependenciesAfter)
 | 
						||
		req, err := http.NewRequestWithContext(ctx, http.MethodPost,
 | 
						||
			"https://api.github.com/graphql",
 | 
						||
			bytes.NewBuffer([]byte(queryStr)),
 | 
						||
		)
 | 
						||
		if err != nil {
 | 
						||
			return retryCheck(err)
 | 
						||
		}
 | 
						||
 | 
						||
		// https://docs.github.com/en/graphql/overview/schema-previews#access-to-a-repository-s-dependency-graph-preview
 | 
						||
		// TODO remove this header if it is no longer preview status in the future.
 | 
						||
		req.Header.Set("Accept", "application/vnd.github.hawkgirl-preview+json")
 | 
						||
		req.Header.Set("Content-Type", "application/json")
 | 
						||
 | 
						||
		resp, err := httpClient.Do(req)
 | 
						||
		if err != nil {
 | 
						||
			return retryCheck(err)
 | 
						||
		}
 | 
						||
		defer resp.Body.Close()
 | 
						||
 | 
						||
		// https://docs.github.com/en/graphql/overview/resource-limitations#rate-limit
 | 
						||
		if rateLimitRemaining, err = strconv.Atoi(resp.Header.Get("X-RateLimit-Remaining")); err != nil {
 | 
						||
			// If the header retrieval fails, rateLimitRemaining will be set to 0,
 | 
						||
			// preventing further retries. To enable retry, we reset it to 5000.
 | 
						||
			rateLimitRemaining = 5000
 | 
						||
			return retryCheck(errof.New(errof.ErrFailedToAccessGithubAPI, "Failed to get X-RateLimit-Remaining header"))
 | 
						||
		}
 | 
						||
 | 
						||
		body, err := io.ReadAll(resp.Body)
 | 
						||
		if err != nil {
 | 
						||
			return retryCheck(err)
 | 
						||
		}
 | 
						||
 | 
						||
		graph = DependencyGraph{}
 | 
						||
		if err := json.Unmarshal(body, &graph); err != nil {
 | 
						||
			return retryCheck(err)
 | 
						||
		}
 | 
						||
 | 
						||
		if len(graph.Errors) > 0 || graph.Data.Repository.URL == "" {
 | 
						||
			// this mainly occurs on timeout
 | 
						||
			// reduce the number of dependencies to be fetched for the next retry
 | 
						||
			if dependenciesFirst > 50 {
 | 
						||
				dependenciesFirst -= 5
 | 
						||
			}
 | 
						||
			return retryCheck(errof.New(errof.ErrFailedToAccessGithubAPI,
 | 
						||
				fmt.Sprintf("Failed to access to GitHub API. Repository: %s/%s; Response: %s", owner, repo, string(body))))
 | 
						||
		}
 | 
						||
 | 
						||
		return nil
 | 
						||
	}
 | 
						||
	notify := func(err error, t time.Duration) {
 | 
						||
		logging.Log.Warnf("Failed attempts (count: %d). retrying in %s. err: %+v", count, t, err)
 | 
						||
	}
 | 
						||
 | 
						||
	if err = backoff.RetryNotify(operation, backoff.NewExponentialBackOff(), notify); err != nil {
 | 
						||
		return err
 | 
						||
	}
 | 
						||
 | 
						||
	dependenciesAfter = ""
 | 
						||
	for _, m := range graph.Data.Repository.DependencyGraphManifests.Edges {
 | 
						||
		manifest, ok := r.GitHubManifests[m.Node.BlobPath]
 | 
						||
		if !ok {
 | 
						||
			manifest = models.DependencyGraphManifest{
 | 
						||
				BlobPath:     m.Node.BlobPath,
 | 
						||
				Filename:     m.Node.Filename,
 | 
						||
				Repository:   m.Node.Repository.URL,
 | 
						||
				Dependencies: []models.Dependency{},
 | 
						||
			}
 | 
						||
		}
 | 
						||
		for _, d := range m.Node.Dependencies.Edges {
 | 
						||
			manifest.Dependencies = append(manifest.Dependencies, models.Dependency{
 | 
						||
				PackageName:    d.Node.PackageName,
 | 
						||
				PackageManager: d.Node.PackageManager,
 | 
						||
				Repository:     d.Node.Repository.URL,
 | 
						||
				Requirements:   d.Node.Requirements,
 | 
						||
			})
 | 
						||
		}
 | 
						||
		r.GitHubManifests[m.Node.BlobPath] = manifest
 | 
						||
 | 
						||
		if m.Node.Dependencies.PageInfo.HasNextPage {
 | 
						||
			dependenciesAfter = fmt.Sprintf(`, after: \"%s\"`, m.Node.Dependencies.PageInfo.EndCursor)
 | 
						||
		}
 | 
						||
	}
 | 
						||
	if dependenciesAfter != "" {
 | 
						||
		return fetchDependencyGraph(r, httpClient, owner, repo, after, dependenciesAfter, first, dependenciesFirst)
 | 
						||
	}
 | 
						||
 | 
						||
	if graph.Data.Repository.DependencyGraphManifests.PageInfo.HasNextPage {
 | 
						||
		after = fmt.Sprintf(`, after: \"%s\"`, graph.Data.Repository.DependencyGraphManifests.PageInfo.EndCursor)
 | 
						||
		return fetchDependencyGraph(r, httpClient, owner, repo, after, dependenciesAfter, first, dependenciesFirst)
 | 
						||
	}
 | 
						||
 | 
						||
	return nil
 | 
						||
}
 | 
						||
 | 
						||
// DependencyGraph is a GitHub API response
 | 
						||
type DependencyGraph struct {
 | 
						||
	Data struct {
 | 
						||
		Repository struct {
 | 
						||
			URL                      string `json:"url"`
 | 
						||
			DependencyGraphManifests struct {
 | 
						||
				PageInfo struct {
 | 
						||
					EndCursor   string `json:"endCursor"`
 | 
						||
					HasNextPage bool   `json:"hasNextPage"`
 | 
						||
				} `json:"pageInfo"`
 | 
						||
				Edges []struct {
 | 
						||
					Node struct {
 | 
						||
						BlobPath   string `json:"blobPath"`
 | 
						||
						Filename   string `json:"filename"`
 | 
						||
						Repository struct {
 | 
						||
							URL string `json:"url"`
 | 
						||
						}
 | 
						||
						Parseable         bool `json:"parseable"`
 | 
						||
						ExceedsMaxSize    bool `json:"exceedsMaxSize"`
 | 
						||
						DependenciesCount int  `json:"dependenciesCount"`
 | 
						||
						Dependencies      struct {
 | 
						||
							PageInfo struct {
 | 
						||
								EndCursor   string `json:"endCursor"`
 | 
						||
								HasNextPage bool   `json:"hasNextPage"`
 | 
						||
							} `json:"pageInfo"`
 | 
						||
							Edges []struct {
 | 
						||
								Node struct {
 | 
						||
									PackageName    string `json:"packageName"`
 | 
						||
									PackageManager string `json:"packageManager"`
 | 
						||
									Repository     struct {
 | 
						||
										URL string `json:"url"`
 | 
						||
									}
 | 
						||
									Requirements    string `json:"requirements"`
 | 
						||
									HasDependencies bool   `json:"hasDependencies"`
 | 
						||
								} `json:"node"`
 | 
						||
							} `json:"edges"`
 | 
						||
						} `json:"dependencies"`
 | 
						||
					} `json:"node"`
 | 
						||
				} `json:"edges"`
 | 
						||
			} `json:"dependencyGraphManifests"`
 | 
						||
		} `json:"repository"`
 | 
						||
	} `json:"data"`
 | 
						||
	Errors []struct {
 | 
						||
		Type      string        `json:"type,omitempty"`
 | 
						||
		Path      []interface{} `json:"path,omitempty"`
 | 
						||
		Locations []struct {
 | 
						||
			Line   int `json:"line"`
 | 
						||
			Column int `json:"column"`
 | 
						||
		} `json:"locations,omitempty"`
 | 
						||
		Message string `json:"message"`
 | 
						||
	} `json:"errors,omitempty"`
 | 
						||
}
 |