@@ -3,7 +3,6 @@ package javascript
33import (
44 "bufio"
55 "encoding/json"
6- "errors"
76 "fmt"
87 "io"
98 "net/url"
@@ -12,6 +11,7 @@ import (
1211 "strings"
1312
1413 "github.com/DataDog/datadog-sbom-generator/internal/cachedregexp"
14+ "github.com/DataDog/datadog-sbom-generator/internal/utility/fileposition"
1515 "github.com/DataDog/datadog-sbom-generator/pkg/lockfile"
1616 "github.com/DataDog/datadog-sbom-generator/pkg/models"
1717)
@@ -53,12 +53,42 @@ func parseYarnPackageBlock(block []string) []YarnPackage {
5353 return packages
5454}
5555
56- func groupYarnPackageLines (scanner * bufio.Scanner ) []YarnPackage {
56+ // findLastNonEmptyLineInRange finds the 1-indexed line number of the last non-empty line
57+ // within the 0-indexed range [startIdx, endIdx].
58+ func findLastNonEmptyLineInRange (lines []string , startIdx , endIdx int ) int {
59+ if endIdx >= len (lines ) {
60+ endIdx = len (lines ) - 1
61+ }
62+
63+ for i := endIdx ; i >= startIdx ; i -- {
64+ if strings .TrimSpace (lines [i ]) != "" {
65+ return i + 1 // 1-indexed
66+ }
67+ }
68+
69+ return startIdx + 1
70+ }
71+
72+ // buildYarnBlockPosition creates a FilePosition from 1-indexed start and end line numbers.
73+ func buildYarnBlockPosition (lines []string , startLine , endLine int ) models.FilePosition {
74+ colStart := fileposition .GetFirstNonEmptyCharacterIndexInLine (lines [startLine - 1 ])
75+ colEnd := fileposition .GetLastNonEmptyCharacterIndexInLine (lines [endLine - 1 ])
76+
77+ return models.FilePosition {
78+ Line : models.Position {Start : startLine , End : endLine },
79+ Column : models.Position {Start : colStart , End : colEnd },
80+ }
81+ }
82+
83+ func groupYarnPackageLines (scanner * bufio.Scanner , lines []string ) []YarnPackage {
5784 var groups []YarnPackage
5885 var group []string
86+ var blockStartLine int // 1-indexed line number of block start
5987
88+ lineNum := 0
6089 var line string
6190 for scanner .Scan () {
91+ lineNum ++
6292 line = scanner .Text ()
6393
6494 if shouldSkipYarnLine (line ) {
@@ -69,16 +99,26 @@ func groupYarnPackageLines(scanner *bufio.Scanner) []YarnPackage {
6999 if ! strings .HasPrefix (line , " " ) {
70100 if len (group ) > 0 {
71101 packages := parseYarnPackageBlock (group )
102+ // Set BlockLocation on each package
103+ blockEndLine := findLastNonEmptyLineInRange (lines , blockStartLine - 1 , lineNum - 2 )
104+ for i := range packages {
105+ packages [i ].BlockLocation = buildYarnBlockPosition (lines , blockStartLine , blockEndLine )
106+ }
72107 groups = append (groups , packages ... )
73108 }
74109 group = make ([]string , 0 )
110+ blockStartLine = lineNum
75111 }
76112
77113 group = append (group , line )
78114 }
79115
80116 if len (group ) > 0 {
81117 packages := parseYarnPackageBlock (group )
118+ blockEndLine := findLastNonEmptyLineInRange (lines , blockStartLine - 1 , len (lines )- 1 )
119+ for i := range packages {
120+ packages [i ].BlockLocation = buildYarnBlockPosition (lines , blockStartLine , blockEndLine )
121+ }
82122 groups = append (groups , packages ... )
83123 }
84124
@@ -336,7 +376,7 @@ func buildDependencyTree(rootPkgName, rootPkgTargetVersion, rootPkgRegistry stri
336376 return results
337377}
338378
339- func parseYarnPackage (dependency YarnPackage ) lockfile.PackageDetails {
379+ func parseYarnPackage (dependency YarnPackage , filePath string ) lockfile.PackageDetails {
340380 if dependency .Version == "" {
341381 _ , _ = fmt .Fprintf (
342382 os .Stderr ,
@@ -350,6 +390,9 @@ func parseYarnPackage(dependency YarnPackage) lockfile.PackageDetails {
350390 nameLocation = & models.FilePosition {Filename : dependency .WorkspacePath }
351391 }
352392
393+ blockLocation := dependency .BlockLocation
394+ blockLocation .Filename = filePath
395+
353396 return lockfile.PackageDetails {
354397 Name : dependency .Name ,
355398 Version : dependency .Version ,
@@ -358,6 +401,7 @@ func parseYarnPackage(dependency YarnPackage) lockfile.PackageDetails {
358401 Ecosystem : models .EcosystemNPM ,
359402 Commit : tryExtractCommit (dependency .Resolution ),
360403 NameLocation : nameLocation ,
404+ BlockLocation : blockLocation ,
361405 }
362406}
363407
@@ -450,12 +494,110 @@ func isJSONFormat(content []byte) bool {
450494//
451495// Returns a slice of YarnPackage structs compatible with the existing YAML parser output,
452496// allowing the rest of the extraction logic to work identically for both formats.
453- func parseYarnBerryJSON (content []byte ) ([]YarnPackage , error ) {
497+ // extractYarnBerryJSONPositions scans JSON lines for entry keys within the "entries" object.
498+ // Entry keys appear as " \"package@npm:^1.0.0\": {" at 4-space indent inside "entries".
499+ func extractYarnBerryJSONPositions (lines []string ) map [string ]models.FilePosition {
500+ positions := make (map [string ]models.FilePosition )
501+
502+ inEntries := false
503+ var currentKey string
504+ braceDepth := 0
505+
506+ for i , line := range lines {
507+ lineNum := i + 1
508+ trimmed := strings .TrimSpace (line )
509+
510+ // Detect "entries": {
511+ if ! inEntries && strings .Contains (trimmed , `"entries"` ) && strings .HasSuffix (trimmed , "{" ) {
512+ inEntries = true
513+ braceDepth = 1
514+
515+ continue
516+ }
517+
518+ if ! inEntries {
519+ continue
520+ }
521+
522+ // Track brace depth
523+ for _ , ch := range trimmed {
524+ if ch == '{' {
525+ braceDepth ++
526+ } else if ch == '}' {
527+ braceDepth --
528+ }
529+ }
530+
531+ if braceDepth <= 0 {
532+ // Close last entry
533+ if currentKey != "" {
534+ closeBerryEntry (positions , currentKey , i , lines )
535+ currentKey = ""
536+ }
537+
538+ inEntries = false
539+
540+ continue
541+ }
542+
543+ // Entry key at depth 1 (exactly 4-space indent, opens an object): " \"package@npm:^1.0.0\": {"
544+ // Require HasSuffix("{") to avoid false positives on internal fields like "checksum": "..."
545+ // which also sit at depth 2 but do not open a new brace.
546+ if braceDepth == 2 && strings .HasSuffix (trimmed , "{" ) && strings .HasPrefix (line , " " ) {
547+ // This is a new entry key
548+ if currentKey != "" {
549+ closeBerryEntry (positions , currentKey , i , lines )
550+ }
551+
552+ // Extract the key between quotes
553+ firstQuote := strings .Index (trimmed , `"` )
554+ lastQuote := strings .Index (trimmed [firstQuote + 1 :], `"` )
555+
556+ if firstQuote >= 0 && lastQuote >= 0 {
557+ key := trimmed [firstQuote + 1 : firstQuote + 1 + lastQuote ]
558+ currentKey = key
559+
560+ colStart := fileposition .GetFirstNonEmptyCharacterIndexInLine (line )
561+
562+ positions [currentKey ] = models.FilePosition {
563+ Line : models.Position {Start : lineNum , End : 0 },
564+ Column : models.Position {Start : colStart , End : 0 },
565+ }
566+ }
567+ }
568+ }
569+
570+ if currentKey != "" {
571+ closeBerryEntry (positions , currentKey , len (lines ), lines )
572+ }
573+
574+ return positions
575+ }
576+
577+ func closeBerryEntry (positions map [string ]models.FilePosition , key string , beforeIndex int , lines []string ) {
578+ pos := positions [key ]
579+ lastNonEmpty := beforeIndex - 1
580+ for lastNonEmpty >= 0 && strings .TrimSpace (lines [lastNonEmpty ]) == "" {
581+ lastNonEmpty --
582+ }
583+
584+ if lastNonEmpty >= 0 {
585+ pos .Line .End = lastNonEmpty + 1
586+ pos .Column .End = fileposition .GetLastNonEmptyCharacterIndexInLine (lines [lastNonEmpty ])
587+ } else {
588+ pos .Line .End = pos .Line .Start
589+ }
590+
591+ positions [key ] = pos
592+ }
593+
594+ func parseYarnBerryJSON (content []byte , lines []string ) ([]YarnPackage , error ) {
454595 var berryJSON YarnBerryJSON
455596 if err := json .Unmarshal (content , & berryJSON ); err != nil {
456597 return nil , fmt .Errorf ("failed to parse yarn.lock JSON: %w" , err )
457598 }
458599
600+ positions := extractYarnBerryJSONPositions (lines )
459601 packages := make ([]YarnPackage , 0 , len (berryJSON .Entries ))
460602
461603 for entryKey , entry := range berryJSON .Entries {
@@ -484,6 +626,12 @@ func parseYarnBerryJSON(content []byte) ([]YarnPackage, error) {
484626 })
485627 }
486628
629+ // Look up position by entry key
630+ var blockPos models.FilePosition
631+ if pos , ok := positions [entryKey ]; ok {
632+ blockPos = pos
633+ }
634+
487635 // Create one YarnPackage per target version
488636 for _ , targetVersion := range targetVersions {
489637 packages = append (packages , YarnPackage {
@@ -493,6 +641,7 @@ func parseYarnBerryJSON(content []byte) ([]YarnPackage, error) {
493641 Resolution : resolution ,
494642 Dependencies : dependencies ,
495643 WorkspacePath : workspacePath ,
644+ BlockLocation : blockPos ,
496645 })
497646 }
498647 }
@@ -501,41 +650,24 @@ func parseYarnBerryJSON(content []byte) ([]YarnPackage, error) {
501650}
502651
503652func (e YarnLockExtractor ) Extract (f lockfile.DepFile , context lockfile.ScanContext ) ([]lockfile.PackageDetails , error ) {
504- // Peek first bytes to detect format without loading entire file into memory
505- buf := make ([]byte , 200 )
506- n , err := f .Read (buf )
507- if err != nil && ! errors .Is (err , io .EOF ) {
653+ content , err := io .ReadAll (f )
654+ if err != nil {
508655 return []lockfile.PackageDetails {}, fmt .Errorf ("error reading yarn.lock: %w" , err )
509656 }
510657
511- // Try to reset file position for streaming
512- var reader io.Reader = f
513- if seeker , ok := f .(io.Seeker ); ok {
514- if _ , err := seeker .Seek (0 , 0 ); err != nil {
515- return []lockfile.PackageDetails {}, fmt .Errorf ("error seeking yarn.lock: %w" , err )
516- }
517- } else {
518- // If we can't seek, prepend the peeked bytes back to the reader
519- reader = io .MultiReader (strings .NewReader (string (buf [:n ])), f )
520- }
658+ lines := fileposition .BytesToLines (content )
521659
522660 var yarnPackages []YarnPackage
523- if isJSONFormat (buf [: n ] ) {
661+ if isJSONFormat (content ) {
524662 // Parse JSON format (Yarn v4+)
525- // JSON requires loading entire file into memory for parsing
526- content , err := io .ReadAll (reader )
527- if err != nil {
528- return []lockfile.PackageDetails {}, fmt .Errorf ("error reading yarn.lock JSON: %w" , err )
529- }
530- yarnPackages , err = parseYarnBerryJSON (content )
663+ yarnPackages , err = parseYarnBerryJSON (content , lines )
531664 if err != nil {
532665 return []lockfile.PackageDetails {}, err
533666 }
534667 } else {
535- // Parse YAML format (Yarn v1-3) using streaming scanner
536- // This avoids loading the entire file into memory
537- scanner := bufio .NewScanner (reader )
538- yarnPackages = groupYarnPackageLines (scanner )
668+ // Parse YAML-like format (Yarn v1-3)
669+ scanner := bufio .NewScanner (strings .NewReader (string (content )))
670+ yarnPackages = groupYarnPackageLines (scanner , lines )
539671
540672 if err := scanner .Err (); err != nil {
541673 return []lockfile.PackageDetails {}, fmt .Errorf ("error while scanning %s: %w" , f .Path (), err )
@@ -561,7 +693,7 @@ func (e YarnLockExtractor) Extract(f lockfile.DepFile, context lockfile.ScanCont
561693 }
562694
563695 dependencyWorkspaces := createDependencyWorkspaceMap (workspaces , allResolvedPackages )
564- packages := createPackageDetails (allResolvedPackages , dependencyWorkspaces )
696+ packages := createPackageDetails (allResolvedPackages , dependencyWorkspaces , f . Path () )
565697
566698 pkgIndex := indexByNameAndVersions (packages )
567699 for index , pkg := range packages {
@@ -608,12 +740,12 @@ func createDependencyWorkspaceMap(workspaces []YarnPackage, allResolvedPackages
608740 return dependencyWorkspaces
609741}
610742
611- func createPackageDetails (allResolvedPackages []YarnPackage , dependencyWorkspaces map [string ][]string ) []lockfile.PackageDetails {
743+ func createPackageDetails (allResolvedPackages []YarnPackage , dependencyWorkspaces map [string ][]string , filePath string ) []lockfile.PackageDetails {
612744 packages := make ([]lockfile.PackageDetails , 0 , len (allResolvedPackages ))
613745
614746 // Create lockfile.PackageDetails for regular packages, with workspace information where applicable
615747 for _ , yarnPackage := range allResolvedPackages {
616- basePackage := parseYarnPackage (yarnPackage )
748+ basePackage := parseYarnPackage (yarnPackage , filePath )
617749 depKey := getWorkspaceDependencyKey (yarnPackage .Name , yarnPackage .Version , yarnPackage .TargetVersion )
618750
619751 if workspacePaths , exists := dependencyWorkspaces [depKey ]; exists {
0 commit comments