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 }