Skip to content

Latest commit

 

History

History
566 lines (497 loc) · 22.2 KB

0000-sbom-command.md

File metadata and controls

566 lines (497 loc) · 22.2 KB

SBOM Generation for npm Projects

Summary

Update the npm CLI with a new command which will generate a Software Bill of Materials (SBOM) containing an inventory of the current project's dependencies. Users will have the option to generate an SBOM conforming to either the Software Package Data Exchange (SPDX) or CycloneDX specifications.

Motivation

Finding and remediating vulnerabilities in open source projects is a critical component of securing the software supply chain. However, this requires that enterprises understand what OSS components are used across their infrastructure and applications. When new vulnerabilities are discovered, they need a complete inventory of their software assets in order to properly assess their exposure. Knowing about the critical Log4j vulnerability doesn’t do you any good unless you can also pinpoint where in your organization you’re running the vulnerable code.

SBOMs help to solve this problem by providing a standardized way to document the components that comprise a software application. A proper SBOM should tell you exactly which packages you have deployed and which versions of those packages you are using.

Beyond the security benefit there may be a regulatory requirement to provide SBOMs in some sectors. In response to some recent, high-visibility attacks, The White House has issued an Executive Order which specifically includes directives which would make SBOMs a requirement for any vendor selling to the federal government.

Adding SBOM generation to the tool which many developers are already using to manage their project dependencies eliminates any friction which may come from having to adopt/learn a separate tool.

Detailed Explanation

A new sbom command will be added to the npm CLI which will generate an SBOM for the current project. The SBOM will use the current project as the root and enumerate all of its dependencies (similar to the way npm-ls does) in one of the two supported SBOM formats. See the Example SBOMs section for sample CycloneDX and SPDX SBOM documents.

Supported command options:

--sbom-format - SBOM format to use for output. Valid values are “spdx” or “cyclonedx”. In the future, the set of valid values can be expanded to select differents versions of the supported output formats (e.g. "cyclonedx1.4" vs "cyclonedx1.5").

--sbom-type - Type of project for which the SBOM is being generated. Valid values are "library", "application", and "framework". For CycloneDX SBOMs, this value will be recorded as the type of the root component. For SPDX SBOMs, this value will be recorded as the primaryPackagePurpose of the root package. Defaults to "library".

--omit - Dependency types to omit from generated SBOM. Valid values are “dev”, “optional”, and “peer” (can be set multiple times). By default, all development, optional, and peer dependencies will be included in the generated SBOM unless explicitly excluded.

--package-lock-only - Constructs the SBOM based on the tree described by the package-lock.json, rather than the contents of node_modules. For CycloneDX SBOMs, the lifecycle phase will be set to "pre-build" when this option is true. Defaults to false. If the node_modules folder is not present, this flag will be required in order to generate an SBOM.

--workspace - When used with a project utilizing workspaces, generates an SBOM containing only the identified workspaces (the flag can be specified multiple times to capture multiple workspaces). The SBOM will be rooted in the base directory of the project but will only include the specified child workspace(s).

--workspaces - When used with a project utilizing workspaces, generates an SBOM that includes ONLY the project's child workspaces. Any dependencies which are associated exclusively with the root project will be omitted. This flag can be negated (--no-workspaces) to filter out all of the project's workspaces.

If the user runs the sbom command without first installing the dependencies for the project (i.e. there is no node_modules folder present) an error will be displayed. An SBOM can be generated solely based on the contents of the package-lock.json but requires the user to explicitly specify the --package-lock-only flag.

Initially, we'll support the most widely used versions of the SPDX and CycloneDX specifications (likely v2.3 for SPDX and v1.5 for CycloneDX). Best effort will be made to support new versions as they gain adoption across the ecosystem.

Rationale and Alternatives

There are a few existing tools which can be used to generate an SBOM from an npm project:

  • @cyclonedex/cyclonedx-npm - A CLI for generating a CycloneDX-style SBOM from an npm project. This project is written in TypeScript and actually invokes npm-ls in order to get dependency information for the project.
  • spdx-sbom-generator - A CLI for generating SPDX-style SBOMs for a number of different package managers (including npm). Currently, this tool only works with npm projects using lockfileVersion 1 so it’s not viable for a large number of projects (current lockfileVersion is 3)

While you can effectively generate the same output we’re proposing with this combination of tools, there is value in having this capability supported directly in npm. Beyond the obvious developer-experience benefit of having SBOM generation baked-in to the CLI, it gives us a future path to do things like automatic-signing of SBOMs or integration of SBOMs into the package publishing process.

Implementation

