mirror of
https://github.com/anchore/syft.git
synced 2026-08-19 16:48:27 +02:00
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>
205 lines
9.1 KiB
Go
205 lines
9.1 KiB
Go
package packagemetadata
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/anchore/syft/syft/pkg"
|
|
)
|
|
|
|
type jsonType struct {
|
|
ty any
|
|
name string
|
|
legacyNames []string
|
|
noLookupLegacyName string // legacy name that conflict with other types, thus should not affect the lookup
|
|
}
|
|
|
|
func jsonNames(ty any, name string, legacyNames ...string) jsonType {
|
|
return jsonType{
|
|
ty: ty,
|
|
name: name,
|
|
legacyNames: expandLegacyNameVariants(legacyNames...),
|
|
}
|
|
}
|
|
|
|
func jsonNamesWithoutLookup(ty any, name string, noLookupLegacyName string) jsonType {
|
|
return jsonType{
|
|
ty: ty,
|
|
name: name,
|
|
noLookupLegacyName: noLookupLegacyName,
|
|
}
|
|
}
|
|
|
|
type jsonTypeMapping struct {
|
|
typeToName map[reflect.Type]string
|
|
typeToLegacyName map[reflect.Type]string
|
|
nameToType map[string]reflect.Type
|
|
}
|
|
|
|
func makeJSONTypes(types ...jsonType) jsonTypeMapping {
|
|
out := jsonTypeMapping{
|
|
typeToName: make(map[reflect.Type]string),
|
|
typeToLegacyName: make(map[reflect.Type]string),
|
|
nameToType: make(map[string]reflect.Type),
|
|
}
|
|
for _, t := range types {
|
|
typ := reflect.TypeOf(t.ty)
|
|
out.typeToName[typ] = t.name
|
|
if len(t.noLookupLegacyName) > 0 {
|
|
out.typeToLegacyName[typ] = t.noLookupLegacyName
|
|
} else if len(t.legacyNames) > 0 {
|
|
out.typeToLegacyName[typ] = t.legacyNames[0]
|
|
}
|
|
out.nameToType[strings.ToLower(t.name)] = typ
|
|
for _, name := range t.legacyNames {
|
|
out.nameToType[strings.ToLower(name)] = typ
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// jsonNameFromType is lookup of all known package metadata types to their current JSON name and all previously known aliases.
|
|
// It is important that if a name needs to change that the old name is kept in this map (as an alias) for backwards
|
|
// compatibility to support decoding older JSON documents.
|
|
var jsonTypes = makeJSONTypes(
|
|
jsonNames(pkg.AlpmDBEntry{}, "alpm-db-entry", "AlpmMetadata"),
|
|
jsonNames(pkg.ApkDBEntry{}, "apk-db-entry", "ApkMetadata"),
|
|
jsonNames(pkg.BinarySignature{}, "binary-signature", "BinaryMetadata"),
|
|
jsonNames(pkg.BitnamiSBOMEntry{}, "bitnami-sbom-entry"),
|
|
jsonNames(pkg.CocoaPodfileLockEntry{}, "cocoa-podfile-lock-entry", "CocoapodsMetadataType"),
|
|
jsonNames(pkg.ConanV1LockEntry{}, "c-conan-lock-entry", "ConanLockMetadataType"),
|
|
jsonNames(pkg.ConanV2LockEntry{}, "c-conan-lock-v2-entry"),
|
|
jsonNames(pkg.ConanfileEntry{}, "c-conan-file-entry", "ConanMetadataType"),
|
|
jsonNames(pkg.ConaninfoEntry{}, "c-conan-info-entry"),
|
|
jsonNames(pkg.CpanDistribution{}, "cpan-distribution"),
|
|
jsonNames(pkg.CpanUnpackedRelease{}, "cpan-unpacked-release"),
|
|
jsonNames(pkg.DartPubspecLockEntry{}, "dart-pubspec-lock-entry", "DartPubMetadata"),
|
|
jsonNames(pkg.DartPubspec{}, "dart-pubspec"),
|
|
jsonNames(pkg.DenoLockEntry{}, "deno-lock-entry"),
|
|
jsonNames(pkg.DenoRemoteLockEntry{}, "deno-remote-lock-entry"),
|
|
jsonNames(pkg.DotnetDepsEntry{}, "dotnet-deps-entry", "DotnetDepsMetadata"),
|
|
jsonNames(pkg.DotnetPortableExecutableEntry{}, "dotnet-portable-executable-entry"),
|
|
jsonNames(pkg.DpkgArchiveEntry{}, "dpkg-archive-entry"),
|
|
jsonNames(pkg.DpkgDBEntry{}, "dpkg-db-entry", "DpkgMetadata"),
|
|
jsonNames(pkg.ELFBinaryPackageNoteJSONPayload{}, "elf-binary-package-note-json-payload"),
|
|
jsonNames(pkg.RubyGemspec{}, "ruby-gemspec", "GemMetadata"),
|
|
jsonNames(pkg.GitHubActionsUseStatement{}, "github-actions-use-statement"),
|
|
jsonNames(pkg.GolangBinaryBuildinfoEntry{}, "go-module-buildinfo-entry", "GolangBinMetadata", "GolangMetadata"),
|
|
jsonNames(pkg.GolangModuleEntry{}, "go-module-entry", "GolangModMetadata"),
|
|
jsonNames(pkg.GolangSourceEntry{}, "go-source-entry"),
|
|
jsonNames(pkg.HackageStackYamlLockEntry{}, "haskell-hackage-stack-lock-entry", "HackageMetadataType"),
|
|
jsonNamesWithoutLookup(pkg.HackageStackYamlEntry{}, "haskell-hackage-stack-entry", "HackageMetadataType"), // the legacy value is split into two types, where the other is preferred
|
|
jsonNames(pkg.JavaArchive{}, "java-archive", "JavaMetadata"),
|
|
jsonNames(pkg.JavaVMInstallation{}, "java-jvm-installation"),
|
|
jsonNames(pkg.MicrosoftKbPatch{}, "microsoft-kb-patch", "KbPatchMetadata"),
|
|
jsonNames(pkg.LinuxKernel{}, "linux-kernel-archive", "LinuxKernel"),
|
|
jsonNames(pkg.LinuxKernelModule{}, "linux-kernel-module", "LinuxKernelModule"),
|
|
jsonNames(pkg.ElixirMixLockEntry{}, "elixir-mix-lock-entry", "MixLockMetadataType"),
|
|
jsonNames(pkg.NixStoreEntry{}, "nix-store-entry", "NixStoreMetadata"),
|
|
jsonNames(pkg.NpmPackage{}, "javascript-npm-package", "NpmPackageJsonMetadata"),
|
|
jsonNames(pkg.NpmPackageLockEntry{}, "javascript-npm-package-lock-entry", "NpmPackageLockJsonMetadata"),
|
|
jsonNames(pkg.YarnLockEntry{}, "javascript-yarn-lock-entry", "YarnLockJsonMetadata"),
|
|
jsonNames(pkg.PnpmLockEntry{}, "javascript-pnpm-lock-entry"),
|
|
jsonNames(pkg.BunLockEntry{}, "javascript-bun-lock-entry"),
|
|
jsonNames(pkg.PEBinary{}, "pe-binary"),
|
|
jsonNames(pkg.PhpComposerLockEntry{}, "php-composer-lock-entry", "PhpComposerJsonMetadata"),
|
|
jsonNamesWithoutLookup(pkg.PhpComposerInstalledEntry{}, "php-composer-installed-entry", "PhpComposerJsonMetadata"), // the legacy value is split into two types, where the other is preferred
|
|
//nolint:staticcheck
|
|
jsonNames(pkg.PhpPeclEntry{}, "php-pecl-entry", "PhpPeclMetadata"),
|
|
jsonNames(pkg.PhpPearEntry{}, "php-pear-entry"),
|
|
jsonNames(pkg.PortageEntry{}, "portage-db-entry", "PortageMetadata"),
|
|
jsonNames(pkg.PythonPackage{}, "python-package", "PythonPackageMetadata"),
|
|
jsonNames(pkg.PythonPdmLockEntry{}, "python-pdm-lock-entry"),
|
|
jsonNames(pkg.PythonPipfileLockEntry{}, "python-pipfile-lock-entry", "PythonPipfileLockMetadata"),
|
|
jsonNames(pkg.PythonPoetryLockEntry{}, "python-poetry-lock-entry", "PythonPoetryLockMetadata"),
|
|
jsonNames(pkg.PythonRequirementsEntry{}, "python-pip-requirements-entry", "PythonRequirementsMetadata"),
|
|
jsonNames(pkg.PythonUvLockEntry{}, "python-uv-lock-entry"),
|
|
jsonNames(pkg.ErlangRebarLockEntry{}, "erlang-rebar-lock-entry", "RebarLockMetadataType"),
|
|
jsonNames(pkg.RDescription{}, "r-description", "RDescriptionFileMetadataType"),
|
|
jsonNames(pkg.RpmDBEntry{}, "rpm-db-entry", "RpmMetadata", "RpmdbMetadata"),
|
|
jsonNamesWithoutLookup(pkg.RpmArchive{}, "rpm-archive", "RpmMetadata"), // the legacy value is split into two types, where the other is preferred
|
|
jsonNames(pkg.SwiftPackageManagerResolvedEntry{}, "swift-package-manager-lock-entry", "SwiftPackageManagerMetadata"),
|
|
jsonNames(pkg.SwiplPackEntry{}, "swiplpack-package"),
|
|
jsonNames(pkg.OpamPackage{}, "opam-package"),
|
|
jsonNames(pkg.RustCargoLockEntry{}, "rust-cargo-lock-entry", "RustCargoPackageMetadata"),
|
|
jsonNamesWithoutLookup(pkg.RustBinaryAuditEntry{}, "rust-cargo-audit-entry", "RustCargoPackageMetadata"), // the legacy value is split into two types, where the other is preferred
|
|
jsonNames(pkg.SnapEntry{}, "snap-entry"),
|
|
jsonNames(pkg.WordpressPluginEntry{}, "wordpress-plugin-entry", "WordpressMetadata"),
|
|
jsonNames(pkg.HomebrewFormula{}, "homebrew-formula"),
|
|
jsonNames(pkg.AppleAppBundleEntry{}, "apple-app-bundle-entry"),
|
|
jsonNames(pkg.LuaRocksPackage{}, "luarocks-package"),
|
|
jsonNames(pkg.TerraformLockProviderEntry{}, "terraform-lock-provider-entry"),
|
|
jsonNames(pkg.DotnetPackagesLockEntry{}, "dotnet-packages-lock-entry"),
|
|
jsonNames(pkg.CondaMetaPackage{}, "conda-metadata-entry", "CondaPackageMetadata"),
|
|
jsonNames(pkg.GGUFFileHeader{}, "gguf-file-header"),
|
|
jsonNames(pkg.SafeTensorsModelInfo{}, "safetensors-model-info"),
|
|
jsonNames(pkg.VcpkgManifest{}, "vcpkg-manifest"),
|
|
)
|
|
|
|
func expandLegacyNameVariants(names ...string) []string {
|
|
var candidates []string
|
|
for _, name := range names {
|
|
candidates = append(candidates, name)
|
|
if strings.HasSuffix(name, "MetadataType") {
|
|
candidates = append(candidates, strings.TrimSuffix(name, "Type"))
|
|
} else if strings.HasSuffix(name, "Metadata") {
|
|
candidates = append(candidates, name+"Type")
|
|
}
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
func AllTypeNames() []string {
|
|
names := make([]string, 0)
|
|
for _, t := range AllTypes() {
|
|
names = append(names, reflect.TypeOf(t).Name())
|
|
}
|
|
return names
|
|
}
|
|
|
|
func JSONName(metadata any) string {
|
|
if name, exists := jsonTypes.typeToName[reflect.TypeOf(metadata)]; exists {
|
|
return name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func JSONLegacyName(metadata any) string {
|
|
if name, exists := jsonTypes.typeToLegacyName[reflect.TypeOf(metadata)]; exists {
|
|
return name
|
|
}
|
|
return JSONName(metadata)
|
|
}
|
|
|
|
func ReflectTypeFromJSONName(name string) reflect.Type {
|
|
name = strings.ToLower(name)
|
|
return jsonTypes.nameToType[name]
|
|
}
|
|
|
|
// JSONNameFromString converts a Go struct name string (e.g., "pkg.AlpmDBEntry" or "AlpmDBEntry")
|
|
// to its JSON schema name (e.g., "alpm-db-entry"). Returns empty string if not found.
|
|
func JSONNameFromString(typeName string) string {
|
|
// strip "pkg." prefix if present
|
|
typeName = strings.TrimPrefix(typeName, "pkg.")
|
|
|
|
// look through all types to find matching struct name
|
|
for typ, jsonName := range jsonTypes.typeToName {
|
|
if typ.Name() == typeName {
|
|
return jsonName
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ToUpperCamelCase converts kebab-case to UpperCamelCase
|
|
// e.g., "alpm-db-entry" -> "AlpmDbEntry"
|
|
func ToUpperCamelCase(kebab string) string {
|
|
parts := strings.Split(kebab, "-")
|
|
for i, part := range parts {
|
|
if len(part) > 0 {
|
|
parts[i] = strings.ToUpper(part[0:1]) + part[1:]
|
|
}
|
|
}
|
|
return strings.Join(parts, "")
|
|
}
|