feat(elixir): emit dependency relationships from mix.lock (#4985)

adds dependency-of relationships between elixir locked packages, matching how other
ecosystem catalogers (alpine, arch, debian, redhat, python) express the
dependency graph via the shared dependency.Processor/Specifier mechanism.

Signed-off-by: Chris Greeno <cgreeno@gmail.com>
This commit is contained in:
Chris Greeno 2026-06-29 15:22:38 +01:00 committed by GitHub
parent 1143c12a97
commit 37fee88b5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 4593 additions and 39 deletions

View File

@ -3,7 +3,7 @@ package internal
const (
// JSONSchemaVersion is the current schema version output by the JSON encoder
// This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment.
JSONSchemaVersion = "16.1.5"
JSONSchemaVersion = "16.1.6"
// Changelog
// 16.1.0 - reformulated the python pdm fields (added "URL" and removed the unused "path" field).
@ -12,5 +12,6 @@ const (
// 16.1.3 - add GGUFFileParts to GGUFFileHeader metadata
// 16.1.4 - add BunLockEntry metadata type for bun.lock support
// 16.1.5 - add DenoLockEntry and DenoRemoteLockEntry metadata types for deno.lock support
// 16.1.6 - add Dependencies to ElixirMixLockEntry metadata
)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "anchore.io/schema/syft/json/16.1.5/document",
"$id": "anchore.io/schema/syft/json/16.1.6/document",
"$ref": "#/$defs/Document",
"$defs": {
"AlpmDbEntry": {
@ -1267,6 +1267,13 @@
"pkgHashExt": {
"type": "string",
"description": "PkgHashExt is the extended package hash format (inner checksum is deprecated - SHA-256 of concatenated file contents excluding CHECKSUM file, now replaced by outer checksum)"
},
"dependencies": {
"items": {
"type": "string"
},
"type": "array",
"description": "Dependencies are the names of the packages this entry depends on, as\ndeclared in the entry's dependency list within mix.lock. Used to derive\ndependency-of relationships between locked packages."
}
},
"type": "object",

View File

@ -6,10 +6,12 @@ package elixir
import (
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
"github.com/anchore/syft/syft/pkg/cataloger/internal/dependency"
)
// NewMixLockCataloger returns a cataloger object for Elixir mix.lock files.
func NewMixLockCataloger() pkg.Cataloger {
return generic.NewCataloger("elixir-mix-lock-cataloger").
WithParserByGlobs(parseMixLock, "**/mix.lock")
WithParserByGlobs(parseMixLock, "**/mix.lock").
WithProcessors(dependency.Processor(mixLockDependencySpecifier))
}

View File

@ -30,3 +30,15 @@ func TestCataloger_Globs(t *testing.T) {
})
}
}
func TestCataloger_Relationships(t *testing.T) {
expectedRelationships := []string{
"cowlib @ 2.11.0 (mix.lock) [dependency-of] cowboy @ 2.9.0 (mix.lock)",
"ranch @ 1.8.0 (mix.lock) [dependency-of] cowboy @ 2.9.0 (mix.lock)",
}
pkgtest.NewCatalogTester().
FromDirectory(t, "testdata/relationships").
ExpectsRelationshipStrings(expectedRelationships).
TestCataloger(t, NewMixLockCataloger())
}

View File

@ -0,0 +1,28 @@
package elixir
import (
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/dependency"
)
var _ dependency.Specifier = mixLockDependencySpecifier
// mixLockDependencySpecifier declares that a mix.lock entry provides its own
// package name and requires the names listed in its dependency list, so the
// dependency processor can resolve dependency-of relationships between the
// locked packages.
func mixLockDependencySpecifier(p pkg.Package) dependency.Specification {
meta, ok := p.Metadata.(pkg.ElixirMixLockEntry)
if !ok {
log.Tracef("cataloger failed to extract mix.lock metadata for package %+v", p.Name)
return dependency.Specification{}
}
return dependency.Specification{
ProvidesRequires: dependency.ProvidesRequires{
Provides: []string{p.Name},
Requires: meta.Dependencies,
},
}
}

View File