The npm-sbom command will use arborist to construct the dependency tree for the current project and then invoke querySelectorAll to select the set of nodes to be included in the SBOM.

Errors

When using the node_modules to render the SBOM (i.e. when NOT using the --package-lock-only flag) any of the following conditions will cause an error to be reported and prevent the SBOM from being generated:

  • Any missing dependencies which are NOT marked as optional
  • Any invalid dependencies (e.g. a mismatch between the package-lock.json and the node_modules)

Format Details

Both of the SBOM formats present a flat list of dependencies (CycloneDX groups these under a key named components while SPDX groups them under a key named packages). The following sections describe how a dependency will be presented for the different SBOM formats.

CycloneDX

{
  "type": "library",
  "name": "debug",
  "version": "4.3.4",
  "bom-ref": "debug@4.3.4",
  "purl": "pkg:npm/debug@4.3.4",
  "scope": "required",
  "externalReferences": [
    {
      "type": "distribution",
      "url": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
    }
  ],
  "properties": [
    {
      "name": "cdx:npm:package:path",
      "value": "node_modules/debug"
    }
  ],
  "hashes": [
    {
      "alg": "SHA-512",
      "content": "3d15851ee494dde0ed4093ef9cd63b..."
    }
  ]
}

The properties collection also provides for a standard property under the npm taxonomy for annotating development dependencies. For any package which was determined to be a development dependency of the root project, we would add the following to the properties collection:

{
  "name": "cdx:npm:package:development",
  "value": "true"
}

Similarly, there are named properties defined for identifying things like "bundled", "private", and "extraneous" dependencies. Dependencies will be annotated with this properties as appropriate.

The CycloneDX specification also provides fields for capturing other package metadata like author, license, website, etc. Not all packages provide this information, but these fields will be populated when the information is available.

For generating the CycloneDX SBOM, we could utilize the @cyclonedx/cyclonedx-library (2.9MB unpacked) package which provides data models and serializers for generating valid CycloneDX documents. This library has direct dependencies on spdx-expression-parse (which is already included as part of the npm CLI) and packageurl-js (39kB unpacked).

SPDX

{
  "name": "debug",
  "SPDXID": "SPDXRef-Package-debug-4.3.4",
  "versionInfo": "4.3.4",
  "downloadLocation": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
  "filesAnalyzed": false,
  "externalRefs": [
    {
      "referenceCategory": "PACKAGE-MANAGER",
      "referernceType": "npm",
      "referenceLocator": "debug@4.3.4"
    },
    {
      "referenceCategory": "PACKAGE-MANAGER",
      "referernceType": "purl",
      "referenceLocator": "pkg:npm/debug@4.3.4"
    }
  ],
  "checksums": [
    {
      "algorithm": "SHA512",
      "checksumValue": "3d15851ee494dde0ed4093ef9cd63b..."
    }
  ]
}

The example record shown above conforms to version 2.3 of the SPDX Package specification.

The downloadLocation field will report the resolved location calculated by Arborist. This may point to a package registry, or a git URL for dependencies referenced directly from git. The top-level project will specify a value of NOASSERTION for the download location as this information is not available.

All packages will specify a false value for the filesAnaylzed field to signal that no assertions are being made about the files contained within a package.

The externalRefs field will contain two PACKAGE-MANAGER references, one using the npm reference type and the other using the purl reference type.

Prior Art

As it relates to the CycloneDX SBOM format, much of the capability described as part of the new npm-sbom command is already available in the @cyclonedx/cyclonedx-npm project. The @cyclonedx/cyclonedx-npm project also includes documentation about deriving SBOM results from an npm project and component deduplication.

Unresolved Questions and Bikeshedding

  • Does npm-sbom command have a notion of a “default” SBOM format? Do we give preference to one of CycloneDX/SPDX or do we remain totally neutral (possibly at the expense of DX)?

    Recommendation: Remain neutral with regard to SPDX vs CycloneDX. Make the --sbom-format flag mandatory.

  • Both CycloneDX and SPDX support multiple document formats (JSON, XML, Protocol Buffers, etc). Should we support output of multiple formats, or do we stick w/ JSON?

    Recommendation: Stick with JSON-only for the first version of this feature.

Example SBOMs

The sections below show what the different SBOM formats would look like for a basic npm project with a handful of dependencies.

The package.json file below describes a “hello-world” application with two direct dependencies: the debug package and the @tsconfig/node14 package (which is listed as a development dependency). The debug package itself has a dependency on the ms package.

