github.com/wolfi-dev/wolfictl@v0.16.11/pkg/cli/components/advisory/matchwatcher/model.go (about) 1 package matchwatcher 2 3 import ( 4 "fmt" 5 "strings" 6 7 "github.com/charmbracelet/bubbles/spinner" 8 tea "github.com/charmbracelet/bubbletea" 9 "github.com/charmbracelet/lipgloss" 10 "github.com/savioxavier/termlink" 11 "github.com/wolfi-dev/wolfictl/pkg/cli/styles" 12 "github.com/wolfi-dev/wolfictl/pkg/vuln" 13 ) 14 15 var _ tea.Model = (*Model)(nil) 16 17 var ( 18 helpKeyStyle = styles.FaintAccent().Copy().Bold(true) 19 helpExplanationStyle = styles.Faint().Copy() 20 21 styleSubtle = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) 22 23 styleNegligible = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) 24 styleLow = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")) 25 styleMedium = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffff00")) 26 styleHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff9900")) 27 styleCritical = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) 28 ) 29 30 func New(vulnEvents chan interface{}, countTotalPackages int) Model { 31 return Model{ 32 vulnEvents: vulnEvents, 33 countTotalPackages: countTotalPackages, 34 spinner: spinner.New( 35 spinner.WithSpinner(spinner.Points), 36 spinner.WithStyle(styles.Faint()), 37 ), 38 } 39 } 40 41 type Model struct { 42 // Err is an output the Model can return if something goes wrong. 43 Err error 44 45 vulnEvents <-chan interface{} 46 countTotalPackages int 47 48 countPassedPackages int 49 countFailedPackages int 50 51 packages []string 52 packageStateMap map[string]packageState 53 54 showEveryVulnerability bool 55 56 exiting bool 57 58 spinner spinner.Model 59 } 60 61 type packageState struct { 62 name string 63 searching bool 64 matchesFound []vuln.Match 65 } 66 67 func (m Model) appendPackage(ps packageState) Model { 68 if m.packageStateMap == nil { 69 m.packageStateMap = make(map[string]packageState) 70 } 71 72 m.packages = append(m.packages, ps.name) 73 m.packageStateMap[ps.name] = ps 74 return m 75 } 76 77 func (m Model) updatePackage(ps packageState) Model { 78 if m.packageStateMap == nil { 79 m.packageStateMap = make(map[string]packageState) 80 } 81 82 m.packageStateMap[ps.name] = ps 83 return m 84 } 85 86 func (m Model) removePackage(name string) Model { 87 if m.packageStateMap == nil { 88 m.packageStateMap = make(map[string]packageState) 89 } 90 91 delete(m.packageStateMap, name) 92 for i, pkg := range m.packages { 93 if pkg == name { 94 m.packages = append(m.packages[:i], m.packages[i+1:]...) 95 break 96 } 97 } 98 return m 99 } 100 101 func (m Model) Init() tea.Cmd { 102 return tea.Batch( 103 m.processNextEventCmd(), 104 m.spinner.Tick, 105 ) 106 } 107 108 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 109 switch msg := msg.(type) { 110 case tea.KeyMsg: 111 switch msg.String() { 112 case "ctrl+c": 113 m.exiting = true 114 return m, tea.Quit 115 116 case "v": 117 if m.showEveryVulnerability { 118 m.showEveryVulnerability = false 119 } else { 120 m.showEveryVulnerability = true 121 } 122 return m, nil 123 124 default: 125 return m, nil 126 } 127 128 case packageState: 129 if msg.searching { 130 return m.appendPackage(msg), m.processNextEventCmd() 131 } 132 133 // If the scan was clean, we don't need to show the package anymore. 134 if len(msg.matchesFound) == 0 { 135 m.countPassedPackages++ 136 m = m.removePackage(msg.name) 137 } else { 138 m.countFailedPackages++ 139 m = m.updatePackage(msg) 140 } 141 142 return m, m.processNextEventCmd() 143 144 case errMsg: 145 m.Err = msg.err 146 return m, tea.Quit 147 148 case doneMsg: 149 m.exiting = true 150 return m, tea.Quit 151 152 default: 153 var cmd tea.Cmd 154 m.spinner, cmd = m.spinner.Update(msg) 155 return m, cmd 156 } 157 } 158 159 func (m Model) processNextEventCmd() tea.Cmd { 160 return func() tea.Msg { 161 // TODO: make this preempt-able 162 if m.vulnEvents == nil { 163 return nil 164 } 165 166 e := <-m.vulnEvents 167 switch e := e.(type) { 168 case vuln.EventPackageMatchingStarting: 169 return packageState{ 170 name: e.Package, 171 searching: true, 172 } 173 174 case vuln.EventPackageMatchingFinished: 175 return packageState{ 176 name: e.Package, 177 searching: false, 178 matchesFound: e.Matches, 179 } 180 181 case vuln.EventPackageMatchingError: 182 return errMsg{ 183 err: e.Err, 184 } 185 186 case vuln.EventMatchingFinished: 187 return doneMsg{} 188 } 189 190 return nil 191 } 192 } 193 194 // doneMsg is a message that is sent when the matching reporter is done. 195 type doneMsg struct{} 196 197 type errMsg struct { 198 err error 199 } 200 201 func (m Model) View() string { 202 // Summary 203 204 viewSummary := styles.Secondary().Render(fmt.Sprintf( 205 "%d clean, %d vulnerable, %d remaining", 206 m.countPassedPackages, 207 m.countFailedPackages, 208 m.countTotalPackages-m.countPassedPackages-m.countFailedPackages, 209 )) 210 211 // Package list 212 213 var searchingPackageRows []string 214 var vulnerablePackageRows []string 215 216 for _, pkg := range m.packages { 217 state := m.packageStateMap[pkg] 218 219 if state.searching { 220 msg := fmt.Sprintf( 221 "%s %s %s", 222 m.spinner.View(), 223 styles.Secondary().Render("searching for vulnerabilities:"), 224 pkg, 225 ) 226 searchingPackageRows = append(searchingPackageRows, msg) 227 228 continue 229 } 230 231 vulnCount := len(state.matchesFound) 232 if vulnCount == 0 { 233 continue 234 } 235 236 if m.showEveryVulnerability { 237 row := strings.Builder{} 238 fmt.Fprintf(&row, "%s", pkg) 239 240 for i := range state.matchesFound { 241 match := state.matchesFound[i] 242 severity := "" 243 if match.Vulnerability.Severity != "" { 244 severity = renderSeverity(match.Vulnerability.Severity) + " " 245 } 246 fmt.Fprintf(&row, "\n %s%s", severity, styleSubtle.Render(hyperlinkCVE(match.Vulnerability.ID))) 247 } 248 249 vulnerablePackageRows = append(vulnerablePackageRows, row.String()) 250 continue 251 } 252 253 vulnsWord := "vulnerability" 254 if vulnCount != 1 { 255 vulnsWord = "vulnerabilities" 256 } 257 258 row := fmt.Sprintf( 259 "%s: %s", 260 pkg, 261 styles.Accented().Render(fmt.Sprintf("%d new %s", vulnCount, vulnsWord)), 262 ) 263 vulnerablePackageRows = append(vulnerablePackageRows, row) 264 } 265 266 viewSearchingPackages := strings.Join(searchingPackageRows, "\n") 267 viewVulnerablePackages := strings.Join(vulnerablePackageRows, "\n") 268 269 // Help text (only if there are vulnerabilities) 270 viewHelp := "" 271 if m.countFailedPackages > 0 { 272 action := "expand" 273 if m.showEveryVulnerability { 274 action = "collapse" 275 } 276 277 viewHelp = fmt.Sprintf("%s%s", 278 helpKeyStyle.Render("v"), 279 helpExplanationStyle.Render(" to "+action+" vulnerabilities"), 280 ) 281 } 282 283 // Put it all together 284 285 if viewSearchingPackages != "" { 286 viewSearchingPackages += "\n\n" 287 } 288 289 if viewVulnerablePackages != "" { 290 viewVulnerablePackages += "\n\n" 291 } 292 293 if m.exiting { 294 return fmt.Sprintf( 295 "%s%s", 296 viewVulnerablePackages, 297 viewSummary+"\n", 298 ) 299 } 300 301 return fmt.Sprintf( 302 "%s%s%s%s", 303 viewSearchingPackages, 304 viewVulnerablePackages, 305 viewSummary+"\n", 306 viewHelp+"\n", 307 ) 308 } 309 310 var termSupportsHyperlinks = termlink.SupportsHyperlinks() 311 312 func hyperlinkCVE(id string) string { 313 if !termSupportsHyperlinks { 314 return id 315 } 316 317 return termlink.Link(id, fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", id)) 318 } 319 320 func renderSeverity(severity vuln.Severity) string { 321 s := string(severity) 322 323 switch severity { 324 case vuln.SeverityLow: 325 return styleLow.Render(s) 326 case vuln.SeverityMedium: 327 return styleMedium.Render(s) 328 case vuln.SeverityHigh: 329 return styleHigh.Render(s) 330 case vuln.SeverityCritical: 331 return styleCritical.Render(s) 332 default: 333 return styleNegligible.Render(s) 334 } 335 }