github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/winget/winget.go (about) 1 // Copyright 2025 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package winget extracts installed packages from Windows Package Manager (Winget) database. 16 package winget 17 18 import ( 19 "context" 20 "database/sql" 21 "errors" 22 "fmt" 23 "os" 24 "path/filepath" 25 "strings" 26 27 "github.com/google/osv-scalibr/extractor" 28 "github.com/google/osv-scalibr/extractor/filesystem" 29 "github.com/google/osv-scalibr/extractor/filesystem/os/winget/metadata" 30 "github.com/google/osv-scalibr/inventory" 31 "github.com/google/osv-scalibr/plugin" 32 "github.com/google/osv-scalibr/purl" 33 _ "modernc.org/sqlite" // Import sqlite driver 34 ) 35 36 const ( 37 // Name is the unique identifier for the Winget extractor. 38 Name = "os/winget" 39 ) 40 41 // Extractor extracts installed packages from Windows Package Manager databases. 42 type Extractor struct{} 43 44 // New creates a new Winget extractor instance. 45 func New() filesystem.Extractor { 46 return &Extractor{} 47 } 48 49 // NewDefault creates a new Winget extractor with default configuration. 50 func NewDefault() filesystem.Extractor { 51 return New() 52 } 53 54 // Name returns the unique identifier for this extractor. 55 func (e Extractor) Name() string { return Name } 56 57 // Version returns the version of this extractor. 58 func (e Extractor) Version() int { return 0 } 59 60 // Requirements returns the system requirements for this extractor. 61 func (e Extractor) Requirements() *plugin.Capabilities { 62 return &plugin.Capabilities{OS: plugin.OSWindows} 63 } 64 65 // FileRequired determines if the given file should be processed by this extractor. 66 func (e Extractor) FileRequired(api filesystem.FileAPI) bool { 67 path := api.Path() 68 normalized := filepath.ToSlash(path) 69 70 // Check if this is a Winget database file 71 if strings.HasSuffix(normalized, "/installed.db") && 72 (strings.Contains(normalized, "/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe/") || 73 strings.Contains(normalized, "/StoreEdgeFD/")) { 74 return true 75 } 76 77 // Check for system-wide repository database 78 if strings.HasSuffix(normalized, "/StateRepository-Machine.srd") && 79 strings.Contains(normalized, "/AppRepository/") { 80 return true 81 } 82 83 return false 84 } 85 86 // Package represents a package extracted from the Winget database. 87 type Package struct { 88 ID string 89 Name string 90 Version string 91 Moniker string 92 Channel string 93 Tags []string 94 Commands []string 95 } 96 97 // Extract extracts packages from a Winget database file. 98 func (e *Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) { 99 absPath, err := input.GetRealPath() 100 if err != nil { 101 return inventory.Inventory{}, fmt.Errorf("GetRealPath(%v): %w", input, err) 102 } 103 104 if input.Root == "" { 105 // The file got copied to a temporary dir, remove it at the end. 106 defer func() { 107 dir := filepath.Dir(absPath) 108 if err := os.RemoveAll(dir); err != nil { 109 fmt.Printf("Warning: failed to clean up temporary directory %s: %v\n", dir, err) 110 } 111 }() 112 } 113 114 db, err := sql.Open("sqlite", absPath) 115 if err != nil { 116 return inventory.Inventory{}, fmt.Errorf("failed to open Winget database %s: %w", absPath, err) 117 } 118 defer db.Close() 119 120 if err := e.validateDatabase(ctx, db); err != nil { 121 return inventory.Inventory{}, fmt.Errorf("invalid Winget database %s: %w", absPath, err) 122 } 123 124 packages, err := e.extractPackages(ctx, db) 125 if err != nil { 126 return inventory.Inventory{}, fmt.Errorf("failed to extract packages from %s: %w", absPath, err) 127 } 128 129 var extPackages []*extractor.Package 130 for _, pkg := range packages { 131 // Return if canceled or exceeding deadline 132 if err := ctx.Err(); err != nil { 133 return inventory.Inventory{}, fmt.Errorf("%s halted due to context error: %w", e.Name(), err) 134 } 135 136 extPkg := &extractor.Package{ 137 Name: pkg.ID, 138 Version: pkg.Version, 139 PURLType: purl.TypeWinget, 140 Locations: []string{input.Path}, 141 Metadata: &metadata.Metadata{ 142 Name: pkg.Name, 143 ID: pkg.ID, 144 Version: pkg.Version, 145 Moniker: pkg.Moniker, 146 Channel: pkg.Channel, 147 Tags: pkg.Tags, 148 Commands: pkg.Commands, 149 }, 150 } 151 extPackages = append(extPackages, extPkg) 152 } 153 154 return inventory.Inventory{Packages: extPackages}, nil 155 } 156 157 func (e *Extractor) validateDatabase(ctx context.Context, db *sql.DB) error { 158 var tableName string 159 err := db.QueryRowContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='manifest'").Scan(&tableName) 160 if err != nil { 161 if err == sql.ErrNoRows { 162 return errors.New("database does not contain manifest table") 163 } 164 return fmt.Errorf("failed to query database schema: %w", err) 165 } 166 return nil 167 } 168 169 func (e *Extractor) extractPackages(ctx context.Context, db *sql.DB) ([]*Package, error) { 170 query := ` 171 SELECT 172 i.id as package_id, 173 n.name as package_name, 174 v.version as package_version, 175 m.moniker as package_moniker, 176 c.channel as channel, 177 GROUP_CONCAT(DISTINCT t.tag) as tags, 178 GROUP_CONCAT(DISTINCT cmd.command) as commands 179 FROM manifest man 180 JOIN ids i ON man.id = i.rowid 181 JOIN names n ON man.name = n.rowid 182 JOIN versions v ON man.version = v.rowid 183 JOIN monikers m ON man.moniker = m.rowid 184 JOIN channels c ON man.channel = c.rowid 185 LEFT JOIN tags_map tm ON man.rowid = tm.manifest 186 LEFT JOIN tags t ON tm.tag = t.rowid 187 LEFT JOIN commands_map cm ON man.rowid = cm.manifest 188 LEFT JOIN commands cmd ON cm.command = cmd.rowid 189 GROUP BY man.rowid 190 ` 191 192 rows, err := db.QueryContext(ctx, query) 193 if err != nil { 194 return nil, fmt.Errorf("failed to query packages: %w", err) 195 } 196 defer rows.Close() 197 198 var packages []*Package 199 for rows.Next() { 200 // Return if canceled or exceeding deadline 201 if err := ctx.Err(); err != nil { 202 return packages, fmt.Errorf("winget extractor halted due to context error: %w", err) 203 } 204 205 var pkg Package 206 var tagsStr, commandsStr sql.NullString 207 208 err := rows.Scan( 209 &pkg.ID, 210 &pkg.Name, 211 &pkg.Version, 212 &pkg.Moniker, 213 &pkg.Channel, 214 &tagsStr, 215 &commandsStr, 216 ) 217 if err != nil { 218 return nil, fmt.Errorf("failed to scan package row: %w", err) 219 } 220 221 if tagsStr.Valid && tagsStr.String != "" { 222 pkg.Tags = strings.Split(tagsStr.String, ",") 223 } 224 if commandsStr.Valid && commandsStr.String != "" { 225 pkg.Commands = strings.Split(commandsStr.String, ",") 226 } 227 228 packages = append(packages, &pkg) 229 } 230 231 if err := rows.Err(); err != nil { 232 return nil, fmt.Errorf("error iterating package rows: %w", err) 233 } 234 235 return packages, nil 236 }