Files
syft/syft/pkg/type.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

285 lines
6.7 KiB
Go

package pkg
import (
"github.com/anchore/packageurl-go"
)
// Type represents a Package Type for or within a language ecosystem (there may be multiple package types within a language ecosystem)
type Type string
func (t Type) String() string {
return string(t)
}
const (
// the full set of supported packages
UnknownPkg Type = "UnknownPackage"
AlpmPkg Type = "alpm"
ApkPkg Type = "apk"
BinaryPkg Type = "binary"
BitnamiPkg Type = "bitnami"
CocoapodsPkg Type = "pod"
ConanPkg Type = "conan"
CondaPkg Type = "conda"
CpanPkg Type = "cpan"
DartPubPkg Type = "dart-pub"
DebPkg Type = "deb"
DotnetPkg Type = "dotnet"
ErlangOTPPkg Type = "erlang-otp"
GemPkg Type = "gem"
GithubActionPkg Type = "github-action"
GithubActionWorkflowPkg Type = "github-action-workflow"
GoModulePkg Type = "go-module"
GraalVMNativeImagePkg Type = "graalvm-native-image"
HackagePkg Type = "hackage"
HexPkg Type = "hex"
JavaPkg Type = "java-archive"
JenkinsPluginPkg Type = "jenkins-plugin"
KbPkg Type = "msrc-kb"
LinuxKernelPkg Type = "linux-kernel"
LinuxKernelModulePkg Type = "linux-kernel-module"
NixPkg Type = "nix"
NpmPkg Type = "npm"
OpamPkg Type = "opam"
PhpComposerPkg Type = "php-composer"
PhpPeclPkg Type = "php-pecl" // Deprecated: will be removed in syft v2.0
PhpPearPkg Type = "php-pear"
PortagePkg Type = "portage"
PythonPkg Type = "python"
Rpkg Type = "R-package"
LuaRocksPkg Type = "lua-rocks"
RpmPkg Type = "rpm"
RustPkg Type = "rust-crate"
SwiftPkg Type = "swift"
SwiplPackPkg Type = "swiplpack"
TerraformPkg Type = "terraform"
VcpkgPkg Type = "vcpkg"
WordpressPluginPkg Type = "wordpress-plugin"
HomebrewPkg Type = "homebrew"
AppleAppBundlePkg Type = "apple-app-bundle"
ModelPkg Type = "model"
)
// AllPkgs represents all supported package types
var AllPkgs = []Type{
AlpmPkg,
ApkPkg,
BinaryPkg,
BitnamiPkg,
CocoapodsPkg,
ConanPkg,
CondaPkg,
CpanPkg,
DartPubPkg,
DebPkg,
DotnetPkg,
ErlangOTPPkg,
GemPkg,
GithubActionPkg,
GithubActionWorkflowPkg,
GraalVMNativeImagePkg,
GoModulePkg,
HackagePkg,
HexPkg,
JavaPkg,
JenkinsPluginPkg,
KbPkg,
LinuxKernelPkg,
LinuxKernelModulePkg,
NixPkg,
NpmPkg,
OpamPkg,
PhpComposerPkg,
PhpPeclPkg,
PhpPearPkg,
PortagePkg,
PythonPkg,
Rpkg,
LuaRocksPkg,
RpmPkg,
RustPkg,
SwiftPkg,
SwiplPackPkg,
TerraformPkg,
VcpkgPkg,
WordpressPluginPkg,
HomebrewPkg,
AppleAppBundlePkg,
ModelPkg,
}
// PackageURLType returns the PURL package type for the current package.
//
//nolint:funlen, gocyclo
func (t Type) PackageURLType() string {
switch t {
case AlpmPkg:
return "alpm"
case ApkPkg:
return packageurl.TypeAlpine
case BitnamiPkg:
return packageurl.TypeBitnami
case CocoapodsPkg:
return packageurl.TypeCocoapods
case ConanPkg:
return packageurl.TypeConan
case CondaPkg:
return packageurl.TypeGeneric
case CpanPkg:
return packageurl.TypeCpan
case DartPubPkg:
return packageurl.TypePub
case DebPkg:
return "deb"
case DotnetPkg:
return packageurl.TypeNuget
case ErlangOTPPkg:
return packageurl.TypeOTP
case GemPkg:
return packageurl.TypeGem
case HexPkg:
return packageurl.TypeHex
case GithubActionPkg, GithubActionWorkflowPkg:
// note: this is not a real purl type, but it is the closest thing we have for now
return packageurl.TypeGithub
case GoModulePkg:
return packageurl.TypeGolang
case HackagePkg:
return packageurl.TypeHackage
case JavaPkg, JenkinsPluginPkg:
return packageurl.TypeMaven
case LinuxKernelPkg:
return "generic/linux-kernel"
case LinuxKernelModulePkg:
return packageurl.TypeGeneric
case PhpComposerPkg:
return packageurl.TypeComposer
case PhpPearPkg, PhpPeclPkg:
return "pear"
case PythonPkg:
return packageurl.TypePyPi
case PortagePkg:
return "portage"
case LuaRocksPkg:
return packageurl.TypeLuaRocks
case NixPkg:
return "nix"
case NpmPkg:
return packageurl.TypeNPM
case OpamPkg:
return "opam"
case Rpkg:
return packageurl.TypeCran
case RpmPkg:
return packageurl.TypeRPM
case RustPkg:
return "cargo"
case SwiftPkg:
return packageurl.TypeSwift
case SwiplPackPkg:
return "swiplpack"
case TerraformPkg:
return "terraform"
case VcpkgPkg:
return "vcpkg"
case WordpressPluginPkg:
return "wordpress-plugin"
case HomebrewPkg:
return "homebrew"
default:
// TODO: should this be a "generic" purl type instead?
return ""
}
}
func TypeFromPURL(p string) Type {
purl, err := packageurl.FromString(p)
if err != nil {
return UnknownPkg
}
ptype := purl.Type
if ptype == "generic" {
ptype = purl.Name
}
return TypeByName(ptype)
}
//nolint:funlen,gocyclo
func TypeByName(name string) Type {
switch name {
case "alpm":
return AlpmPkg
case packageurl.TypeAlpine, "alpine":
return ApkPkg
case packageurl.TypeBitnami:
return BitnamiPkg
case packageurl.TypeDebian:
return DebPkg
case packageurl.TypeComposer:
return PhpComposerPkg
case "pear", "pecl":
return PhpPearPkg
case packageurl.TypeGolang:
return GoModulePkg
case packageurl.TypeGem:
return GemPkg
case "cargo", "crate":
return RustPkg
case "conda":
return CondaPkg
case packageurl.TypeCpan:
return CpanPkg
case packageurl.TypePub:
return DartPubPkg
case "dotnet", packageurl.TypeNuget: // "dotnet" is here to support legacy use cases; "nuget" is the canonical purl type
return DotnetPkg
case packageurl.TypeCocoapods:
return CocoapodsPkg
case packageurl.TypeConan:
return ConanPkg
case packageurl.TypeHackage:
return HackagePkg
case packageurl.TypeHex:
return HexPkg
case packageurl.TypeLuaRocks:
return LuaRocksPkg
case packageurl.TypeMaven:
return JavaPkg
case packageurl.TypeNPM:
return NpmPkg
case packageurl.TypePyPi:
return PythonPkg
case "portage":
return PortagePkg
case packageurl.TypeOTP:
return ErlangOTPPkg
case "linux-kernel":
return LinuxKernelPkg
case "linux-kernel-module":
return LinuxKernelModulePkg
case "nix":
return NixPkg
case "opam":
return OpamPkg
case packageurl.TypeCran:
return Rpkg
case packageurl.TypeRPM:
return RpmPkg
case packageurl.TypeSwift:
return SwiftPkg
case "swiplpack":
return SwiplPackPkg
case "terraform":
return TerraformPkg
case "vcpkg":
return VcpkgPkg
case "wordpress-plugin":
return WordpressPluginPkg
case "homebrew":
return HomebrewPkg
default:
return UnknownPkg
}
}