@ -0,0 +1,103 @@
package elixir
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/dependency"
)
func Test_extractMixLockDependencies(t *testing.T) {
tests := []struct {
name string
line string
want []string
}{
{
name: "no dependencies",
line: ` "castore": {:hex, :castore, "0.1.17", "hash", [:mix], [], "hexpm", "ext"},`,
want: nil,
},
{
name: "single dependency",
line: ` "esbuild": {:hex, :esbuild, "0.5.0", "hash", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ext"},`,
want: []string{"castore"},
},
{
name: "multiple dependencies",
line: ` "cowboy": {:hex, :cowboy, "2.9.0", "hash", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib]}, {:ranch, "1.8.0", [hex: :ranch]}], "hexpm", "ext"},`,
want: []string{"cowlib", "ranch"},
},
{
name: "git source is skipped like hex source",
line: ` "mydep": {:git, "https://github.com/example/mydep.git", "ref", [{:jason, "~> 1.0", [hex: :jason]}]},`,
want: []string{"jason"},
},
{
name: "not a package line",
line: `%{`,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, extractMixLockDependencies(tt.line))
})
}
}
func Test_mixLockDependencySpecifier(t *testing.T) {
tests := []struct {
name string
p pkg.Package
want dependency.Specification
}{
{
name: "provides its name and requires its dependencies",
p: pkg.Package{
Name: "cowboy",
Metadata: pkg.ElixirMixLockEntry{
Name: "cowboy",
Dependencies: []string{"cowlib", "ranch"},
},
},
want: dependency.Specification{
ProvidesRequires: dependency.ProvidesRequires{
Provides: []string{"cowboy"},
Requires: []string{"cowlib", "ranch"},
},
},
},
{
name: "no dependencies still provides its name",
p: pkg.Package{
Name: "castore",
Metadata: pkg.ElixirMixLockEntry{
Name: "castore",
},
},
want: dependency.Specification{
ProvidesRequires: dependency.ProvidesRequires{
Provides: []string{"castore"},
},
},
},
{
name: "wrong metadata type yields empty specification",
p: pkg.Package{
Name: "mystery",
Metadata: pkg.RubyGemspec{},
},
want: dependency.Specification{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, mixLockDependencySpecifier(tt.p))
})
}
}

View File

@ -21,6 +21,14 @@ var _ generic.Parser = parseMixLock
var mixLockDelimiter = regexp.MustCompile(`[%{}\n" ,:]+`)
// mixLockDependency matches each `{:name,` tuple opener on a mix.lock line. The
// first match is the entry's own source tuple (e.g. `{:hex, :name, ...}`); the
// remaining matches are the entry's dependency tuples within its dependency
// list (e.g. `[{:cowlib, ...}, {:ranch, ...}]`). Build-tool lists like
// `[:mix]` or `[:make, :rebar3]` and option keyword lists like
// `[hex: :cowlib, ...]` use bare atoms, not `{:atom,`, so they don't match.
var mixLockDependency = regexp.MustCompile(`\{\s*:(\w+)\s*,`)
// parseMixLock parses a mix.lock and returns the discovered Elixir packages.
func parseMixLock(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
var errs error
@ -56,13 +64,30 @@ func parseMixLock(_ context.Context, _ file.Resolver, _ *generic.Environment, re
packages = append(packages,
newPackage(
pkg.ElixirMixLockEntry{
Name: name,
Version: version,
PkgHash: hash,
PkgHashExt: hashExt,
Name: name,
Version: version,
PkgHash: hash,
PkgHashExt: hashExt,
Dependencies: extractMixLockDependencies(line),
},
reader.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
),
)
}
}
// extractMixLockDependencies returns the names of the packages depended on by
// the entry described on a single mix.lock line, by reading its dependency
// list. The entry's own source tuple (the first `{:atom,` on the line) is
// skipped; everything after it is a dependency.
func extractMixLockDependencies(line string) []string {
matches := mixLockDependency.FindAllStringSubmatch(line, -1)
if len(matches) <= 1 {
return nil
}
deps := make([]string, 0, len(matches)-1)
for _, m := range matches[1:] {
deps = append(deps, m[1])
}
return deps
}

View File

