Skip to content

Commit

Permalink
feat: Add support for package.json parsing with approximate semver re…
Browse files Browse the repository at this point in the history
…solution
  • Loading branch information
abhisek committed Nov 25, 2024
1 parent 4d5bbff commit 4a1538d
Show file tree
Hide file tree
Showing 4 changed files with 244 additions and 1 deletion.
99 changes: 99 additions & 0 deletions pkg/parser/fixtures/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
{
"name": "express",
"description": "Fast, unopinionated, minimalist web framework",
"version": "4.18.2",
"author": "TJ Holowaychuk <[email protected]>",
"contributors": [
"Aaron Heckmann <[email protected]>",
"Ciaran Jessup <[email protected]>",
"Douglas Christopher Wilson <[email protected]>",
"Guillermo Rauch <[email protected]>",
"Jonathan Ong <[email protected]>",
"Roman Shtylman <[email protected]>",
"Young Jae Sim <[email protected]>"
],
"license": "MIT",
"repository": "expressjs/express",
"homepage": "http://expressjs.com/",
"keywords": [
"express",
"framework",
"sinatra",
"web",
"http",
"rest",
"restful",
"router",
"app",
"api"
],
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.1",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.5.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.2.0",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.1",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.7",
"proxy-addr": "~2.0.7",
"qs": "6.11.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.18.0",
"serve-static": "1.15.0",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"devDependencies": {
"after": "0.8.2",
"connect-redis": "3.4.2",
"cookie-parser": "1.4.6",
"cookie-session": "2.0.0",
"ejs": "3.1.8",
"eslint": "8.24.0",
"express-session": "1.17.2",
"hbs": "4.2.0",
"marked": "0.7.0",
"method-override": "3.0.0",
"mocha": "10.0.0",
"morgan": "1.10.0",
"multiparty": "4.2.3",
"nyc": "15.1.0",
"pbkdf2-password": "1.2.1",
"supertest": "6.3.0",
"vhost": "~3.0.2"
},
"engines": {
"node": ">= 0.10.0"
},
"files": [
"LICENSE",
"History.md",
"Readme.md",
"index.js",
"lib/"
],
"scripts": {
"lint": "eslint .",
"test": "mocha --require test/support/env --reporter spec --bail --check-leaks test/ test/acceptance/",
"test-ci": "nyc --reporter=lcovonly --reporter=text npm test",
"test-cov": "nyc --reporter=html --reporter=text npm test",
"test-tap": "mocha --require test/support/env --reporter tap --check-leaks test/ test/acceptance/"
}
}
96 changes: 95 additions & 1 deletion pkg/parser/npm_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"regexp"

"github.com/safedep/vet/pkg/common/logger"
"github.com/safedep/vet/pkg/common/utils"
Expand All @@ -31,6 +32,80 @@ type npmPackageLock struct {
Packages map[string]npmPackageLockPackage `json:"packages"`
}

// https://docs.npmjs.com/cli/v10/configuring-npm/package-json
type npmPackageJson struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Author string `json:"author"`
Contributors []string `json:"contributors"`
License string `json:"license"`
Repository string `json:"repository"`
Homepage string `json:"homepage"`
Keywords []string `json:"keywords"`
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
Engines map[string]string `json:"engines"`
Files []string `json:"files"`
Scripts map[string]string `json:"scripts"`
}

