Support for scanning manifest files (#36)

* feat: add extractor logic for manifest files

* feat: add manifest-based package installation detection

* feat: add osv-scanner for extracting lockfiles

* refactor: add comment for cmd parse

* refactor: add Ecosystem() method to PackageManager interface

* refactor: implement package-manager-specific extractors & replace osv-scanner with scalibr

* Update extractor/extractor.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Sahil Bansal <bansalsahil315@gmail.com>

---------

Signed-off-by: Sahil Bansal <bansalsahil315@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sahil Bansal
2025-06-12 18:58:58 +05:30
committed by GitHub
co-authored by Copilot
parent 302e17fe48
commit 5e9f2af972
13 changed files with 690 additions and 16 deletions
-1
View File
@@ -2,7 +2,6 @@ package npm
import (
"context"
_ "embed"
"fmt"
"github.com/safedep/dry/log"
+50
View File
@@ -0,0 +1,50 @@
package extractor
import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
)
// PackageManagerExtractor defines the interface for package-manager-specific extractors
type PackageManagerExtractor interface {
// Returns the list of supported lockfiles by the package manager
GetSupportedFiles() []string
// Returns the package manager ecosysetm
GetEcosystem() packagev1.Ecosystem
// Returns the package manager name
GetPackageManager() PackageManagerName
// Extracts the packages from the lockfile
Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error)
}
type PackageManagerName string
const (
Npm PackageManagerName = "npm"
Pnpm PackageManagerName = "pnpm"
Pip PackageManagerName = "pip"
)
type ExtractorManager struct {
extractors map[PackageManagerName]PackageManagerExtractor
}
func NewExtractorManager() *ExtractorManager {
return &ExtractorManager{
extractors: map[PackageManagerName]PackageManagerExtractor{
Npm: &NpmExtractor{},
Pnpm: &PnpmExtractor{},
Pip: &PipExtractor{},
},
}
}
func (e *ExtractorManager) GetExtractorForPackageManager(pmn PackageManagerName) PackageManagerExtractor {
return e.extractors[pmn]
}
func (e *ExtractorManager) GetSupportedFilesForPackageManager(pmn PackageManagerName) []string {
return e.extractors[pmn].GetSupportedFiles()
}
+78
View File
@@ -0,0 +1,78 @@
package extractor
import (
"fmt"
"os"
"path/filepath"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
)
type ExtractorConfig struct {
ExtractorPackageManager PackageManagerName
ScanDir string
}
type extractor struct {
Config ExtractorConfig
extractorManager ExtractorManager
}
func NewDefaultExtractorConfig() *ExtractorConfig {
return &ExtractorConfig{
ScanDir: ".",
}
}
func New(config ExtractorConfig) *extractor {
return &extractor{
Config: config,
extractorManager: *NewExtractorManager(),
}
}
func (e *extractor) ExtractManifest() ([]*packagev1.PackageVersion, error) {
packagesToAnalyze := []*packagev1.PackageVersion{}
// Get the list of lockfiles to check based on ecosystem
filesToCheck := e.getFilesToCheck()
for _, filename := range filesToCheck {
filePath := filepath.Join(e.Config.ScanDir, filename)
// Check if the file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
continue
}
extractor, err := e.getExtractorForFile()
if err != nil {
log.Warnf("failed to get extractor\n")
continue
}
// Extract packages from this lockfile
packages, err := extractor.Extract(filePath, e.Config.ScanDir)
if err != nil {
log.Warnf("failed to extract from %s: %v\n", filePath, err)
continue
}
packagesToAnalyze = append(packagesToAnalyze, packages...)
}
return packagesToAnalyze, nil
}
func (e *extractor) getFilesToCheck() []string {
return e.extractorManager.GetSupportedFilesForPackageManager(e.Config.ExtractorPackageManager)
}
func (e *extractor) getExtractorForFile() (PackageManagerExtractor, error) {
extractor := e.extractorManager.GetExtractorForPackageManager(e.Config.ExtractorPackageManager)
if extractor == nil {
return nil, fmt.Errorf("no extractor found for the specified package manager: %s", e.Config.ExtractorPackageManager)
}
return extractor, nil
}
+125
View File
@@ -0,0 +1,125 @@
package extractor
import (
"context"
"fmt"
"os"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/google/osv-scalibr/extractor/filesystem"
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/packagelockjson"
"github.com/google/osv-scalibr/extractor/filesystem/language/javascript/pnpmlock"
"github.com/google/osv-scalibr/fs"
)
// NpmExtractor handles package-lock.json files
type NpmExtractor struct{}
func (n *NpmExtractor) GetSupportedFiles() []string {
return []string{"package-lock.json"}
}
func (n *NpmExtractor) GetEcosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_NPM
}
func (n *NpmExtractor) GetPackageManager() PackageManagerName {
return Npm
}
func (n *NpmExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
return parseNpmPackageLockFile(lockfilePath, scanDir)
}
func parseNpmPackageLockFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
packagelockExtractor := packagelockjson.NewDefault()
file, err := os.Open(lockfilePath)
if err != nil {
return nil, fmt.Errorf("failed to open lockfile: %s", err)
}
defer file.Close()
inputConfig := &filesystem.ScanInput{
FS: fs.DirFS(scanDir),
Path: lockfilePath,
Reader: file,
}
inventory, err := packagelockExtractor.Extract(context.Background(), inputConfig)
if err != nil {
return nil, fmt.Errorf("failed to extract packages: %s", err)
}
var packages []*packagev1.PackageVersion
for _, invPkg := range inventory.Packages {
pkg := &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: invPkg.Name,
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
},
Version: invPkg.Version,
}
packages = append(packages, pkg)
}
return packages, nil
}
// PnpmExtractor handles pnpm-lock.yaml files
type PnpmExtractor struct{}
func (p *PnpmExtractor) GetSupportedFiles() []string {
return []string{"pnpm-lock.yaml"}
}
func (p *PnpmExtractor) GetEcosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_NPM
}
func (p *PnpmExtractor) GetPackageManager() PackageManagerName {
return Pnpm
}
func (p *PnpmExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
return parsePnpmLockFile(lockfilePath, scanDir)
}
func parsePnpmLockFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
pnpmLockExtractor := pnpmlock.New()
file, err := os.Open(lockfilePath)
if err != nil {
return nil, fmt.Errorf("failed to open lockfile: %s", err)
}
defer file.Close()
inputConfig := &filesystem.ScanInput{
FS: fs.DirFS(scanDir),
Path: lockfilePath,
Reader: file,
}
inventory, err := pnpmLockExtractor.Extract(context.Background(), inputConfig)
if err != nil {
return nil, fmt.Errorf("failed to extract packages: %s", err)
}
var packages []*packagev1.PackageVersion
for _, invPkg := range inventory.Packages {
pkg := &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: invPkg.Name,
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
},
Version: invPkg.Version,
}
packages = append(packages, pkg)
}
return packages, nil
}
+68
View File
@@ -0,0 +1,68 @@
package extractor
import (
"context"
"fmt"
"os"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/google/osv-scalibr/extractor/filesystem"
"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
"github.com/google/osv-scalibr/fs"
)
// PipExtractor handles requirements.txt files
type PipExtractor struct{}
func (p *PipExtractor) GetSupportedFiles() []string {
return []string{"requirements.txt"}
}
func (p *PipExtractor) GetEcosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_PYPI
}
func (p *PipExtractor) GetPackageManager() PackageManagerName {
return Pip
}
func (n *PipExtractor) Extract(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
return parseRequirementsTxtFile(lockfilePath, scanDir)
}
func parseRequirementsTxtFile(lockfilePath, scanDir string) ([]*packagev1.PackageVersion, error) {
requirementsExtractor := requirements.NewDefault()
file, err := os.Open(lockfilePath)
if err != nil {
return nil, fmt.Errorf("failed to open lockfile: %s", err)
}
defer file.Close()
inputConfig := &filesystem.ScanInput{
FS: fs.DirFS(scanDir),
Path: lockfilePath,
Reader: file,
}
inventory, err := requirementsExtractor.Extract(context.Background(), inputConfig)
if err != nil {
return nil, fmt.Errorf("failed to extract packages: %s", err)
}
var packages []*packagev1.PackageVersion
for _, invPkg := range inventory.Packages {
pkg := &packagev1.PackageVersion{
Package: &packagev1.Package{
Name: invPkg.Name,
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_PYPI,
},
Version: invPkg.Version,
}
packages = append(packages, pkg)
}
return packages, nil
}
+8 -1
View File
@@ -9,11 +9,12 @@ require (
buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.6-20250418165058-162f6b0cc319.1
github.com/Masterminds/semver v1.5.0
github.com/fatih/color v1.18.0
github.com/google/osv-scalibr v0.2.1
github.com/jedib0t/go-pretty/v6 v6.6.7
github.com/safedep/dry v0.0.0-20250514080944-bb77f30c7175
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
google.golang.org/grpc v1.71.1
google.golang.org/grpc v1.72.0
)
require (
@@ -64,6 +65,9 @@ require (
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.9 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.14.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -100,6 +104,7 @@ require (
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jgautheron/goconst v1.7.1 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.4 // indirect
@@ -133,6 +138,7 @@ require (
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/package-url/packageurl-go v0.1.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
@@ -207,6 +213,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.1 // indirect
+18 -2
View File
@@ -119,6 +119,12 @@ github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYF
github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w=
github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -194,6 +200,8 @@ github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHS
github.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/osv-scalibr v0.2.1 h1:d1SpwzXfNiRafUMNpei3tQg8dDLSWe7JAPNEHxhzxDk=
github.com/google/osv-scalibr v0.2.1/go.mod h1:gTmbCPgh9ooYnU55N32qPxHgFubkxgiDxsoCAMQc2Nc=
github.com/google/pprof v0.0.0-20250418163039-24c5476c6587 h1:b/8HpQhvKLSNzH5oTXN2WkNcMl6YB5K3FRbb+i+Ml34=
github.com/google/pprof v0.0.0-20250418163039-24c5476c6587/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -228,6 +236,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk=
@@ -321,9 +331,13 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/package-url/packageurl-go v0.1.3 h1:4juMED3hHiz0set3Vq3KeQ75KD1avthoXLtmE3I0PLs=
github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -653,8 +667,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -663,6 +677,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+63
View File
@@ -11,6 +11,7 @@ import (
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/extractor"
"github.com/safedep/pmg/packagemanager"
)
@@ -73,6 +74,12 @@ func (g *packageManagerGuard) Run(ctx context.Context, args []string, parsedComm
log.Debugf("Running package manager guard with args: %v", args)
if !parsedCommand.HasInstallTarget() {
// Check if this is a manifest-based installation
if parsedCommand.ShouldExtractFromManifest() {
log.Debugf("Detected manifest-based installation, extracting packages from manifest files")
return g.handleManifestInstallation(ctx, parsedCommand)
}
log.Debugf("No install target found, continuing execution")
return g.continueExecution(ctx, parsedCommand)
}
@@ -268,3 +275,59 @@ func (g *packageManagerGuard) clearStatus() {
g.interaction.ClearStatus()
}
func (g *packageManagerGuard) handleManifestInstallation(ctx context.Context, parsedCommand *packagemanager.ParsedCommand) error {
g.setStatus("Extracting packages from manifest files")
extractorConfig := extractor.NewDefaultExtractorConfig()
extractorConfig.ExtractorPackageManager = extractor.PackageManagerName(g.packageManager.Name())
packageExtractor := extractor.New(*extractorConfig)
packages, err := packageExtractor.ExtractManifest()
if err != nil {
return fmt.Errorf("failed to extract packages from manifest files: %w", err)
}
if len(packages) == 0 {
log.Debugf("No packages found in manifest files, continuing execution")
return g.continueExecution(ctx, parsedCommand)
}
log.Debugf("Extracted %d packages from manifest files", len(packages))
// Analyze the extracted packages
g.setStatus(fmt.Sprintf("Analyzing %d packages from manifest files", len(packages)))
analysisResults, err := g.concurrentAnalyzePackages(ctx, packages)
if err != nil {
return fmt.Errorf("failed to analyze packages: %w", err)
}
confirmableMalwarePackages := []*analyzer.PackageVersionAnalysisResult{}
for _, result := range analysisResults {
if result.Action == analyzer.ActionBlock {
return g.blockInstallation(result)
}
if result.Action == analyzer.ActionConfirm {
confirmableMalwarePackages = append(confirmableMalwarePackages, result)
}
}
if len(confirmableMalwarePackages) > 0 {
confirmed, err := g.getConfirmationOnMalware(ctx, confirmableMalwarePackages)
if err != nil {
return fmt.Errorf("failed to get confirmation on malware: %w", err)
}
if !confirmed {
return g.blockInstallation(confirmableMalwarePackages...)
}
}
log.Debugf("No malicious packages found in manifest files, continuing execution")
g.clearStatus()
return g.continueExecution(ctx, parsedCommand)
}
+29 -7
View File
@@ -40,7 +40,11 @@ func NewNpmPackageManager(config NpmPackageManagerConfig) (*npmPackageManager, e
var _ PackageManager = &npmPackageManager{}
func (npm *npmPackageManager) Name() string {
return "npm"
return npm.Config.CommandName
}
func (npm *npmPackageManager) Ecosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_NPM
}
func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error) {
@@ -50,8 +54,8 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
command := Command{Exe: npm.Config.CommandName, Args: args}
// No command specified
if len(args) < 2 {
// Since manifest-based installs like 'npm i' are now valid commands
if len(args) < 1 {
return &ParsedCommand{
Command: command,
}, nil
@@ -59,8 +63,12 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
// Extract packages from args
var packages []string
var isManifestInstall bool
var foundInstallCmd bool
for idx, arg := range args {
if slices.Contains(npm.Config.InstallCommands, arg) {
foundInstallCmd = true
// All subsequent args are packages except for flags
for i := idx + 1; i < len(args); i++ {
if strings.HasPrefix(args[i], "-") {
@@ -74,8 +82,14 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
}
}
// No packages found
if len(packages) == 0 {
// If install command was found but no explicit packages,
// this is a manifest-based installation (install from package.json)
if foundInstallCmd && len(packages) == 0 {
isManifestInstall = true
}
// No packages found and not a manifest install
if len(packages) == 0 && !isManifestInstall {
return &ParsedCommand{
Command: command,
}, nil
@@ -105,9 +119,17 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
})
}
var manifestFiles []string
if isManifestInstall {
// npm/pnpm installs from package.json by default
manifestFiles = append(manifestFiles, "package.json")
}
return &ParsedCommand{
Command: command,
InstallTargets: installTargets,
Command: command,
InstallTargets: installTargets,
IsManifestInstall: isManifestInstall,
ManifestFiles: manifestFiles,
}, nil
}
+87
View File
@@ -121,3 +121,90 @@ func TestNpmParseCommand(t *testing.T) {
})
}
}
func TestNpmParseCommand_ManifestInstallation(t *testing.T) {
pm, err := NewNpmPackageManager(DefaultNpmPackageManagerConfig())
assert.NoError(t, err)
cases := []struct {
name string
args []string
expectedManifest bool
expectedFiles []string
expectedTargets int
}{
{
name: "npm install without args (bare install)",
args: []string{"install"},
expectedManifest: true,
expectedFiles: []string{"package.json"},
expectedTargets: 0,
},
{
name: "npm i without args (short form)",
args: []string{"i"},
expectedManifest: true,
expectedFiles: []string{"package.json"},
expectedTargets: 0,
},
{
name: "npm install with explicit package",
args: []string{"install", "react"},
expectedManifest: false,
expectedFiles: nil,
expectedTargets: 1,
},
{
name: "npm install with multiple packages",
args: []string{"install", "react", "vue"},
expectedManifest: false,
expectedFiles: nil,
expectedTargets: 2,
},
{
name: "npm install with flags but no packages",
args: []string{"install", "--save-dev"},
expectedManifest: true,
expectedFiles: []string{"package.json"},
expectedTargets: 0,
},
{
name: "npm install with mixed args",
args: []string{"install", "react", "--save"},
expectedManifest: false,
expectedFiles: nil,
expectedTargets: 1,
},
{
name: "non-install command",
args: []string{"run", "build"},
expectedManifest: false,
expectedFiles: nil,
expectedTargets: 0,
},
{
name: "pnpm install without args",
args: []string{"install"},
expectedManifest: true,
expectedFiles: []string{"package.json"},
expectedTargets: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
parsed, err := pm.ParseCommand(tc.args)
assert.NoError(t, err)
assert.Equal(t, tc.expectedManifest, parsed.IsManifestInstall, "IsManifestInstall mismatch")
assert.Equal(t, tc.expectedFiles, parsed.ManifestFiles, "ManifestFiles mismatch")
assert.Equal(t, tc.expectedTargets, len(parsed.InstallTargets), "InstallTargets count mismatch")
// Test helper methods
assert.Equal(t, tc.expectedManifest, parsed.HasManifestInstall(), "HasManifestInstall mismatch")
expectedShouldExtract := tc.expectedManifest && tc.expectedTargets == 0
assert.Equal(t, expectedShouldExtract, parsed.ShouldExtractFromManifest(), "ShouldExtractFromManifest mismatch")
})
}
}
+19
View File
@@ -30,12 +30,28 @@ type ParsedCommand struct {
// Parsed install target if this is an install command
InstallTargets []*PackageInstallTarget
// IsManifestInstall indicates if this is a manifest-based installation
// (e.g., npm install, pip install -r requirements.txt)
IsManifestInstall bool
// ManifestFiles contains the list of manifest files to install from
// (e.g., ["requirements.txt"] for pip install -r requirements.txt)
ManifestFiles []string
}
func (pc *ParsedCommand) HasInstallTarget() bool {
return len(pc.InstallTargets) > 0
}
func (pc *ParsedCommand) HasManifestInstall() bool {
return pc.IsManifestInstall
}
func (pc *ParsedCommand) ShouldExtractFromManifest() bool {
return pc.IsManifestInstall && !pc.HasInstallTarget()
}
// PackageManager is the contract for implementing a package manager
type PackageManager interface {
// Name of the package manager implementation
@@ -44,6 +60,9 @@ type PackageManager interface {
// ParseCommand parses the command and returns a parsed command
// specific to the package manager implementation
ParseCommand(args []string) (*ParsedCommand, error)
// Ecosystem of the package manager
Ecosystem() packagev1.Ecosystem
}
// PackageResolver is the contract for resolving package info
+58 -5
View File
@@ -37,30 +37,81 @@ func (pip *pipPackageManager) Name() string {
return "pip"
}
func (pip *pipPackageManager) Ecosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_PYPI
}
func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error) {
if len(args) > 0 && args[0] == "pip" {
args = args[1:]
}
command := Command{Exe: pip.Config.CommandName, Args: args}
if len(args) < 2 {
// Since manifest-based installs like 'npm i' are now valid commands
if len(args) < 1 {
return &ParsedCommand{
Command: command,
}, nil
}
var packages []string
var manifestFiles []string
var isManifestInstall bool
var foundInstallCmd bool
for idx, arg := range args {
if slices.Contains(pip.Config.InstallCommands, arg) {
foundInstallCmd = true
// Check for manifest-based installation flags
for i := idx + 1; i < len(args); i++ {
if strings.HasPrefix(args[i], "-") {
currentArg := args[i]
// Handle -r/--requirement flags
if currentArg == "-r" || currentArg == "--requirement" {
isManifestInstall = true
if i+1 < len(args) {
manifestFiles = append(manifestFiles, args[i+1])
i++ // skip the filename
}
continue
}
packages = append(packages, args[i])
// Handle combined -r flag (e.g., -rrequirements.txt)
if strings.HasPrefix(currentArg, "-r") && len(currentArg) > 2 {
isManifestInstall = true
manifestFiles = append(manifestFiles, currentArg[2:])
continue
}
// Handle other flags that indicate manifest installation
if currentArg == "-e" || currentArg == "--editable" ||
currentArg == "-c" || currentArg == "--constraint" {
if i+1 < len(args) {
i++ // skip the next argument
}
continue
}
// If it's a flag, skip it
if strings.HasPrefix(currentArg, "-") {
continue
}
// Otherwise, it's a package name
packages = append(packages, currentArg)
}
break
}
}
// If install command was found but no explicit packages and no manifest flags,
// check if it's a bare "pip install" (which should look for default manifest files)
if foundInstallCmd && len(packages) == 0 && len(manifestFiles) == 0 {
isManifestInstall = true
// pip install without args typically looks for requirements.txt
manifestFiles = append(manifestFiles, "requirements.txt")
}
var installTargets []*PackageInstallTarget
for _, pkg := range packages {
@@ -96,8 +147,10 @@ func (pip *pipPackageManager) ParseCommand(args []string) (*ParsedCommand, error
}
return &ParsedCommand{
Command: command,
InstallTargets: installTargets,
Command: command,
InstallTargets: installTargets,
IsManifestInstall: isManifestInstall,
ManifestFiles: manifestFiles,
}, nil
}
+87
View File
@@ -111,6 +111,93 @@ func TestPipParsePackageInfo(t *testing.T) {
}
}
func TestPipParseCommand_ManifestInstallation(t *testing.T) {
pm, err := NewPipPackageManager(DefaultPipPackageManagerConfig())
assert.NoError(t, err)
cases := []struct {
name string
args []string
expectedManifest bool
expectedFiles []string
expectedTargets int
}{
{
name: "pip install with -r flag",
args: []string{"install", "-r", "requirements.txt"},
expectedManifest: true,
expectedFiles: []string{"requirements.txt"},
expectedTargets: 0,
},
{
name: "pip install with --requirement flag",
args: []string{"install", "--requirement", "requirements.txt"},
expectedManifest: true,
expectedFiles: []string{"requirements.txt"},
expectedTargets: 0,
},
{
name: "pip install with combined -r flag",
args: []string{"install", "-rrequirements.txt"},
expectedManifest: true,
expectedFiles: []string{"requirements.txt"},
expectedTargets: 0,
},
{
name: "pip install without args (bare install)",
args: []string{"install"},
expectedManifest: true,
expectedFiles: []string{"requirements.txt"},
expectedTargets: 0,
},
{
name: "pip install with explicit package",
args: []string{"install", "django"},
expectedManifest: false,
expectedFiles: nil,
expectedTargets: 1,
},
{
name: "pip install with mixed args",
args: []string{"install", "django", "-r", "requirements.txt"},
expectedManifest: true,
expectedFiles: []string{"requirements.txt"},
expectedTargets: 1,
},
{
name: "pip install with multiple -r flags",
args: []string{"install", "-r", "requirements.txt", "-r", "dev-requirements.txt"},
expectedManifest: true,
expectedFiles: []string{"requirements.txt", "dev-requirements.txt"},
expectedTargets: 0,
},
{
name: "non-install command",
args: []string{"list"},
expectedManifest: false,
expectedFiles: nil,
expectedTargets: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
parsed, err := pm.ParseCommand(tc.args)
assert.NoError(t, err)
assert.Equal(t, tc.expectedManifest, parsed.IsManifestInstall, "IsManifestInstall mismatch")
assert.Equal(t, tc.expectedFiles, parsed.ManifestFiles, "ManifestFiles mismatch")
assert.Equal(t, tc.expectedTargets, len(parsed.InstallTargets), "InstallTargets count mismatch")
// Test helper methods
assert.Equal(t, tc.expectedManifest, parsed.HasManifestInstall(), "HasManifestInstall mismatch")
expectedShouldExtract := tc.expectedManifest && tc.expectedTargets == 0
assert.Equal(t, expectedShouldExtract, parsed.ShouldExtractFromManifest(), "ShouldExtractFromManifest mismatch")
})
}
}
func TestPipConvertCompatibleRelease(t *testing.T) {
cases := []struct {
name string