Files
syft/syft/pkg/cataloger/perl/processor.go
T
Alex Goodman 8d5567c423 feat: catalog CPAN distributions installed by perl clients (#5131)
* feat: catalog CPAN distributions installed by perl clients

Adds `perl` as a language and `cpan` as a package type, with two catalogers behind them.

- `perl-cpan-installed-cataloger` reads `.meta/*/install.json` (cpanm, cpm, carton) and `auto/**/.packlist` (anything installed through `ExtUtils::MakeMaker` or `Module::Build`, including CPAN.pm). Both globs are unanchored, so a local-lib or carton application tree is found the same as a system install.
- `perl-cpan-meta-cataloger` reads an unpacked release's own `META.json` or `META.yml`, gated on a sibling `MANIFEST` so source checkouts are ignored.

Packages are keyed on the **distribution**, not the module, because that is what CPAN, MetaCPAN and the advisory data all use. `LWP.pm` belongs to `libwww-perl` and reporting it as `LWP` would make every advisory for it unreachable. The distribution name comes from `install.json`'s `dist` field, and from the `auto/` path for packlists.

purls are `pkg:cpan/<distribution>@<version>`, with the PAUSE author added as an `author` qualifier when the evidence carries it. Metadata comes in two types: `cpan-distribution` for installed evidence and `cpan-unpacked-release` for a release sitting on disk. The tiers differ in more than a name, so a consumer should not have to string-match a cataloger name to tell them apart: an unpacked release has no PAUSE path and therefore no author and no file list, and "installed and loadable by the interpreter" is a materially stronger claim than "a source tree exists here".

A packlist's version comes from `perllocal.pod`. EUMM writes it and the packlist from the same variables in the same install target, so the `auto/` path segments and the perllocal `Module` name are the same key, and the recorded `VERSION` is what was evaluated at build time rather than what can be read back statically. Scraping `$VERSION` out of the main `.pm` is the fallback, since `NO_PERLLOCAL` suppresses the file and Module::Build never writes one. Three rules pick the stanza: dashed module name, longest `installed into` libdir that prefixes the packlist path, and last match wins because the file is append-only. Without the libdir rule a second perl on the image silently supplies the version.

Where scraping is the fallback, it reads more than a plain `our $VERSION = '...'`. A version declared in the package statement (`package Foo::Bar v1.0.0;`) counts, which matters because a distribution can declare it that way and carry no `$VERSION` at all: `CPAN::02Packages::Search` is one, and without this it reports no version and matches nothing. Fully qualified `$Foo::Bar::VERSION` counts too, which is what older Dist::Zilla emitted and what real `JSON::PP` 2.27300 still carries. `qv('1.2.3')` is handled. A version computed at runtime is not, and those are reported without one rather than guessed at.

Packlist entries are parsed the way `ExtUtils::Packlist` writes them: a line can carry space-separated metadata after the path (`/path/to/File.pm type=file`), which is what `installperl` produces, so the suffix is stripped rather than the line being cut at its first space.

`META.yml` is read alongside `META.json`, because about 43% of current CPAN releases ship no `META.json` and they skew old, which is where the advisories are. Where both sit in one directory the `META.json` wins. A `META.yml` a strict parser rejects skips that directory rather than failing the scan, which is routine rather than defensive for pre-spec releases.

A packlist is literally a file list, so its paths are surfaced through `pkg.FileOwner`. They are reported as recorded, unfiltered: a path the packlist claims and the filesystem lacks means the file was removed or overwritten out from under the installer, which is worth seeing rather than hiding.

Build leftovers under `~/.cpanm/work` and `~/.cpan/build` are skipped. They genuinely are unpacked release tarballs, `MANIFEST` included, so the `MANIFEST` gate admits them and a path exclusion is the only signal available. Without it every distribution on an image that did not clean up is reported twice. The cost is that a tarball deliberately kept under `~/.cpan/build` stops being reported.

Two behaviors that look wrong but are not:

- a distribution can be reported twice at different versions. `libwww-perl` 5.836 bundles `HTTP-Date`, `HTTP-Message` and `LWP-MediaTypes`, and installing it over the modern standalone releases overwrites their `.pm` files. Both versions are genuinely present, so the merge refuses to pair disagreeing versions rather than hiding one.
- a distribution with no readable version is reported without one rather than dropped, so it stays visible.

The coverage boundary is stated in full in the `package perl` doc comment. In short: modules installed from distro packages are out of scope, since packagers strip CPAN metadata with `NO_PACKLIST` and deb, rpm and apk already report them. Core and dual-life distributions bundled with the interpreter have no coverage, because nothing on disk carries their versions; the interpreter itself is reported separately. Vendored trees, `App::FatPacker` output and PAR archives are invisible, because what they carry is module identity with no offline map to a distribution. A packlist-derived name is the installer's `NAME` and may not be the distribution name, which is left to the vulnerability data to resolve.

One correction worth calling out, since it is easy to arrive at twice: a sibling `MANIFEST` is the only thing separating an unpacked release from a source checkout. An earlier version of the guard also rejected any tree containing a `dist.ini`, on the theory that it marked a Dist::Zilla source tree. dzil ships `dist.ini` *inside* the tarballs it builds and lists it in the generated `MANIFEST`, so that exclusion was silently skipping real releases, `URI` among them.

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

* test(cli): bump coverage image package count for the new perl fixture

The CPAN fixture added to `image-pkg-coverage` (`URI-5.35`) is shared with the CLI suite through a symlink, so the squashed package count goes from 42 to 43.

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

* fix(perl): take CPAN distribution name and version from install.json dist

cpanm writes the main module's `$VERSION` into `version` and the release distvname into `dist`, and the two often disagree (`Carp-Assert-More-2.9.0` records `2.009000`). Trimming `version` off `dist` failed on those, so the whole distvname became the package name, and the module version never paired with the packlist record for the same install, which reported the distribution twice.

- name and version are now split out of `dist` at the last `-` followed by a version, as CPAN::DistnameInfo does
- a `-TRIAL` suffix is dropped, so `Try-Tiny-0.26-TRIAL` reports as `Try-Tiny` `0.26`
- the PAUSE path fallback uses the same split

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

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-09-30 14:15:05 -04:00

226 lines
8.3 KiB
Go

package perl
import (
"context"
"path"
"strings"
"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/internal/dependency"
)
// mergeDistributions collapses the two kinds of installed evidence into one package per installed
// distribution. A distribution installed by cpanm has both an install.json and a .packlist.
//
// Pairing is scoped to one install tree, so two installs of the same distribution and version into
// separate trees stay two packages. A scan target can hold more than one project tree, and preserving
// that there are two trees is the point: collapsing them asserts one installed thing where there are
// two and loses which tree each belongs to. Multiple projects in one scan target are treated the same
// way elsewhere.
//
// (name, version) alone is not enough to pair them either. A packlist lives at auto/<Module>/.packlist, so its
// name is derived from a MODULE path, while install.json yields the distribution: libwww-perl's packlist
// is auto/LWP/.packlist and produces "LWP" against the distribution "libwww-perl". Those never collide,
// so pairing also consults the `provides` module map, which lists LWP under libwww-perl. Without that
// step every distribution whose main module is named differently from the distribution produces two
// packages, one with the right name and no version and one with the wrong name and the right version,
// and neither one matches an advisory.
func mergeDistributions(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
if len(pkgs) < 2 {
return pkgs, rels, err
}
var merged []pkg.Package
indexByKey := make(map[string]int)
// install.json evidence is placed first so that the module index is complete before any packlist
// package needs to resolve against it, independent of the order the parsers ran in
ordered := make([]pkg.Package, 0, len(pkgs))
for _, p := range pkgs {
if fromInstallJSON(p) {
ordered = append(ordered, p)
}
}
for _, p := range pkgs {
if !fromInstallJSON(p) {
ordered = append(ordered, p)
}
}
indexByModule := make(map[string]int)
for _, p := range ordered {
tree := installTree(p)
key := tree + "|" + p.Name + "@" + p.Version
if idx, exists := indexByKey[key]; exists {
merged[idx] = mergeInto(merged[idx], p)
continue
}
// a packlist package names a module, so fold it into whichever distribution in the same tree
// provides it. The version must still agree, or be unreadable: a packlist and an install.json
// in one tree that disagree describe one install that overwrote another's files, and merging
// them would report a single version and hide the other.
if !fromInstallJSON(p) {
if idx, exists := indexByModule[tree+"|"+moduleFromDashedName(p.Name)]; exists &&
(p.Version == "" || p.Version == merged[idx].Version) {
merged[idx] = mergeInto(merged[idx], p)
continue
}
}
indexByKey[key] = len(merged)
for _, m := range providedModules(p) {
// first distribution to claim a module wins; duplicate claims are not expected and
// silently reassigning would make the result order dependent
if _, taken := indexByModule[tree+"|"+m]; !taken {
indexByModule[tree+"|"+m] = len(merged)
}
}
merged = append(merged, p)
}
// locations and metadata drive the package ID, and both may have changed
for i := range merged {
merged[i].SetID()
}
return merged, rels, err
}
// installTree returns the library tree the package's own evidence was found in: the directory holding
// the .meta or auto directory the file sits under. Both kinds of evidence for one install land there,
// in sibling directories rather than the same one, so
// /opt/app/lib/perl5/x86_64-linux-gnu/.meta/Text-CSV-2.06/install.json and
// /opt/app/lib/perl5/x86_64-linux-gnu/auto/Text/CSV/.packlist both yield
// /opt/app/lib/perl5/x86_64-linux-gnu and still pair.
//
// The owned-file list cannot stand in for this: ExtUtils::Packlist merges an existing packlist for the
// same module when it writes a new one, so a local-lib install's packlist legitimately claims files
// living outside its own prefix.
func installTree(p pkg.Package) string {
for _, location := range p.Locations.ToSlice() {
if tree := libRoot(location.Path()); tree != "" {
return tree
}
}
return ""
}
// libRoot cuts a path at the last auto/ or .meta/ segment, which is where a lib tree ends and the
// installer's own bookkeeping begins. A path with neither, such as a perllocal.pod or a scraped .pm,
// yields "": those are supporting evidence and never the only location a package has.
func libRoot(p string) string {
parts := strings.Split(p, "/")
for i := len(parts) - 1; i > 0; i-- {
if parts[i] == "auto" || parts[i] == ".meta" {
return strings.Join(parts[:i], "/")
}
}
return ""
}
// moduleFromDashedName converts a packlist-derived name back into the module it came from, since
// auto/IO/Socket/SSL/.packlist flattens IO::Socket::SSL into IO-Socket-SSL.
func moduleFromDashedName(name string) string {
return strings.ReplaceAll(name, "-", "::")
}
// mergeInto keeps the install.json-derived package, which is the higher fidelity of the two, and folds
// the other's evidence into it.
func mergeInto(a, b pkg.Package) pkg.Package {
winner, loser := a, b
if !fromInstallJSON(a) && fromInstallJSON(b) {
winner, loser = b, a
}
locations := file.NewLocationSet(winner.Locations.ToSlice()...)
locations.Add(loser.Locations.ToSlice()...)
winner.Locations = locations
licenses := pkg.NewLicenseSet(winner.Licenses.ToSlice()...)
licenses.Add(loser.Licenses.ToSlice()...)
winner.Licenses = licenses
// the file list only ever comes from a packlist, so the install.json winner has to take the loser's or
// the merged package would own nothing. OwnedFiles dedupes, which covers a distribution that merged
// more than one packlist.
from, fromOK := loser.Metadata.(pkg.CpanDistribution)
into, intoOK := winner.Metadata.(pkg.CpanDistribution)
if fromOK && intoOK && len(from.Files) > 0 {
into.Files = append(into.Files, from.Files...)
winner.Metadata = into
}
return winner
}
// fromInstallJSON reports whether a package was built from an install.json rather than from a
// .packlist. MainModule is only ever set by the packlist parser, so its absence is the direct signal.
func fromInstallJSON(p pkg.Package) bool {
md, ok := p.Metadata.(pkg.CpanDistribution)
return ok && md.MainModule == ""
}
// resolveDependencies turns MYMETA.json prereqs into relationships. Prereqs name modules while packages
// are keyed by distribution, so each prereq is looked up in the provides map of the cataloged set. A
// prereq that resolves to nothing (a core module such as Carp, strict, or perl, or a distribution that
// simply is not installed) is dropped rather than turned into a placeholder package.
func resolveDependencies(_ context.Context, resolver file.Resolver, pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
if resolver == nil || len(pkgs) == 0 {
return pkgs, rels, err
}
requires := make(map[artifact.ID][]string)
for i := range pkgs {
if pkgs[i].ID() == "" {
pkgs[i].SetID()
}
requires[pkgs[i].ID()] = prereqModules(resolver, pkgs[i])
}
specifier := func(p pkg.Package) dependency.Specification {
return dependency.Specification{
ProvidesRequires: dependency.ProvidesRequires{
Provides: providedModules(p),
Requires: requires[p.ID()],
},
}
}
return pkgs, append(rels, dependency.Resolve(specifier, pkgs)...), err
}
func providedModules(p pkg.Package) []string {
md, ok := p.Metadata.(pkg.CpanDistribution)
if !ok {
return nil
}
modules := make([]string, 0, len(md.Modules))
for _, m := range md.Modules {
modules = append(modules, m.Name)
}
return modules
}
// prereqModules re-reads the MYMETA.json already recorded as evidence on the package. It is read a
// second time (the parser reads it for licenses) rather than carried on the package, since prereqs
// belong in relationships and not in the metadata struct.
func prereqModules(resolver file.Resolver, p pkg.Package) []string {
for _, location := range p.Locations.ToSlice() {
if path.Base(location.Path()) != "MYMETA.json" {
continue
}
m, err := decodeMeta(resolver, location)
if err != nil {
continue
}
return m.prereqModules()
}
return nil
}