diff --git a/internal/task/package_tasks.go b/internal/task/package_tasks.go index 3347c50ddb7..7401da16d5b 100644 --- a/internal/task/package_tasks.go +++ b/internal/task/package_tasks.go @@ -13,6 +13,7 @@ import ( "github.com/anchore/syft/syft/pkg/cataloger/dart" "github.com/anchore/syft/syft/pkg/cataloger/debian" "github.com/anchore/syft/syft/pkg/cataloger/dotnet" + "github.com/anchore/syft/syft/pkg/cataloger/electron" "github.com/anchore/syft/syft/pkg/cataloger/elixir" "github.com/anchore/syft/syft/pkg/cataloger/erlang" "github.com/anchore/syft/syft/pkg/cataloger/gentoo" @@ -180,6 +181,7 @@ func DefaultPackageTaskFactories() Factories { newSimplePackageTaskFactory(conda.NewCondaMetaCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.PackageTag, "conda"), newSimplePackageTaskFactory(snap.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "snap"), newSimplePackageTaskFactory(ai.NewGGUFCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, "ai", "model", "gguf", "ml"), + newSimplePackageTaskFactory(electron.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.ImageTag, pkgcataloging.LanguageTag, "electron", JavaScript, "asar"), // deprecated catalogers //////////////////////////////////////// // these are catalogers that should not be selectable other than specific inclusion via name or "deprecated" tag (to remain backwards compatible) diff --git a/syft/pkg/cataloger/binary/capabilities.yaml b/syft/pkg/cataloger/binary/capabilities.yaml index c98e5c57e68..8f0d3f20dea 100644 --- a/syft/pkg/cataloger/binary/capabilities.yaml +++ b/syft/pkg/cataloger/binary/capabilities.yaml @@ -666,6 +666,36 @@ catalogers: cpes: - cpe:2.3:a:google:chrome:*:*:*:*:*:*:*:* type: BinaryPkg + - method: glob + criteria: + - '**/Electron Framework' + packages: + - class: electron-binary-macos + name: electron + purl: pkg:generic/electron + cpes: + - cpe:2.3:a:electronjs:electron:*:*:*:*:*:*:*:* + type: BinaryPkg + - method: glob + criteria: + - '**/electron' + packages: + - class: electron-binary + name: electron + purl: pkg:generic/electron + cpes: + - cpe:2.3:a:electronjs:electron:*:*:*:*:*:*:*:* + type: BinaryPkg + - method: glob + criteria: + - '**/{code,Code.exe}' + packages: + - class: visual-studio-code-binary + name: electron + purl: pkg:generic/electron + cpes: + - cpe:2.3:a:electronjs:electron:*:*:*:*:*:*:*:* + type: BinaryPkg - method: glob criteria: - '**/ffmpeg' diff --git a/syft/pkg/cataloger/binary/classifier_cataloger_test.go b/syft/pkg/cataloger/binary/classifier_cataloger_test.go index a64ea072b4e..031672de9bc 100644 --- a/syft/pkg/cataloger/binary/classifier_cataloger_test.go +++ b/syft/pkg/cataloger/binary/classifier_cataloger_test.go @@ -1917,6 +1917,28 @@ func Test_Cataloger_PositiveCases(t *testing.T) { Metadata: metadata("chrome-binary"), }, }, + { + logicalFixture: "electron/28.0.0/linux-amd64", + expected: pkg.Package{ + Name: "electron", + Version: "28.0.0", + Type: "binary", + PURL: "pkg:generic/electron@28.0.0", + Locations: locations("electron"), + Metadata: metadata("electron-binary"), + }, + }, + { + logicalFixture: "electron-framework/28.0.0/darwin-arm64", + expected: pkg.Package{ + Name: "electron", + Version: "28.0.0", + Type: "binary", + PURL: "pkg:generic/electron@28.0.0", + Locations: locations("Electron Framework"), + Metadata: metadata("electron-binary-macos"), + }, + }, { logicalFixture: "ffmpeg/7.1.1/darwin-arm64", expected: pkg.Package{ diff --git a/syft/pkg/cataloger/binary/classifiers.go b/syft/pkg/cataloger/binary/classifiers.go index 56f7865debf..73e41931efa 100644 --- a/syft/pkg/cataloger/binary/classifiers.go +++ b/syft/pkg/cataloger/binary/classifiers.go @@ -913,6 +913,40 @@ func DefaultClassifiers() []binutils.Classifier { PURL: mustPURL("pkg:generic/chrome@version"), CPEs: singleCPE("cpe:2.3:a:google:chrome:*:*:*:*:*:*:*:*"), }, + { + Class: "electron-binary-macos", + FileGlob: "**/Electron Framework", + EvidenceMatcher: m.FileContentsVersionMatcher( + // Electron/28.0.0 pattern found in Electron framework binaries + `Electron/(?P[0-9]+\.[0-9]+\.[0-9]+)`), + Package: "electron", + PURL: mustPURL("pkg:generic/electron@version"), + CPEs: singleCPE("cpe:2.3:a:electronjs:electron:*:*:*:*:*:*:*:*", cpe.NVDDictionaryLookupSource), + }, + { + Class: "electron-binary", + FileGlob: "**/electron", + EvidenceMatcher: binutils.MatchAll( + // Match both Electron and Chrome version patterns to confirm it's Electron + m.FileContentsVersionMatcher(`Electron/(?P[0-9]+\.[0-9]+\.[0-9]+)`), + m.FileContentsVersionMatcher(`Chrome/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+`), + ), + Package: "electron", + PURL: mustPURL("pkg:generic/electron@version"), + CPEs: singleCPE("cpe:2.3:a:electronjs:electron:*:*:*:*:*:*:*:*", cpe.NVDDictionaryLookupSource), + }, + { + // VS Code renames the electron bin: code (linux) / Code.exe (win) + Class: "visual-studio-code-binary", + FileGlob: "**/{code,Code.exe}", + EvidenceMatcher: binutils.MatchAll( + m.FileContentsVersionMatcher(`Electron/(?P[0-9]+\.[0-9]+\.[0-9]+)`), + m.FileContentsVersionMatcher(`Chrome/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+`), + ), + Package: "electron", + PURL: mustPURL("pkg:generic/electron@version"), + CPEs: singleCPE("cpe:2.3:a:electronjs:electron:*:*:*:*:*:*:*:*", cpe.NVDDictionaryLookupSource), + }, { Class: "ffmpeg-binary", FileGlob: "**/ffmpeg", diff --git a/syft/pkg/cataloger/binary/testdata/classifiers/snippets/electron-framework/28.0.0/darwin-arm64/Electron Framework b/syft/pkg/cataloger/binary/testdata/classifiers/snippets/electron-framework/28.0.0/darwin-arm64/Electron Framework new file mode 100644 index 00000000000..2fe9de08db5 Binary files /dev/null and b/syft/pkg/cataloger/binary/testdata/classifiers/snippets/electron-framework/28.0.0/darwin-arm64/Electron Framework differ diff --git a/syft/pkg/cataloger/binary/testdata/classifiers/snippets/electron/28.0.0/linux-amd64/electron b/syft/pkg/cataloger/binary/testdata/classifiers/snippets/electron/28.0.0/linux-amd64/electron new file mode 100644 index 00000000000..f879b585535 Binary files /dev/null and b/syft/pkg/cataloger/binary/testdata/classifiers/snippets/electron/28.0.0/linux-amd64/electron differ diff --git a/syft/pkg/cataloger/electron/capabilities.yaml b/syft/pkg/cataloger/electron/capabilities.yaml new file mode 100644 index 00000000000..d9fedc93e18 --- /dev/null +++ b/syft/pkg/cataloger/electron/capabilities.yaml @@ -0,0 +1,83 @@ +# Cataloger capabilities. See ../README.md for documentation. + +catalogers: + - ecosystem: javascript # MANUAL + name: electron-cataloger # AUTO-GENERATED + type: generic # AUTO-GENERATED + source: # AUTO-GENERATED + file: syft/pkg/cataloger/electron/cataloger.go + function: NewCataloger + selectors: # AUTO-GENERATED + - asar + - directory + - electron + - image + - javascript + - language + - package + parsers: # AUTO-GENERATED structure + - function: parsePackageJSON + detector: # AUTO-GENERATED + method: glob # AUTO-GENERATED + criteria: # AUTO-GENERATED + - '**/Contents/Resources/app/node_modules/*/package.json' + - '**/Contents/Resources/app/node_modules/*/*/package.json' + - '**/Contents/Resources/app/node_modules/*/*/*/package.json' + - '**/resources/app/node_modules/*/package.json' + - '**/resources/app/node_modules/*/*/package.json' + - '**/resources/app/node_modules/*/*/*/package.json' + capabilities: # MANUAL - preserved across regeneration + - name: license + default: true + - name: dependency.depth + default: + - direct + - indirect + - name: dependency.edges + default: "" + - name: dependency.kinds + default: + - runtime + - name: package_manager.files.listing + default: false + - name: package_manager.files.digests + default: false + - name: package_manager.package_integrity_hash + default: false + - function: parseAsarArchive + detector: # AUTO-GENERATED + method: glob # AUTO-GENERATED + criteria: # AUTO-GENERATED + - '**/app.asar' + - '**/Contents/Resources/app.asar' + - '**/Contents/Resources/electron.asar' + - '**/Contents/Resources/app/node_modules.asar' + - '**/resources/app.asar' + - '**/resources/electron.asar' + - '**/resources/app/node_modules.asar' + metadata_types: # AUTO-GENERATED + - pkg.NpmPackage + package_types: # AUTO-GENERATED + - npm + purl_types: # AUTO-GENERATED + - npm + json_schema_types: # AUTO-GENERATED + - JavascriptNpmPackage + capabilities: # MANUAL - preserved across regeneration + - name: license + default: true + - name: dependency.depth + default: + - direct + - indirect + - name: dependency.edges + default: "" + - name: dependency.kinds + default: + - runtime + - name: package_manager.files.listing + default: false + - name: package_manager.files.digests + default: false + - name: package_manager.package_integrity_hash + default: false diff --git a/syft/pkg/cataloger/electron/cataloger.go b/syft/pkg/cataloger/electron/cataloger.go new file mode 100644 index 00000000000..f6a7fe03a7c --- /dev/null +++ b/syft/pkg/cataloger/electron/cataloger.go @@ -0,0 +1,30 @@ +package electron + +import ( + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/generic" +) + +const catalogerName = "electron-cataloger" + +// NewCataloger returns a cataloger for packaged Electron apps. +func NewCataloger() pkg.Cataloger { + return generic.NewCataloger(catalogerName). + WithParserByGlobs(parseAsarArchive, + "**/app.asar", + "**/Contents/Resources/app.asar", // macOS + "**/Contents/Resources/electron.asar", // macOS alt + "**/Contents/Resources/app/node_modules.asar", // macOS VS Code style + "**/resources/app.asar", // Linux/Win + "**/resources/electron.asar", // Linux/Win alt + "**/resources/app/node_modules.asar", // Linux/Win VS Code style + ). + WithParserByGlobs(parsePackageJSON, + "**/Contents/Resources/app/node_modules/*/package.json", // macOS + "**/Contents/Resources/app/node_modules/*/*/package.json", // macOS scoped + "**/Contents/Resources/app/node_modules/*/*/*/package.json", // macOS nested + "**/resources/app/node_modules/*/package.json", // Linux/Win + "**/resources/app/node_modules/*/*/package.json", // Linux/Win scoped + "**/resources/app/node_modules/*/*/*/package.json", // Linux/Win nested + ) +} diff --git a/syft/pkg/cataloger/electron/cataloger_test.go b/syft/pkg/cataloger/electron/cataloger_test.go new file mode 100644 index 00000000000..5c5f64decfa --- /dev/null +++ b/syft/pkg/cataloger/electron/cataloger_test.go @@ -0,0 +1,399 @@ +package electron + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" +) + +func TestCataloger_Name(t *testing.T) { + c := NewCataloger() + assert.Equal(t, catalogerName, c.Name()) +} + +func TestParseAsarArchive(t *testing.T) { + asarPath := filepath.Join("test-fixtures", "app.asar") + + f, err := os.Open(asarPath) + require.NoError(t, err) + defer f.Close() + + location := file.NewLocation(asarPath) + reader := file.NewLocationReadCloser(location, f) + + pkgs, relationships, err := parseAsarArchive(context.Background(), nil, nil, reader) + require.NoError(t, err) + assert.Empty(t, relationships) + require.Len(t, pkgs, 2, "expected 2 packages: test-app and lodash") + + pkgMap := make(map[string]pkg.Package) + for _, p := range pkgs { + pkgMap[p.Name] = p + } + + testApp, ok := pkgMap["test-app"] + require.True(t, ok, "test-app package not found") + assert.Equal(t, "1.0.0", testApp.Version) + assert.Equal(t, pkg.NpmPkg, testApp.Type) + assert.Equal(t, pkg.JavaScript, testApp.Language) + assert.Contains(t, testApp.PURL, "pkg:npm/test-app@1.0.0") + + lodash, ok := pkgMap["lodash"] + require.True(t, ok, "lodash package not found") + assert.Equal(t, "4.17.21", lodash.Version) + assert.Equal(t, pkg.NpmPkg, lodash.Type) + assert.Equal(t, pkg.JavaScript, lodash.Language) + assert.Contains(t, lodash.PURL, "pkg:npm/lodash@4.17.21") + + for _, p := range pkgs { + for _, loc := range p.Locations.ToSlice() { + assert.Contains(t, loc.AccessPath, "app.asar:") + } + } +} + +func TestParsePackageJSONFromContents(t *testing.T) { + tests := []struct { + name string + contents string + expected pkg.Package + }{ + { + name: "simple package", + contents: `{ + "name": "lodash", + "version": "4.17.21", + "description": "Lodash modular utilities", + "license": "MIT" + }`, + expected: pkg.Package{ + Name: "lodash", + Version: "4.17.21", + Type: pkg.NpmPkg, + Language: pkg.JavaScript, + }, + }, + { + name: "scoped package", + contents: `{ + "name": "@babel/core", + "version": "7.23.0", + "description": "Babel compiler core" + }`, + expected: pkg.Package{ + Name: "@babel/core", + Version: "7.23.0", + Type: pkg.NpmPkg, + Language: pkg.JavaScript, + }, + }, + { + name: "author as object", + contents: `{ + "name": "test-pkg", + "version": "1.0.0", + "author": { + "name": "John Doe", + "email": "john@example.com", + "url": "https://example.com" + } + }`, + expected: pkg.Package{ + Name: "test-pkg", + Version: "1.0.0", + Type: pkg.NpmPkg, + Language: pkg.JavaScript, + }, + }, + { + name: "license as object", + contents: `{ + "name": "test-pkg", + "version": "2.0.0", + "license": { + "type": "Apache-2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + } + }`, + expected: pkg.Package{ + Name: "test-pkg", + Version: "2.0.0", + Type: pkg.NpmPkg, + Language: pkg.JavaScript, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + location := file.NewLocation("test/package.json") + p, err := parsePackageJSONFromContents([]byte(tt.contents), location) + require.NoError(t, err) + + assert.Equal(t, tt.expected.Name, p.Name) + assert.Equal(t, tt.expected.Version, p.Version) + assert.Equal(t, tt.expected.Type, p.Type) + assert.Equal(t, tt.expected.Language, p.Language) + }) + } +} + +func TestExtractAuthor(t *testing.T) { + tests := []struct { + name string + author any + expected string + }{ + { + name: "nil author", + author: nil, + expected: "", + }, + { + name: "string author", + author: "John Doe ", + expected: "John Doe ", + }, + { + name: "object author with all fields", + author: map[string]any{ + "name": "John Doe", + "email": "john@example.com", + "url": "https://example.com", + }, + expected: "John Doe (https://example.com)", + }, + { + name: "object author with name only", + author: map[string]any{ + "name": "John Doe", + }, + expected: "John Doe", + }, + { + name: "unsupported type", + author: 123, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractAuthor(tt.author) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractLicense(t *testing.T) { + tests := []struct { + name string + license any + expected string + }{ + { + name: "nil license", + license: nil, + expected: "", + }, + { + name: "string license", + license: "MIT", + expected: "MIT", + }, + { + name: "object license", + license: map[string]any{ + "type": "Apache-2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0", + }, + expected: "Apache-2.0", + }, + { + name: "unsupported type", + license: []string{"MIT", "Apache-2.0"}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractLicense(tt.license) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractRepositoryURL(t *testing.T) { + tests := []struct { + name string + repo any + expected string + }{ + { + name: "nil repo", + repo: nil, + expected: "", + }, + { + name: "string repo", + repo: "https://github.com/user/repo", + expected: "https://github.com/user/repo", + }, + { + name: "object repo", + repo: map[string]any{ + "type": "git", + "url": "git+https://github.com/user/repo.git", + }, + expected: "git+https://github.com/user/repo.git", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractRepositoryURL(tt.repo) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPackageURL(t *testing.T) { + tests := []struct { + name string + pkgName string + version string + expected string + }{ + { + name: "simple package", + pkgName: "lodash", + version: "4.17.21", + expected: "pkg:npm/lodash@4.17.21", + }, + { + name: "scoped package", + pkgName: "@babel/core", + version: "7.23.0", + expected: "pkg:npm/%40babel/core@7.23.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := packageURL(tt.pkgName, tt.version) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseAsarArchive_InvalidASAR(t *testing.T) { + location := file.NewLocation("invalid.asar") + + tmpPath := filepath.Join(t.TempDir(), "invalid.asar") + require.NoError(t, os.WriteFile(tmpPath, []byte("invalid asar content"), 0644)) + + f, err := os.Open(tmpPath) + require.NoError(t, err) + + reader := file.NewLocationReadCloser(location, f) + + pkgs, _, err := parseAsarArchive(context.Background(), nil, nil, reader) + assert.Error(t, err) + assert.Nil(t, pkgs) +} + +func TestParsePackageJSONFromContents_InvalidJSON(t *testing.T) { + location := file.NewLocation("test/package.json") + + _, err := parsePackageJSONFromContents([]byte("not valid json"), location) + assert.Error(t, err) + + p, err := parsePackageJSONFromContents([]byte("{}"), location) + assert.NoError(t, err) + assert.Empty(t, p.Name) + assert.Empty(t, p.Version) +} + +func TestFindPackageJSONFilesFromHeader_NoPackages(t *testing.T) { + header := &asarHeader{ + Files: map[string]asarEntry{}, + } + paths := findPackageJSONFilesFromHeader(header) + assert.Empty(t, paths) +} + +func TestFindPackageJSONFilesFromHeader_WithPackages(t *testing.T) { + header := &asarHeader{ + Files: map[string]asarEntry{ + "package.json": {Size: 100, Offset: "0"}, + "node_modules": { + Files: map[string]asarEntry{ + "lodash": { + Files: map[string]asarEntry{ + "package.json": {Size: 50, Offset: "100"}, + }, + }, + }, + }, + }, + } + paths := findPackageJSONFilesFromHeader(header) + assert.Len(t, paths, 2) + assert.Contains(t, paths, "package.json") + assert.Contains(t, paths, "node_modules/lodash/package.json") +} + +func TestParseAsarHeader(t *testing.T) { + asarPath := filepath.Join("test-fixtures", "app.asar") + data, err := os.ReadFile(asarPath) + require.NoError(t, err) + + header, headerSize, err := parseAsarHeader(data) + require.NoError(t, err) + assert.NotNil(t, header) + assert.True(t, headerSize > 0) + + _, hasRootPkg := header.Files["package.json"] + assert.True(t, hasRootPkg, "should have root package.json") + + _, hasNodeModules := header.Files["node_modules"] + assert.True(t, hasNodeModules, "should have node_modules") +} + +func TestFindEntry(t *testing.T) { + header := &asarHeader{ + Files: map[string]asarEntry{ + "package.json": {Size: 100, Offset: "0"}, + "node_modules": { + Files: map[string]asarEntry{ + "lodash": { + Files: map[string]asarEntry{ + "package.json": {Size: 50, Offset: "100"}, + }, + }, + }, + }, + }, + } + + entry, found := findEntry(header, "package.json") + assert.True(t, found) + assert.NotNil(t, entry) + assert.Equal(t, int64(100), entry.Size) + + entry, found = findEntry(header, "node_modules/lodash/package.json") + assert.True(t, found) + assert.NotNil(t, entry) + assert.Equal(t, int64(50), entry.Size) + + _, found = findEntry(header, "nonexistent.json") + assert.False(t, found) +} diff --git a/syft/pkg/cataloger/electron/parse_asar.go b/syft/pkg/cataloger/electron/parse_asar.go new file mode 100644 index 00000000000..81697c0c7d1 --- /dev/null +++ b/syft/pkg/cataloger/electron/parse_asar.go @@ -0,0 +1,372 @@ +package electron + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/anchore/packageurl-go" + "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/generic" +) + +var ( + _ generic.Parser = parseAsarArchive + _ generic.Parser = parsePackageJSON +) + +func parsePackageJSON(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { + defer internal.CloseAndLogError(reader, reader.Path()) + + p, err := parsePackageJSONFromReader(reader, reader.Location) + if err != nil { + return nil, nil, err + } + + if p.Name == "" || p.Version == "" { + return nil, nil, nil + } + + return []pkg.Package{p}, nil, nil +} + +type asarHeader struct { + Files map[string]asarEntry `json:"files"` +} + +type asarEntry struct { + Files map[string]asarEntry `json:"files,omitempty"` + Size int64 `json:"size,omitempty"` + Offset string `json:"offset,omitempty"` +} + +func parseAsarArchive(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { + defer internal.CloseAndLogError(reader, reader.Path()) + + asarData, err := io.ReadAll(reader) //nolint:gocritic // ASAR is random-access by byte offset; the full archive must be buffered + if err != nil { + return nil, nil, fmt.Errorf("read ASAR: %w", err) + } + + header, headerSize, err := parseAsarHeader(asarData) + if err != nil { + return nil, nil, fmt.Errorf("parse ASAR header: %w", err) + } + + packageJSONPaths := findPackageJSONFilesFromHeader(header) + if len(packageJSONPaths) == 0 { + log.Debugf("no package.json in ASAR: %s", reader.Path()) + return nil, nil, nil + } + + var pkgs []pkg.Package + + for _, pkgPath := range packageJSONPaths { + entry, found := findEntry(header, pkgPath) + if !found { + log.Debugf("entry not found: %s", pkgPath) + continue + } + + contents, err := readAsarFile(asarData, headerSize, entry) + if err != nil { + log.Debugf("read pkg %s: %v", pkgPath, err) + continue + } + + nestedLocation := file.NewLocationFromCoordinates(reader.Coordinates) + nestedLocation.AccessPath = fmt.Sprintf("%s:%s", reader.Path(), pkgPath) + + npmPkg, err := parsePackageJSONFromContents(contents, nestedLocation) + if err != nil { + log.Debugf("parse pkg %s: %v", pkgPath, err) + continue + } + + if npmPkg.Name == "" || npmPkg.Version == "" { + continue + } + + pkgs = append(pkgs, npmPkg) + } + + log.Debugf("%d pkgs in ASAR: %s", len(pkgs), reader.Path()) + + return pkgs, nil, nil +} + +// parseAsarHeader decodes ASAR Chromium pickle format: +// [4B pickle hdr][4B hdr size w/ pad][4B hdr size][4B json size][json][pad][files] +func parseAsarHeader(data []byte) (*asarHeader, int64, error) { + if len(data) < 16 { + return nil, 0, errors.New("ASAR file too small") + } + + pickleSize := binary.LittleEndian.Uint32(data[0:4]) + if pickleSize != 4 { + return nil, 0, fmt.Errorf("bad pickle hdr size: %d", pickleSize) + } + + headerSize1 := binary.LittleEndian.Uint32(data[4:8]) + jsonSize := binary.LittleEndian.Uint32(data[12:16]) + + jsonStart := int64(16) + jsonEnd := jsonStart + int64(jsonSize) + + if int64(len(data)) < jsonEnd { + return nil, 0, fmt.Errorf("ASAR truncated: need %d bytes", jsonEnd) + } + + var header asarHeader + if err := json.Unmarshal(data[jsonStart:jsonEnd], &header); err != nil { + return nil, 0, fmt.Errorf("unmarshal ASAR header: %w", err) + } + + contentStart := int64(8) + int64(headerSize1) + + return &header, contentStart, nil +} + +func findEntry(header *asarHeader, entryPath string) (*asarEntry, bool) { + parts := strings.Split(entryPath, "/") + + current := header.Files + for i, part := range parts { + entry, ok := current[part] + if !ok { + return nil, false + } + + if i == len(parts)-1 { + return &entry, true + } + + current = entry.Files + } + + return nil, false +} + +func readAsarFile(data []byte, headerSize int64, entry *asarEntry) ([]byte, error) { + var offset int64 + if entry.Offset != "" { + var err error + offset, err = strconv.ParseInt(entry.Offset, 10, 64) + if err != nil { + return nil, fmt.Errorf("parse offset: %w", err) + } + } + + start := headerSize + offset + end := start + entry.Size + + if end > int64(len(data)) { + return nil, errors.New("file extends beyond ASAR bounds") + } + + return data[start:end], nil +} + +func findPackageJSONFilesFromHeader(header *asarHeader) []string { + var paths []string + walkHeader(header.Files, "", func(entryPath string) { + if filepath.Base(entryPath) != "package.json" { + return + } + + if entryPath == "package.json" { + paths = append(paths, entryPath) + return + } + + dir := path.Dir(entryPath) + + if strings.Contains(entryPath, "node_modules/") { + if path.Base(dir) == "node_modules" { + return + } + paths = append(paths, entryPath) + return + } + + // root-level pkgs in node_modules.asar + if !strings.Contains(dir, "/") && dir != "" { + paths = append(paths, entryPath) + return + } + + // nested pkgs under top-level + if dir != "" && !strings.HasPrefix(entryPath, ".") { + paths = append(paths, entryPath) + } + }) + + return paths +} + +func walkHeader(files map[string]asarEntry, currentPath string, fn func(path string)) { + for name, entry := range files { + entryPath := name + if currentPath != "" { + entryPath = path.Join(currentPath, name) + } + + if entry.Files != nil { + walkHeader(entry.Files, entryPath, fn) + } else { + fn(entryPath) + } + } +} + +type packageJSONForParsing struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author any `json:"author"` + Homepage string `json:"homepage"` + License any `json:"license"` + Repository any `json:"repository"` + Private bool `json:"private"` +} + +func parsePackageJSONFromContents(contents []byte, location file.Location) (pkg.Package, error) { + return parsePackageJSONFromReader(bytes.NewReader(contents), location) +} + +func parsePackageJSONFromReader(reader io.Reader, location file.Location) (pkg.Package, error) { + var pkgJSON packageJSONForParsing + if err := json.NewDecoder(skipBOM(reader)).Decode(&pkgJSON); err != nil { + return pkg.Package{}, fmt.Errorf("unmarshal package.json: %w", err) + } + + author := extractAuthor(pkgJSON.Author) + + license := extractLicense(pkgJSON.License) + + repoURL := extractRepositoryURL(pkgJSON.Repository) + + var licenses pkg.LicenseSet + if license != "" { + licenses = pkg.NewLicenseSet(pkg.NewLicenseFromLocations(license, location)) + } + + p := pkg.Package{ + Name: pkgJSON.Name, + Version: pkgJSON.Version, + PURL: packageURL(pkgJSON.Name, pkgJSON.Version), + Locations: file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)), + Language: pkg.JavaScript, + Licenses: licenses, + Type: pkg.NpmPkg, + Metadata: pkg.NpmPackage{ + Name: pkgJSON.Name, + Version: pkgJSON.Version, + Description: pkgJSON.Description, + Author: author, + Homepage: pkgJSON.Homepage, + URL: repoURL, + Private: pkgJSON.Private, + }, + } + + p.SetID() + + return p, nil +} + +func skipBOM(reader io.Reader) io.Reader { + br := bufio.NewReader(reader) + if prefix, err := br.Peek(3); err == nil && bytes.Equal(prefix, []byte{0xef, 0xbb, 0xbf}) { + _, _ = br.Discard(3) + } + return br +} + +func extractAuthor(author any) string { + if author == nil { + return "" + } + + switch v := author.(type) { + case string: + return v + case map[string]any: + var parts []string + if name, ok := v["name"].(string); ok && name != "" { + parts = append(parts, name) + } + if email, ok := v["email"].(string); ok && email != "" { + parts = append(parts, fmt.Sprintf("<%s>", email)) + } + if url, ok := v["url"].(string); ok && url != "" { + parts = append(parts, fmt.Sprintf("(%s)", url)) + } + return strings.Join(parts, " ") + default: + return "" + } +} + +func extractLicense(license any) string { + if license == nil { + return "" + } + + switch v := license.(type) { + case string: + return v + case map[string]any: + if licType, ok := v["type"].(string); ok { + return licType + } + } + return "" +} + +func extractRepositoryURL(repo any) string { + if repo == nil { + return "" + } + + switch v := repo.(type) { + case string: + return v + case map[string]any: + if url, ok := v["url"].(string); ok { + return url + } + } + return "" +} + +func packageURL(name, version string) string { + var namespace string + + if ns, n, ok := strings.Cut(name, "/"); ok { + namespace = ns + name = n + } + + return packageurl.NewPackageURL( + packageurl.TypeNPM, + namespace, + name, + version, + nil, + "", + ).ToString() +} diff --git a/syft/pkg/cataloger/electron/test-fixtures/app.asar b/syft/pkg/cataloger/electron/test-fixtures/app.asar new file mode 100644 index 00000000000..a0f2b91afa5 Binary files /dev/null and b/syft/pkg/cataloger/electron/test-fixtures/app.asar differ