github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/runners/packages/info.go (about) 1 package packages 2 3 import ( 4 "fmt" 5 "strconv" 6 "strings" 7 8 "github.com/ActiveState/cli/internal/captain" 9 "github.com/ActiveState/cli/internal/errs" 10 "github.com/ActiveState/cli/internal/locale" 11 "github.com/ActiveState/cli/internal/logging" 12 "github.com/ActiveState/cli/internal/multilog" 13 "github.com/ActiveState/cli/internal/output" 14 "github.com/ActiveState/cli/internal/rtutils/ptr" 15 "github.com/ActiveState/cli/pkg/platform/api/inventory/inventory_models" 16 "github.com/ActiveState/cli/pkg/platform/api/vulnerabilities/request" 17 "github.com/ActiveState/cli/pkg/platform/authentication" 18 "github.com/ActiveState/cli/pkg/platform/model" 19 "github.com/ActiveState/cli/pkg/project" 20 "github.com/go-openapi/strfmt" 21 ) 22 23 // InfoRunParams tracks the info required for running Info. 24 type InfoRunParams struct { 25 Package captain.PackageValue 26 Timestamp captain.TimeValue 27 Language string 28 } 29 30 // Info manages the information execution context. 31 type Info struct { 32 out output.Outputer 33 proj *project.Project 34 auth *authentication.Auth 35 } 36 37 // NewInfo prepares an information execution context for use. 38 func NewInfo(prime primeable) *Info { 39 return &Info{ 40 out: prime.Output(), 41 proj: prime.Project(), 42 auth: prime.Auth(), 43 } 44 } 45 46 // Run executes the information behavior. 47 func (i *Info) Run(params InfoRunParams, nstype model.NamespaceType) error { 48 logging.Debug("ExecuteInfo") 49 50 var nsTypeV *model.NamespaceType 51 var ns *model.Namespace 52 53 if params.Package.Namespace != "" { 54 ns = ptr.To(model.NewRawNamespace(params.Package.Namespace)) 55 } else { 56 nsTypeV = &nstype 57 } 58 59 if nsTypeV != nil { 60 language, err := targetedLanguage(params.Language, i.proj, i.auth) 61 if err != nil { 62 return locale.WrapError(err, fmt.Sprintf("%s_err_cannot_obtain_language", *nsTypeV)) 63 } 64 ns = ptr.To(model.NewNamespacePkgOrBundle(language, nstype)) 65 } 66 67 normalized, err := model.FetchNormalizedName(*ns, params.Package.Name, i.auth) 68 if err != nil { 69 multilog.Error("Failed to normalize '%s': %v", params.Package.Name, err) 70 normalized = params.Package.Name 71 } 72 73 ts, err := getTime(¶ms.Timestamp, i.auth, i.proj) 74 if err != nil { 75 return errs.Wrap(err, "Unable to get timestamp from params") 76 } 77 78 packages, err := model.SearchIngredientsStrict(ns.String(), normalized, false, false, ts, i.auth) // ideally case-sensitive would be true (PB-4371) 79 if err != nil { 80 return locale.WrapError(err, "package_err_cannot_obtain_search_results") 81 } 82 83 if len(packages) == 0 { 84 return errs.AddTips( 85 locale.NewInputError("err_package_info_no_packages", "", params.Package.String()), 86 locale.T("package_try_search"), 87 locale.T("package_info_request"), 88 ) 89 } 90 91 pkg := packages[0] 92 ingredientVersion := pkg.LatestVersion 93 94 if params.Package.Version != "" { 95 ingredientVersion, err = specificIngredientVersion(pkg.Ingredient.IngredientID, params.Package.Version, i.auth) 96 if err != nil { 97 return locale.WrapExternalError(err, "info_err_version_not_found", "Could not find version {{.V0}} for package {{.V1}}", params.Package.Version, params.Package.Name) 98 } 99 } 100 101 authors, err := model.FetchAuthors(pkg.Ingredient.IngredientID, ingredientVersion.IngredientVersionID, i.auth) 102 if err != nil { 103 return locale.WrapError(err, "package_err_cannot_obtain_authors_info", "Cannot obtain authors info") 104 } 105 106 var vulns []*model.VulnerabilityIngredient 107 if i.auth.Authenticated() { 108 vulnerabilityIngredients := make([]*request.Ingredient, len(pkg.Versions)) 109 for i, p := range pkg.Versions { 110 vulnerabilityIngredients[i] = &request.Ingredient{ 111 Name: *pkg.Ingredient.Name, 112 Namespace: *pkg.Ingredient.PrimaryNamespace, 113 Version: p.Version, 114 } 115 } 116 117 vulns, err = model.FetchVulnerabilitiesForIngredients(i.auth, vulnerabilityIngredients) 118 if err != nil { 119 return locale.WrapError(err, "package_err_cannot_obtain_vulnerabilities_info", "Cannot obtain vulnerabilities info") 120 } 121 } 122 123 i.out.Print(&infoOutput{i.out, structuredOutput{ 124 pkg.Ingredient, 125 ingredientVersion, 126 authors, 127 pkg.Versions, 128 vulns, 129 }}) 130 131 return nil 132 } 133 134 func specificIngredientVersion(ingredientID *strfmt.UUID, version string, auth *authentication.Auth) (*inventory_models.IngredientVersion, error) { 135 ingredientVersions, err := model.FetchIngredientVersions(ingredientID, auth) 136 if err != nil { 137 return nil, locale.WrapError(err, "info_err_cannot_obtain_version", "Could not retrieve ingredient version information") 138 } 139 140 for _, iv := range ingredientVersions { 141 if iv.Version != nil && *iv.Version == version { 142 return iv, nil 143 } 144 } 145 146 return nil, locale.NewInputError("err_no_ingredient_version_found", "No ingredient version found") 147 } 148 149 // PkgDetailsTable describes package details. 150 type PkgDetailsTable struct { 151 Description string `opts:"omitEmpty" locale:"package_description,[HEADING]Description[/RESET]" json:"description"` 152 Author string `opts:"omitEmpty" locale:"package_author,[HEADING]Author[/RESET]" json:"author"` 153 Authors string `opts:"omitEmpty" locale:"package_authors,[HEADING]Authors[/RESET]" json:"authors"` 154 Website string `opts:"omitEmpty" locale:"package_website,[HEADING]Website[/RESET]" json:"website"` 155 License string `opts:"omitEmpty" locale:"package_license,[HEADING]License[/RESET]" json:"license"` 156 } 157 158 type infoResult struct { 159 name string 160 version string 161 plainVersions []string 162 PkgVersionVulnsTotal int `opts:"omitEmpty" locale:"package_vulnerabilities,[HEADING]Vulnerabilities[/RESET]"` 163 PkgVersionVulns []string `opts:"verticalTable,omitEmpty" locale:"package_cves,[HEADING]CVEs[/RESET]"` 164 *PkgDetailsTable `locale:"," opts:"verticalTable,omitEmpty"` 165 Versions []string `locale:"," json:"versions"` 166 } 167 168 func newInfoResult(so structuredOutput) *infoResult { 169 res := infoResult{ 170 PkgDetailsTable: &PkgDetailsTable{}, 171 } 172 173 if so.Ingredient.Name != nil { 174 res.name = *so.Ingredient.Name 175 } 176 177 if so.IngredientVersion.Version != nil { 178 res.version = *so.IngredientVersion.Version 179 } 180 181 if so.Ingredient.Description != nil { 182 res.PkgDetailsTable.Description = *so.Ingredient.Description 183 } 184 185 if so.Ingredient.Website != "" { 186 res.PkgDetailsTable.Website = so.Ingredient.Website.String() 187 } 188 189 if so.IngredientVersion.LicenseExpression != nil { 190 res.PkgDetailsTable.License = fmt.Sprintf("[CYAN]%s[/RESET]", *so.IngredientVersion.LicenseExpression) 191 } 192 193 for _, version := range so.Versions { 194 res.plainVersions = append(res.plainVersions, version.Version) 195 } 196 197 if len(so.Authors) == 1 { 198 if so.Authors[0].Name != nil { 199 res.Author = fmt.Sprintf("[CYAN]%s[/RESET]", *so.Authors[0].Name) 200 } 201 } else if len(so.Authors) > 1 { 202 var authorsOutput []string 203 for _, author := range so.Authors { 204 if author.Name != nil { 205 authorsOutput = append(authorsOutput, *author.Name) 206 } 207 } 208 res.Authors = fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(authorsOutput, ", ")) 209 } 210 211 if len(so.Vulnerabilities) > 0 { 212 var currentVersionVulns *model.VulnerabilityIngredient 213 alternateVersionsVulns := make(map[string]*model.VulnerabilityIngredient) 214 // Iterate over the vulnerabilities to populate the maps above. 215 for _, v := range so.Vulnerabilities { 216 alternateVersionsVulns[v.Version] = v 217 if v.Version == res.version { 218 currentVersionVulns = v 219 } 220 } 221 222 if currentVersionVulns != nil { 223 res.PkgVersionVulnsTotal = currentVersionVulns.Vulnerabilities.Length() 224 // Build the vulnerabilities output for the specific version requested. 225 // This is organized by severity level. 226 if len(currentVersionVulns.Vulnerabilities.Critical) > 0 { 227 criticalOutput := fmt.Sprintf("[RED]%d Critical: [/RESET]", len(currentVersionVulns.Vulnerabilities.Critical)) 228 criticalOutput += fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(currentVersionVulns.Vulnerabilities.Critical, ", ")) 229 res.PkgVersionVulns = append(res.PkgVersionVulns, criticalOutput) 230 } 231 232 if len(currentVersionVulns.Vulnerabilities.High) > 0 { 233 highOutput := fmt.Sprintf("[ORANGE]%d High: [/RESET]", len(currentVersionVulns.Vulnerabilities.High)) 234 highOutput += fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(currentVersionVulns.Vulnerabilities.High, ", ")) 235 res.PkgVersionVulns = append(res.PkgVersionVulns, highOutput) 236 } 237 238 if len(currentVersionVulns.Vulnerabilities.Medium) > 0 { 239 mediumOutput := fmt.Sprintf("[YELLOW]%d Medium: [/RESET]", len(currentVersionVulns.Vulnerabilities.Medium)) 240 mediumOutput += fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(currentVersionVulns.Vulnerabilities.Medium, ", ")) 241 res.PkgVersionVulns = append(res.PkgVersionVulns, mediumOutput) 242 } 243 244 if len(currentVersionVulns.Vulnerabilities.Low) > 0 { 245 lowOutput := fmt.Sprintf("[MAGENTA]%d Low: [/RESET]", len(currentVersionVulns.Vulnerabilities.Low)) 246 lowOutput += fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(currentVersionVulns.Vulnerabilities.Low, ", ")) 247 res.PkgVersionVulns = append(res.PkgVersionVulns, lowOutput) 248 } 249 } 250 251 // Build the output for the alternate versions of this package. 252 // This output counts the number of vulnerabilities per severity level. 253 for _, version := range so.Versions { 254 alternateVersion, ok := alternateVersionsVulns[version.Version] 255 if !ok { 256 res.Versions = append(res.Versions, fmt.Sprintf("[GREEN]%s[/RESET]", version.Version)) 257 continue 258 } 259 260 var vulnTotals []string 261 if len(alternateVersion.Vulnerabilities.Critical) > 0 { 262 vulnTotals = append(vulnTotals, fmt.Sprintf("[RED]%d Critical[/RESET]", len(alternateVersion.Vulnerabilities.Critical))) 263 } 264 if len(alternateVersion.Vulnerabilities.High) > 0 { 265 vulnTotals = append(vulnTotals, fmt.Sprintf("[ORANGE]%d High[/RESET]", len(alternateVersion.Vulnerabilities.High))) 266 } 267 if len(alternateVersion.Vulnerabilities.Medium) > 0 { 268 vulnTotals = append(vulnTotals, fmt.Sprintf("[YELLOW]%d Medium[/RESET]", len(alternateVersion.Vulnerabilities.Medium))) 269 } 270 if len(alternateVersion.Vulnerabilities.Low) > 0 { 271 vulnTotals = append(vulnTotals, fmt.Sprintf("[MAGENTA]%d Low[/RESET]", len(alternateVersion.Vulnerabilities.Low))) 272 } 273 274 output := fmt.Sprintf("%s (CVE: %s)", version.Version, strings.Join(vulnTotals, ", ")) 275 res.Versions = append(res.Versions, output) 276 } 277 } else { 278 // If we do not have vulnerability information, we still want to display the available versions. 279 for _, version := range so.Versions { 280 res.Versions = append(res.Versions, version.Version) 281 } 282 } 283 284 return &res 285 } 286 287 type structuredOutput struct { 288 Ingredient *inventory_models.Ingredient `json:"ingredient"` 289 IngredientVersion *inventory_models.IngredientVersion `json:"ingredient_version"` 290 Authors model.Authors `json:"authors"` 291 Versions []*inventory_models.SearchIngredientsResponseVersion `json:"versions"` 292 Vulnerabilities []*model.VulnerabilityIngredient `json:"vulnerabilities,omitempty"` 293 } 294 295 type infoOutput struct { 296 out output.Outputer 297 so structuredOutput 298 } 299 300 func (o *infoOutput) MarshalOutput(_ output.Format) interface{} { 301 res := newInfoResult(o.so) 302 print := o.out.Print 303 { 304 print(output.Title( 305 locale.Tl( 306 "package_info_description_header", 307 "[HEADING]Package Information:[/RESET] [CYAN]{{.V0}}@{{.V1}}[/RESET]", 308 res.name, 309 res.version, 310 ), 311 )) 312 print( 313 struct { 314 *PkgDetailsTable `opts:"verticalTable"` 315 }{res.PkgDetailsTable}, 316 ) 317 print("") 318 } 319 320 { 321 if res.PkgVersionVulnsTotal > 0 { 322 print(output.Title( 323 locale.Tl( 324 "package_info_vulnerabilities_header", 325 "[HEADING]This package has {{.V0}} Vulnerabilities (CVEs):[/RESET]", 326 strconv.Itoa(res.PkgVersionVulnsTotal), 327 ), 328 )) 329 print(res.PkgVersionVulns) 330 print("") 331 print(locale.Tl("package_info_vulnerabilities_help", " To view details for these CVE's run '[ACTIONABLE]state cve open <ID>[/RESET]'")) 332 print("") 333 } 334 } 335 336 { 337 if len(res.Versions) > 0 { 338 print(output.Title( 339 locale.Tl( 340 "packages_info_versions_available", 341 "{{.V0}} Version(s) Available:", 342 strconv.Itoa(len(res.Versions)), 343 ), 344 )) 345 print(res.Versions) 346 print("") 347 } 348 } 349 350 { 351 print(output.Title(locale.Tl("packages_info_next_header", "What's next?"))) 352 print(whatsNextMessages(res.name, res.plainVersions)) 353 } 354 355 return output.Suppress 356 } 357 358 func (o *infoOutput) MarshalStructured(_ output.Format) interface{} { 359 return o.so 360 } 361 362 func whatsNextMessages(name string, versions []string) []string { 363 nextMsgs := make([]string, 0, 3) 364 365 nextMsgs = append(nextMsgs, 366 locale.Tl( 367 "install_latest_version", 368 "To install the latest version, run "+ 369 "'[ACTIONABLE]state install {{.V0}}[/RESET]'", 370 name, 371 ), 372 ) 373 374 if len(versions) == 0 { 375 return nextMsgs 376 } 377 version := versions[0] 378 379 nextMsgs = append(nextMsgs, 380 locale.Tl( 381 "install_specific_version", 382 "To install a specific version, run "+ 383 "'[ACTIONABLE]state install {{.V0}}@{{.V1}}[/RESET]'", 384 name, version, 385 ), 386 ) 387 388 if len(versions) > 1 { 389 version = versions[1] 390 } 391 nextMsgs = append(nextMsgs, 392 locale.Tl( 393 "show_specific_version", 394 "To view details for a specific version, run "+ 395 "'[ACTIONABLE]state info {{.V0}}@{{.V1}}[/RESET]'", 396 name, version, 397 ), 398 ) 399 400 return nextMsgs 401 }