github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/language/javascript/bunlock/bunlock.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 bunlock extracts bun.lock files 16 package bunlock 17 18 import ( 19 "context" 20 "encoding/json" 21 "errors" 22 "fmt" 23 "io" 24 "path/filepath" 25 "slices" 26 "strings" 27 28 "github.com/google/osv-scalibr/extractor" 29 "github.com/google/osv-scalibr/extractor/filesystem" 30 "github.com/google/osv-scalibr/extractor/filesystem/osv" 31 "github.com/google/osv-scalibr/inventory" 32 "github.com/google/osv-scalibr/plugin" 33 "github.com/google/osv-scalibr/purl" 34 "github.com/tidwall/jsonc" 35 ) 36 37 const ( 38 // Name is the unique name of this extractor. 39 Name = "javascript/bunlock" 40 ) 41 42 type bunLockfile struct { 43 Version int `json:"lockfileVersion"` 44 Packages map[string][]any `json:"packages"` 45 } 46 47 // Extractor extracts npm packages from bun.lock files. 48 type Extractor struct{} 49 50 // New returns a new instance of the extractor. 51 func New() filesystem.Extractor { return &Extractor{} } 52 53 // Name of the extractor. 54 func (e Extractor) Name() string { return Name } 55 56 // Version of the extractor. 57 func (e Extractor) Version() int { return 0 } 58 59 // Requirements of the extractor. 60 func (e Extractor) Requirements() *plugin.Capabilities { 61 return &plugin.Capabilities{} 62 } 63 64 // FileRequired returns true if the specified file matches bun lockfile patterns. 65 func (e Extractor) FileRequired(api filesystem.FileAPI) bool { 66 path := api.Path() 67 if filepath.Base(path) != "bun.lock" { 68 return false 69 } 70 // Skip lockfiles inside node_modules directories since the packages they list aren't 71 // necessarily installed by the root project. We instead use the more specific top-level 72 // lockfile for the root project dependencies. 73 dir := filepath.ToSlash(filepath.Dir(path)) 74 return !slices.Contains(strings.Split(dir, "/"), "node_modules") 75 } 76 77 // structurePackageDetails returns the name, version, and commit of a package 78 // specified as a tuple in a bun.lock 79 func structurePackageDetails(pkgs []any) (string, string, string, error) { 80 if len(pkgs) == 0 { 81 return "", "", "", errors.New("empty package tuple") 82 } 83 84 str, ok := pkgs[0].(string) 85 86 if !ok { 87 return "", "", "", errors.New("first element of package tuple is not a string") 88 } 89 90 str, isScoped := strings.CutPrefix(str, "@") 91 name, version, _ := strings.Cut(str, "@") 92 93 if isScoped { 94 name = "@" + name 95 } 96 97 version, commit, _ := strings.Cut(version, "#") 98 99 // bun.lock does not track both the commit and version, 100 // so if we have a commit then we don't have a version 101 if commit != "" { 102 version = "" 103 } 104 105 // file dependencies do not have a semantic version recorded 106 if strings.HasPrefix(version, "file:") { 107 version = "" 108 } 109 110 return name, version, commit, nil 111 } 112 113 // Extract extracts packages from bun.lock files passed through the scan input. 114 func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) { 115 var parsedLockfile *bunLockfile 116 117 b, err := io.ReadAll(input.Reader) 118 119 if err != nil { 120 return inventory.Inventory{}, fmt.Errorf("could not extract: %w", err) 121 } 122 123 if err := json.Unmarshal(jsonc.ToJSON(b), &parsedLockfile); err != nil { 124 return inventory.Inventory{}, fmt.Errorf("could not extract %w", err) 125 } 126 127 packages := make([]*extractor.Package, 0, len(parsedLockfile.Packages)) 128 129 var errs []error 130 131 for key, pkg := range parsedLockfile.Packages { 132 name, version, commit, err := structurePackageDetails(pkg) 133 134 if err != nil { 135 errs = append(errs, fmt.Errorf("could not extract '%s': %w", key, err)) 136 137 continue 138 } 139 140 packages = append(packages, &extractor.Package{ 141 Name: name, 142 Version: version, 143 PURLType: purl.TypeNPM, 144 SourceCode: &extractor.SourceCodeIdentifier{ 145 Commit: commit, 146 }, 147 Metadata: osv.DepGroupMetadata{ 148 DepGroupVals: []string{}, 149 }, 150 Locations: []string{input.Path}, 151 }) 152 } 153 154 return inventory.Inventory{Packages: packages}, errors.Join(errs...) 155 }