{
  "name": "hello-world",
  "version": "1.0.0",
  "license": "Apache-2.0",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/example/hello-world.git"
  },
  "dependencies": {
    "debug": "^4.3.0"
  },
  "devDependencies": {
    "@tsconfig/node14": "^14.1.0"
  }
}

The complete dependency tree for this project looks like this:

$ npm ls

hello-world@1.0.0
├── @tsconfig/node14@14.1.0
└─┬ debug@4.3.4
  └── ms@2.1.2

CycloneDX

The proposed CycloneDX SBOM generated for the project above would look like the following:

{
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
  "bomFormat": "CycloneDX",
  "specVersion": "1.5",
  "serialNumber": "urn:uuid:0ffefc31-0159-4197-8551-26103dd0280f",
  "version": 1,
  "metadata": {
    "timestamp": "2023-09-12T21:40:13.091Z",
    "lifecycles": [
      {
        "phase": "build"
      }
    ],
    "tools": [
      {
        "vendor": "npm",
        "name": "cli",
        "version": "10.1.0"
      }
    ],
    "component": {
      "bom-ref": "hello-world@1.0.0",
      "type": "application",
      "name": "hello-world",
      "version": "1.0.0",
      "scope": "required",
      "purl": "pkg:npm/hello-world@1.0.0",
      "properties": [
        {
          "name": "cdx:npm:package:path",
          "value": ""
        }
      ],
      "externalReferences": [],
      "licenses": [
        {
          "license": {
            "id": "ISC"
          }
        }
      ]
    }
  },
  "components": [
    {
      "bom-ref": "@tsconfig/node14@14.1.0",
      "type": "library",
      "name": "@tsconfig/node14",
      "version": "14.1.0",
      "scope": "optional",
      "description": "A base TSConfig for working with Node 14.",
      "purl": "pkg:npm/%40tsconfig/node14@14.1.0",
      "properties": [
        {
          "name": "cdx:npm:package:path",
          "value": "node_modules/@tsconfig/node14"
        },
        {
          "name": "cdx:npm:package:development",
          "value": "true"
        }
      ],
      "externalReferences": [
        {
          "type": "distribution",
          "url": "https://registry.npmjs.org/@tsconfig/node14/-/node14-14.1.0.tgz"
        },
        {
          "type": "vcs",
          "url": "git+https://github.com/tsconfig/bases.git"
        },
        {
          "type": "website",
          "url": "https://github.com/tsconfig/bases#readme"
        },
        {
          "type": "issue-tracker",
          "url": "https://github.com/tsconfig/bases/issues"
        }
      ],
      "hashes": [
        {
          "alg": "SHA-512",
          "content": "566b021b4e18479f..."
        }
      ],
      "licenses": [
        {
          "license": {
            "id": "MIT"
          }
        }
      ]
    },
    {
      "bom-ref": "debug@4.3.4",
      "type": "library",
      "name": "debug",
      "version": "4.3.4",
      "scope": "required",
      "author": "Josh Junon",
      "description": "Lightweight debugging utility for Node.js and the browser",
      "purl": "pkg:npm/debug@4.3.4",
      "properties": [
        {
          "name": "cdx:npm:package:path",
          "value": "node_modules/debug"
        }
      ],
      "externalReferences": [
        {
          "type": "distribution",
          "url": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
        },
        {
          "type": "vcs",
          "url": "git://github.com/debug-js/debug.git"
        },
        {
          "type": "website",
          "url": "https://github.com/debug-js/debug#readme"
        },
        {
          "type": "issue-tracker",
          "url": "https://github.com/debug-js/debug/issues"
        }
      ],
      "hashes": [
        {
          "alg": "SHA-512",
          "content": "3d15851ee494dde0..."
        }
      ],
      "licenses": [
        {
          "license": {
            "id": "MIT"
          }
        }
      ]
    },
    {
      "bom-ref": "ms@2.1.2",
      "type": "library",
      "name": "ms",
      "version": "2.1.2",
      "scope": "required",
      "description": "Tiny millisecond conversion utility",
      "purl": "pkg:npm/ms@2.1.2",
      "properties": [
        {
          "name": "cdx:npm:package:path",
          "value": "node_modules/ms"
        }
      ],
      "externalReferences": [
        {
          "type": "distribution",
          "url": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
        },
        {
          "type": "vcs",
          "url": "git+https://github.com/zeit/ms.git"
        },
        {
          "type": "website",
          "url": "https://github.com/zeit/ms#readme"
        },
        {
          "type": "issue-tracker",
          "url": "https://github.com/zeit/ms/issues"
        }
      ],
      "hashes": [
        {
          "alg": "SHA-512",
          "content": "b0690fc7e56332d9..."
        }
      ],
      "licenses": [
        {
          "license": {
            "id": "MIT"
          }
        }
      ]
    }
  ],
  "dependencies": [
    {
      "ref": "hello-world@1.0.0",
      "dependsOn": [
        "debug@4.3.4",
        "@tsconfig/node14@14.1.0"
      ]
    },
    {
      "ref": "@tsconfig/node14@14.1.0",
      "dependsOn": []
    },
    {
      "ref": "debug@4.3.4",
      "dependsOn": [
        "ms@2.1.2"
      ]
    },
    {
      "ref": "ms@2.1.2",
      "dependsOn": []
    }
  ]
}