@ -48,10 +48,11 @@ func TestParseMixLock(t *testing.T) {
Locations: locations,
PURL: "pkg:hex/cowboy@2.9.0",
Metadata: pkg.ElixirMixLockEntry{
Name: "cowboy",
Version: "2.9.0",
PkgHash: "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452",
PkgHashExt: "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde",
Name: "cowboy",
Version: "2.9.0",
PkgHash: "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452",
PkgHashExt: "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde",
Dependencies: []string{"cowlib", "ranch"},
},
},
{
@ -62,10 +63,11 @@ func TestParseMixLock(t *testing.T) {
Locations: locations,
PURL: "pkg:hex/cowboy_telemetry@0.4.0",
Metadata: pkg.ElixirMixLockEntry{
Name: "cowboy_telemetry",
Version: "0.4.0",
PkgHash: "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c",
PkgHashExt: "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de",
Name: "cowboy_telemetry",
Version: "0.4.0",
PkgHash: "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c",
PkgHashExt: "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de",
Dependencies: []string{"cowboy", "telemetry"},
},
},
{
@ -90,10 +92,11 @@ func TestParseMixLock(t *testing.T) {
Locations: locations,
PURL: "pkg:hex/db_connection@2.4.2",
Metadata: pkg.ElixirMixLockEntry{
Name: "db_connection",
Version: "2.4.2",
PkgHash: "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3",
PkgHashExt: "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668",
Name: "db_connection",
Version: "2.4.2",
PkgHash: "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3",
PkgHashExt: "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668",
Dependencies: []string{"connection", "telemetry"},
},
},
{
@ -132,10 +135,11 @@ func TestParseMixLock(t *testing.T) {
Locations: locations,
PURL: "pkg:hex/ecto@3.8.1",
Metadata: pkg.ElixirMixLockEntry{
Name: "ecto",
Version: "3.8.1",
PkgHash: "35e0bd8c8eb772e14a5191a538cd079706ecb45164ea08a7523b4fc69ab70f56",
PkgHashExt: "f1b68f8d5fe3ab89e24f57c03db5b5d0aed3602077972098b3a6006a1be4b69b",
Name: "ecto",
Version: "3.8.1",
PkgHash: "35e0bd8c8eb772e14a5191a538cd079706ecb45164ea08a7523b4fc69ab70f56",
PkgHashExt: "f1b68f8d5fe3ab89e24f57c03db5b5d0aed3602077972098b3a6006a1be4b69b",
Dependencies: []string{"decimal", "jason", "telemetry"},
},
},
{
@ -146,10 +150,11 @@ func TestParseMixLock(t *testing.T) {
Locations: locations,
PURL: "pkg:hex/ecto_sql@3.8.1",
Metadata: pkg.ElixirMixLockEntry{
Name: "ecto_sql",
Version: "3.8.1",
PkgHash: "1acaaba32ca0551fd19e492fc7c80414e72fc1a7140fc9395aaa53c2e8629798",
PkgHashExt: "ba7fc75882edce6f2ceca047315d5db27ead773cafea47f1724e35f1e7964525",
Name: "ecto_sql",
Version: "3.8.1",
PkgHash: "1acaaba32ca0551fd19e492fc7c80414e72fc1a7140fc9395aaa53c2e8629798",
PkgHashExt: "ba7fc75882edce6f2ceca047315d5db27ead773cafea47f1724e35f1e7964525",
Dependencies: []string{"db_connection", "ecto", "myxql", "postgrex", "tds", "telemetry"},
},
},
{
@ -160,10 +165,11 @@ func TestParseMixLock(t *testing.T) {
Locations: locations,
PURL: "pkg:hex/esbuild@0.5.0",
Metadata: pkg.ElixirMixLockEntry{
Name: "esbuild",
Version: "0.5.0",
PkgHash: "d5bb08ff049d7880ee3609ed5c4b864bd2f46445ea40b16b4acead724fb4c4a3",
PkgHashExt: "f183a0b332d963c4cfaf585477695ea59eef9a6f2204fdd0efa00e099694ffe5",
Name: "esbuild",
Version: "0.5.0",
PkgHash: "d5bb08ff049d7880ee3609ed5c4b864bd2f46445ea40b16b4acead724fb4c4a3",
PkgHashExt: "f183a0b332d963c4cfaf585477695ea59eef9a6f2204fdd0efa00e099694ffe5",
Dependencies: []string{"castore"},
},
},
{
@ -174,10 +180,11 @@ func TestParseMixLock(t *testing.T) {
Locations: locations,
PURL: "pkg:hex/ex_doc@0.28.4",
Metadata: pkg.ElixirMixLockEntry{
Name: "ex_doc",
Version: "0.28.4",
PkgHash: "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298",
PkgHashExt: "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed",
Name: "ex_doc",
Version: "0.28.4",
PkgHash: "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298",
PkgHashExt: "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed",
Dependencies: []string{"earmark_parser", "makeup_elixir", "makeup_erlang"},
},
},
{
@ -216,10 +223,11 @@ func TestParseMixLock(t *testing.T) {
Locations: locations,
PURL: "pkg:hex/jason@1.3.0",
Metadata: pkg.ElixirMixLockEntry{
Name: "jason",
Version: "1.3.0",
PkgHash: "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946",
PkgHashExt: "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac",
Name: "jason",
Version: "1.3.0",
PkgHash: "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946",
PkgHashExt: "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac",
Dependencies: []string{"decimal"},
},
},
}

View File

@ -0,0 +1,5 @@
%{
"cowlib": {:hex, :cowlib, "2.11.0", "aaa", [:make, :rebar3], [], "hexpm", "bbb"},
"ranch": {:hex, :ranch, "1.8.0", "ccc", [:make, :rebar3], [], "hexpm", "ddd"},
"cowboy": {:hex, :cowboy, "2.9.0", "eee", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "fff"},
}

View File

@ -13,4 +13,9 @@ type ElixirMixLockEntry struct {
// PkgHashExt is the extended package hash format (inner checksum is deprecated - SHA-256 of concatenated file contents excluding CHECKSUM file, now replaced by outer checksum)
PkgHashExt string `mapstructure:"pkgHashExt" json:"pkgHashExt"`
// Dependencies are the names of the packages this entry depends on, as
// declared in the entry's dependency list within mix.lock. Used to derive
// dependency-of relationships between locked packages.
Dependencies []string `mapstructure:"dependencies" json:"dependencies,omitempty"`
}