Files
syft/syft/pkg/cataloger/golang/parse_go_mod.go
T
Alex Goodman 1dc16fbac4 fix: report scanner errors and raise the line cap in metadata parsers (#5353)
* fix: report scanner errors instead of returning a partial parse as complete

A line longer than `bufio.Scanner`'s 64KB buffer ends the scan with `ErrTooLong`, which only `Err()` reports. Parsers now return what they found along with the error (recorded as an unknown for the file), or log it where there is no error to return.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: allow lines up to 1MB in line-oriented package metadata parsers

Adds `internal.NewLineScanner`, a `bufio.Scanner` capped at 1MB per line rather than the 64KB default, the same cap the alpm parser already uses. Long single-line fields (descriptions, license text, dependency lists) now parse instead of ending the scan early.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: use io.ReadFull where a short read was silently accepted

- dotnet bundle 7-bit int decoding could read a zero-byte result as a zero byte
- wordpress plugin header parsing included the unread tail of its buffer

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: finish moving line scanners to the 1MB cap and checking scanner errors

- java manifest, pom.properties, Gemfile.lock, requirements.txt, homebrew formula, alpm and the purl/cpe decoders now use `internal.NewLineScanner`
- snap kernel changelog reports a scan error instead of silently dropping the base kernel package
- the attestation output reader keeps draining the pipe after a scan error so cosign can't block on a full pipe

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix: stop logging the full setup.py line once per dependency match

The debug log ran once per regex match and included the whole line, so log volume grew with the square of line length. With 1MB lines that is hundreds of GB of debug output from a single crafted file.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix(gentoo): don't dereference a nil location when a LICENSE scan fails

`extractLicenses` accepts a nil location, but the scan error log added earlier on this branch dereferenced it.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* fix(gentoo): bound license group expansion on hostile license_groups

- dedup within a group uses a set instead of `slices.Contains`, which was quadratic in tokens per line and got much worse under the 1MB line cap
- each group is expanded once and memoized, so a chain of groups that reference each other repeatedly no longer expands exponentially
- repeated references to the same group within one line are skipped

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-09-29 16:52:58 -04:00

548 lines
19 KiB
Go

package golang
import (
"context"
"fmt"
"go/build"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/spf13/afero"
"golang.org/x/mod/modfile"
"golang.org/x/tools/go/packages"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/unknown"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/internal/fileresolver"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
type goModCataloger struct {
usePackagesLib bool
licenseResolver goLicenseResolver
}
func newGoModCataloger(opts CatalogerConfig) *goModCataloger {
return &goModCataloger{
usePackagesLib: opts.UsePackagesLib,
licenseResolver: newGoLicenseResolver(modFileCatalogerName, opts),
}
}
// parseGoModFile takes a go.mod and tries to resolve and lists all packages discovered.
func (c *goModCataloger) parseGoModFile(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) (pkgs []pkg.Package, relationships []artifact.Relationship, err error) {
modDir := filepath.Dir(string(reader.Location.Reference().RealPath))
digests, err := parseGoSumFile(resolver, reader)
if err != nil {
log.Debugf("unable to get go.sum: %v", err)
}
scanRoot := ""
if dir, ok := resolver.(*fileresolver.Directory); ok && dir != nil {
scanRoot = dir.Chroot.Base()
}
// base case go.mod file parsing
modFile, err := c.parseModFileContents(reader)
if err != nil {
return nil, nil, err
}
// source analysis using go toolchain if available
var sourceModules map[string]*packages.Module
var catalogedModules []pkg.Package
if c.usePackagesLib {
var sourcePackages map[string][]pkgInfo
var sourceDependencies map[string][]string
var sourceModuleToPkg map[string]artifact.Identifiable
sourcePackages, sourceModules, sourceDependencies, err = c.loadPackages(modDir, reader.Location)
catalogedModules, sourceModuleToPkg = c.catalogModules(ctx, scanRoot, sourcePackages, sourceModules, reader, digests)
relationships = buildModuleRelationships(catalogedModules, sourceDependencies, sourceModuleToPkg)
}
// only use go.mod packages NOT found in source analysis
goModPackages := c.createGoModPackages(ctx, resolver, modFile, sourceModules, reader, digests)
c.applyReplaceDirectives(ctx, resolver, modFile, sourceModules, goModPackages, reader, digests)
c.applyExcludeDirectives(modFile, goModPackages)
pkgs = c.assembleResults(catalogedModules, goModPackages)
return pkgs, relationships, err
}
// loadPackages uses golang.org/x/tools/go/packages to get dependency information.
func (c *goModCataloger) loadPackages(modDir string, loc file.Location) (pkgs map[string][]pkgInfo, modules map[string]*packages.Module, dependencies map[string][]string, unknownErr error) {
cfg := &packages.Config{
// Mode flags control what information is loaded for each package.
// Performance impact increases significantly with each additional flag:
//
// packages.NeedModule - Required for module metadata (path, version, replace directives).
// Essential for SBOM generation. Minimal performance impact.
//
// packages.NeedName - Required for package names & package Path. Minimal performance impact.
// Needed to identify packages and filter out standard library packages.
//
// packages.NeedFiles - Loads source file paths for each package.
// Moderate performance impact as it requires filesystem traversal.
// Required for license discovery.
//
// packages.NeedDeps - Loads the dependency graph between packages.
// High performance impact as it builds the complete import graph.
// Critical for generating accurate dependency relationships in SBOM.
//
// packages.NeedImports - Loads import information for each package.
// High performance impact, especially with large codebases.
// Required for building module-to-module dependency mappings.
//
// Adding flags like NeedTypes, NeedSyntax, or NeedTypesInfo would dramatically
// increase memory usage and processing time (10x+ slower) but are not needed
// for SBOM generation as we only require dependency and module metadata.
Mode: packages.NeedModule | packages.NeedName | packages.NeedFiles | packages.NeedDeps | packages.NeedImports,
Dir: modDir,
Tests: true,
// disable workspace mode so that we only use the go.mod in the target directory,
// not any parent go.work file that may exist
Env: append(os.Environ(), "GOWORK=off"),
}
// From Go documentation: "all" expands to all packages in the main module
// and their dependencies, including dependencies needed by tests.
//
// The special pattern "all" specifies all the active modules,
// first the main module and then dependencies sorted by module path.
// A pattern containing "..." specifies the active modules whose module paths match the pattern.
// On implementation we could not find a test case that differentiated between all and ...
// There may be a case where ... is non inclusive so we default to all for the inclusive guarantee
rootPkgs, err := packages.Load(cfg, "all")
if err != nil {
log.Debugf("error loading packages: %v", err)
}
// Check for any errors in loading
for _, p := range rootPkgs {
if len(p.Errors) > 0 {
// Log errors but continue processing
for _, e := range p.Errors {
log.Debugf("package load error for %s: %v", p.PkgPath, e)
unknownErr = unknown.Append(unknownErr, loc, err)
}
}
}
// note: dependencies have already pruned local imports and only focuses on module => module dependencies
return c.visitPackages(rootPkgs, loc, unknownErr)
}
type pkgInfo struct {
// pkgPath is the import path of the package.
pkgPath string
// modulePath is the module path of the package.
modulePath string
// pkgDir is the directory containing the package's source code.
pkgDir string
// moduleDir is the directory containing the module's source code.
moduleDir string
}
// visitPackages processes Go module import graphs to get all modules
func (c *goModCataloger) visitPackages(
rootPkgs []*packages.Package,
loc file.Location,
uke error,
) (pkgs map[string][]pkgInfo, modules map[string]*packages.Module, dependencies map[string][]string, unknownErr error) {
modules = make(map[string]*packages.Module)
// note: packages are specific to inside the module - they do not include transitive pkgInfo
// packages is used for identifying licensing documents for modules that could contain multiple licenses
// dependencies cover transitive module imports; see p.Imports array in packages.Visit
pkgs = make(map[string][]pkgInfo)
// dependencies are module => module dependencies
dependencies = make(map[string][]string)
// persist unknown errs from previous parts of the catalog
unknownErr = uke
// closure (p *Package) bool
// return bool determines whether the imports of package p are visited.
packages.Visit(rootPkgs, func(p *packages.Package) bool {
if len(p.Errors) > 0 {
for _, err := range p.Errors {
unknownErr = unknown.Append(unknownErr, loc, err)
}
return false
}
// skip for common causes
if shouldSkipVisit(p) {
return false
}
// different from above; we still might want to visit imports
// ignoring a package shouldn't end walking the tree
// since we need to get the full picture for license discovery
// for _, prefix := range c.config.IgnorePaths {
// if strings.HasPrefix(p.PkgPath, prefix) {
// return c.config.IncludeIgnoredDeps
// }
//}
pkgDir := resolvePkgDir(p)
if pkgDir == "" {
return true
}
module := newModule(p.Module)
if module.Dir == "" {
// We continue processing even when module.Dir is empty because we still want to:
// 1. Extract module dependencies from p.Imports for dependency graph construction
// 2. Create syft packages with available metadata (name, version, etc.)
// 3. Build relationships between modules even without complete filesystem info
// Not having the DIR here just means that we're not going to process the licenses
// Common causes for module.Dir being empty:
// - Vendored dependencies where Go toolchain loses some module metadata
// - Replace directives pointing to non-existent or inaccessible paths
// A known cause is that the module is vendored, so some information is lost.
isVendored := strings.Contains(pkgDir, "/vendor/")
if !isVendored {
log.Debugf("module %s does not have dir and it's not vendored", module.Path)
}
}
// extract module dependencies
for _, imp := range p.Imports {
if imp.Module != nil && imp.Module.Path != module.Path {
if dependencies[module.Path] == nil {
dependencies[module.Path] = []string{imp.Module.Path}
} else {
dependencies[module.Path] = append(dependencies[module.Path], imp.Module.Path)
}
}
}
info := pkgInfo{
pkgPath: p.PkgPath,
modulePath: module.Path,
pkgDir: pkgDir,
moduleDir: module.Dir,
}
if !slices.Contains(pkgs[module.Path], info) { // avoid duplicates
pkgs[module.Path] = append(pkgs[module.Path], info)
}
modules[p.Module.Path] = module
return true
}, nil)
return pkgs, modules, dependencies, unknownErr
}
// create syft packages from Go modules found by the go toolchain
func (c *goModCataloger) catalogModules(
ctx context.Context,
scanRoot string,
pkgs map[string][]pkgInfo,
modules map[string]*packages.Module,
reader file.LocationReadCloser,
digests map[string]string,
) ([]pkg.Package, map[string]artifact.Identifiable) {
syftPackages := make([]pkg.Package, 0)
moduleToPackage := make(map[string]artifact.Identifiable)
for _, m := range modules {
if isRelativeImportOrMain(m.Path) {
// relativeImport modules are already accounted for by their full module paths at other portions of syft's cataloging
// example: something like ../../ found as a module for go.mod b, which is sub to go.mod a is accounted for
// in another call to the goModCataloger when go.mod a is parsed
// local modules that use a "main" heuristic, no module naming (sometimes common pre go module support)
// are also not built as syft packages
continue
}
pkgInfos := pkgs[m.Path]
moduleLicenses := resolveModuleLicenses(ctx, scanRoot, pkgInfos, afero.NewOsFs())
// we do out of source lookups for module parsing
// locations are NOT included in the SBOM because of this
goModulePkg := pkg.Package{
Name: m.Path,
Version: m.Version,
Locations: file.NewLocationSet(reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Licenses: moduleLicenses,
Language: pkg.Go,
Type: pkg.GoModulePkg,
PURL: packageURL(m.Path, m.Version),
Metadata: createSourceMetadata(digests[fmt.Sprintf("%s %s", m.Path, m.Version)]),
}
goModulePkg.SetID()
moduleToPackage[m.Path] = goModulePkg
syftPackages = append(syftPackages, goModulePkg)
}
return syftPackages, moduleToPackage
}
// buildModuleRelationships creates artifact relationships between Go modules.
func buildModuleRelationships(
syftPkgs []pkg.Package,
dependencies map[string][]string,
moduleToPkg map[string]artifact.Identifiable,
) []artifact.Relationship {
var rels []artifact.Relationship
seen := make(map[string]struct{})
for _, fromPkg := range syftPkgs {
for _, dep := range dependencies[fromPkg.Name] {
if dep == fromPkg.Name {
continue
}
toPkg, ok := moduleToPkg[dep]
if !ok {
continue
}
key := string(fromPkg.ID()) + string(toPkg.ID())
if _, exists := seen[key]; exists {
continue
}
rels = append(rels, artifact.Relationship{
From: toPkg, // dep
To: fromPkg, // parent
Type: artifact.DependencyOfRelationship,
})
seen[key] = struct{}{}
}
}
return rels
}
func (c *goModCataloger) parseModFileContents(reader file.LocationReadCloser) (*modfile.File, error) {
contents, err := io.ReadAll(reader) //nolint:gocritic // modfile.Parse requires []byte
if err != nil {
return nil, fmt.Errorf("failed to read go module: %w", err)
}
f, err := modfile.Parse(reader.RealPath, contents, nil)
if err != nil {
return nil, fmt.Errorf("failed to parse go module: %w", err)
}
return f, nil
}
// note this handles the deduplication from source by checking if the mod path exists in the sourceModules map
func (c *goModCataloger) createGoModPackages(ctx context.Context, resolver file.Resolver, modFile *modfile.File, sourceModules map[string]*packages.Module, reader file.LocationReadCloser, digests map[string]string) map[string]pkg.Package {
goModPackages := make(map[string]pkg.Package)
for _, m := range modFile.Require {
if sourceModules == nil || sourceModules[m.Mod.Path] == nil {
lics := c.licenseResolver.getLicenses(ctx, resolver, m.Mod.Path, m.Mod.Version)
goModPkg := pkg.Package{
Name: m.Mod.Path,
Version: m.Mod.Version,
Licenses: pkg.NewLicenseSet(lics...),
Locations: file.NewLocationSet(reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
PURL: packageURL(m.Mod.Path, m.Mod.Version),
Language: pkg.Go,
Type: pkg.GoModulePkg,
Metadata: pkg.GolangModuleEntry{
H1Digest: digests[fmt.Sprintf("%s %s", m.Mod.Path, m.Mod.Version)],
},
}
goModPkg.SetID()
goModPackages[m.Mod.Path] = goModPkg
}
}
return goModPackages
}
// applyReplaceDirectives processes replace directives from go.mod
func (c *goModCataloger) applyReplaceDirectives(ctx context.Context, resolver file.Resolver, modFile *modfile.File, sourceModules map[string]*packages.Module, goModPackages map[string]pkg.Package, reader file.LocationReadCloser, digests map[string]string) {
for _, m := range modFile.Replace {
if sourceModules != nil && sourceModules[m.Old.Path] != nil {
continue
}
lics := c.licenseResolver.getLicenses(ctx, resolver, m.New.Path, m.New.Version)
var finalPath string
if !strings.HasPrefix(m.New.Path, ".") && !strings.HasPrefix(m.New.Path, "/") {
finalPath = m.New.Path
delete(goModPackages, m.Old.Path)
} else {
finalPath = m.Old.Path
}
goModPkg := pkg.Package{
Name: finalPath,
Version: m.New.Version,
Licenses: pkg.NewLicenseSet(lics...),
Locations: file.NewLocationSet(reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
PURL: packageURL(finalPath, m.New.Version),
Language: pkg.Go,
Type: pkg.GoModulePkg,
Metadata: pkg.GolangModuleEntry{
H1Digest: digests[fmt.Sprintf("%s %s", finalPath, m.New.Version)],
},
}
goModPkg.SetID()
goModPackages[finalPath] = goModPkg
}
}
func (c *goModCataloger) applyExcludeDirectives(modFile *modfile.File, goModPackages map[string]pkg.Package) {
for _, m := range modFile.Exclude {
delete(goModPackages, m.Mod.Path)
}
}
func (c *goModCataloger) assembleResults(catalogedPkgs []pkg.Package, goModPackages map[string]pkg.Package) []pkg.Package {
pkgsSlice := make([]pkg.Package, 0)
pkgsSlice = append(pkgsSlice, catalogedPkgs...)
for _, p := range goModPackages {
pkgsSlice = append(pkgsSlice, p)
}
pkg.Sort(pkgsSlice)
return pkgsSlice
}
func parseGoSumFile(resolver file.Resolver, reader file.LocationReadCloser) (map[string]string, error) {
out := map[string]string{}
if resolver == nil {
return out, fmt.Errorf("no resolver provided")
}
goSumPath := strings.TrimSuffix(reader.RealPath, ".mod") + ".sum"
goSumLocation := resolver.RelativeFileByPath(reader.Location, goSumPath)
if goSumLocation == nil {
return nil, fmt.Errorf("unable to resolve: %s", goSumPath)
}
contents, err := resolver.FileContentsByLocation(*goSumLocation)
if err != nil {
return nil, err
}
defer internal.CloseAndLogError(contents, goSumLocation.AccessPath)
// go.sum has the format like:
// github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
// github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
// github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
scanner := internal.NewLineScanner(contents)
// optionally, resize scanner's capacity for lines over 64K, see next example
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, " ")
if len(parts) < 3 {
continue
}
nameVersion := fmt.Sprintf("%s %s", parts[0], parts[1])
hash := parts[2]
out[nameVersion] = hash
}
if err := scanner.Err(); err != nil {
return out, fmt.Errorf("unable to read go.sum: %w", err)
}
return out, nil
}
// createSourceMetadata creates metadata for packages found through source analysis using build.Default
func createSourceMetadata(h1Digest string) pkg.GolangSourceEntry {
return pkg.GolangSourceEntry{
H1Digest: h1Digest,
OperatingSystem: build.Default.GOOS,
Architecture: build.Default.GOARCH,
BuildTags: strings.Join(build.Default.BuildTags, ","),
CgoEnabled: build.Default.CgoEnabled,
}
}
func resolvePkgDir(p *packages.Package) string {
switch {
case len(p.GoFiles) > 0:
return filepath.Dir(p.GoFiles[0])
case len(p.CompiledGoFiles) > 0:
return filepath.Dir(p.CompiledGoFiles[0])
case len(p.OtherFiles) > 0:
return filepath.Dir(p.OtherFiles[0])
default:
return ""
}
}
func shouldSkipVisit(p *packages.Package) bool {
// skip packages that don't have module info
if p.Module == nil {
return true
}
// skip stdlib
if isStdLib(p) {
return true
}
return false
}
// isStdLib returns true if this package is part of the Go standard library.
func isStdLib(pkg *packages.Package) bool {
if pkg.Name == "unsafe" {
// Special case unsafe stdlib, because it does not contain go files.
return true
}
if len(pkg.GoFiles) == 0 {
return false
}
prefix := build.Default.GOROOT
sep := string(filepath.Separator)
if !strings.HasSuffix(prefix, sep) {
prefix += sep
}
return strings.HasPrefix(pkg.GoFiles[0], prefix)
}
// handle replace directives
func newModule(mod *packages.Module) *packages.Module {
// Example of a module with replace directive: k8s.io/kubernetes => k8s.io/kubernetes v1.11.1
// {
// "Path": "k8s.io/kubernetes",
// "Version": "v0.17.9",
// "Replace": {
// "Path": "k8s.io/kubernetes",
// "Version": "v1.11.1",
// "Time": "2018-07-17T04:20:29Z",
// "Dir": "/home/gongyuan_kubeflow_org/go/pkg/mod/k8s.io/kubernetes@v1.11.1",
// "GoMod": "/home/gongyuan_kubeflow_org/go/pkg/mod/cache/download/k8s.io/kubernetes/@v/v1.11.1.mod"
// },
// "Dir": "/home/gongyuan_kubeflow_org/go/pkg/mod/k8s.io/kubernetes@v1.11.1",
// "GoMod": "/home/gongyuan_kubeflow_org/go/pkg/mod/cache/download/k8s.io/kubernetes/@v/v1.11.1.mod"
// }
// handle replace directives
// Note, we specifically want to replace version field.
// Haven't confirmed, but we may also need to override the
// entire struct when using replace directive with local folders.
tmp := *mod
if tmp.Replace != nil {
tmp = *tmp.Replace
}
return &tmp
}
func isRelativeImportOrMain(p string) bool {
if p == "main" {
return true
}
// true for ".", "..", "./...", "../..."
return build.IsLocalImport(p)
}