SPDX

The proposed SPDX SBOM generated for the project above would look like the following:

{
  "spdxVersion": "SPDX-2.3",
  "dataLicense": "CC0-1.0",
  "SPDXID": "SPDXRef-DOCUMENT",
  "name": "hello-world@1.0.0",
  "documentNamespace": "http://spdx.org/spdxdocs/hello-world-1.0.0-<uuid>",
  "creationInfo": {
    "created": "2023-09-12T21:32:11.984Z",
    "creators": [
      "Tool: npm/cli-10.1.0"
    ]
  },
  "documentDescribes": [
    "SPDXRef-Package-hello-world-1.0.0"
  ],
  "packages": [
    {
      "name": "hello-world",
      "SPDXID": "SPDXRef-Package-hello-world-1.0.0",
      "versionInfo": "1.0.0",
      "packageFileName": "",
      "primaryPackagePurpose": "LIBRARY",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "homepage": "NOASSERTION",
      "licenseDeclared": "ISC",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:npm/hello-world@1.0.0"
        }
      ]
    },
    {
      "name": "@tsconfig/node14",
      "SPDXID": "SPDXRef-Package-tsconfig.node14-14.1.0",
      "versionInfo": "14.1.0",
      "packageFileName": "node_modules/@tsconfig/node14",
      "description": "A base TSConfig for working with Node 14.",
      "downloadLocation": "https://registry.npmjs.org/@tsconfig/node14/...",
      "filesAnalyzed": false,
      "homepage": "https://github.com/tsconfig/bases#readme",
      "licenseDeclared": "MIT",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:npm/%40tsconfig/node14@14.1.0"
        }
      ],
      "checksums": [
        {
          "algorithm": "SHA512",
          "checksumValue": "566b021b4e18479f..."
        }
      ]
    },
    {
      "name": "debug",
      "SPDXID": "SPDXRef-Package-debug-4.3.4",
      "versionInfo": "4.3.4",
      "packageFileName": "node_modules/debug",
      "description": "Lightweight debugging utility for Node.js and the browser",
      "downloadLocation": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
      "filesAnalyzed": false,
      "homepage": "https://github.com/debug-js/debug#readme",
      "licenseDeclared": "MIT",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:npm/debug@4.3.4"
        }
      ],
      "checksums": [
        {
          "algorithm": "SHA512",
          "checksumValue": "3d15851ee494dde0..."
        }
      ]
    },
    {
      "name": "ms",
      "SPDXID": "SPDXRef-Package-ms-2.1.2",
      "versionInfo": "2.1.2",
      "packageFileName": "node_modules/ms",
      "description": "Tiny millisecond conversion utility",
      "downloadLocation": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
      "filesAnalyzed": false,
      "homepage": "https://github.com/zeit/ms#readme",
      "licenseDeclared": "MIT",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:npm/ms@2.1.2"
        }
      ],
      "checksums": [
        {
          "algorithm": "SHA512",
          "checksumValue": "b0690fc7e56332d9..."
        }
      ]
    }
  ],
  "relationships": [
    {
      "spdxElementId": "SPDXRef-DOCUMENT",
      "relatedSpdxElement": "SPDXRef-Package-hello-world-1.0.0",
      "relationshipType": "DESCRIBES"
    },
    {
      "spdxElementId": "SPDXRef-Package-hello-world-1.0.0",
      "relatedSpdxElement": "SPDXRef-Package-debug-4.3.4",
      "relationshipType": "DEPENDS_ON"
    },
    {
      "spdxElementId": "SPDXRef-Package-hello-world-1.0.0",
      "relatedSpdxElement": "SPDXRef-Package-tsconfig.node14-14.1.0",
      "relationshipType": "DEV_DEPENDENCY_OF"
    },
    {
      "spdxElementId": "SPDXRef-Package-debug-4.3.4",
      "relatedSpdxElement": "SPDXRef-Package-ms-2.1.2",
      "relationshipType": "DEPENDS_ON"
    }
  ]
}

References