var (
exactVersionMatchRegex = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)$`)
startsWithDigitRegex = regexp.MustCompile(`^[0-9]`)
semverExtractorRegex = regexp.MustCompile(`([0-9]+\.[0-9]+\.[0-9]+)`)
)

// Parse package.json. This is required because npm library packages do
// not lock dependencies (rightly so).
func parseNpmPackageJsonAsGraph(packageJsonPath string, config *ParserConfig) (*models.PackageManifest, error) {
data, err := os.ReadFile(packageJsonPath)
if err != nil {
return nil, err
}

var packageJson npmPackageJson
err = json.NewDecoder(bytes.NewReader(data)).Decode(&packageJson)
if err != nil {
return nil, err
}

logger.Debugf("npmGraphParser: Found %d dependencies in package.json",
len(packageJson.Dependencies))

manifest := models.NewPackageManifestFromLocal(packageJsonPath, models.EcosystemNpm)

dependencies := packageJson.Dependencies
if config.IncludeDevDependencies {
for k, v := range packageJson.DevDependencies {
if _, ok := dependencies[k]; !ok {
dependencies[k] = v
} else {
logger.Warnf("npmGraphParser: Dev dependency %s is already present in dependencies", k)
}
}
}

for depName, depVersion := range dependencies {
// We are supporting only package dependencies. Actual dependencies
// can be Url, Git URL, GitHub URL as well. We are not handling those
// for now.

resolvedVersion, err := npmVersionConstraintResolveVersion(depVersion)
if err != nil {
logger.Warnf("npmGraphParser: Could not resolve version for %s: %v", depVersion, err)
continue
}

pkgDetails := models.NewPackageDetail(models.EcosystemNpm, depName, resolvedVersion)
manifest.AddPackage(&models.Package{
PackageDetails: pkgDetails,
})
}

return manifest, nil
}

func parseNpmPackageLockAsGraph(lockfilePath string, config *ParserConfig) (*models.PackageManifest, error) {
data, err := os.ReadFile(lockfilePath)
if err != nil {
Expand All @@ -51,7 +126,7 @@ func parseNpmPackageLockAsGraph(lockfilePath string, config *ParserConfig) (*mod
logger.Debugf("npmGraphParser: Found %d packages in lockfile",
len(lockfile.Packages))

manifest := models.NewPackageManifest(lockfilePath, models.EcosystemNpm)
manifest := models.NewPackageManifestFromLocal(lockfilePath, models.EcosystemNpm)
dependencyGraph := manifest.DependencyGraph

if dependencyGraph == nil {
Expand Down Expand Up @@ -133,3 +208,22 @@ func npmGraphFindBySemverRange(graph *models.DependencyGraph[*models.Package],
name, semver string) *models.DependencyGraphNode[*models.Package] {
return utils.FindDependencyGraphNodeBySemverRange(graph, name, semver)
}

// There is no way for us to resolve the version from a semver constraint
// because it depends on a lot of factors including other dependencies, platform,
// node version etc. To keep things simple, we will choose the lowest version
func npmVersionConstraintResolveVersion(constraint string) (string, error) {
if exactVersionMatchRegex.MatchString(constraint) {
return constraint, nil
}

if startsWithDigitRegex.MatchString(constraint) {
return constraint, nil
}

if semverExtractorRegex.MatchString(constraint) {
return semverExtractorRegex.FindString(constraint), nil
}

return constraint, nil
}
47 changes: 47 additions & 0 deletions pkg/parser/npm_graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,50 @@ func TestNpmGraphParserPathToRootFromDependent(t *testing.T) {
assert.Equal(t, "@aws-sdk/client-sts", bNodeToRoot[2].GetName())
assert.Equal(t, "@aws-sdk/client-s3", bNodeToRoot[3].GetName())
}

func TestNpmVersionConstraintResolveVersion(t *testing.T) {
cases := []struct {
name string
input string
output string
err error
}{
{
name: "resolved version",
input: "1.2.3",
output: "1.2.3",
},
{
name: "semver with tilde",
input: "~1.2.3",
output: "1.2.3",
},
{
name: "semver with tilde space",
input: "~ 1.2.3",
output: "1.2.3",
},
{
name: "semver with greater equal",
input: ">=1.2.3",
output: "1.2.3",
},
{
name: "non-semver version",
input: "latest",
output: "latest",
},
}

for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
version, err := npmVersionConstraintResolveVersion(test.input)
if test.err != nil {
assert.Error(t, err)
assert.ErrorContains(t, err, test.err.Error())
} else {
assert.Equal(t, test.output, version)
}
})
}
}
3 changes: 3 additions & 0 deletions pkg/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ type dependencyGraphParser func(lockfilePath string, config *ParserConfig) (*mod

// Maintain a map of lockfileAs to dependencyGraphParser
var dependencyGraphParsers map[string]dependencyGraphParser = map[string]dependencyGraphParser{
"package.json": parseNpmPackageJsonAsGraph,
"package-lock.json": parseNpmPackageLockAsGraph,
customParserCycloneDXSBOM: parseSbomCycloneDxAsGraph,
customParserTypeJavaArchive: parseJavaArchiveAsGraph,
Expand Down Expand Up @@ -240,6 +241,8 @@ func (pw *parserWrapper) Ecosystem() string {
return models.EcosystemMaven
case "buildscript-gradle.lockfile":
return models.EcosystemMaven
case "package.json":
return models.EcosystemNpm
case customParserTypePyWheel:
return models.EcosystemPyPI
case customParserCycloneDXSBOM:
Expand Down

0 comments on commit 4a1538d

Please sign in to comment.