1
0
mirror of https://github.com/SAP/jenkins-library.git synced 2024-12-14 11:03:09 +02:00
sap-jenkins-library/pkg/blackduck/blackduck.go
Andrei Kireev 49f4c81344
Add new unified fields to Mend and Blackduck SARIF (#4611)
* Add new unified fields to Mend and Blackduck SARIF

* fmt project

---------

Co-authored-by: Dmitrii Pavlukhin <dmitrii.pavlukhin@sap.com>
2023-10-17 11:48:52 +02:00

585 lines
18 KiB
Go

package blackduck
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"time"
piperhttp "github.com/SAP/jenkins-library/pkg/http"
"github.com/SAP/jenkins-library/pkg/reporting"
"github.com/package-url/packageurl-go"
"github.com/pkg/errors"
)
// ReportsDirectory defines the subfolder for the Blackduck reports which are generated
const ReportsDirectory = "blackduck"
const (
HEADER_PROJECT_DETAILS_V4 = "application/vnd.blackducksoftware.project-detail-4+json"
HEADER_USER_V4 = "application/vnd.blackducksoftware.user-4+json"
HEADER_BOM_V6 = "application/vnd.blackducksoftware.bill-of-materials-6+json"
)
// Projects defines the response to a BlackDuck project API request
type Projects struct {
TotalCount int `json:"totalCount,omitempty"`
Items []Project `json:"items,omitempty"`
}
// Project defines a BlackDuck project
type Project struct {
Name string `json:"name,omitempty"`
Metadata `json:"_meta,omitempty"`
}
// Metadata defines BlackDuck metadata for e.g. projects
type Metadata struct {
Href string `json:"href,omitempty"`
Links []Link `json:"links,omitempty"`
}
// Link defines BlackDuck links to e.g. versions of projects
type Link struct {
Rel string `json:"rel,omitempty"`
Href string `json:"href,omitempty"`
}
// ProjectVersions defines the response to a BlackDuck project version API request
type ProjectVersions struct {
TotalCount int `json:"totalCount,omitempty"`
Items []ProjectVersion `json:"items,omitempty"`
}
// ProjectVersion defines a version of a BlackDuck project
type ProjectVersion struct {
Name string `json:"versionName,omitempty"`
Metadata `json:"_meta,omitempty"`
}
type Components struct {
TotalCount int `json:"totalCount,omitempty"`
Items []Component `json:"items,omitempty"`
}
type Component struct {
Name string `json:"componentName,omitempty"`
Version string `json:"componentVersionName,omitempty"`
ComponentOriginName string `json:"componentVersionOriginName,omitempty"`
PrimaryLanguage string `json:"primaryLanguage,omitempty"`
PolicyStatus string `json:"policyStatus,omitempty"`
MatchTypes []string `json:"matchTypes,omitempty"`
Origins []ComponentOrigin `json:"origins,omitempty"`
Metadata `json:"_meta,omitempty"`
}
type ComponentOrigin struct {
ExternalNamespace string `json:"externalNamespace,omitempty"`
ExternalID string `json:"externalId,omitempty"`
}
// ToPackageUrl creates the package URL for the component
func (c *Component) ToPackageUrl() *packageurl.PackageURL {
purlParts := transformComponentOriginToPurlParts(c)
// Namespace could not be in purlParts
var purlType, namespace, name, version string
if len(purlParts) >= 3 {
version = purlParts[len(purlParts)-1]
name = purlParts[len(purlParts)-2]
purlType = purlParts[0]
}
if len(purlParts) == 4 {
namespace = purlParts[1]
}
return packageurl.NewPackageURL(purlType, namespace, name, version, nil, "")
}
// MatchedType returns matched type of component: direct/transitive
func (c *Component) MatchedType() string {
for _, matchedType := range c.MatchTypes {
if matchedType == "FILE_DEPENDENCY_DIRECT" {
return "direct"
} else if matchedType == "FILE_DEPENDENCY_TRANSITIVE" {
return "transitive"
}
}
return ""
}
type Vulnerabilities struct {
TotalCount int `json:"totalCount,omitempty"`
Items []Vulnerability `json:"items,omitempty"`
}
type Vulnerability struct {
Name string `json:"componentName,omitempty"`
Version string `json:"componentVersionName,omitempty"`
ComponentVersionOriginID string `json:"componentVersionOriginId,omitempty"`
ComponentVersionOriginName string `json:"componentVersionOriginName,omitempty"`
Ignored bool `json:"ignored,omitempty"`
VulnerabilityWithRemediation `json:"vulnerabilityWithRemediation,omitempty"`
Component *Component
projectName string
projectVersion string
projectVersionLink string
}
type VulnerabilityWithRemediation struct {
VulnerabilityName string `json:"vulnerabilityName,omitempty"`
BaseScore float32 `json:"baseScore,omitempty"`
Severity string `json:"severity,omitempty"`
RemediationStatus string `json:"remediationStatus,omitempty"`
RemediationComment string `json:"remediationComment,omitempty"`
Description string `json:"description,omitempty"`
OverallScore float32 `json:"overallScore,omitempty"`
CweID string `json:"cweId,omitempty"`
ExploitabilitySubscore float32 `json:"exploitabilitySubscore,omitempty"`
ImpactSubscore float32 `json:"impactSubscore,omitempty"`
RelatedVulnerability string `json:"relatedVulnerability,omitempty"`
RemidiatedBy string `json:"remediationCreatedBy,omitempty"`
}
// Title returns the issue title representation of the contents
func (v Vulnerability) Title() string {
return v.VulnerabilityWithRemediation.VulnerabilityName
}
// ToMarkdown returns the markdown representation of the contents
func (v Vulnerability) ToMarkdown() ([]byte, error) {
vul := reporting.VulnerabilityReport{
ProjectName: v.projectName,
ProjectVersion: v.projectVersion,
BlackDuckProjectLink: v.projectVersionLink,
ArtifactID: v.Component.Name,
Description: v.Description,
DependencyType: v.Component.MatchedType(),
Origin: v.ComponentVersionOriginID,
// no information available about footer, yet
Footer: "",
// no information available about group, yet
Group: "",
// no information available about publish date and resolution yet
PublishDate: "",
Resolution: "",
Score: float64(v.VulnerabilityWithRemediation.BaseScore),
Severity: v.VulnerabilityWithRemediation.Severity,
Version: v.Version,
PackageURL: v.Component.ToPackageUrl().ToString(),
VulnerabilityLink: v.RelatedVulnerability,
VulnerabilityName: v.VulnerabilityName,
}
return vul.ToMarkdown()
}
// ToTxt returns the textual representation of the contents
func (v Vulnerability) ToTxt() string {
return fmt.Sprintf(`Vulnerability %v
Severity: %v
Base (NVD) Score: %v
Temporal Score: %v
Package: %v
Installed Version: %v
Package URL: %v
Description: %v
Fix Resolution: %v
Link: [%v](%v)`,
v.VulnerabilityName,
v.Severity,
v.VulnerabilityWithRemediation.BaseScore,
v.VulnerabilityWithRemediation.OverallScore,
v.Name,
v.Version,
v.Component.ToPackageUrl().ToString(),
v.Description,
"",
"",
"",
)
}
type PolicyStatus struct {
OverallStatus string `json:"overallStatus,omitempty"`
PolicyVersionDetails `json:"componentVersionPolicyViolationDetails,omitempty"`
}
type PolicyVersionDetails struct {
Name string `json:"name,omitempty"`
SeverityLevels []SeverityLevels `json:"severityLevels,omitEmpty"`
}
type SeverityLevels struct {
Name string `json:"name,omitempty"`
Value int `json:"value,omitempty"`
}
// Client defines a BlackDuck client
type Client struct {
BearerToken string `json:"bearerToken,omitempty"`
BearerExpiresInMilliseconds int64 `json:"expiresInMilliseconds,omitempty"`
lastAuthentication time.Time
token string
httpClient piperhttp.Sender
serverURL string
projectVersion *ProjectVersion
}
// NewClient creates a new BlackDuck client
func NewClient(token, serverURL string, httpClient piperhttp.Sender) Client {
return Client{
httpClient: httpClient,
serverURL: serverURL,
token: token,
}
}
// GetProject returns a project with a given name
func (b *Client) GetProject(projectName string) (*Project, error) {
if !b.authenticationValid(time.Now()) {
if err := b.authenticate(); err != nil {
return nil, err
}
}
headers := http.Header{}
headers.Add("Accept", HEADER_PROJECT_DETAILS_V4)
respBody, err := b.sendRequest("GET", "/api/projects", map[string]string{"q": fmt.Sprintf("name:%v", projectName)}, nil, headers)
if err != nil {
return nil, errors.Wrapf(err, "failed to get project '%v'", projectName)
}
projects := Projects{}
err = json.Unmarshal(respBody, &projects)
if err != nil {
return nil, errors.Wrapf(err, "failed to retrieve details for project '%v'", projectName)
} else if projects.TotalCount == 0 {
return nil, fmt.Errorf("project '%v' not found", projectName)
}
// even if more than one projects found, let's return the first one with exact project name match
for _, project := range projects.Items {
if project.Name == projectName {
return &project, nil
}
}
return nil, fmt.Errorf("project '%v' not found", projectName)
}
// GetProjectVersion returns a project version with a given name
func (b *Client) GetProjectVersion(projectName, projectVersion string) (*ProjectVersion, error) {
// get version from cache if it is there
if b.projectVersion != nil {
return b.projectVersion, nil
}
project, err := b.GetProject(projectName)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("Accept", HEADER_PROJECT_DETAILS_V4)
var versionPath string
for _, link := range project.Links {
if link.Rel == "versions" {
versionPath = urlPath(link.Href)
break
}
}
//While sending a request to 'versions', get all 100 versions from that project by setting limit=100
//More than 100 project versions is currently not supported/recommended by Blackduck
respBody, err := b.sendRequest("GET", versionPath, map[string]string{"offset": "0", "limit": "100"}, nil, headers)
if err != nil {
return nil, errors.Wrapf(err, "failed to get project version '%v:%v'", projectName, projectVersion)
}
projectVersions := ProjectVersions{}
err = json.Unmarshal(respBody, &projectVersions)
if err != nil {
return nil, errors.Wrapf(err, "failed to retrieve details for project version '%v:%v'", projectName, projectVersion)
} else if projectVersions.TotalCount == 0 {
return nil, fmt.Errorf("project version '%v:%v' not found", projectName, projectVersion)
}
// even if more than one projects found, let's return the first one with exact project name match
for _, version := range projectVersions.Items {
if version.Name == projectVersion {
// save version to cache in order not to do several same requests
b.projectVersion = &version
return &version, nil
}
}
return nil, fmt.Errorf("failed to get project version '%v'", projectVersion)
}
func (b *Client) GetProjectVersionLink(projectName, versionName string) (string, error) {
projectVersion, err := b.GetProjectVersion(projectName, versionName)
if err != nil {
return "", err
}
if projectVersion != nil {
return projectVersion.Href, nil
}
return "", nil
}
func (b *Client) GetComponents(projectName, versionName string) (*Components, error) {
projectVersion, err := b.GetProjectVersion(projectName, versionName)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("Accept", HEADER_BOM_V6)
var componentsPath string
for _, link := range projectVersion.Links {
if link.Rel == "components" {
componentsPath = urlPath(link.Href)
break
}
}
respBody, err := b.sendRequest("GET", componentsPath, map[string]string{"offset": "0", "limit": "999"}, nil, headers)
if err != nil {
return nil, errors.Wrapf(err, "Failed to get components list for project version '%v:%v'", projectName, versionName)
}
components := Components{}
err = json.Unmarshal(respBody, &components)
if err != nil {
return nil, errors.Wrapf(err, "failed to retrieve component details for project version '%v:%v'", projectName, versionName)
} else if components.TotalCount == 0 {
return nil, fmt.Errorf("No Components found for project version '%v:%v'", projectName, versionName)
}
//Just return the components, the details of the components are not necessary
return &components, nil
}
func (b *Client) GetComponentsWithLicensePolicyRule(projectName, versionName string) (*Components, error) {
projectVersion, err := b.GetProjectVersion(projectName, versionName)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("Accept", HEADER_BOM_V6)
var componentsPath string
for _, link := range projectVersion.Links {
if link.Rel == "components" {
componentsPath = urlPath(link.Href)
break
}
}
respBody, err := b.sendRequest("GET", componentsPath, map[string]string{"offset": "0", "limit": "999", "filter": "policyCategory:license"}, nil, headers)
if err != nil {
return nil, errors.Wrapf(err, "Failed to get components list for project version '%v:%v'", projectName, versionName)
}
components := Components{}
err = json.Unmarshal(respBody, &components)
if err != nil {
return nil, errors.Wrapf(err, "failed to retrieve component details for project version '%v:%v'", projectName, versionName)
}
//Just return the components, the details of the components are not necessary
return &components, nil
}
// func (b *Client) GetComponentPolicyStatus(component Component) (ComponentPolicyStatus, error) {
// var policyStatusUrl string
// for _, link := range component.Links {
// if link.Rel == "policy-status" {
// policyStatusUrl = urlPath(link.Href)
// }
// }
// headers := http.Header{}
// headers.Add("Accept", HEADER_BOM_V6)
// respBody, err := b.sendRequest("GET", policyStatusUrl, map[string]string{}, nil, headers)
// }
func (b *Client) GetVulnerabilities(projectName, versionName string) (*Vulnerabilities, error) {
projectVersion, err := b.GetProjectVersion(projectName, versionName)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("Accept", HEADER_BOM_V6)
var vulnerableComponentsPath string
for _, link := range projectVersion.Links {
if link.Rel == "vulnerable-components" {
vulnerableComponentsPath = urlPath(link.Href)
break
}
}
respBody, err := b.sendRequest("GET", vulnerableComponentsPath, map[string]string{"offset": "0", "limit": "999"}, nil, headers)
if err != nil {
return nil, errors.Wrapf(err, "Failed to get Vulnerabilties for project version '%v:%v'", projectName, versionName)
}
vulnerabilities := Vulnerabilities{}
err = json.Unmarshal(respBody, &vulnerabilities)
if err != nil {
return nil, errors.Wrapf(err, "failed to retrieve Vulnerability details for project version '%v:%v'", projectName, versionName)
}
return &vulnerabilities, nil
}
func (b *Client) GetPolicyStatus(projectName, versionName string) (*PolicyStatus, error) {
projectVersion, err := b.GetProjectVersion(projectName, versionName)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("Accept", HEADER_BOM_V6)
var policyStatusPath string
for _, link := range projectVersion.Links {
if link.Rel == "policy-status" {
policyStatusPath = urlPath(link.Href)
break
}
}
respBody, err := b.sendRequest("GET", policyStatusPath, map[string]string{}, nil, headers)
if err != nil {
return nil, errors.Wrapf(err, "Failed to get Policy Violation status for project version '%v:%v'", projectName, versionName)
}
policyStatus := PolicyStatus{}
err = json.Unmarshal(respBody, &policyStatus)
if err != nil {
return nil, errors.Wrapf(err, "failed to retrieve Policy violation details for project version '%v:%v'", projectName, versionName)
}
return &policyStatus, nil
}
func (b *Client) authenticate() error {
headers := http.Header{}
headers.Add("Authorization", fmt.Sprintf("token %v", b.token))
headers.Add("Accept", HEADER_USER_V4)
b.lastAuthentication = time.Now()
respBody, err := b.sendRequest(http.MethodPost, "/api/tokens/authenticate", map[string]string{}, nil, headers)
if err != nil {
return errors.Wrap(err, "authentication to BlackDuck API failed")
}
err = json.Unmarshal(respBody, b)
if err != nil {
return errors.Wrap(err, "failed to parse BlackDuck response")
}
return nil
}
func (b *Client) sendRequest(method, apiEndpoint string, params map[string]string, body io.Reader, header http.Header) ([]byte, error) {
responseBody := []byte{}
blackDuckAPIUrl, err := b.apiURL(apiEndpoint)
if err != nil {
return responseBody, errors.Wrap(err, "failed to get api url")
}
q := url.Values{}
for key, val := range params {
q.Add(key, val)
}
blackDuckAPIUrl.RawQuery = q.Encode()
if len(b.BearerToken) > 0 {
header.Add("Authorization", fmt.Sprintf("Bearer %v", b.BearerToken))
}
response, err := b.httpClient.SendRequest(method, blackDuckAPIUrl.String(), nil, header, nil)
if err != nil {
return responseBody, errors.Wrap(err, "request to BlackDuck API failed")
}
responseBody, err = io.ReadAll(response.Body)
if err != nil {
return responseBody, errors.Wrap(err, "reading BlackDuck response failed")
}
return responseBody, nil
}
func (b *Client) apiURL(apiEndpoint string) (*url.URL, error) {
blackDuckURL, err := url.Parse(b.serverURL)
if err != nil {
return nil, err
}
blackDuckURL.Path = path.Join(blackDuckURL.Path, apiEndpoint)
return blackDuckURL, nil
}
func (b *Client) authenticationValid(now time.Time) bool {
// check bearer token timeout
expiryTime := b.lastAuthentication.Add(time.Millisecond * time.Duration(b.BearerExpiresInMilliseconds))
return now.Sub(expiryTime) < 0
}
func urlPath(fullUrl string) string {
theUrl, _ := url.Parse(fullUrl)
return theUrl.Path
}
func transformComponentOriginToPurlParts(component *Component) []string {
result := []string{}
purlType := packageurl.TypeGeneric
gav := []string{"", component.Name, component.Version}
origins := component.Origins
if origins != nil && len(origins) > 0 {
if strings.Contains(origins[0].ExternalID, "/") {
gav = strings.Split(origins[0].ExternalID, "/")
} else {
gav = strings.Split(origins[0].ExternalID, ":")
}
switch strings.ToLower(origins[0].ExternalNamespace) {
case "maven":
purlType = packageurl.TypeMaven
case "node":
purlType = packageurl.TypeNPM
case "npmjs":
purlType = packageurl.TypeNPM
case "golang":
purlType = packageurl.TypeGolang
case "docker":
purlType = packageurl.TypeDocker
case "":
purlType = packageurl.TypeGeneric
default:
purlType = strings.ToLower(origins[0].ExternalNamespace)
}
}
result = append(result, purlType)
result = append(result, gav...)
if len(result) > 0 && !strings.Contains(result[len(result)-1], ".") {
result = result[:len(result)-1]
}
return result
}