github.com/ouraigua/jenkins-library@v0.0.0-20231028010029-fbeaf2f3aa9b/pkg/protecode/protecode.go (about)

     1  package protecode
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"net/url"
    10  	"strconv"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/sirupsen/logrus"
    15  
    16  	piperHttp "github.com/SAP/jenkins-library/pkg/http"
    17  	"github.com/SAP/jenkins-library/pkg/log"
    18  )
    19  
    20  // ReportsDirectory defines the subfolder for the Protecode reports which are generated
    21  const ReportsDirectory = "protecode"
    22  
    23  // ProductData holds the product information of the protecode product
    24  type ProductData struct {
    25  	Products []Product `json:"products,omitempty"`
    26  }
    27  
    28  // Product holds the id of the protecode product
    29  type Product struct {
    30  	ProductID int    `json:"product_id,omitempty"`
    31  	FileName  string `json:"name,omitempty"`
    32  }
    33  
    34  // ResultData holds the information about the protecode result
    35  type ResultData struct {
    36  	Result Result `json:"results,omitempty"`
    37  }
    38  
    39  // Result holds the detail information about the protecode result
    40  type Result struct {
    41  	ProductID  int         `json:"product_id,omitempty"`
    42  	ReportURL  string      `json:"report_url,omitempty"`
    43  	Status     string      `json:"status,omitempty"`
    44  	Components []Component `json:"components,omitempty"`
    45  }
    46  
    47  // Component the protecode component information
    48  type Component struct {
    49  	Vulns []Vulnerability `json:"vulns,omitempty"`
    50  }
    51  
    52  // Vulnerability the protecode vulnerability information
    53  type Vulnerability struct {
    54  	Exact  bool     `json:"exact,omitempty"`
    55  	Vuln   Vuln     `json:"vuln,omitempty"`
    56  	Triage []Triage `json:"triage,omitempty"`
    57  }
    58  
    59  // Vuln holds the information about the vulnerability
    60  type Vuln struct {
    61  	Cve        string `json:"cve,omitempty"`
    62  	Cvss       string `json:"cvss,omitempty"`
    63  	Cvss3Score string `json:"cvss3_score,omitempty"`
    64  }
    65  
    66  // Triage holds the triaging information
    67  type Triage struct {
    68  	ID          int    `json:"id,omitempty"`
    69  	VulnID      string `json:"vuln_id,omitempty"`
    70  	Component   string `json:"component,omitempty"`
    71  	Vendor      string `json:"vendor,omitempty"`
    72  	Codetype    string `json:"codetype,omitempty"`
    73  	Version     string `json:"version,omitempty"`
    74  	Modified    string `json:"modified,omitempty"`
    75  	Scope       string `json:"scope,omitempty"`
    76  	Description string `json:"description,omitempty"`
    77  	User        User   `json:"user,omitempty"`
    78  }
    79  
    80  // User holds the user information
    81  type User struct {
    82  	ID        int    `json:"id,omitempty"`
    83  	Email     string `json:"email,omitempty"`
    84  	Firstname string `json:"firstname,omitempty"`
    85  	Lastname  string `json:"lastname,omitempty"`
    86  	Username  string `json:"username,omitempty"`
    87  }
    88  
    89  // Protecode ist the protecode client which is used by the step
    90  type Protecode struct {
    91  	serverURL string
    92  	client    piperHttp.Uploader
    93  	duration  time.Duration
    94  	logger    *logrus.Entry
    95  }
    96  
    97  // Used to reduce wait time during tests
    98  var protecodePollInterval = 10 * time.Second
    99  
   100  // Just calls SetOptions which makes sure logger is set.
   101  // Added to make test code more resilient
   102  func makeProtecode(opts Options) Protecode {
   103  	ret := Protecode{}
   104  	ret.SetOptions(opts)
   105  	return ret
   106  }
   107  
   108  // Options struct which can be used to configure the Protecode struct
   109  type Options struct {
   110  	ServerURL  string
   111  	Duration   time.Duration
   112  	Username   string
   113  	Password   string
   114  	UserAPIKey string
   115  	Logger     *logrus.Entry
   116  }
   117  
   118  // SetOptions setter function to set the internal properties of the protecode
   119  func (pc *Protecode) SetOptions(options Options) {
   120  	pc.serverURL = options.ServerURL
   121  	pc.client = &piperHttp.Client{}
   122  	pc.duration = options.Duration
   123  
   124  	if options.Logger != nil {
   125  		pc.logger = options.Logger
   126  	} else {
   127  		pc.logger = log.Entry().WithField("package", "SAP/jenkins-library/pkg/protecode")
   128  	}
   129  
   130  	httpOptions := piperHttp.ClientOptions{MaxRequestDuration: options.Duration, Logger: options.Logger}
   131  
   132  	// If userAPIKey is not empty then we will use it for user authentication, instead of username & password
   133  	if options.UserAPIKey != "" {
   134  		httpOptions.Token = "Bearer " + options.UserAPIKey
   135  	} else {
   136  		httpOptions.Username = options.Username
   137  		httpOptions.Password = options.Password
   138  	}
   139  	pc.client.SetOptions(httpOptions)
   140  }
   141  
   142  // SetHttpClient setter function to set the http client
   143  func (pc *Protecode) SetHttpClient(client piperHttp.Uploader) {
   144  	pc.client = client
   145  }
   146  
   147  func (pc *Protecode) createURL(path string, pValue string, fParam string) string {
   148  
   149  	protecodeURL, err := url.Parse(pc.serverURL)
   150  	if err != nil {
   151  		//TODO: bubble up error
   152  		pc.logger.WithError(err).Fatal("Malformed URL")
   153  	}
   154  
   155  	if len(path) > 0 {
   156  		protecodeURL.Path += fmt.Sprintf("%v", path)
   157  	}
   158  
   159  	if len(pValue) > 0 {
   160  		protecodeURL.Path += fmt.Sprintf("%v", pValue)
   161  	}
   162  
   163  	// Prepare Query Parameters
   164  	if len(fParam) > 0 {
   165  		// encodedFParam := url.QueryEscape(fParam)
   166  		params := url.Values{}
   167  		params.Add("q", fmt.Sprintf("file:%v", fParam))
   168  
   169  		// Add Query Parameters to the URL
   170  		protecodeURL.RawQuery = params.Encode() // Escape Query Parameters
   171  	}
   172  
   173  	return protecodeURL.String()
   174  }
   175  
   176  func (pc *Protecode) mapResponse(r io.ReadCloser, response interface{}) {
   177  	defer r.Close()
   178  
   179  	buf := new(bytes.Buffer)
   180  	buf.ReadFrom(r)
   181  	newStr := buf.String()
   182  	if len(newStr) > 0 {
   183  
   184  		unquoted, err := strconv.Unquote(newStr)
   185  		if err != nil {
   186  			err = json.Unmarshal([]byte(newStr), response)
   187  			if err != nil {
   188  				//TODO: bubble up error
   189  				pc.logger.WithError(err).Fatalf("Error during unqote response: %v", newStr)
   190  			}
   191  		} else {
   192  			err = json.Unmarshal([]byte(unquoted), response)
   193  		}
   194  
   195  		if err != nil {
   196  			//TODO: bubble up error
   197  			pc.logger.WithError(err).Fatalf("Error during decode response: %v", newStr)
   198  		}
   199  	}
   200  }
   201  
   202  func (pc *Protecode) sendAPIRequest(method string, url string, headers map[string][]string) (*io.ReadCloser, int, error) {
   203  
   204  	r, err := pc.client.SendRequest(method, url, nil, headers, nil)
   205  	if err != nil {
   206  		if r != nil {
   207  			return nil, r.StatusCode, err
   208  		}
   209  		return nil, 400, err
   210  	}
   211  
   212  	//return &r.Body, nil
   213  	return &r.Body, r.StatusCode, nil
   214  }
   215  
   216  // ParseResultForInflux parses the result from the scan into the internal format
   217  func (pc *Protecode) ParseResultForInflux(result Result, excludeCVEs string) (map[string]int, []Vuln) {
   218  
   219  	var vulns []Vuln
   220  
   221  	var m map[string]int = make(map[string]int)
   222  	m["count"] = 0
   223  	m["cvss2GreaterOrEqualSeven"] = 0
   224  	m["cvss3GreaterOrEqualSeven"] = 0
   225  	m["historical_vulnerabilities"] = 0
   226  	m["triaged_vulnerabilities"] = 0
   227  	m["excluded_vulnerabilities"] = 0
   228  	m["minor_vulnerabilities"] = 0
   229  	m["major_vulnerabilities"] = 0
   230  	m["vulnerabilities"] = 0
   231  
   232  	for _, components := range result.Components {
   233  		for _, vulnerability := range components.Vulns {
   234  
   235  			exact := isExact(vulnerability)
   236  			countVulnerability := isExact(vulnerability) && !isExcluded(vulnerability, excludeCVEs) && !isTriaged(vulnerability)
   237  
   238  			if exact && isExcluded(vulnerability, excludeCVEs) {
   239  				m["excluded_vulnerabilities"]++
   240  			}
   241  			if exact && isTriaged(vulnerability) {
   242  				m["triaged_vulnerabilities"]++
   243  			}
   244  			if countVulnerability {
   245  				m["count"]++
   246  				m["vulnerabilities"]++
   247  
   248  				//collect all vulns here
   249  				vulns = append(vulns, vulnerability.Vuln)
   250  			}
   251  			if countVulnerability && isSevereCVSS3(vulnerability) {
   252  				m["cvss3GreaterOrEqualSeven"]++
   253  				m["major_vulnerabilities"]++
   254  			}
   255  			if countVulnerability && isSevereCVSS2(vulnerability) {
   256  				m["cvss2GreaterOrEqualSeven"]++
   257  				m["major_vulnerabilities"]++
   258  			}
   259  			if countVulnerability && !isSevereCVSS3(vulnerability) && !isSevereCVSS2(vulnerability) {
   260  				m["minor_vulnerabilities"]++
   261  			}
   262  			if !exact {
   263  				m["historical_vulnerabilities"]++
   264  			}
   265  		}
   266  	}
   267  
   268  	return m, vulns
   269  }
   270  
   271  func isExact(vulnerability Vulnerability) bool {
   272  	return vulnerability.Exact
   273  }
   274  
   275  func isExcluded(vulnerability Vulnerability, excludeCVEs string) bool {
   276  	return strings.Contains(excludeCVEs, vulnerability.Vuln.Cve)
   277  }
   278  
   279  func isTriaged(vulnerability Vulnerability) bool {
   280  	return len(vulnerability.Triage) > 0
   281  }
   282  
   283  func isSevereCVSS3(vulnerability Vulnerability) bool {
   284  	threshold := 7.0
   285  	cvss3, _ := strconv.ParseFloat(vulnerability.Vuln.Cvss3Score, 64)
   286  	return cvss3 >= threshold
   287  }
   288  
   289  func isSevereCVSS2(vulnerability Vulnerability) bool {
   290  	threshold := 7.0
   291  	cvss3, _ := strconv.ParseFloat(vulnerability.Vuln.Cvss3Score, 64)
   292  	parsedCvss, _ := strconv.ParseFloat(vulnerability.Vuln.Cvss, 64)
   293  	return cvss3 == 0 && parsedCvss >= threshold
   294  }
   295  
   296  // DeleteScan deletes if configured the scan on the protecode server
   297  func (pc *Protecode) DeleteScan(cleanupMode string, productID int) {
   298  	switch cleanupMode {
   299  	case "none":
   300  	case "binary":
   301  	case "complete":
   302  		pc.logger.Info("Deleting scan from server.")
   303  		protecodeURL := pc.createURL("/api/product/", fmt.Sprintf("%v/", productID), "")
   304  		headers := map[string][]string{}
   305  
   306  		pc.sendAPIRequest("DELETE", protecodeURL, headers)
   307  	default:
   308  		//TODO: bubble up error
   309  		pc.logger.Fatalf("Unknown cleanup mode %v", cleanupMode)
   310  	}
   311  }
   312  
   313  // LoadReport loads the report of the protecode scan
   314  func (pc *Protecode) LoadReport(reportFileName string, productID int) *io.ReadCloser {
   315  
   316  	protecodeURL := pc.createURL("/api/product/", fmt.Sprintf("%v/pdf-report", productID), "")
   317  	headers := map[string][]string{
   318  		"Cache-Control": {"no-cache, no-store, must-revalidate"},
   319  		"Pragma":        {"no-cache"},
   320  		"Outputfile":    {reportFileName},
   321  	}
   322  
   323  	readCloser, _, err := pc.sendAPIRequest(http.MethodGet, protecodeURL, headers)
   324  	if err != nil {
   325  		//TODO: bubble up error
   326  		pc.logger.WithError(err).Fatalf("It is not possible to load report %v", protecodeURL)
   327  	}
   328  
   329  	return readCloser
   330  }
   331  
   332  // UploadScanFile upload the scan file to the protecode server
   333  func (pc *Protecode) UploadScanFile(cleanupMode, group, customDataJSONMap, filePath, fileName, version string, productID int, replaceBinary bool) *ResultData {
   334  	log.Entry().Debugf("[DEBUG] ===> UploadScanFile started.....")
   335  
   336  	deleteBinary := (cleanupMode == "binary" || cleanupMode == "complete")
   337  
   338  	var headers = make(map[string][]string)
   339  	if len(customDataJSONMap) > 0 {
   340  		customDataHeaders := map[string]string{}
   341  		if err := json.Unmarshal([]byte(customDataJSONMap), &customDataHeaders); err != nil {
   342  			log.Entry().Warn("[WARN] ===> customDataJSONMap flag must be a valid JSON map. Check the value of --customDataJSONMap and try again.")
   343  		} else {
   344  			for k, v := range customDataHeaders {
   345  				headers["META-"+strings.ToUpper(k)] = []string{v}
   346  			}
   347  		}
   348  	}
   349  
   350  	headers["Group"] = []string{group}
   351  	headers["Delete-Binary"] = []string{fmt.Sprintf("%v", deleteBinary)}
   352  
   353  	if (replaceBinary) && (version != "") {
   354  		log.Entry().Debugf("[DEBUG] ===> replaceBinary && version != empty ")
   355  		headers["Replace"] = []string{fmt.Sprintf("%v", productID)}
   356  		headers["Version"] = []string{version}
   357  	} else if replaceBinary {
   358  		headers["Replace"] = []string{fmt.Sprintf("%v", productID)}
   359  		log.Entry().Debugf("[DEBUG] ===> replaceBinary")
   360  	} else if version != "" {
   361  		log.Entry().Debugf("[DEBUG] ===> version != empty ")
   362  		headers["Version"] = []string{version}
   363  	}
   364  
   365  	uploadURL := fmt.Sprintf("%v/api/upload/%v", pc.serverURL, fileName)
   366  
   367  	r, err := pc.client.UploadRequest(http.MethodPut, uploadURL, filePath, "file", headers, nil, "binary")
   368  	if err != nil {
   369  		//TODO: bubble up error
   370  		pc.logger.WithError(err).Fatalf("Error during upload request %v", uploadURL)
   371  	} else {
   372  		pc.logger.Info("Upload successful")
   373  	}
   374  
   375  	// For replaceBinary option response doesn't contain any result but just a message saying that product successfully replaced.
   376  	if replaceBinary && r.StatusCode == 201 {
   377  		result := new(ResultData)
   378  		result.Result.ProductID = productID
   379  		return result
   380  
   381  	} else {
   382  		result := new(ResultData)
   383  		pc.mapResponse(r.Body, result)
   384  		return result
   385  
   386  	}
   387  
   388  	//return result
   389  }
   390  
   391  // DeclareFetchURL configures the fetch url for the protecode scan
   392  func (pc *Protecode) DeclareFetchURL(cleanupMode, group, customDataJSONMap, fetchURL, version string, productID int, replaceBinary bool) *ResultData {
   393  	deleteBinary := (cleanupMode == "binary" || cleanupMode == "complete")
   394  
   395  	var headers = make(map[string][]string)
   396  	if len(customDataJSONMap) > 0 {
   397  		customDataHeaders := map[string]string{}
   398  		if err := json.Unmarshal([]byte(customDataJSONMap), &customDataHeaders); err != nil {
   399  			log.Entry().Warn("[WARN] ===> customDataJSONMap flag must be a valid JSON map. Check the value of --customDataJSONMap and try again.")
   400  		} else {
   401  			for k, v := range customDataHeaders {
   402  				headers["META-"+strings.ToUpper(k)] = []string{v}
   403  			}
   404  		}
   405  	}
   406  
   407  	headers["Group"] = []string{group}
   408  	headers["Delete-Binary"] = []string{fmt.Sprintf("%v", deleteBinary)}
   409  	headers["Url"] = []string{fetchURL}
   410  	headers["Content-Type"] = []string{"application/json"}
   411  	if (replaceBinary) && (version != "") {
   412  		log.Entry().Debugf("[DEBUG][FETCH_URL] ===> replaceBinary && version != empty ")
   413  		headers["Replace"] = []string{fmt.Sprintf("%v", productID)}
   414  		headers["Version"] = []string{version}
   415  	} else if replaceBinary {
   416  		log.Entry().Debugf("[DEBUG][FETCH_URL] ===> replaceBinary")
   417  		headers["Replace"] = []string{fmt.Sprintf("%v", productID)}
   418  	} else if version != "" {
   419  		log.Entry().Debugf("[DEBUG][FETCH_URL] ===> version != empty ")
   420  		headers["Version"] = []string{version}
   421  	}
   422  
   423  	protecodeURL := fmt.Sprintf("%v/api/fetch/", pc.serverURL)
   424  	r, statusCode, err := pc.sendAPIRequest(http.MethodPost, protecodeURL, headers)
   425  	if err != nil {
   426  		//TODO: bubble up error
   427  		pc.logger.WithError(err).Fatalf("Error during declare fetch url: %v", protecodeURL)
   428  	}
   429  
   430  	// For replaceBinary option response doesn't contain any result but just a message saying that product successfully replaced.
   431  	if replaceBinary && statusCode == 201 {
   432  		result := new(ResultData)
   433  		result.Result.ProductID = productID
   434  		return result
   435  
   436  	} else {
   437  		result := new(ResultData)
   438  		pc.mapResponse(*r, result)
   439  		return result
   440  	}
   441  
   442  	// return result
   443  }
   444  
   445  // 2021-04-20 d :
   446  // Found, via web search, an announcement that the set of status codes is expanding from
   447  // B, R, F
   448  // to
   449  // B, R, F, S, D, P.
   450  // Only R and F indicate work has completed.
   451  func scanInProgress(status string) bool {
   452  	return status != statusReady && status != statusFailed
   453  }
   454  
   455  // PollForResult polls the protecode scan for the result scan
   456  func (pc *Protecode) PollForResult(productID int, timeOutInMinutes string) ResultData {
   457  
   458  	var response ResultData
   459  	var err error
   460  
   461  	ticker := time.NewTicker(protecodePollInterval)
   462  	defer ticker.Stop()
   463  
   464  	var ticks int64 = 6
   465  	if len(timeOutInMinutes) > 0 {
   466  		parsedTimeOutInMinutes, _ := strconv.ParseInt(timeOutInMinutes, 10, 64)
   467  		ticks = parsedTimeOutInMinutes * 6
   468  	}
   469  
   470  	pc.logger.Infof("Poll for result %v times", ticks)
   471  
   472  	for i := ticks; i > 0; i-- {
   473  
   474  		response, err = pc.pullResult(productID)
   475  		if err != nil {
   476  			ticker.Stop()
   477  			i = 0
   478  			return response
   479  		}
   480  		if !scanInProgress(response.Result.Status) {
   481  			ticker.Stop()
   482  			i = 0
   483  			break
   484  		}
   485  
   486  		select {
   487  		case t := <-ticker.C:
   488  			pc.logger.Debugf("Tick : %v Processing status for productID %v", t, productID)
   489  		}
   490  	}
   491  
   492  	if scanInProgress(response.Result.Status) {
   493  		response, err = pc.pullResult(productID)
   494  
   495  		if len(response.Result.Components) < 1 {
   496  			// 2020-04-20 d :
   497  			// We are required to scan all images including 3rd party ones.
   498  			// We have found that Crossplane makes use docker images that contain no
   499  			// executable code.
   500  			// So we can no longer treat an empty Components list as an error.
   501  			pc.logger.Warn("Protecode scan did not identify any components.")
   502  		}
   503  
   504  		if err != nil || response.Result.Status == statusBusy {
   505  			//TODO: bubble up error
   506  			pc.logger.Fatalf("No result after polling err: %v protecode status: %v", err, response.Result.Status)
   507  		}
   508  	}
   509  
   510  	return response
   511  }
   512  
   513  func (pc *Protecode) pullResult(productID int) (ResultData, error) {
   514  	protecodeURL := pc.createURL("/api/product/", fmt.Sprintf("%v/", productID), "")
   515  	headers := map[string][]string{
   516  		"acceptType": {"application/json"},
   517  	}
   518  	r, _, err := pc.sendAPIRequest(http.MethodGet, protecodeURL, headers)
   519  
   520  	if err != nil {
   521  		return *new(ResultData), err
   522  	}
   523  	result := new(ResultData)
   524  	pc.mapResponse(*r, result)
   525  
   526  	return *result, nil
   527  
   528  }
   529  
   530  // verify provided product id
   531  func (pc *Protecode) VerifyProductID(ProductID int) bool {
   532  	pc.logger.Infof("Verification of product id (%v) started ... ", ProductID)
   533  
   534  	// TODO: Optimise product id verification
   535  	_, err := pc.pullResult(ProductID)
   536  
   537  	// If response has an error then we assume this product id doesn't exist or user has no access
   538  	if err != nil {
   539  		return false
   540  	}
   541  
   542  	// Otherwise product exists
   543  	return true
   544  
   545  }
   546  
   547  // LoadExistingProduct loads the existing product from protecode service
   548  func (pc *Protecode) LoadExistingProduct(group string, fileName string) int {
   549  	var productID int = -1
   550  
   551  	protecodeURL := pc.createURL("/api/apps/", fmt.Sprintf("%v/", group), fileName)
   552  	headers := map[string][]string{
   553  		"acceptType": {"application/json"},
   554  	}
   555  
   556  	response := pc.loadExisting(protecodeURL, headers)
   557  
   558  	if len(response.Products) > 0 {
   559  		// Highest product id means the latest scan for this particular product, therefore we take a product id with the highest number
   560  		for i := 0; i < len(response.Products); i++ {
   561  			// Check filename, it should be the same as we searched
   562  			if response.Products[i].FileName == fileName {
   563  				if productID < response.Products[i].ProductID {
   564  					productID = response.Products[i].ProductID
   565  				}
   566  			}
   567  		}
   568  	}
   569  
   570  	return productID
   571  }
   572  
   573  //
   574  
   575  func (pc *Protecode) loadExisting(protecodeURL string, headers map[string][]string) *ProductData {
   576  
   577  	r, _, err := pc.sendAPIRequest(http.MethodGet, protecodeURL, headers)
   578  	if err != nil {
   579  		//TODO: bubble up error
   580  		pc.logger.WithError(err).Fatalf("Error during load existing product: %v", protecodeURL)
   581  	}
   582  
   583  	result := new(ProductData)
   584  	pc.mapResponse(*r, result)
   585  
   586  	return result
   587  }