├── .release-please-manifest.json ├── logo-dark.png ├── commitlint.config.js ├── logo-light.png ├── .husky └── commit-msg ├── .prettierrc ├── src ├── charts │ ├── index.js │ ├── csv.js │ ├── treemap.js │ ├── tree.js │ ├── tooltip.js │ └── utils.js ├── errors.js ├── files │ ├── utils.js │ ├── npmrc.js │ ├── index.js │ ├── workspace.js │ ├── packages.js │ └── lockfiles.js ├── cli │ ├── index.js │ ├── checkUpdates.js │ ├── logger.js │ ├── summary.js │ ├── tips.js │ ├── utils.js │ ├── progress.js │ ├── handleCrash.js │ └── cmds │ │ ├── resolve.js │ │ └── audit.js ├── issues │ ├── vulnerabilities │ │ ├── github.js │ │ ├── yarnPnpm.js │ │ ├── composer.js │ │ ├── yarnClassic.js │ │ └── npm.js │ ├── vulnerabilities.js │ ├── meta.js │ ├── utils.js │ ├── licenses.json │ └── license.js ├── registry │ ├── utils.js │ ├── composer.js │ ├── index.js │ └── npm.js ├── fetch.js ├── graph │ ├── generateComposerGraph.js │ ├── generateYarnGraph.js │ ├── generatePnpmGraph.js │ ├── generateNpmGraph.js │ ├── index.js │ └── utils.js ├── validateConfig.js └── index.js ├── .npmignore ├── .eslintrc.json ├── release-please-config.json ├── .github ├── FUNDING.yml ├── workflows │ └── close_inactive_issues.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── LICENSE ├── .gitignore ├── package.json ├── SECURITY.md ├── .circleci └── config.yml ├── CONTRIBUTING.md ├── CODE-OF-CONDUCT.md └── README.md /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "1.56.1" 3 | } -------------------------------------------------------------------------------- /logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandworm-hq/sandworm-audit/HEAD/logo-dark.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {extends: ['@commitlint/config-conventional']}; 2 | -------------------------------------------------------------------------------- /logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandworm-hq/sandworm-audit/HEAD/logo-light.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "all", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true, 7 | "bracketSpacing": false 8 | } 9 | -------------------------------------------------------------------------------- /src/charts/index.js: -------------------------------------------------------------------------------- 1 | const buildTree = require('./tree'); 2 | const buildTreemap = require('./treemap'); 3 | 4 | module.exports = { 5 | buildTree, 6 | buildTreemap, 7 | }; 8 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | class UsageError extends Error { 2 | constructor(message) { 3 | super(message); 4 | this.name = 'UsageError'; 5 | } 6 | } 7 | 8 | module.exports = { 9 | UsageError, 10 | }; 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | sandworm 2 | .husky/commit-msg 3 | .husky/pre-commit 4 | .circleci 5 | .github 6 | .vscode 7 | .eslintrc.json 8 | .prettierrc 9 | test.js 10 | commitlint.config.js 11 | .release-please-manifest.json 12 | release-please-config.json 13 | *.md 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-base", 4 | "prettier" 5 | ], 6 | "parserOptions": { 7 | "ecmaVersion": 2020 8 | }, 9 | "plugins": [ 10 | "prettier" 11 | ], 12 | "rules": { 13 | "no-trailing-spaces": "error" 14 | } 15 | } -------------------------------------------------------------------------------- /src/files/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const loadJsonFile = (filePath) => { 4 | let content; 5 | if (fs.existsSync(filePath)) { 6 | content = JSON.parse(fs.readFileSync(filePath).toString()); 7 | } 8 | return content; 9 | }; 10 | 11 | module.exports = {loadJsonFile}; 12 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "changelog-path": "CHANGELOG.md", 5 | "release-type": "node", 6 | "bump-minor-pre-major": false, 7 | "bump-patch-for-minor-pre-major": false, 8 | "draft": false, 9 | "prerelease": false 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const Yargs = require('yargs'); 3 | 4 | // eslint-disable-next-line no-unused-expressions 5 | Yargs(process.argv.slice(2)) 6 | .scriptName('Sandworm') 7 | .commandDir('cmds') 8 | .demandCommand() 9 | .help() 10 | .alias('v', 'version') 11 | .describe('v', 'Show version number').argv; 12 | -------------------------------------------------------------------------------- /src/issues/vulnerabilities/github.js: -------------------------------------------------------------------------------- 1 | const fetch = require('../../fetch'); 2 | 3 | const fromGitHub = async (githubAdvisoryId) => { 4 | const responseRaw = await fetch(`https://api.github.com/advisories/${githubAdvisoryId}`); 5 | const response = await responseRaw.json(); 6 | return response; 7 | }; 8 | 9 | module.exports = fromGitHub; 10 | -------------------------------------------------------------------------------- /src/cli/checkUpdates.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const semver = require('semver'); 3 | const {loadManifest} = require('../files'); 4 | const {getRegistryData} = require('../registry'); 5 | 6 | module.exports = async () => { 7 | try { 8 | const {version: currentVersion} = await loadManifest(path.join(__dirname, '../..')); 9 | const data = await getRegistryData('npm', '@sandworm/audit'); 10 | const latestVersion = data['dist-tags']?.latest; 11 | 12 | return semver.lt(currentVersion, latestVersion); 13 | } catch (error) { 14 | return false; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/files/npmrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const ini = require('ini'); 4 | const os = require('os'); 5 | 6 | module.exports = { 7 | loadNpmConfigs: (appPath) => { 8 | try { 9 | const projectPath = path.join(appPath, '.npmrc'); 10 | const userPath = path.join(os.homedir(), '.npmrc'); 11 | const projectConfigs = fs.existsSync(projectPath) 12 | ? ini.parse(fs.readFileSync(projectPath, 'utf-8')) 13 | : {}; 14 | const userConfigs = fs.existsSync(userPath) 15 | ? ini.parse(fs.readFileSync(userPath, 'utf-8')) 16 | : {}; 17 | 18 | return Object.assign(userConfigs, projectConfigs); 19 | } catch (error) { 20 | return {}; 21 | } 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: sandworm-hq 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/close_inactive_issues.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v5 14 | with: 15 | days-before-issue-stale: 30 16 | days-before-issue-close: 14 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 19 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /src/cli/logger.js: -------------------------------------------------------------------------------- 1 | const logger = console; 2 | const colors = { 3 | RESET: '\x1b[0m', 4 | BLACK: '\x1b[30m', 5 | CYAN: '\x1b[36m', 6 | BG_CYAN: '\x1b[46m', 7 | BG_GRAY: '\x1b[100m', 8 | RED: '\x1b[31m', 9 | DIM: '\x1b[2m', 10 | CRIMSON: '\x1b[38m', 11 | GREEN: '\x1b[32m', 12 | UNDERLINE: '\x1b[4m', 13 | }; 14 | const SEVERITY_ICONS = { 15 | critical: '🔴', 16 | high: '🟠', 17 | moderate: '🟡', 18 | low: '⚪', 19 | }; 20 | 21 | logger.colors = colors; 22 | logger.SEVERITY_ICONS = SEVERITY_ICONS; 23 | logger.logColor = (color, message) => logger.log(`${color}%s${colors.RESET}`, message); 24 | 25 | logger.logCliHeader = () => { 26 | logger.logColor(logger.colors.CYAN, 'Sandworm 🪱'); 27 | logger.logColor(logger.colors.DIM, 'Security and License Compliance Audit'); 28 | }; 29 | 30 | module.exports = logger; 31 | -------------------------------------------------------------------------------- /src/registry/utils.js: -------------------------------------------------------------------------------- 1 | const normalizeComposerManifest = (manifest, latestVersion) => { 2 | const normalizedManifest = { 3 | ...manifest, 4 | published: manifest.time, 5 | repository: manifest.source, 6 | bugs: manifest.support?.issues, 7 | dependencies: manifest.require, 8 | devDependencies: manifest['require-dev'], 9 | deprecated: manifest.abandoned, 10 | }; 11 | 12 | if (latestVersion) { 13 | normalizedManifest.latestVersion = latestVersion; 14 | } 15 | 16 | delete normalizedManifest.support; 17 | delete normalizedManifest.source; 18 | delete normalizedManifest.time; 19 | delete normalizedManifest.require; 20 | delete normalizedManifest['require-dev']; 21 | delete normalizedManifest.abandoned; 22 | 23 | return normalizedManifest; 24 | }; 25 | 26 | module.exports = {normalizeComposerManifest}; 27 | -------------------------------------------------------------------------------- /src/fetch.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-promise-executor-return 2 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 3 | 4 | const fetch = async (url, opts) => { 5 | let retryCount = 3; 6 | const nodeFetch = (await import('node-fetch')).default; 7 | 8 | while (retryCount > 0) { 9 | try { 10 | // eslint-disable-next-line no-await-in-loop 11 | const responseRaw = await nodeFetch(url, opts); 12 | if (!responseRaw.ok) { 13 | throw new Error(`Error ${responseRaw.status} from registry: ${responseRaw.statusText}`); 14 | } else { 15 | return responseRaw; 16 | } 17 | } catch (e) { 18 | retryCount -= 1; 19 | if (retryCount === 0) { 20 | throw e; 21 | } 22 | // eslint-disable-next-line no-await-in-loop 23 | await sleep(1000); 24 | } 25 | } 26 | 27 | throw new Error(); 28 | }; 29 | 30 | module.exports = fetch; 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: gabidobo 7 | 8 | --- 9 | 10 | **Sandworm version** 11 | The library version you're using. 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **Manifest files** 17 | Whenever possible, please provide your `package.json` manifest file content (or at least a list of all the dependencies within), as well as your lockfile. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior. 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **System (please complete the following information):** 26 | - Node Version [e.g. 14.17.3] 27 | - Package Manager [e.g. npm] 28 | - Package Manager Version [e.g. 8.5.5] 29 | - OS: [e.g. Ubuntu 22.04] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /src/issues/vulnerabilities/yarnPnpm.js: -------------------------------------------------------------------------------- 1 | const {exec} = require('child_process'); 2 | const {reportFromNpmAdvisory} = require('../utils'); 3 | 4 | const fromYarnOrPnpm = ({appPath, packageGraph, usePnpm = false, includeDev}) => 5 | new Promise((resolve, reject) => { 6 | exec( 7 | usePnpm ? 'pnpm audit --json' : 'yarn npm audit --recursive --json', 8 | {cwd: appPath}, 9 | (err, stdout, stderr) => { 10 | if (stderr) { 11 | reject(new Error(stderr)); 12 | } else { 13 | try { 14 | const reports = []; 15 | const {advisories} = JSON.parse(stdout || '{"advisories": {}}'); 16 | Object.values(advisories || {}).forEach((advisory) => { 17 | reports.push(reportFromNpmAdvisory(advisory, packageGraph, includeDev)); 18 | }); 19 | 20 | resolve(reports); 21 | } catch (error) { 22 | reject(new Error(`${error.message} => ${stdout}`)); 23 | } 24 | } 25 | }, 26 | ); 27 | }); 28 | 29 | module.exports = fromYarnOrPnpm; 30 | -------------------------------------------------------------------------------- /src/cli/summary.js: -------------------------------------------------------------------------------- 1 | const {getUniqueIssueId} = require('../issues/utils'); 2 | const logger = require('./logger'); 3 | 4 | const groupIssuesBySeverity = (issuesByType) => { 5 | const grouped = { 6 | critical: [], 7 | high: [], 8 | moderate: [], 9 | low: [], 10 | }; 11 | 12 | Object.values(issuesByType || {}).forEach((issues) => 13 | (issues || []).forEach((issue) => grouped[issue.severity].push(issue)), 14 | ); 15 | 16 | return grouped; 17 | }; 18 | 19 | module.exports = (issuesByType) => { 20 | const issuesBySeverity = groupIssuesBySeverity(issuesByType); 21 | Object.values(issuesBySeverity).forEach((issues) => { 22 | issues.forEach((issue) => { 23 | const {sources} = issue.findings; 24 | logger.log( 25 | `${logger.SEVERITY_ICONS[issue.severity]} ${logger.colors.RED}%s${logger.colors.RESET} %s ${ 26 | logger.colors.DIM 27 | }%s${logger.colors.RESET}`, 28 | `${sources[0]?.name}@${sources[0]?.version}`, 29 | `${issue.shortTitle || issue.title}`, 30 | `${getUniqueIssueId(issue)}`, 31 | ); 32 | }); 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sandworm Security 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/issues/vulnerabilities/composer.js: -------------------------------------------------------------------------------- 1 | const {exec} = require('child_process'); 2 | const {reportFromComposerAdvisory} = require('../utils'); 3 | 4 | const fromComposerRaw = (appPath) => 5 | new Promise((resolve, reject) => { 6 | exec('composer audit --format=json --locked', {cwd: appPath}, (err, stdout) => { 7 | // composer uses stderr for notifications, ignore it 8 | try { 9 | const output = JSON.parse(stdout); 10 | const allAdvisories = Object.values(output.advisories || {}).reduce( 11 | (agg, a) => [...agg, ...(Array.isArray(a) ? a : Object.values(a))], 12 | [], 13 | ); 14 | 15 | resolve(allAdvisories); 16 | } catch (error) { 17 | reject(error); 18 | } 19 | }); 20 | }); 21 | 22 | const fromComposer = async ({appPath, packageGraph, includeDev}) => { 23 | const allAdvisories = await fromComposerRaw(appPath); 24 | return allAdvisories.reduce(async (aggPromise, advisory) => { 25 | const agg = await aggPromise; 26 | return [...agg, await reportFromComposerAdvisory(advisory, packageGraph, includeDev)]; 27 | }, Promise.resolve([])); 28 | }; 29 | 30 | module.exports = fromComposer; 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Coverage info 14 | coverage 15 | 16 | # Test results 17 | junit*.xml 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Dependency directories 26 | node_modules/ 27 | jspm_packages/ 28 | 29 | # TypeScript cache 30 | *.tsbuildinfo 31 | 32 | # Optional npm cache directory 33 | .npm 34 | 35 | # Optional eslint cache 36 | .eslintcache 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | # Output of 'npm pack' 42 | *.tgz 43 | 44 | # Yarn Integrity file 45 | .yarn-integrity 46 | 47 | # dotenv environment variable files 48 | .env 49 | .env.development.local 50 | .env.test.local 51 | .env.production.local 52 | .env.local 53 | 54 | # yarn v2 55 | .yarn/cache 56 | .yarn/unplugged 57 | .yarn/build-state.yml 58 | .yarn/install-state.gz 59 | .pnp.* 60 | 61 | # Quick dev tests file 62 | test.js 63 | 64 | # Sandworm audit report output 65 | sandworm 66 | 67 | # Webstorm internal 68 | .idea 69 | 70 | # heap dumps 71 | *.heapsnapshot 72 | 73 | .DS_Store 74 | -------------------------------------------------------------------------------- /src/graph/generateComposerGraph.js: -------------------------------------------------------------------------------- 1 | const { 2 | processDependenciesForPackage, 3 | processPlaceholders, 4 | makeNode, 5 | seedNodes, 6 | } = require('./utils'); 7 | 8 | const generateComposerGraph = ({data, manifest}) => { 9 | const allPackages = []; 10 | const placeholders = []; 11 | 12 | seedNodes({ 13 | initialNodes: [ 14 | { 15 | ...manifest, 16 | dependencies: manifest.require, 17 | devDependencies: manifest['require-dev'], 18 | }, 19 | ], 20 | allPackages, 21 | placeholders, 22 | altTilde: true, 23 | }); 24 | 25 | const root = allPackages[0]; 26 | 27 | (data.packages || []).forEach((packageData) => { 28 | const {name, version} = packageData; 29 | 30 | const newPackage = makeNode({ 31 | name, 32 | version, 33 | }); 34 | 35 | processDependenciesForPackage({ 36 | dependencies: { 37 | dependencies: packageData.require, 38 | devDependencies: packageData['require-dev'], 39 | }, 40 | newPackage, 41 | allPackages, 42 | placeholders, 43 | altTilde: true, 44 | }); 45 | 46 | processPlaceholders({newPackage, placeholders}); 47 | 48 | allPackages.push(newPackage); 49 | }); 50 | 51 | return {root, allPackages}; 52 | }; 53 | 54 | module.exports = generateComposerGraph; 55 | -------------------------------------------------------------------------------- /src/graph/generateYarnGraph.js: -------------------------------------------------------------------------------- 1 | const { 2 | parseDependencyString, 3 | processDependenciesForPackage, 4 | processPlaceholders, 5 | makeNode, 6 | seedNodes, 7 | } = require('./utils'); 8 | 9 | const generateYarnGraph = ({data, manifest, workspace}) => { 10 | const allPackages = []; 11 | const placeholders = []; 12 | 13 | seedNodes({ 14 | initialNodes: [manifest, ...(workspace?.workspaceProjects || [])], 15 | allPackages, 16 | placeholders, 17 | }); 18 | 19 | const root = allPackages[0]; 20 | 21 | Object.entries(data).forEach(([id, packageData]) => { 22 | const {version, resolved, integrity, resolution, checksum} = packageData; 23 | id.split(', ').forEach((individualId) => { 24 | const {name} = parseDependencyString(individualId); 25 | const newPackage = makeNode({ 26 | name, 27 | version, 28 | resolved: resolved || resolution, 29 | integrity: integrity || checksum, 30 | }); 31 | 32 | processDependenciesForPackage({ 33 | dependencies: packageData, 34 | newPackage, 35 | allPackages, 36 | placeholders, 37 | }); 38 | 39 | processPlaceholders({newPackage, placeholders}); 40 | 41 | allPackages.push(newPackage); 42 | }); 43 | }); 44 | 45 | return {root, allPackages}; 46 | }; 47 | 48 | module.exports = generateYarnGraph; 49 | -------------------------------------------------------------------------------- /src/issues/vulnerabilities/yarnClassic.js: -------------------------------------------------------------------------------- 1 | const {exec} = require('child_process'); 2 | const {reportFromNpmAdvisory} = require('../utils'); 3 | 4 | const fromYarnClassic = ({appPath, packageGraph, includeDev}) => 5 | new Promise((resolve, reject) => { 6 | exec('yarn audit --json', {cwd: appPath}, (err, stdout, stderr) => { 7 | if (stderr) { 8 | try { 9 | const error = stderr.split('\n').find((rawLine) => { 10 | if (!rawLine) { 11 | return false; 12 | } 13 | const event = JSON.parse(rawLine); 14 | return event.type === 'error'; 15 | }); 16 | 17 | if (error) { 18 | reject(new Error(error)); 19 | return; 20 | } 21 | } catch (error) { 22 | reject(new Error(stderr)); 23 | return; 24 | } 25 | } 26 | 27 | resolve( 28 | stdout.split('\n').reduce((agg, rawLine) => { 29 | if (!rawLine) { 30 | return agg; 31 | } 32 | try { 33 | const event = JSON.parse(rawLine); 34 | if (event.type === 'auditAdvisory') { 35 | const {advisory} = event.data; 36 | const report = reportFromNpmAdvisory(advisory, packageGraph, includeDev); 37 | if (!agg.find(({id}) => id === report.id)) { 38 | agg.push(report); 39 | } 40 | } 41 | return agg; 42 | } catch (error) { 43 | return agg; 44 | } 45 | }, []), 46 | ); 47 | }); 48 | }); 49 | 50 | module.exports = fromYarnClassic; 51 | -------------------------------------------------------------------------------- /src/cli/tips.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | 3 | const tips = [ 4 | { 5 | message: 'Configure Sandworm with a config file', 6 | example: '.sandworm.config.json', 7 | url: 'https://docs.sandworm.dev/audit/configuration', 8 | }, 9 | { 10 | message: 'Tell Sandworm which licenses are disallowed', 11 | example: 'sandworm-audit --license-policy \'{"critical": ["cat:Network Protective"]}\'', 12 | url: 'https://docs.sandworm.dev/audit/license-policies', 13 | }, 14 | { 15 | message: 'Run Sandworm in your CI to enforce security rules', 16 | example: 'sandworm audit --skip-all --fail-on=\'["*.critical", "*.high"]\'', 17 | url: 'https://docs.sandworm.dev/audit/fail-policies', 18 | }, 19 | { 20 | message: 'Mark issues as resolved with Sandworm', 21 | example: 'sandworm resolve ISSUE-ID', 22 | url: 'https://docs.sandworm.dev/audit/resolving-issues', 23 | }, 24 | { 25 | message: 'Save issue resolution info to your repo', 26 | example: 'resolved-issues.json', 27 | url: 'https://docs.sandworm.dev/audit/resolving-issues', 28 | }, 29 | ]; 30 | 31 | const tip = () => { 32 | const currentTipIndex = Math.floor(Math.random() * tips.length); 33 | const {message, example, url} = tips[currentTipIndex]; 34 | 35 | const currentTip = `${logger.colors.DIM}\n//\n// ${logger.colors.RESET}💡 ${ 36 | logger.colors.DIM 37 | }${message}${example ? `\n// ${example}` : ''}${ 38 | url ? `\n// ${logger.colors.UNDERLINE}${url}${logger.colors.RESET}` : '' 39 | }\n${logger.colors.DIM}//${logger.colors.RESET}\n`; 40 | 41 | return currentTip; 42 | }; 43 | 44 | module.exports = tip; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sandworm/audit", 3 | "version": "1.56.1", 4 | "description": "Security & License Compliance For Your App's Dependencies 🪱", 5 | "main": "src/index.js", 6 | "bin": { 7 | "sandworm-audit": "src/cli/index.js", 8 | "sandworm": "src/cli/index.js" 9 | }, 10 | "scripts": { 11 | "lint": "yarn eslint ./src/", 12 | "prepare": "husky install" 13 | }, 14 | "engines": { 15 | "node": ">=14.19.0" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/sandworm-hq/sandworm-audit.git" 20 | }, 21 | "keywords": [ 22 | "security", 23 | "audit", 24 | "dependencies" 25 | ], 26 | "author": "Sandworm ", 27 | "license": "MIT", 28 | "homepage": "https://sandworm.dev", 29 | "dependencies": { 30 | "@pnpm/lockfile-file": "7.0.5", 31 | "@pnpm/logger": "5.0.0", 32 | "@sandworm/utils": "1.16.0", 33 | "@yarnpkg/lockfile": "1.1.0", 34 | "@yarnpkg/parsers": "3.0.0-rc.39", 35 | "d3-node": "3.0.0", 36 | "fast-glob": "^3.3.1", 37 | "ini": "^4.0.0", 38 | "js-yaml": "^4.1.0", 39 | "node-fetch": "^3.3.1", 40 | "ora": "6.1.2", 41 | "prompts": "^2.4.2", 42 | "semver": "7.5.2", 43 | "yargs": "17.6.0" 44 | }, 45 | "devDependencies": { 46 | "@commitlint/cli": "^17.1.2", 47 | "@commitlint/config-conventional": "^17.1.0", 48 | "eslint": "^8.24.0", 49 | "eslint-config-airbnb": "^19.0.4", 50 | "eslint-config-prettier": "^8.5.0", 51 | "eslint-plugin-import": "^2.26.0", 52 | "eslint-plugin-prettier": "^4.2.1", 53 | "husky": "^8.0.0", 54 | "prettier": "^2.7.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/issues/vulnerabilities.js: -------------------------------------------------------------------------------- 1 | const fromComposer = require('./vulnerabilities/composer'); 2 | const fromNpm = require('./vulnerabilities/npm'); 3 | const fromYarnClassic = require('./vulnerabilities/yarnClassic'); 4 | const fromYarnOrPnpm = require('./vulnerabilities/yarnPnpm'); 5 | 6 | const getDependencyVulnerabilities = async ({ 7 | appPath, 8 | packageManager = 'npm', 9 | packageGraph = {}, 10 | onProgress = () => {}, 11 | includeDev, 12 | }) => { 13 | let vulnerabilities; 14 | 15 | try { 16 | if (packageManager === 'npm') { 17 | onProgress('Getting vulnerability report from npm'); 18 | vulnerabilities = await fromNpm({appPath, packageGraph, includeDev}); 19 | } else if (packageManager === 'yarn-classic') { 20 | onProgress('Getting vulnerability report from yarn'); 21 | vulnerabilities = await fromYarnClassic({appPath, packageGraph, includeDev}); 22 | } else if (packageManager === 'yarn') { 23 | onProgress('Getting vulnerability report from yarn'); 24 | vulnerabilities = await fromYarnOrPnpm({appPath, packageGraph, includeDev}); 25 | } else if (packageManager === 'pnpm') { 26 | onProgress('Getting vulnerability report from pnpm'); 27 | vulnerabilities = await fromYarnOrPnpm({appPath, packageGraph, includeDev, usePnpm: true}); 28 | } else if (packageManager === 'composer') { 29 | onProgress('Getting vulnerability report from composer'); 30 | vulnerabilities = await fromComposer({appPath, packageGraph, includeDev}); 31 | } 32 | } catch (error) { 33 | throw new Error(`Error getting vulnerability report from ${packageManager}: ${error.message}`); 34 | } 35 | 36 | return vulnerabilities; 37 | }; 38 | 39 | module.exports = { 40 | getDependencyVulnerabilities, 41 | }; 42 | -------------------------------------------------------------------------------- /src/cli/utils.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | 3 | const SEVERITIES = ['critical', 'high', 'moderate', 'low']; 4 | 5 | const getIssueCounts = (issuesByType) => { 6 | const issueCountsByType = {}; 7 | const issueCountsBySeverity = {}; 8 | Object.entries(issuesByType).forEach(([type, issues]) => { 9 | issueCountsByType[type] = {}; 10 | SEVERITIES.forEach((severity) => { 11 | const count = (issues || []).filter( 12 | ({severity: issueSeverity}) => issueSeverity === severity, 13 | ).length; 14 | issueCountsByType[type][severity] = count; 15 | issueCountsBySeverity[severity] = (issueCountsBySeverity[severity] || 0) + count; 16 | }); 17 | }); 18 | const totalIssueCount = Object.values(issueCountsBySeverity).reduce( 19 | (agg, count) => agg + count, 20 | 0, 21 | ); 22 | 23 | return { 24 | issueCountsByType, 25 | issueCountsBySeverity, 26 | totalIssueCount, 27 | }; 28 | }; 29 | 30 | const failIfRequested = ({failOn, issueCountsByType}) => { 31 | Object.entries(issueCountsByType).forEach(([issueType, typeCountBySeverity]) => { 32 | Object.entries(typeCountBySeverity).forEach(([issueSeverity, count]) => { 33 | if (count > 0) { 34 | failOn.forEach((failOnOption) => { 35 | const [failType, failSeverity] = failOnOption.split('.'); 36 | if ( 37 | (failType === '*' && failSeverity === '*') || 38 | (failType === issueType && failSeverity === issueSeverity) || 39 | (failType === '*' && failSeverity === issueSeverity) || 40 | (failType === issueType && failSeverity === '*') 41 | ) { 42 | logger.logColor(logger.colors.RED, `❌ Failing because of rule "${failOnOption}"`); 43 | process.exit(1); 44 | } 45 | }); 46 | } 47 | }); 48 | }); 49 | }; 50 | 51 | module.exports = { 52 | getIssueCounts, 53 | failIfRequested, 54 | }; 55 | -------------------------------------------------------------------------------- /src/graph/generatePnpmGraph.js: -------------------------------------------------------------------------------- 1 | const { 2 | processDependenciesForPackage, 3 | processPlaceholders, 4 | makeNode, 5 | SEMVER_REGEXP, 6 | seedNodes, 7 | } = require('./utils'); 8 | 9 | const parsePath = (path) => { 10 | // parse pnpm lockfile package names like: 11 | // (lockfile v5) 12 | // /babel-preset-jest/29.2.0 13 | // /babel-preset-jest/29.2.0_@babel+core@7.20.7 14 | // /ts-node/10.9.1_xl7wyiapi7jo5c2pfz5vjm55na 15 | // (lockfile v6) 16 | // /@nestjs/schematics/9.1.0(typescript@5.0.3) 17 | // /ts-node/10.9.1(@types/node@14.18.36)(typescript@4.9.3) 18 | // see https://github.com/pnpm/pnpm/pull/5810 19 | const results = path.match(new RegExp(`^/(.*?)/(${SEMVER_REGEXP.source})(.*?)$`)); 20 | const name = results?.[1]; 21 | const version = results?.[2]; 22 | 23 | return {name, version}; 24 | }; 25 | 26 | const generatePnpmGraph = ({data, manifest, workspace}) => { 27 | const allPackages = []; 28 | const placeholders = []; 29 | 30 | seedNodes({ 31 | initialNodes: [manifest, ...(workspace?.workspaceProjects || [])], 32 | allPackages, 33 | placeholders, 34 | }); 35 | 36 | const root = allPackages[0]; 37 | 38 | Object.entries(data).forEach(([id, packageData]) => { 39 | const { 40 | resolution, 41 | dev, 42 | // peerDependenciesMeta 43 | // transitivePeerDependencies, 44 | } = packageData; 45 | const {name, version} = parsePath(id); 46 | const newPackage = makeNode({ 47 | name, 48 | version, 49 | dev, 50 | ...(resolution && resolution.integrity && {integrity: resolution.integrity}), 51 | }); 52 | 53 | processDependenciesForPackage({ 54 | dependencies: packageData, 55 | newPackage, 56 | allPackages, 57 | placeholders, 58 | }); 59 | 60 | processPlaceholders({newPackage, placeholders}); 61 | 62 | allPackages.push(newPackage); 63 | }); 64 | 65 | return {root, allPackages}; 66 | }; 67 | 68 | module.exports = generatePnpmGraph; 69 | -------------------------------------------------------------------------------- /src/registry/composer.js: -------------------------------------------------------------------------------- 1 | const semver = require('semver'); 2 | const fetch = require('../fetch'); 3 | const {reportFromComposerAdvisory} = require('../issues/utils'); 4 | const {normalizeComposerManifest} = require('./utils'); 5 | 6 | const DEFAULT_REGISTRY_URL = 'https://repo.packagist.org/'; 7 | 8 | const getComposerRegistryData = async (packageName, packageVersion) => { 9 | const packageUrl = new URL(`/p2/${packageName}.json`, DEFAULT_REGISTRY_URL); 10 | 11 | const responseRaw = await fetch(packageUrl.href); 12 | const response = await responseRaw.json(); 13 | 14 | const allVersions = response.packages[packageName]; 15 | const fullData = allVersions[0]; 16 | const requestedVersion = packageVersion || fullData.version; 17 | const versionData = allVersions.find(({version}) => version === requestedVersion); 18 | const joinedData = { 19 | ...fullData, 20 | ...versionData, 21 | }; 22 | 23 | return normalizeComposerManifest(joinedData, fullData.version); 24 | }; 25 | 26 | const getComposerRegistryAudit = async ({ 27 | packageName, 28 | packageVersion, 29 | packageGraph, 30 | includeDev, 31 | }) => { 32 | const responseRaw = await fetch( 33 | `https://packagist.org/api/security-advisories/?packages=${packageName}`, 34 | ); 35 | const response = await responseRaw.json(); 36 | const rawAdvisories = Array.isArray(response.advisories) 37 | ? response.advisories 38 | : response.advisories[packageName] || []; 39 | 40 | return rawAdvisories 41 | .filter(({affectedVersions}) => 42 | semver.satisfies( 43 | packageVersion, 44 | affectedVersions.replaceAll(',', ' ').replaceAll('|', ' || '), 45 | ), 46 | ) 47 | .reduce(async (aggPromise, advisory) => { 48 | const agg = await aggPromise; 49 | return [...agg, await reportFromComposerAdvisory(advisory, packageGraph, includeDev)]; 50 | }, Promise.resolve([])); 51 | }; 52 | 53 | module.exports = { 54 | getComposerRegistryData, 55 | getComposerRegistryAudit, 56 | }; 57 | -------------------------------------------------------------------------------- /src/files/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const lockfiles = require('./lockfiles'); 4 | const packages = require('./packages'); 5 | const {loadJsonFile} = require('./utils'); 6 | const {loadNpmConfigs} = require('./npmrc'); 7 | 8 | const RESOLVED_ISSUES_FILENAME = 'resolved-issues.json'; 9 | 10 | const outputFilenames = (name, version) => { 11 | const prefix = name && `${name.replace('/', '-')}${version ? `@${version}` : ''}`; 12 | 13 | return { 14 | tree: `${prefix ? `${prefix}-` : ''}tree.svg`, 15 | treemap: `${prefix ? `${prefix}-` : ''}treemap.svg`, 16 | dependenciesCsv: `${prefix ? `${prefix}-` : ''}dependencies.csv`, 17 | json: `${prefix ? `${prefix}-` : ''}report.json`, 18 | }; 19 | }; 20 | 21 | const loadManifest = (appPath, packageType) => { 22 | const npmManifestPath = path.join(appPath, 'package.json'); 23 | const composerManifestPath = path.join(appPath, 'composer.json'); 24 | const manifests = {}; 25 | 26 | if (fs.existsSync(npmManifestPath)) { 27 | const manifest = loadJsonFile(npmManifestPath); 28 | manifests.npm = { 29 | ...manifest, 30 | language: 'javascript', 31 | }; 32 | } 33 | if (fs.existsSync(composerManifestPath)) { 34 | const manifest = loadJsonFile(composerManifestPath); 35 | manifests.composer = { 36 | ...manifest, 37 | language: 'php', 38 | }; 39 | } 40 | 41 | if (packageType) { 42 | return manifests[packageType] || null; 43 | } 44 | 45 | return manifests.npm || manifests.composer || null; 46 | }; 47 | 48 | module.exports = { 49 | RESOLVED_ISSUES_FILENAME, 50 | ...lockfiles, 51 | ...packages, 52 | loadManifest, 53 | loadNpmConfigs, 54 | loadResolvedIssues: (appPath) => loadJsonFile(path.join(appPath, RESOLVED_ISSUES_FILENAME)) || [], 55 | saveResolvedIssues: (appPath, content) => 56 | fs.promises.writeFile( 57 | path.join(appPath, RESOLVED_ISSUES_FILENAME), 58 | JSON.stringify(content, null, 2), 59 | ), 60 | outputFilenames, 61 | }; 62 | -------------------------------------------------------------------------------- /src/cli/progress.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | 3 | let currentSpinner; 4 | let tipTimeout; 5 | 6 | const getStartMessage = (stage) => { 7 | switch (stage) { 8 | case 'graph': 9 | return 'Building dependency graph...'; 10 | case 'vulnerabilities': 11 | return 'Getting vulnerability list...'; 12 | case 'licenses': 13 | return 'Scanning licenses...'; 14 | case 'issues': 15 | return 'Scanning issues...'; 16 | case 'tree': 17 | return 'Drawing tree chart...'; 18 | case 'treemap': 19 | return 'Drawing treemap chart...'; 20 | case 'csv': 21 | return 'Building CSV...'; 22 | default: 23 | return ''; 24 | } 25 | }; 26 | 27 | const getEndMessage = (stage) => { 28 | switch (stage) { 29 | case 'graph': 30 | return `Built dependency graph`; 31 | case 'vulnerabilities': 32 | return 'Got vulnerabilities'; 33 | case 'licenses': 34 | return 'Scanned licenses'; 35 | case 'issues': 36 | return 'Scanned issues'; 37 | case 'tree': 38 | return 'Tree chart done'; 39 | case 'treemap': 40 | return 'Treemap chart done'; 41 | case 'csv': 42 | return 'CSV done'; 43 | default: 44 | return ''; 45 | } 46 | }; 47 | 48 | const onProgress = 49 | ({ora}) => 50 | ({type, stage, message, progress}) => { 51 | switch (type) { 52 | case 'start': 53 | currentSpinner = ora().start(getStartMessage(stage)); 54 | break; 55 | case 'end': 56 | if (tipTimeout) { 57 | clearTimeout(tipTimeout); 58 | tipTimeout = null; 59 | } 60 | currentSpinner.succeed(getEndMessage(stage)); 61 | break; 62 | case 'update': 63 | currentSpinner.text = message; 64 | break; 65 | case 'progress': 66 | currentSpinner.text = `${getStartMessage(stage)} ${logger.colors.DIM}${progress}${ 67 | logger.colors.RESET 68 | }`; 69 | break; 70 | default: 71 | break; 72 | } 73 | }; 74 | 75 | module.exports = onProgress; 76 | -------------------------------------------------------------------------------- /src/files/workspace.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const fg = require('fast-glob'); 4 | const yaml = require('js-yaml'); 5 | 6 | const {loadManifest, getPackageSize} = require('.'); 7 | 8 | const loadWorkspace = async (startPath) => { 9 | const resolvedAppPath = path.resolve(startPath); 10 | const manifestPath = path.join(resolvedAppPath, 'package.json'); 11 | const pnpmConfigPath = path.join(resolvedAppPath, 'pnpm-workspace.yaml'); 12 | let packagePaths; 13 | 14 | if (fs.existsSync(pnpmConfigPath)) { 15 | const pnpmConfig = yaml.load(fs.readFileSync(pnpmConfigPath, 'utf8')); 16 | 17 | if (Array.isArray(pnpmConfig.packages)) { 18 | packagePaths = pnpmConfig.packages; 19 | } 20 | } 21 | 22 | if (!packagePaths && fs.existsSync(manifestPath)) { 23 | const manifest = await loadManifest(resolvedAppPath, 'npm'); 24 | 25 | if (Array.isArray(manifest.workspaces)) { 26 | packagePaths = manifest.workspaces; 27 | } else if (Array.isArray(manifest.workspaces?.packages)) { 28 | packagePaths = manifest.workspaces.packages; 29 | } 30 | } 31 | 32 | if (packagePaths) { 33 | const entries = await fg(packagePaths, { 34 | onlyDirectories: true, 35 | unique: true, 36 | cwd: resolvedAppPath, 37 | }); 38 | 39 | const workspaceProjects = await entries.reduce(async (aggPromise, relativePath) => { 40 | const agg = await aggPromise; 41 | const projectPath = path.join(resolvedAppPath, relativePath); 42 | const projectManifest = await loadManifest(projectPath, 'npm'); 43 | agg.push({ 44 | ...projectManifest, 45 | relativePath, 46 | size: await getPackageSize(projectPath), 47 | }); 48 | return agg; 49 | }, Promise.resolve([])); 50 | 51 | return { 52 | path: resolvedAppPath, 53 | workspaceProjects, 54 | }; 55 | } 56 | 57 | if (resolvedAppPath !== '/') { 58 | return loadWorkspace(path.join(resolvedAppPath, '..')); 59 | } 60 | 61 | return null; 62 | }; 63 | 64 | module.exports = {loadWorkspace}; 65 | -------------------------------------------------------------------------------- /src/charts/csv.js: -------------------------------------------------------------------------------- 1 | function jsonToCsv(items) { 2 | if (!items?.[0]) { 3 | return ''; 4 | } 5 | const headerNames = Object.keys(items[0]); 6 | const rowItems = items.map((row) => 7 | headerNames 8 | .map((fieldName) => { 9 | let normalized = JSON.stringify(row[fieldName], (_, value) => value ?? '-'); 10 | 11 | if (typeof normalized === 'string') { 12 | normalized = normalized.replace(/\\"/g, '""'); 13 | } 14 | 15 | return normalized; 16 | }) 17 | .join(','), 18 | ); 19 | return [headerNames.join(','), ...rowItems].join('\r\n'); 20 | } 21 | 22 | module.exports = (dependencies) => { 23 | const processedDependencies = (dependencies || []).map((dep) => { 24 | const { 25 | name, 26 | version, 27 | flags, 28 | parents, 29 | size, 30 | license, 31 | repository, 32 | published, 33 | publisher, 34 | latestVersion, 35 | } = dep; 36 | return { 37 | name, 38 | version, 39 | latestVersion, 40 | repository: typeof repository === 'string' ? repository : repository?.url, 41 | published, 42 | publisher: 43 | // eslint-disable-next-line no-nested-ternary 44 | typeof publisher === 'string' 45 | ? publisher 46 | : typeof publisher === 'object' 47 | ? `${publisher.name}${publisher.email ? ` (${publisher.email})` : ''}` 48 | : undefined, 49 | size, 50 | license, 51 | isProd: !!flags.prod, 52 | isDev: !!flags.dev, 53 | isOptional: !!flags.optional, 54 | isPeer: !!flags.peer, 55 | isBundled: !!flags.bundled, 56 | parents: Object.values(parents || {}) 57 | .reduce( 58 | (agg, deps) => 59 | agg.concat( 60 | Object.values(deps).map(({name: dname, version: dversion}) => `${dname}@${dversion}`), 61 | ), 62 | [], 63 | ) 64 | .join(','), 65 | }; 66 | }); 67 | 68 | return { 69 | csvData: jsonToCsv(processedDependencies), 70 | jsonData: processedDependencies, 71 | }; 72 | }; 73 | -------------------------------------------------------------------------------- /src/issues/vulnerabilities/npm.js: -------------------------------------------------------------------------------- 1 | const {exec} = require('child_process'); 2 | const {getFindings} = require('../utils'); 3 | 4 | const fromNpm = ({appPath, packageGraph, includeDev}) => 5 | new Promise((resolve, reject) => { 6 | exec('npm audit --package-lock-only --json', {cwd: appPath}, (err, stdout, stderr) => { 7 | if (stderr) { 8 | reject(new Error(stderr)); 9 | } else { 10 | try { 11 | const {vulnerabilities} = JSON.parse(stdout); 12 | const directIssues = Object.values(vulnerabilities).filter(({via}) => 13 | via.find((cause) => typeof cause !== 'string'), 14 | ); 15 | 16 | const reports = []; 17 | directIssues.forEach(({via, fixAvailable}) => { 18 | via 19 | .filter((v) => typeof v !== 'string') 20 | .forEach((v) => { 21 | const report = { 22 | findings: getFindings({ 23 | packageGraph, 24 | packageName: v.name, 25 | range: v.range, 26 | includeDev, 27 | }), 28 | source: v.source, 29 | githubAdvisoryId: 30 | v.url?.startsWith?.('https://github.com/advisories/') && 31 | v.url.replace('https://github.com/advisories/', ''), 32 | name: v.name, 33 | title: v.title, 34 | type: 'vulnerability', 35 | // overview missing here, 36 | url: v.url, 37 | severity: v.severity, 38 | range: v.range, 39 | recommendation: 40 | typeof fixAvailable === 'object' 41 | ? `Update ${fixAvailable.name} to ${fixAvailable.version}` 42 | : undefined, 43 | }; 44 | 45 | reports.push(report); 46 | }); 47 | }); 48 | 49 | resolve(reports); 50 | } catch (error) { 51 | reject(new Error(`${error.message} => ${stdout}`)); 52 | } 53 | } 54 | }); 55 | }); 56 | 57 | module.exports = fromNpm; 58 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Security is a top priority for Sandworm. Thanks for helping make Sandworm safe for everyone. 4 | 5 | ## Reporting a Vulnerability 6 | 7 | The Sandworm team takes all security vulnerabilities seriously. Thank you for improving the security of our open source software. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions. 8 | 9 | If you believe you have found a security vulnerability in any Sandworm-owned repository, please report it to us through coordinated disclosure. 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** 12 | 13 | Instead, please send an email to: 14 | 15 | ``` 16 | security@sandworm.dev 17 | ``` 18 | 19 | Please include as much of the information listed below as you can to help us better understand and resolve the issue: 20 | 21 | - The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) 22 | - Full paths of source file(s) related to the manifestation of the issue 23 | - The location of the affected source code (tag/branch/commit or direct URL) 24 | - Any special configuration required to reproduce the issue 25 | - Step-by-step instructions to reproduce the issue 26 | - Proof-of-concept or exploit code (if possible) 27 | - Impact of the issue, including how an attacker might exploit the issue 28 | 29 | The lead maintainer will acknowledge your email within 24 hours and send a more detailed response within 48 hours indicating the next steps in handling your report. After the initial reply to your report, the security team will endeavor to inform you of the progress toward a fix and full announcement and may ask for additional information or guidance. 30 | 31 | ## Disclosure Policy 32 | 33 | When the security team receives a security bug report, they will assign it to a primary handler. This person will coordinate the fix and release process, involving the following steps: 34 | 35 | - Confirm the problem and determine the affected versions. 36 | - Audit code to find any potential similar problems. 37 | - Prepare fixes for all releases still under maintenance. These fixes will be released as fast as possible. 38 | -------------------------------------------------------------------------------- /src/graph/generateNpmGraph.js: -------------------------------------------------------------------------------- 1 | const { 2 | processDependenciesForPackage, 3 | processPlaceholders, 4 | makeNode, 5 | seedNodes, 6 | } = require('./utils'); 7 | 8 | const packageNameFromPath = (path) => { 9 | // TODO: For locally linked packages this might not include a `node_modules` string 10 | const parts = path.split('node_modules'); 11 | return parts[parts.length - 1].slice(1); 12 | }; 13 | 14 | const generateNpmGraph = ({lockfileVersion, data, manifest, workspace}) => { 15 | const allPackages = []; 16 | const placeholders = []; 17 | 18 | seedNodes({ 19 | initialNodes: [manifest, ...(workspace?.workspaceProjects || [])], 20 | allPackages, 21 | placeholders, 22 | }); 23 | 24 | const root = allPackages[0]; 25 | 26 | if (lockfileVersion === 1) { 27 | const processNode = ([packageName, packageData]) => { 28 | const {version: packageVersion, resolved, integrity} = packageData; 29 | 30 | if ( 31 | !allPackages.find(({name, version}) => name === packageName && version === packageVersion) 32 | ) { 33 | const newPackage = makeNode({ 34 | name: packageName, 35 | version: packageVersion, 36 | ...(resolved && {resolved}), 37 | ...(integrity && {integrity}), 38 | }); 39 | 40 | processDependenciesForPackage({ 41 | dependencies: {dependencies: packageData.requires}, 42 | newPackage, 43 | allPackages, 44 | placeholders, 45 | }); 46 | 47 | processPlaceholders({newPackage, placeholders}); 48 | 49 | allPackages.push(newPackage); 50 | } 51 | 52 | if (packageData.dependencies) { 53 | Object.entries(packageData.dependencies).forEach(processNode); 54 | } 55 | }; 56 | 57 | processNode([data.name, data]); 58 | } else { 59 | Object.entries(data.packages).forEach(([packageLocation, packageData]) => { 60 | const {name: originalName, version, resolved, integrity} = packageData; 61 | const name = originalName || packageNameFromPath(packageLocation); 62 | 63 | const newPackage = makeNode({ 64 | name, 65 | version, 66 | ...(resolved && {resolved}), 67 | ...(integrity && {integrity}), 68 | }); 69 | 70 | processDependenciesForPackage({ 71 | dependencies: packageData, 72 | newPackage, 73 | allPackages, 74 | placeholders, 75 | }); 76 | 77 | processPlaceholders({newPackage, placeholders}); 78 | 79 | allPackages.push(newPackage); 80 | }); 81 | } 82 | 83 | return {root, allPackages}; 84 | }; 85 | 86 | module.exports = generateNpmGraph; 87 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | node: circleci/node@5.0.2 4 | jobs: 5 | setup: 6 | executor: 7 | name: node/default 8 | steps: 9 | - checkout 10 | - restore_cache: 11 | keys: 12 | - deps-{{ checksum "yarn.lock" }} 13 | - deps- 14 | - node/install-packages: 15 | pkg-manager: yarn 16 | - save_cache: 17 | key: deps-{{ checksum "yarn.lock" }} 18 | paths: 19 | - node_modules 20 | - persist_to_workspace: 21 | root: . 22 | paths: 23 | - '*' 24 | lint: 25 | executor: 26 | name: node/default 27 | steps: 28 | - attach_workspace: 29 | at: . 30 | - run: 31 | name: Lint code 32 | command: yarn lint 33 | release-pr: 34 | executor: node/default 35 | steps: 36 | - attach_workspace: 37 | at: . 38 | - run: 39 | name: Install release-please 40 | command: yarn global add release-please 41 | - run: 42 | name: Create or update release PR 43 | command: release-please release-pr --token=${GH_TOKEN} --repo-url=sandworm-hq/sandworm-audit 44 | github-release: 45 | executor: node/default 46 | steps: 47 | - attach_workspace: 48 | at: . 49 | - run: 50 | name: Install release-please 51 | command: yarn global add release-please 52 | - run: 53 | name: Create release 54 | command: release-please github-release --token=${GH_TOKEN} --repo-url=sandworm-hq/sandworm-audit 55 | publish-npm: 56 | executor: node/default 57 | steps: 58 | - attach_workspace: 59 | at: . 60 | - run: 61 | name: Auth With NPM 62 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc 63 | - run: 64 | name: Publish to NPM 65 | command: npm publish --access=public 66 | 67 | workflows: 68 | test: 69 | jobs: 70 | - setup 71 | - lint: 72 | requires: 73 | - setup 74 | - github-release: 75 | requires: 76 | - lint 77 | filters: 78 | branches: 79 | only: main 80 | - release-pr: 81 | requires: 82 | - github-release 83 | filters: 84 | branches: 85 | only: main 86 | publish: 87 | jobs: 88 | - setup: 89 | filters: 90 | branches: 91 | ignore: /.*/ 92 | tags: 93 | only: /^audit-v.*/ 94 | - publish-npm: 95 | requires: 96 | - setup 97 | filters: 98 | branches: 99 | ignore: /.*/ 100 | tags: 101 | only: /^audit-v.*/ 102 | -------------------------------------------------------------------------------- /src/registry/index.js: -------------------------------------------------------------------------------- 1 | const {getComposerRegistryData, getComposerRegistryAudit} = require('./composer'); 2 | const {getNpmRegistryData, setupNpmRegistries, getNpmRegistryAudit} = require('./npm'); 3 | 4 | const getRegistryData = async (packageManager, packageName, packageVersion) => { 5 | if ( 6 | typeof packageManager !== 'string' || 7 | typeof packageName !== 'string' || 8 | !['string', 'undefined'].includes(typeof packageVersion) 9 | ) { 10 | throw new Error( 11 | `getRegistryData: invalid arguments given (${packageName} / ${packageVersion})`, 12 | ); 13 | } 14 | 15 | if ( 16 | packageManager === 'npm' || 17 | packageManager === 'yarn' || 18 | packageManager === 'yarn-classic' || 19 | packageManager === 'pnpm' 20 | ) { 21 | return getNpmRegistryData(packageName, packageVersion); 22 | } 23 | 24 | if (packageManager === 'composer') { 25 | return getComposerRegistryData(packageName, packageVersion); 26 | } 27 | 28 | throw new Error(`getRegistryData: unsupported package manager ${packageManager}`); 29 | }; 30 | 31 | const getRegistryDataMultiple = async (packageManager, packages, onProgress = () => {}) => { 32 | const totalCount = packages.length; 33 | let currentCount = 0; 34 | const errors = []; 35 | const data = []; 36 | const threadCount = 10; 37 | const packageQueue = [...packages]; 38 | 39 | await Promise.all( 40 | [...Array(threadCount).keys()].map(async () => { 41 | let currentPackage; 42 | // eslint-disable-next-line no-cond-assign 43 | while ((currentPackage = packageQueue.pop())) { 44 | try { 45 | const {name, version} = currentPackage; 46 | // eslint-disable-next-line no-await-in-loop 47 | const packageData = await getRegistryData(packageManager, name, version); 48 | 49 | currentCount += 1; 50 | onProgress?.(`${currentCount}/${totalCount}`); 51 | data.push(packageData); 52 | } catch (error) { 53 | errors.push(error); 54 | } 55 | } 56 | 57 | return data; 58 | }), 59 | ); 60 | 61 | return { 62 | data, 63 | errors, 64 | }; 65 | }; 66 | 67 | const setupRegistries = (appPath) => { 68 | setupNpmRegistries(appPath); 69 | }; 70 | 71 | const getRegistryAudit = async ({ 72 | packageManager, 73 | packageName, 74 | packageVersion, 75 | packageGraph, 76 | includeDev, 77 | }) => { 78 | if ( 79 | packageManager === 'npm' || 80 | packageManager === 'yarn' || 81 | packageManager === 'yarn-classic' || 82 | packageManager === 'pnpm' 83 | ) { 84 | return getNpmRegistryAudit({packageName, packageVersion, packageGraph, includeDev}); 85 | } 86 | 87 | if (packageManager === 'composer') { 88 | return getComposerRegistryAudit({packageName, packageVersion, packageGraph, includeDev}); 89 | } 90 | 91 | return []; 92 | }; 93 | 94 | module.exports = { 95 | setupRegistries, 96 | getRegistryData, 97 | getRegistryDataMultiple, 98 | getRegistryAudit, 99 | }; 100 | -------------------------------------------------------------------------------- /src/cli/handleCrash.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const prompts = require('prompts'); 3 | const {UsageError} = require('../errors'); 4 | const {loadManifest, loadLockfile} = require('../files'); 5 | const logger = require('./logger'); 6 | 7 | const PUBLIC_ROLLBAR_ACCESS_TOKEN = '7f41bd88e3164d598d6c69a1a88ce6f2'; 8 | 9 | const trackError = async (error, customData) => { 10 | const {default: fetch} = await import('node-fetch'); 11 | return fetch('https://api.rollbar.com/api/1/item/', { 12 | method: 'post', 13 | body: JSON.stringify({ 14 | data: { 15 | environment: 'production', 16 | body: { 17 | message: { 18 | body: error.stack || `${error.name} ${error.message}`, 19 | }, 20 | }, 21 | platform: 'client', 22 | level: 'error', 23 | custom: customData, 24 | }, 25 | }), 26 | headers: { 27 | 'Content-Type': 'application/json', 28 | 'X-Rollbar-Access-Token': PUBLIC_ROLLBAR_ACCESS_TOKEN, 29 | }, 30 | }); 31 | }; 32 | 33 | module.exports = async (error, appPath) => { 34 | logger.log(`\n❌ Failed: ${error.message}`); 35 | if (!(error instanceof UsageError)) { 36 | logger.log(error.stack); 37 | 38 | if (process.stdin.isTTY) { 39 | let cancelled = false; 40 | logger.log(''); 41 | const prompt = await prompts( 42 | { 43 | name: 'send', 44 | type: 'select', 45 | message: 'Send crash report to Sandworm?', 46 | choices: [ 47 | {title: 'Send with dependency info', value: 'deps'}, 48 | {title: 'Send without dependency info', value: 'no-deps'}, 49 | {title: "Don't send", value: 'no'}, 50 | ], 51 | }, 52 | { 53 | onCancel: () => { 54 | cancelled = true; 55 | }, 56 | }, 57 | ); 58 | 59 | if (!cancelled && prompt.send !== 'no') { 60 | const {version} = await loadManifest(path.join(__dirname, '../..')); 61 | const data = { 62 | sandwormVersion: version, 63 | nodeVersion: process.versions.node, 64 | }; 65 | 66 | try { 67 | const lockfileData = await loadLockfile(appPath); 68 | 69 | Object.assign(data, { 70 | manager: lockfileData.manager, 71 | managerVersion: lockfileData.managerVersion, 72 | lockfileVersion: lockfileData.lockfileVersion, 73 | }); 74 | // eslint-disable-next-line no-empty 75 | } catch {} 76 | 77 | if (prompt.send === 'deps') { 78 | try { 79 | const {dependencies, devDependencies, optionalDependencies, peerDependencies} = 80 | await loadManifest(appPath); 81 | 82 | Object.assign(data, { 83 | dependencies: JSON.stringify(dependencies), 84 | devDependencies: JSON.stringify(devDependencies), 85 | optionalDependencies: JSON.stringify(optionalDependencies), 86 | peerDependencies: JSON.stringify(peerDependencies), 87 | }); 88 | // eslint-disable-next-line no-empty 89 | } catch {} 90 | } 91 | 92 | await trackError(error, data); 93 | } 94 | } 95 | } 96 | 97 | process.exit(1); 98 | }; 99 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Introduction 4 | 5 | First off, thank you for considering contributing to Sandworm. 6 | 7 | Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. 8 | 9 | Sandworm is an open source project and we love to receive contributions from our community — you! There are many ways to contribute, from writing tutorials or blog posts, improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into Sandworm itself. 10 | 11 | ## How to report a bug 12 | 13 | If you find a security issue or a vulnerability in Sandworm, please do NOT open an issue. See [SECURITY.md](security.md) instead. 14 | 15 | When filing an issue with our GitHub issue tracker, make sure to answer these five questions: 16 | 17 | * What version of Sandworm are you using? 18 | * What Node version are you using? 19 | * What package manager are you using? 20 | * What did you do? 21 | * What did you expect to see? 22 | * What did you see instead? 23 | 24 | Please add the `bug` label to all bug-reporting issues. 25 | 26 | ## How to suggest a feature or enhancement 27 | 28 | If you find yourself wishing for a feature that doesn't exist in Sandworm, you are probably not alone! Please [create a new discussion here](https://github.com/sandworm-hq/Sandworm/discussions/categories/ideas) which describes the feature you would like to see, why you need it, and how it should work. 29 | 30 | ## Ground Rules 31 | 32 | Contributor responsibilities: 33 | 34 | * Create issues for any major changes and enhancements that you wish to make. Discuss things transparently and get community feedback. 35 | * Keep feature versions as small as possible, preferably one new feature per version. 36 | * Be welcoming to newcomers and encourage diverse new contributors from all backgrounds. See the [Sandworm Community Code of Conduct](code-of-conduct.md). 37 | 38 | ## Contributing 39 | 40 | To contribute on an issue: 41 | 42 | * Create your own fork of the code. 43 | * Do the changes in your fork. 44 | * Be sure you have followed the code style for the project: 45 | * We use a slightly modified version of the [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript) for all the core code, and [react-app](https://www.npmjs.com/package/eslint-config-react-app) for the Inspector React app. 46 | * We use Prettier for formatting (see `.prettierrc`). 47 | * Everything's enforced via ESLint - run `yarn lint` to lint everything. 48 | * Commits should follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. 49 | * Note the [Sandworm Code of Conduct](code-of-conduct.md). 50 | * Send a pull request! 51 | * Working on your first Pull Request? You can learn how from this free series, [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). 52 | * If a maintainer asks you to "rebase" your PR, they're saying that a lot of code has changed, and that you need to update your branch so it's easier to merge. 53 | 54 | ## Attribution 55 | 56 | This Contributing document is adapted from [the Contributing Guides Template](https://github.com/nayafia/contributing-template/blob/master/CONTRIBUTING-template.md). 57 | -------------------------------------------------------------------------------- /src/validateConfig.js: -------------------------------------------------------------------------------- 1 | const {UsageError} = require('./errors'); 2 | 3 | const SUPPORTED_SEVERITIES = ['critical', 'high', 'moderate', 'low']; 4 | const LICENSE_POLICY_KEYS = [...SUPPORTED_SEVERITIES, 'categories']; 5 | 6 | module.exports = ({ 7 | appPath, 8 | dependencyGraph, 9 | licensePolicy, 10 | loadDataFrom, 11 | maxDepth, 12 | minDisplayedSeverity, 13 | onProgress, 14 | output, 15 | width, 16 | }) => { 17 | if (!appPath) { 18 | throw new UsageError( 19 | 'Application path is required - please provide the path to a directory containing your manifest and a lockfile.', 20 | ); 21 | } 22 | if ( 23 | dependencyGraph && 24 | (!dependencyGraph.root || !dependencyGraph.all || !dependencyGraph.prodDependencies) 25 | ) { 26 | throw new UsageError( 27 | 'Provided dependency graph is invalid - missing one or more required fields.', 28 | ); 29 | } 30 | if (!SUPPORTED_SEVERITIES.includes(minDisplayedSeverity)) { 31 | throw new UsageError( 32 | `\`minDisplayedSeverity\` must be one of ${SUPPORTED_SEVERITIES.map((s) => `\`${s}\``).join( 33 | ', ', 34 | )}.`, 35 | ); 36 | } 37 | if (!Number.isInteger(width)) { 38 | throw new UsageError('Width must be a valid integer.'); 39 | } 40 | if (!Number.isInteger(maxDepth)) { 41 | throw new UsageError('Max depth must be a valid integer.'); 42 | } 43 | if (!['registry', 'disk'].includes(loadDataFrom)) { 44 | throw new UsageError('`loadDataFrom` must be one of `registry`, `disk`.'); 45 | } 46 | if (typeof onProgress !== 'function') { 47 | throw new UsageError('`onProgress` must be a function.'); 48 | } 49 | if (licensePolicy) { 50 | if (typeof licensePolicy !== 'object') { 51 | throw new UsageError('License policy must be a valid object.'); 52 | } 53 | Object.entries(licensePolicy).forEach(([key, data]) => { 54 | if (!LICENSE_POLICY_KEYS.includes(key)) { 55 | throw new UsageError( 56 | `License policy keys must be one of ${LICENSE_POLICY_KEYS.map((s) => `\`${s}\``).join( 57 | ', ', 58 | )}.`, 59 | ); 60 | } 61 | if (!Array.isArray(data)) { 62 | throw new UsageError('License policy values must be arrays.'); 63 | } 64 | if (key === 'categories') { 65 | data.forEach((customCat) => { 66 | if (typeof customCat.name !== 'string') { 67 | throw new UsageError('Each custom license category must have a name.'); 68 | } 69 | if (!Array.isArray(customCat.licenses)) { 70 | throw new UsageError( 71 | 'Each custom license category must have a `licenses` array of strings.', 72 | ); 73 | } 74 | customCat.licenses.forEach((l) => { 75 | if (typeof l !== 'string') { 76 | throw new UsageError( 77 | 'Each item in a license policy custom category array must be a string.', 78 | ); 79 | } 80 | }); 81 | }); 82 | } else { 83 | data.forEach((l) => { 84 | if (typeof l !== 'string') { 85 | throw new UsageError('Each item in a license policy severity array must be a string.'); 86 | } 87 | }); 88 | } 89 | }); 90 | } 91 | 92 | if (!Array.isArray(output)) { 93 | throw new UsageError('`output` must be an array.'); 94 | } else { 95 | output.forEach((type) => { 96 | if (!['tree', 'treemap', 'csv'].includes(type)) { 97 | throw new UsageError('`output` elements must be one of "tree", "treemap", or "csv".'); 98 | } 99 | }); 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /src/issues/meta.js: -------------------------------------------------------------------------------- 1 | const {getFindings, makeSandwormIssueId, isWorkspaceProject} = require('./utils'); 2 | 3 | module.exports = { 4 | getMetaIssues: ({dependencies = [], packageGraph, workspace, includeDev}) => { 5 | const issues = []; 6 | const INSTALL_SCRIPT_NAME = { 7 | npm: ['preinstall', 'install', 'postinstall'], 8 | composer: [ 9 | 'pre-install-cmd', 10 | 'post-install-cmd', 11 | 'pre-update-cmd', 12 | 'post-update-cmd', 13 | 'post-root-package-install', 14 | 'pre-package-install', 15 | 'post-package-install', 16 | 'pre-package-update', 17 | 'post-package-update', 18 | ], 19 | }; 20 | 21 | dependencies.forEach((dep) => { 22 | if (dep.deprecated) { 23 | issues.push({ 24 | severity: 'high', 25 | title: 'Deprecated package', 26 | name: dep.name, 27 | version: dep.version, 28 | sandwormIssueCode: 200, 29 | }); 30 | } 31 | 32 | const installScripts = (INSTALL_SCRIPT_NAME[packageGraph.meta.packageType] || []).reduce( 33 | (agg, scriptName) => ({ 34 | ...agg, 35 | ...(dep.scripts?.[scriptName] && {[scriptName]: dep.scripts?.[scriptName]}), 36 | }), 37 | {}, 38 | ); 39 | 40 | Object.entries(installScripts).forEach(([scriptName, scriptString]) => { 41 | if (scriptString !== 'node-gyp rebuild') { 42 | issues.push({ 43 | severity: 'high', 44 | title: `Package uses ${scriptName} script: "${scriptString}"`, 45 | shortTitle: `Uses ${scriptName} script`, 46 | name: dep.name, 47 | version: dep.version, 48 | sandwormIssueCode: 201, 49 | sandwormIssueSpecifier: scriptName, 50 | }); 51 | } 52 | }); 53 | 54 | if ( 55 | (!dep.repository || Object.keys(dep.repository).length === 0) && 56 | !isWorkspaceProject(workspace, dep) 57 | ) { 58 | issues.push({ 59 | severity: 'moderate', 60 | title: 'Package has no specified source code repository', 61 | shortTitle: 'Has no repository', 62 | name: dep.name, 63 | version: dep.version, 64 | sandwormIssueCode: 202, 65 | }); 66 | } 67 | 68 | Object.entries(dep.originalDependencies || {}).forEach(([depname, depstring]) => { 69 | if (depstring.startsWith('http')) { 70 | issues.push({ 71 | severity: 'critical', 72 | title: `Package has HTTP dependency for "${depname}"`, 73 | shortTitle: 'Has HTTP dependency', 74 | name: dep.name, 75 | version: dep.version, 76 | sandwormIssueCode: 203, 77 | sandwormIssueSpecifier: depname, 78 | }); 79 | } else if (depstring.startsWith('git')) { 80 | issues.push({ 81 | severity: 'critical', 82 | title: `Package has GIT dependency for "${depname}"`, 83 | shortTitle: 'Has GIT dependency', 84 | name: dep.name, 85 | version: dep.version, 86 | sandwormIssueCode: 204, 87 | sandwormIssueSpecifier: depname, 88 | }); 89 | } else if (depstring.startsWith('file')) { 90 | issues.push({ 91 | severity: 'moderate', 92 | title: `Package has file dependency for "${depname}"`, 93 | shortTitle: 'Has file dependency', 94 | name: dep.name, 95 | version: dep.version, 96 | sandwormIssueCode: 205, 97 | sandwormIssueSpecifier: depname, 98 | }); 99 | } 100 | }); 101 | }); 102 | 103 | return issues.map((issue) => ({ 104 | ...issue, 105 | sandwormIssueId: makeSandwormIssueId({ 106 | code: issue.sandwormIssueCode, 107 | name: issue.name, 108 | version: issue.version, 109 | specifier: issue.sandwormIssueSpecifier, 110 | }), 111 | findings: getFindings({ 112 | packageGraph, 113 | packageName: issue.name, 114 | range: issue.version, 115 | includeDev, 116 | }), 117 | type: 'meta', 118 | })); 119 | }, 120 | }; 121 | -------------------------------------------------------------------------------- /src/files/packages.js: -------------------------------------------------------------------------------- 1 | const {exec} = require('child_process'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const {normalizeComposerManifest} = require('../registry/utils'); 5 | 6 | const packageSizeCache = {}; 7 | 8 | const getFolderSize = (folderPath) => 9 | new Promise((resolve, reject) => { 10 | const cachedSize = packageSizeCache[folderPath]; 11 | if (cachedSize) { 12 | resolve(cachedSize); 13 | } else { 14 | exec( 15 | process.platform === 'darwin' ? 'du -sk .' : 'du -sb .', 16 | {cwd: folderPath}, 17 | (err, stdout) => { 18 | if (err) { 19 | reject(err); 20 | } else { 21 | const match = /^(\d+)/.exec(stdout); 22 | const size = Number(match[1]) * (process.platform === 'darwin' ? 1024 : 1); 23 | packageSizeCache[folderPath] = size; 24 | resolve(size); 25 | } 26 | }, 27 | ); 28 | } 29 | }); 30 | 31 | const getPackageSize = async (packagePath) => { 32 | try { 33 | let totalSize = await getFolderSize(packagePath); 34 | const modulesPath = path.join(packagePath, 'node_modules'); 35 | if (fs.existsSync(modulesPath)) { 36 | totalSize -= await getFolderSize(modulesPath); 37 | } 38 | return totalSize; 39 | } catch (error) { 40 | return undefined; 41 | } 42 | }; 43 | 44 | const loadInstalledPackages = async (rootPath, subPath = '') => { 45 | const currentPath = path.join(rootPath, subPath); 46 | const currentDirname = currentPath.split(path.sep).pop(); 47 | const manifestFilenames = { 48 | npm: 'package.json', 49 | composer: 'composer.json', 50 | }; 51 | 52 | const packagesAtRoot = ( 53 | await Promise.all( 54 | Object.entries(manifestFilenames).map(async ([manager, manifestFilename]) => { 55 | try { 56 | const manifestContent = await fs.promises.readFile( 57 | path.join(currentPath, manifestFilename), 58 | { 59 | encoding: 'utf-8', 60 | }, 61 | ); 62 | let packageAtRootData = JSON.parse(manifestContent); 63 | 64 | if (manager === 'composer') { 65 | packageAtRootData = normalizeComposerManifest(packageAtRootData); 66 | } 67 | 68 | packageAtRootData.relativePath = subPath; 69 | packageAtRootData.packageType = manager; 70 | packageAtRootData.isDependency = subPath.includes('node_modules'); 71 | // Composer is handled separately below 72 | packageAtRootData.size = await getPackageSize(currentPath); 73 | 74 | return packageAtRootData; 75 | } catch (error) { 76 | return null; 77 | } 78 | }), 79 | ) 80 | ).filter((p) => p); 81 | 82 | if ( 83 | currentDirname === 'vendor' && 84 | fs.existsSync(path.join(currentPath, 'composer', 'installed.json')) 85 | ) { 86 | try { 87 | const composerInstalledData = await fs.promises.readFile( 88 | path.join(currentPath, 'composer', 'installed.json'), 89 | { 90 | encoding: 'utf-8', 91 | }, 92 | ); 93 | const composerInstalled = JSON.parse(composerInstalledData); 94 | const composerVendorPackages = await Promise.all( 95 | (Array.isArray(composerInstalled) 96 | ? composerInstalled 97 | : composerInstalled.packages || [] 98 | ).map(async (p) => ({ 99 | ...normalizeComposerManifest(p), 100 | relativePath: p['install-path'] 101 | ? path.join(subPath, 'composer', p['install-path']) 102 | : undefined, 103 | packageType: 'composer', 104 | isDependency: true, 105 | size: p['install-path'] 106 | ? await getPackageSize(path.join(currentPath, 'composer', p['install-path'])) 107 | : undefined, 108 | })), 109 | ); 110 | 111 | return [...packagesAtRoot, ...composerVendorPackages]; 112 | } catch (error) { 113 | return packagesAtRoot; 114 | } 115 | } else { 116 | const subdirectories = (await fs.promises.readdir(currentPath, {withFileTypes: true})) 117 | .filter((dirent) => dirent.isDirectory()) 118 | .map((dirent) => dirent.name); 119 | 120 | const allChildren = await subdirectories.reduce(async (previous, subdir) => { 121 | const children = await previous; 122 | const subDirChildren = await loadInstalledPackages(rootPath, path.join(subPath, subdir)); 123 | 124 | return [...children, ...subDirChildren]; 125 | }, Promise.resolve([])); 126 | 127 | return [...packagesAtRoot, ...allChildren]; 128 | } 129 | }; 130 | 131 | module.exports = {loadInstalledPackages, getPackageSize}; 132 | -------------------------------------------------------------------------------- /src/files/lockfiles.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const {exec} = require('child_process'); 4 | const {existsWantedLockfile, readWantedLockfile} = require('@pnpm/lockfile-file'); 5 | const yarnLockfileParser = require('@yarnpkg/lockfile'); 6 | const {parseSyml} = require('@yarnpkg/parsers'); 7 | 8 | const getCommandVersion = (command) => 9 | new Promise((resolve) => { 10 | exec(`${command} --version`, (err, stdout, stderr) => { 11 | if (stderr || err) { 12 | resolve(null); 13 | } else { 14 | resolve(stdout?.replace?.('\n', '').match?.(/\d+(\.\d+)+/)?.[0]); 15 | } 16 | }); 17 | }); 18 | 19 | const loadLockfiles = async (appPath) => { 20 | const lockfiles = {}; 21 | 22 | // NPM 23 | try { 24 | const lockfileContent = await fs.promises.readFile(path.join(appPath, 'package-lock.json'), { 25 | encoding: 'utf-8', 26 | }); 27 | try { 28 | const lockfileData = JSON.parse(lockfileContent); 29 | lockfiles.npm = { 30 | manager: 'npm', 31 | packageType: 'npm', 32 | managerVersion: await getCommandVersion('npm'), 33 | data: lockfileData, 34 | lockfileVersion: lockfileData.lockfileVersion, 35 | }; 36 | } catch (err) { 37 | lockfiles.npm = { 38 | manager: 'npm', 39 | packageType: 'npm', 40 | error: `Could not parse package-lock.json: ${err.message}`, 41 | }; 42 | } 43 | // eslint-disable-next-line no-empty 44 | } catch {} 45 | 46 | // YARN 47 | try { 48 | const lockfileContent = await fs.promises.readFile(path.join(appPath, 'yarn.lock'), { 49 | encoding: 'utf-8', 50 | }); 51 | const versionMatch = lockfileContent.match(/yarn lockfile v(\d+)/); 52 | if (versionMatch) { 53 | try { 54 | const lockfileData = yarnLockfileParser.parse(lockfileContent); 55 | 56 | if (lockfileData.type === 'success') { 57 | lockfiles.yarn = { 58 | manager: 'yarn-classic', 59 | packageType: 'npm', 60 | managerVersion: await getCommandVersion('yarn'), 61 | data: lockfileData.object, 62 | lockfileVersion: +versionMatch[1], 63 | }; 64 | } else { 65 | lockfiles.yarn = { 66 | manager: 'yarn-classic', 67 | packageType: 'npm', 68 | error: 'Unresolved git conflicts', 69 | }; 70 | } 71 | } catch (err) { 72 | lockfiles.yarn = {manager: 'yarn-classic', packageType: 'npm', error: err.message}; 73 | } 74 | } 75 | 76 | if (lockfileContent.match(/^__metadata:$/m)) { 77 | try { 78 | const lockfileData = parseSyml(lockfileContent); 79 | lockfiles.yarn = { 80 | manager: 'yarn', 81 | packageType: 'npm', 82 | managerVersion: await getCommandVersion('yarn'), 83 | data: lockfileData, 84 | // eslint-disable-next-line no-underscore-dangle 85 | lockfileVersion: +lockfileData.__metadata.version, 86 | }; 87 | } catch (err) { 88 | lockfiles.yarn = { 89 | manager: 'yarn', 90 | packageType: 'npm', 91 | error: err.message, 92 | }; 93 | } 94 | } 95 | // eslint-disable-next-line no-empty 96 | } catch {} 97 | 98 | // PNPM 99 | if (await existsWantedLockfile(appPath)) { 100 | try { 101 | const lockfileData = await readWantedLockfile(appPath, {}); 102 | lockfiles.pnpm = { 103 | manager: 'pnpm', 104 | packageType: 'npm', 105 | managerVersion: await getCommandVersion('pnpm'), 106 | data: lockfileData, 107 | lockfileVersion: lockfileData.lockfileVersion, 108 | }; 109 | } catch (error) { 110 | lockfiles.pnpm = {manager: 'pnpm', packageType: 'npm', error: error.message}; 111 | } 112 | } 113 | 114 | // COMPOSER 115 | try { 116 | const lockfileContent = await fs.promises.readFile(path.join(appPath, 'composer.lock'), { 117 | encoding: 'utf-8', 118 | }); 119 | try { 120 | const lockfileData = JSON.parse(lockfileContent); 121 | lockfiles.composer = { 122 | manager: 'composer', 123 | packageType: 'composer', 124 | managerVersion: await getCommandVersion('composer'), 125 | data: lockfileData, 126 | lockfileVersion: 1, 127 | }; 128 | } catch (err) { 129 | lockfiles.composer = { 130 | manager: 'composer', 131 | packageType: 'composer', 132 | error: `Could not parse composer.lock: ${err.message}`, 133 | }; 134 | } 135 | // eslint-disable-next-line no-empty 136 | } catch {} 137 | 138 | return lockfiles; 139 | }; 140 | 141 | const loadLockfile = async (appPath, packageType) => { 142 | const lockfiles = await loadLockfiles(appPath); 143 | 144 | if (packageType === 'npm') { 145 | return lockfiles.npm || lockfiles.yarn || lockfiles.pnpm; 146 | } 147 | if (packageType === 'composer') { 148 | return lockfiles.composer; 149 | } 150 | return lockfiles.npm || lockfiles.yarn || lockfiles.pnpm || lockfiles.composer; 151 | }; 152 | 153 | module.exports = { 154 | loadLockfiles, 155 | loadLockfile, 156 | }; 157 | -------------------------------------------------------------------------------- /src/registry/npm.js: -------------------------------------------------------------------------------- 1 | const fetch = require('../fetch'); 2 | const {loadNpmConfigs} = require('../files'); 3 | const {reportFromNpmAdvisory} = require('../issues/utils'); 4 | 5 | const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org/'; 6 | let registriesInfo = []; 7 | 8 | const replaceEnvVars = (str) => 9 | str.replace(/\${([^}]+)}/g, (match, variableName) => process.env[variableName] || ''); 10 | 11 | const getRegistriesInfo = (appPath) => { 12 | const configs = loadNpmConfigs(appPath); 13 | const registries = []; 14 | const authTokens = {}; 15 | 16 | Object.entries(configs).forEach(([key, value]) => { 17 | if (typeof value === 'string') { 18 | const processedValue = replaceEnvVars(value); 19 | if (key === 'registry') { 20 | registries.push({org: 'default', url: processedValue}); 21 | } else if (key?.includes?.(':')) { 22 | const keyParts = key.split(':'); 23 | const configName = keyParts.pop(); 24 | const specifier = keyParts.join(':'); 25 | 26 | if (configName === 'registry') { 27 | registries.push({org: specifier, url: processedValue}); 28 | } else if (configName === '_authToken') { 29 | authTokens[specifier] = processedValue; 30 | } 31 | } 32 | } 33 | }); 34 | 35 | // Support the NPM_CONFIG_REGISTRY env var to configure the default registry url 36 | // Create a default registry entry if none is defined in the npmrc chain 37 | const envVarRegistryConfig = process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY; 38 | const defaultRegistryInfo = registries.find(({org}) => org === 'default'); 39 | 40 | if (defaultRegistryInfo) { 41 | if (envVarRegistryConfig) { 42 | defaultRegistryInfo.url = envVarRegistryConfig; 43 | } 44 | } else { 45 | registries.push({ 46 | org: 'default', 47 | url: envVarRegistryConfig || DEFAULT_REGISTRY_URL, 48 | }); 49 | } 50 | 51 | Object.entries(authTokens) 52 | // longest fragment to match a url should win 53 | .sort(([a], [b]) => b.length - a.length) 54 | .forEach(([fragment, authToken]) => { 55 | registries 56 | .filter( 57 | ({url, token}) => 58 | // match all registries that haven't already been matched 59 | // and the url includes the fragment 60 | // strip the last / in the fragment to safely use string.includes 61 | // as registry url might not end with / 62 | !token && url.includes(fragment.endsWith('/') ? fragment.slice(0, -1) : fragment), 63 | ) 64 | .forEach((registry) => Object.assign(registry, {token: authToken})); 65 | }); 66 | 67 | return registries.map((reg) => ({...reg, url: new URL(reg.url)})); 68 | }; 69 | 70 | const setupNpmRegistries = (appPath) => { 71 | registriesInfo = getRegistriesInfo(appPath); 72 | }; 73 | 74 | const getNpmRegistryInfoForPackage = (packageName) => { 75 | if (packageName.includes('/')) { 76 | const [packageOrg] = packageName.split('/'); 77 | const orgRegistry = registriesInfo.find(({org}) => org === packageOrg); 78 | 79 | if (orgRegistry) { 80 | return orgRegistry; 81 | } 82 | } 83 | 84 | return registriesInfo.find(({org}) => org === 'default'); 85 | }; 86 | 87 | const getNpmRegistryAudit = async ({packageName, packageVersion, packageGraph, includeDev}) => { 88 | const registryInfo = getNpmRegistryInfoForPackage(packageName); 89 | const url = new URL('/-/npm/v1/security/audits', registryInfo?.url || DEFAULT_REGISTRY_URL); 90 | const responseRaw = await fetch(url.href, { 91 | method: 'post', 92 | body: JSON.stringify({ 93 | name: 'sandworm-prompt', 94 | version: '1.0.0', 95 | requires: { 96 | [packageName]: packageVersion, 97 | }, 98 | dependencies: { 99 | [packageName]: { 100 | version: packageVersion, 101 | }, 102 | }, 103 | }), 104 | headers: { 105 | 'Content-Type': 'application/json', 106 | ...(registryInfo?.token && {Authorization: `Bearer ${registryInfo.token}`}), 107 | }, 108 | }); 109 | const response = await responseRaw.json(); 110 | 111 | return Object.values(response.advisories || {}).map((advisory) => 112 | reportFromNpmAdvisory(advisory, packageGraph, includeDev), 113 | ); 114 | }; 115 | 116 | const getNpmRegistryData = async (packageName, packageVersion) => { 117 | const registryInfo = getNpmRegistryInfoForPackage(packageName); 118 | const packageUrl = new URL(`/${packageName}`, registryInfo?.url || DEFAULT_REGISTRY_URL); 119 | 120 | const responseRaw = await fetch(packageUrl.href, { 121 | headers: { 122 | ...(registryInfo?.token && {Authorization: `Bearer ${registryInfo.token}`}), 123 | }, 124 | }); 125 | const response = await responseRaw.json(); 126 | const requestedVersion = packageVersion || response['dist-tags']?.latest; 127 | 128 | return { 129 | ...response, 130 | ...(response.versions?.[requestedVersion] || {}), 131 | published: response.time?.[requestedVersion], 132 | size: response.versions?.[requestedVersion]?.dist?.unpackedSize, 133 | versions: undefined, 134 | time: undefined, 135 | }; 136 | }; 137 | 138 | module.exports = { 139 | setupNpmRegistries, 140 | getNpmRegistryAudit, 141 | getNpmRegistryData, 142 | }; 143 | -------------------------------------------------------------------------------- /src/graph/index.js: -------------------------------------------------------------------------------- 1 | const {UsageError} = require('../errors'); 2 | const {loadLockfile, loadManifest, loadInstalledPackages} = require('../files'); 3 | const {loadWorkspace} = require('../files/workspace'); 4 | const {postProcessGraph, addDependencyGraphData} = require('./utils'); 5 | const {getRegistryData} = require('../registry'); 6 | const generateNpmGraph = require('./generateNpmGraph'); 7 | const generatePnpmGraph = require('./generatePnpmGraph'); 8 | const generateYarnGraph = require('./generateYarnGraph'); 9 | const generateComposerGraph = require('./generateComposerGraph'); 10 | 11 | const generateGraphPromise = async ( 12 | appPath, 13 | { 14 | packageData, 15 | loadDataFrom = false, 16 | rootIsShell = false, 17 | includeDev = false, 18 | packageType, 19 | onProgress, 20 | } = {}, 21 | ) => { 22 | const workspace = await loadWorkspace(appPath); 23 | let lockfile = await loadLockfile(appPath, packageType); 24 | 25 | if (!lockfile && workspace) { 26 | lockfile = await loadLockfile(workspace.path, packageType); 27 | } 28 | 29 | if (!lockfile) { 30 | throw new UsageError('No lockfile found'); 31 | } 32 | 33 | if (lockfile.error) { 34 | throw new Error(lockfile.error); 35 | } 36 | 37 | const manifest = loadManifest(appPath, lockfile.packageType); 38 | 39 | if (!manifest) { 40 | throw new UsageError('Manifest not found at app path'); 41 | } 42 | 43 | let graph; 44 | let errors = []; 45 | 46 | if (lockfile.manager === 'npm') { 47 | graph = await generateNpmGraph({ 48 | lockfileVersion: lockfile.lockfileVersion, 49 | data: lockfile.data, 50 | manifest, 51 | workspace, 52 | }); 53 | } else if (lockfile.manager === 'yarn' || lockfile.manager === 'yarn-classic') { 54 | graph = await generateYarnGraph({ 55 | data: lockfile.data, 56 | manifest, 57 | workspace, 58 | }); 59 | } else if (lockfile.manager === 'pnpm') { 60 | graph = await generatePnpmGraph({ 61 | data: lockfile.data?.packages || {}, 62 | manifest, 63 | workspace, 64 | }); 65 | } else if (lockfile.manager === 'composer') { 66 | graph = await generateComposerGraph({ 67 | data: lockfile.data, 68 | manifest, 69 | }); 70 | } 71 | 72 | const {root, allPackages} = graph; 73 | let processedRoot = postProcessGraph({root}); 74 | let allConnectedPackages = allPackages.filter( 75 | ({name, version, parents}) => 76 | (name === manifest.name && version === manifest.version) || 77 | Object.values(parents).reduce((agg, deps) => agg + Object.keys(deps).length, 0), 78 | ); 79 | 80 | if (rootIsShell) { 81 | const shellName = processedRoot.name; 82 | const shellVersion = processedRoot.version; 83 | [processedRoot] = Object.values(processedRoot.dependencies); 84 | allConnectedPackages = allConnectedPackages.filter( 85 | ({name, version}) => name !== shellName || version !== shellVersion, 86 | ); 87 | } 88 | 89 | const devDependencies = allConnectedPackages.filter(({flags}) => flags.dev); 90 | const prodDependencies = allConnectedPackages.filter(({flags}) => flags.prod); 91 | 92 | let additionalPackageData = packageData || []; 93 | 94 | if (workspace) { 95 | additionalPackageData = additionalPackageData.concat(workspace.workspaceProjects); 96 | } 97 | 98 | if (loadDataFrom === 'disk') { 99 | const installedPackages = await loadInstalledPackages(workspace?.path || appPath); 100 | additionalPackageData = additionalPackageData.concat( 101 | installedPackages.filter(({packageType: pt}) => packageType === pt), 102 | ); 103 | } 104 | 105 | let currentCount = 0; 106 | const totalCount = includeDev ? allConnectedPackages.length : prodDependencies.length; 107 | const registryErrors = await addDependencyGraphData({ 108 | root: processedRoot, 109 | packageData: additionalPackageData, 110 | packageManager: lockfile.manager, 111 | loadDataFrom, 112 | includeDev, 113 | getRegistryData, 114 | onProgress: () => 115 | onProgress?.( 116 | // eslint-disable-next-line no-plusplus 117 | `${currentCount++}/${totalCount}`, 118 | ), 119 | }); 120 | 121 | errors = [...errors, ...registryErrors]; 122 | 123 | return { 124 | root: Object.assign(processedRoot || {}, { 125 | meta: { 126 | lockfileVersion: lockfile.lockfileVersion, 127 | packageManager: lockfile.manager, 128 | packageType: lockfile.packageType, 129 | packageManagerVersion: lockfile.managerVersion, 130 | }, 131 | }), 132 | workspace, 133 | all: allConnectedPackages, 134 | devDependencies, 135 | prodDependencies, 136 | errors, 137 | }; 138 | }; 139 | 140 | const generateGraphAsync = (appPath, options, done = () => {}) => { 141 | (async () => { 142 | const graph = await generateGraphPromise(appPath, options); 143 | done(graph); 144 | })(); 145 | }; 146 | 147 | const generateGraph = ( 148 | appPath, 149 | { 150 | packageData, 151 | loadDataFrom = false, 152 | rootIsShell = false, 153 | includeDev = false, 154 | packageType, 155 | onProgress, 156 | } = {}, 157 | done = undefined, 158 | ) => { 159 | if (typeof done === 'function') { 160 | return generateGraphAsync( 161 | appPath, 162 | {packageData, loadDataFrom, rootIsShell, includeDev, packageType, onProgress}, 163 | done, 164 | ); 165 | } 166 | 167 | return generateGraphPromise(appPath, { 168 | packageData, 169 | loadDataFrom, 170 | rootIsShell, 171 | includeDev, 172 | packageType, 173 | onProgress, 174 | }); 175 | }; 176 | 177 | module.exports = generateGraph; 178 | -------------------------------------------------------------------------------- /src/cli/cmds/resolve.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { 3 | files: {loadConfig}, 4 | } = require('@sandworm/utils'); 5 | const prompts = require('prompts'); 6 | const logger = require('../logger'); 7 | const {loadJsonFile} = require('../../files/utils'); 8 | const { 9 | loadManifest, 10 | outputFilenames, 11 | loadResolvedIssues, 12 | saveResolvedIssues, 13 | } = require('../../files'); 14 | const { 15 | getUniqueIssueId, 16 | excludeResolved, 17 | allIssuesFromReport, 18 | validateResolvedIssues, 19 | resolutionIdMatchesIssueId, 20 | } = require('../../issues/utils'); 21 | const {UsageError} = require('../../errors'); 22 | const handleCrash = require('../handleCrash'); 23 | 24 | const onCancel = () => { 25 | logger.log('↩️ Cancelled'); 26 | process.exit(); 27 | }; 28 | 29 | exports.command = 'resolve [issueId]'; 30 | exports.desc = "Security & License Compliance For Your App's Dependencies 🪱"; 31 | exports.builder = { 32 | issueId: { 33 | demandOption: true, 34 | describe: 'The unique id of the issue to resolve', 35 | type: 'string', 36 | }, 37 | p: { 38 | alias: 'path', 39 | demandOption: false, 40 | describe: 'The path to the application to audit', 41 | type: 'string', 42 | }, 43 | pt: { 44 | alias: 'package-type', 45 | demandOption: false, 46 | describe: 'The type of package to search for at the given path', 47 | type: 'string', 48 | }, 49 | o: { 50 | alias: 'output-path', 51 | demandOption: false, 52 | default: 'sandworm', 53 | describe: 'The path of the audit output directory, relative to the application path', 54 | type: 'string', 55 | }, 56 | }; 57 | 58 | exports.handler = async (argv) => { 59 | const appPath = argv.p || process.cwd(); 60 | 61 | try { 62 | logger.logCliHeader(); 63 | const {issueId} = argv; 64 | const manifest = loadManifest(appPath, argv.pt); 65 | const fileConfig = loadConfig(appPath)?.audit || {}; 66 | const outputPath = path.join(appPath, fileConfig.outputPath || argv.o); 67 | const filenames = outputFilenames(manifest.name, manifest.version); 68 | const report = loadJsonFile(path.join(outputPath, filenames.json)); 69 | 70 | if (!report) { 71 | throw new UsageError('Report for current version not found. Run an audit first.'); 72 | } 73 | 74 | const allResolvedIssues = loadResolvedIssues(appPath); 75 | 76 | if (JSON.stringify(allResolvedIssues) !== JSON.stringify(report.resolvedIssues || [])) { 77 | logger.log('\n⚠️ Report and resolved issues out of sync. Run an audit.\n'); 78 | } 79 | 80 | const currentIssues = allIssuesFromReport(report); 81 | validateResolvedIssues(allResolvedIssues); 82 | 83 | const resolvableIssues = excludeResolved(currentIssues, allResolvedIssues).map((issue) => 84 | Object.assign(issue, {id: getUniqueIssueId(issue)}), 85 | ); 86 | 87 | const issuesToResolve = resolvableIssues.filter(({id}) => 88 | resolutionIdMatchesIssueId(issueId, id), 89 | ); 90 | 91 | if (issuesToResolve.length === 0) { 92 | throw new UsageError('Issue not found in current audit results.'); 93 | } 94 | 95 | logger.log(`Resolving issue ${issueId}:`); 96 | logger.log(`${logger.SEVERITY_ICONS[issuesToResolve[0].severity]} ${issuesToResolve[0].title}`); 97 | logger.log(''); 98 | 99 | const allIssuePaths = issuesToResolve.reduce( 100 | (agg, i) => [ 101 | ...agg, 102 | ...i.findings.paths.map((p) => ({path: p, package: `${i.name}@${i.version || i.range}`})), 103 | ], 104 | [], 105 | ); 106 | 107 | const selectedPaths = await prompts( 108 | { 109 | name: 'paths', 110 | type: 'multiselect', 111 | message: 'Select paths to resolve', 112 | choices: allIssuePaths.map((p) => ({ 113 | title: issuesToResolve.length > 1 ? `${p.package}: ${p.path}` : p.path, 114 | value: p.path, 115 | selected: false, 116 | })), 117 | hint: ' - Space to select. Return to submit. "a" to select/deselect all.', 118 | instructions: false, 119 | min: 1, 120 | }, 121 | {onCancel}, 122 | ); 123 | 124 | const matchingResolutions = allResolvedIssues.filter(({id}) => id === issueId); 125 | let targetExistingResolution; 126 | 127 | if (matchingResolutions.length > 0) { 128 | const target = await prompts( 129 | { 130 | name: 'target', 131 | type: 'select', 132 | message: 'Create new resolution, or attach to existing one?', 133 | choices: [ 134 | {title: 'New resolution', value: 'new'}, 135 | ...matchingResolutions.map((resolution) => ({ 136 | title: resolution.notes, 137 | value: resolution, 138 | })), 139 | ], 140 | }, 141 | {onCancel}, 142 | ); 143 | 144 | if (target.target !== 'new') { 145 | targetExistingResolution = target.target; 146 | } 147 | } 148 | 149 | if (!targetExistingResolution) { 150 | const notes = await prompts( 151 | { 152 | name: 'notes', 153 | type: 'text', 154 | message: 'Enter resolution notes:', 155 | validate: (value) => (value.length === 0 ? 'You need to provide resolution notes' : true), 156 | }, 157 | {onCancel}, 158 | ); 159 | 160 | allResolvedIssues.push({ 161 | id: issueId, 162 | paths: selectedPaths.paths, 163 | notes: notes.notes, 164 | }); 165 | } else { 166 | targetExistingResolution.paths = targetExistingResolution.paths.concat(selectedPaths.paths); 167 | } 168 | 169 | await saveResolvedIssues(appPath, allResolvedIssues); 170 | 171 | logger.log(''); 172 | logger.log('✨ Done'); 173 | } catch (error) { 174 | await handleCrash(error, appPath); 175 | } 176 | }; 177 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [abuse@sandworm.dev](mailto:abuse@sandworm.dev). 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are available at 127 | [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). 128 | -------------------------------------------------------------------------------- /src/charts/treemap.js: -------------------------------------------------------------------------------- 1 | const D3Node = require('d3-node'); 2 | const addTooltips = require('./tooltip'); 3 | const { 4 | getModuleName, 5 | groupByDepth, 6 | getAncestors, 7 | getUid, 8 | humanFileSize, 9 | addSandwormLogo, 10 | addIssues, 11 | processGraph, 12 | addLicenseData, 13 | getIssueLevel, 14 | } = require('./utils'); 15 | 16 | // Modified from the original source below 17 | // Copyright 2021 Observable, Inc. 18 | // Released under the ISC license. 19 | // https://observablehq.com/@d3/treemap 20 | function buildTreemap( 21 | data, 22 | { 23 | tile, 24 | width = 1000, // outer width, in pixels 25 | issues = [], 26 | maxDepth = Infinity, 27 | includeDev = false, 28 | } = {}, 29 | ) { 30 | const d3n = new D3Node(); 31 | const {d3} = d3n; 32 | const moduleCallCounts = []; 33 | const getModuleCallCount = (d) => { 34 | if (d.data.size === 0) { 35 | return 0; 36 | } 37 | const moduleName = getModuleName(d); 38 | const currentCallCount = moduleCallCounts[moduleName] || 0; 39 | moduleCallCounts[moduleName] = currentCallCount + 1; 40 | return currentCallCount; 41 | }; 42 | 43 | const root = d3.hierarchy( 44 | processGraph(data, { 45 | maxDepth, 46 | includeDev, 47 | postprocess: (node) => { 48 | if (node.children.length > 0) { 49 | node.children.push({ 50 | ...node, 51 | dependencies: undefined, 52 | children: [], 53 | }); 54 | // eslint-disable-next-line no-param-reassign 55 | node.size = 0; 56 | } 57 | 58 | return node; 59 | }, 60 | }), 61 | ); 62 | 63 | const color = d3.scaleSequential([0, root.height], d3.interpolateBrBG); 64 | const nodeColor = (d) => { 65 | const issueLevel = getIssueLevel(d, issues); 66 | if (issueLevel === 'direct') { 67 | return 'red'; 68 | } 69 | if (issueLevel === 'indirect') { 70 | return 'purple'; 71 | } 72 | return getModuleCallCount(d) > 0 ? `url(#p-dots-0) ${color(d.height)}` : color(d.height); 73 | }; 74 | const nodeStroke = (d) => { 75 | const issueLevel = getIssueLevel(d, issues); 76 | if (issueLevel !== 'none') { 77 | return 'red'; 78 | } 79 | return undefined; 80 | }; 81 | const nodeFillOpacity = (d) => { 82 | const issueLevel = getIssueLevel(d, issues); 83 | if (issueLevel === 'direct') { 84 | return 1; 85 | } 86 | return getModuleCallCount(d) > 1 ? 0.2 : 0.4; 87 | }; 88 | 89 | // Compute the values of internal nodes by aggregating from the leaves. 90 | root.sum((d) => Math.max(0, d.size || 1)); 91 | // Sort the leaves (typically by descending value for a pleasing layout). 92 | root.sort((a, b) => d3.descending(a.value, b.value)); 93 | 94 | d3 95 | .treemap() 96 | .tile(tile || d3.treemapBinary) 97 | .size([width, width]) 98 | .paddingInner(1) 99 | .paddingTop(19) 100 | .paddingRight(3) 101 | .paddingBottom(3) 102 | .paddingLeft(3) 103 | .round(true)(root); 104 | 105 | const groupedData = groupByDepth(root, maxDepth); 106 | 107 | const svg = d3n 108 | .createSVG() 109 | .attr('viewBox', [0, -40, width, width + 40]) 110 | .attr('style', 'max-width: 100%; height: auto; height: intrinsic;') 111 | .attr('font-family', 'sans-serif') 112 | .attr('font-size', 10); 113 | 114 | svg 115 | .append('defs') 116 | .append('pattern') 117 | .attr('id', 'p-dots-0') 118 | .attr('patternUnits', 'userSpaceOnUse') 119 | .attr('width', 10) 120 | .attr('height', 10) 121 | .append('image') 122 | .attr( 123 | 'xlink:href', 124 | 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMCcgaGVpZ2h0PScxMCc+CiAgPHJlY3Qgd2lkdGg9JzEwJyBoZWlnaHQ9JzEwJyBmaWxsPSJ3aGl0ZSIgLz4KICA8Y2lyY2xlIGN4PSIxIiBjeT0iMSIgcj0iMSIgZmlsbD0iYmxhY2siLz4KPC9zdmc+', 125 | ) 126 | .attr('x', 0) 127 | .attr('y', 0) 128 | .attr('width', 10) 129 | .attr('height', 10); 130 | 131 | addSandwormLogo(svg, 0, -40, width); 132 | 133 | const node = svg 134 | .selectAll('g') 135 | .data(groupedData.filter((v) => !!v)) 136 | .join('g') 137 | .attr('class', 'depth-group') 138 | .selectAll('g') 139 | .data((d) => d) 140 | .join('g') 141 | .attr('transform', (d) => `translate(${d.x0},${d.y0})`); 142 | 143 | node 144 | .append('rect') 145 | .attr('fill', nodeColor) 146 | .attr('stroke', nodeStroke) 147 | .attr('fill-opacity', nodeFillOpacity) 148 | .attr('width', (d) => d.x1 - d.x0) 149 | .attr('height', (d) => d.y1 - d.y0); 150 | 151 | node.append('ancestry').attr('data', (d) => `${getAncestors(d).join('>')}`); 152 | 153 | addIssues(node, issues); 154 | addLicenseData(node); 155 | 156 | node 157 | .append('clipPath') 158 | // eslint-disable-next-line no-return-assign, no-param-reassign 159 | .attr('id', (d) => (d.clipUid = getUid('clip')).id) 160 | .append('rect') 161 | .attr('width', (d) => d.x1 - d.x0) 162 | .attr('height', (d) => d.y1 - d.y0); 163 | 164 | node 165 | .append('text') 166 | .attr('clip-path', (d) => d.clipUid.href) 167 | .selectAll('tspan') 168 | .data((d) => 169 | `${d.data.name}${d.children?.length ? ' + deps' : ''}\n${humanFileSize(d.value)}`.split( 170 | /\n/g, 171 | ), 172 | ) 173 | .join('tspan') 174 | .attr('fill-opacity', (d, i, D) => (i === D.length - 1 ? 0.7 : null)) 175 | .text((d) => d); 176 | 177 | node 178 | .filter((d) => d.children) 179 | .selectAll('tspan') 180 | .attr('dx', 3) 181 | .attr('y', 13); 182 | 183 | node 184 | .filter((d) => !d.children) 185 | .selectAll('tspan') 186 | .attr('x', 3) 187 | .attr('y', (d, i, nodes) => `${(i === nodes.length - 1) * 0.3 + 1.1 + i * 0.9}em`); 188 | 189 | addTooltips(svg); 190 | 191 | return d3n.svgString(); 192 | } 193 | 194 | module.exports = buildTreemap; 195 | -------------------------------------------------------------------------------- /src/charts/tree.js: -------------------------------------------------------------------------------- 1 | const D3Node = require('d3-node'); 2 | const logger = require('../cli/logger'); 3 | const addTooltips = require('./tooltip'); 4 | const { 5 | getModuleName, 6 | addSandwormLogo, 7 | getAncestors, 8 | addIssues, 9 | processGraph, 10 | addLicenseData, 11 | getIssueLevel, 12 | getReportsForNode, 13 | } = require('./utils'); 14 | 15 | // Modified from the original source below 16 | // Copyright 2021 Observable, Inc. 17 | // Released under the ISC license. 18 | // https://observablehq.com/@d3/tree 19 | const buildTree = ( 20 | data, 21 | { 22 | tree, 23 | showVersions = false, 24 | width = 1000, 25 | issues = [], 26 | maxDepth = Infinity, 27 | includeDev = false, 28 | forceBuildLargeTrees = false, 29 | } = {}, 30 | ) => { 31 | const d3n = new D3Node(); 32 | const {d3} = d3n; 33 | const root = d3.hierarchy(processGraph(data, {maxDepth, includeDev})); 34 | 35 | const descendants = root.descendants(); 36 | 37 | if (descendants.length > 100000) { 38 | if (!forceBuildLargeTrees) { 39 | logger.log( 40 | `\n\n⚠️ Your dependency tree has a very large number of nodes (${descendants.length}) and was skipped from the output.`, 41 | ); 42 | return null; 43 | } 44 | 45 | logger.log( 46 | `\n\n⚠️ Your dependency tree has a very large number of nodes (${descendants.length}).`, 47 | ); 48 | logger.log('Generating the tree chart might take a lot of time & memory.'); 49 | logger.log('If the process crashes you have several options:'); 50 | logger.log( 51 | '- Allocate more memory to the node process by exporting `NODE_OPTIONS="--max-old-space-size=16384"`', 52 | ); 53 | logger.log( 54 | '- Reduce the depth of the tree represented by passing the `--max-depth` option to Sandworm', 55 | ); 56 | logger.log('- Use the `--skip-tree` option to skip building the tree'); 57 | logger.log('- Try another package manager'); 58 | } 59 | 60 | // Construct an ordinal color scale 61 | const color = d3.scaleOrdinal( 62 | new Set([getModuleName(root), (root.children || []).map((d) => getModuleName(d))]), 63 | d3.schemeTableau10, 64 | ); 65 | const nodeColor = (d) => color(getModuleName(d.ancestors().reverse()[1] || d)); 66 | const textColor = (d) => { 67 | const issueLevel = getIssueLevel(d, issues); 68 | if (issueLevel === 'direct') { 69 | return 'red'; 70 | } 71 | if (issueLevel === 'indirect') { 72 | return 'purple'; 73 | } 74 | return '#333'; 75 | }; 76 | const textFontWeight = (d) => { 77 | if (getIssueLevel(d, issues) === 'none') { 78 | return 'normal'; 79 | } 80 | return 'bold'; 81 | }; 82 | const strokeColor = (d) => { 83 | if (getIssueLevel(d, issues) === 'none') { 84 | return 'white'; 85 | } 86 | return '#fff7c4'; 87 | }; 88 | const lineColor = (d1, d2) => { 89 | const destinationVulnerabilities = getReportsForNode(d2, issues); 90 | if (destinationVulnerabilities.length) { 91 | return 'red'; 92 | } 93 | return 'black'; 94 | }; 95 | const lineDashStyle = (target) => { 96 | switch (target.data.rel) { 97 | case 'optional': 98 | return '4 6'; 99 | case 'peer': 100 | return '4 1'; 101 | case 'dev': 102 | return '5 10'; 103 | default: 104 | return undefined; 105 | } 106 | }; 107 | 108 | // Compute the layout. 109 | const padding = 1; 110 | const dx = 10; 111 | const dy = width / (root.height + padding); 112 | (tree || d3.tree)().nodeSize([dx, dy])(root); 113 | 114 | // Center the tree. 115 | let x0 = Infinity; 116 | let x1 = -x0; 117 | root.each((d) => { 118 | if (d.x > x1) x1 = d.x; 119 | if (d.x < x0) x0 = d.x; 120 | }); 121 | 122 | // Compute the default height. 123 | const height = x1 - x0 + dx * 2; 124 | const offsetX = Math.floor((-dy * padding) / 2); 125 | const offsetY = Math.floor(x0 - dx) - 50; 126 | 127 | const svg = d3n 128 | .createSVG() 129 | .attr('viewBox', [offsetX, offsetY, width, height + 60]) 130 | .attr('style', 'max-width: 100%; height: auto; height: intrinsic;') 131 | .attr('font-family', 'sans-serif') 132 | .attr('font-size', 10); 133 | 134 | addSandwormLogo(svg, offsetX, offsetY, width); 135 | 136 | svg 137 | .append('g') 138 | .attr('id', 'path-group') 139 | .attr('fill', 'none') 140 | .attr('stroke', '#555') 141 | .attr('stroke-opacity', 0.4) 142 | .attr('stroke-width', 1.5) 143 | .selectAll('path') 144 | .data(root.links()) 145 | .join('path') 146 | .attr( 147 | 'd', 148 | d3 149 | .linkHorizontal() 150 | .x((d) => d.y) 151 | .y((d) => d.x), 152 | ) 153 | .attr('stroke', ({source, target}) => lineColor(source, target)) 154 | .attr('stroke-dasharray', ({target}) => lineDashStyle(target)); 155 | 156 | const node = svg 157 | .append('g') 158 | .attr('id', 'label-group') 159 | .selectAll('g') 160 | .data(descendants) 161 | .join('g') 162 | .attr('style', 'cursor: pointer') 163 | .attr('transform', (d) => `translate(${d.y},${d.x})`); 164 | 165 | node 166 | .append('circle') 167 | .attr('fill', nodeColor) 168 | .attr('stroke', '#999') 169 | .attr('stroke-width', 1) 170 | .attr('r', 3); 171 | 172 | node.append('ancestry').attr('data', (d) => getAncestors(d).join('>')); 173 | 174 | addIssues(node, issues); 175 | addLicenseData(node); 176 | 177 | node 178 | .append('text') 179 | .attr('dy', '0.32em') 180 | .attr('x', (d) => (d.children ? -6 : 6)) 181 | .attr('font-weight', textFontWeight) 182 | .attr('text-anchor', (d) => (d.children ? 'end' : 'start')) 183 | .attr('paint-order', 'stroke') 184 | .attr('fill', textColor) 185 | .attr('stroke-width', 3) 186 | .attr('stroke', strokeColor) 187 | .text((d) => getModuleName(d, showVersions)); 188 | 189 | addTooltips(svg); 190 | 191 | return d3n.svgString(); 192 | }; 193 | 194 | module.exports = buildTree; 195 | -------------------------------------------------------------------------------- /src/charts/tooltip.js: -------------------------------------------------------------------------------- 1 | /* global document, window */ 2 | 3 | const getBody = (string) => string.substring(string.indexOf('{') + 1, string.lastIndexOf('}')); 4 | 5 | const setupTooltips = () => { 6 | const SEVERITY_ICONS = { 7 | critical: '🔴', 8 | high: '🟠', 9 | moderate: '🟡', 10 | low: '⚪', 11 | }; 12 | let tooltipLock = false; 13 | const tooltip = document.getElementById('tooltip'); 14 | const tooltipContainer = document.getElementById('tooltip-container'); 15 | const tooltipBg = document.getElementById('tooltip-bg'); 16 | const tooltipContent = document.getElementById('tooltip-content'); 17 | const labels = Array.prototype.slice.call(document.getElementsByTagName('g')); 18 | const viewBox = document.documentElement.attributes.viewBox.value; 19 | const [offsetX, offsetY, viewBoxWidth, viewBoxHeight] = viewBox.split(',').map(parseFloat); 20 | const maxX = offsetX + viewBoxWidth; 21 | const maxY = offsetY + viewBoxHeight; 22 | const getHTML = (ancestry, issues, licenseName) => { 23 | let html = 24 | '
Path
'; 25 | html += `
${ancestry.join('
')}
`; 26 | 27 | html += 28 | '
License
'; 29 | html += `
${licenseName || 'N/A'}
`; 30 | 31 | if (issues.length) { 32 | html += 33 | '
Issues
'; 34 | issues.forEach(({title, url, severity = 'critical'}) => { 35 | html += `
36 | ${SEVERITY_ICONS[severity]} ${url ? `` : ''}${title}${ 37 | url ? '' : '' 38 | } 39 |
`; 40 | }); 41 | } 42 | 43 | return html; 44 | }; 45 | 46 | document.documentElement.addEventListener('click', (e) => { 47 | if (e.target === document.documentElement) { 48 | tooltipLock = false; 49 | tooltip.setAttribute('visibility', 'hidden'); 50 | } 51 | }); 52 | 53 | labels.forEach((a) => { 54 | if ( 55 | !['label-group', 'path-group', 'tooltip'].includes(a.id) && 56 | !a.classList.contains('depth-group') 57 | ) { 58 | const ancestry = a.getElementsByTagName('ancestry')[0].getAttribute('data').split('>'); 59 | const issues = Array.prototype.slice 60 | .call((a.getElementsByTagName('issues')[0] || {}).children || []) 61 | .map((issue) => ({ 62 | title: issue.getAttribute('title'), 63 | url: issue.getAttribute('url'), 64 | severity: issue.getAttribute('severity'), 65 | })); 66 | const target = a.getElementsByTagName('text')[0]; 67 | let licenseName = null; 68 | const licenseContainers = a.getElementsByTagName('license'); 69 | if (licenseContainers.length > 0) { 70 | licenseName = licenseContainers[0].getAttribute('name'); 71 | } 72 | target.addEventListener('mouseover', (event) => { 73 | if (!tooltipLock) { 74 | tooltipContent.innerHTML = getHTML(ancestry, issues, licenseName); 75 | const {width: currentWidth} = document.documentElement.getBoundingClientRect(); 76 | const scale = currentWidth / viewBoxWidth; 77 | const height = 78 | 10 + // top/bottom padding 79 | 18 + // path header 80 | ancestry.length * 11.5 + // path section height 81 | (issues.length ? 20 : 0) + // vulnerabilities header 82 | issues.length * 15 + // vulnerabilities 83 | 20 + // license header 84 | 15; // license body 85 | const maxAncestorNameLength = ancestry.reduce( 86 | (length, name) => (name.length > length ? name.length : length), 87 | 0, 88 | ); 89 | const width = issues.length ? 180 : maxAncestorNameLength * 6 + 10; 90 | const source = event.composedPath().find((e) => e.nodeName === 'g'); 91 | const rect = source.getBoundingClientRect(); 92 | const defaultPositionX = offsetX + (rect.left + window.scrollX) / scale; 93 | const defaultPositionY = offsetY + (rect.top + window.scrollY) / scale + 20; 94 | const defaultPositionBottomY = defaultPositionY + height; 95 | const defaultPositionRightX = defaultPositionX + width; 96 | let tooltipX = defaultPositionX; 97 | let tooltipY = defaultPositionY; 98 | 99 | if (defaultPositionRightX > maxX) { 100 | tooltipX -= width - rect.width; 101 | } 102 | if (defaultPositionBottomY > maxY) { 103 | tooltipY -= height + 30; 104 | } 105 | 106 | tooltipContainer.setAttribute('height', height); 107 | tooltipContainer.setAttribute('width', width); 108 | tooltipBg.setAttribute('height', height); 109 | tooltipBg.setAttribute('width', width); 110 | tooltip.setAttribute('visibility', 'visible'); 111 | tooltip.setAttribute('transform', `translate(${tooltipX} ${tooltipY})`); 112 | } 113 | }); 114 | target.addEventListener('click', () => { 115 | tooltipLock = !tooltipLock; 116 | if (!tooltipLock) { 117 | tooltip.setAttribute('visibility', 'hidden'); 118 | } 119 | }); 120 | target.addEventListener('mouseleave', () => { 121 | if (!tooltipLock) { 122 | tooltip.setAttribute('visibility', 'hidden'); 123 | } 124 | }); 125 | } 126 | }); 127 | }; 128 | 129 | const addTooltips = (svg) => { 130 | const tooltip = svg.append('g').attr('id', 'tooltip').attr('visibility', 'hidden'); 131 | 132 | tooltip 133 | .append('rect') 134 | .attr('id', 'tooltip-bg') 135 | .attr('x', 0) 136 | .attr('y', 0) 137 | .attr('width', 150) 138 | .attr('height', 80) 139 | .attr('fill', 'white') 140 | .attr('stroke', '#888'); 141 | 142 | tooltip 143 | .append('foreignObject') 144 | .attr('id', 'tooltip-container') 145 | .attr('x', 0) 146 | .attr('y', 0) 147 | .attr('width', 150) 148 | .attr('height', 80) 149 | .append('div') 150 | .attr('xmlns', 'http://www.w3.org/1999/xhtml') 151 | .append('div') 152 | .attr('id', 'tooltip-content') 153 | .attr('style', 'padding: 5px;'); 154 | 155 | svg.append('script').text(` 156 | // 159 | `); 160 | }; 161 | 162 | module.exports = addTooltips; 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sandworm Audit 6 | 7 | 8 | 9 |

 

10 | 11 | Beautiful Security & License Compliance Reports For Your App's Dependencies 🪱 12 | 13 | ## Summary 14 | 15 | - Free & open source command-line tool 16 | - Works with [npm](http://npmjs.com/), [Yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), and [Composer](https://getcomposer.org/) 17 | - Scans your project & dependencies for vulnerabilities, license, and metadata issues 18 | - Supports npm/Yarn/pnpm workspaces 19 | - Supports [marking issues as resolved](https://docs.sandworm.dev/audit/resolving-issues) 20 | - Supports [custom license policies](https://docs.sandworm.dev/audit/license-policies) 21 | - [Configurable fail conditions](https://docs.sandworm.dev/audit/fail-policies) for CI / GIT hook workflows 22 | - Can connect to [private/custom npm registries](https://docs.sandworm.dev/audit/custom-registries) 23 | - Outputs: 24 | - JSON issue & license usage reports 25 | - Easy to grok SVG dependency tree & treemap visualizations 26 | - Powered by D3 27 | - Overlays security vulnerabilities 28 | - Overlays package license info 29 | - CSV of all dependencies & license info 30 | 31 | ### Generate a report 32 | 33 | ![Running Sandworm Audit](https://assets.sandworm.dev/showcase/audit-terminal-output.gif) 34 | 35 | ### Navigate charts 36 | 37 | ![Sandworm treemap and tree dependency charts](https://assets.sandworm.dev/showcase/treemap-and-tree.png) 38 | 39 | ### CSV output 40 | 41 | ![Sandworm dependency CSV](https://assets.sandworm.dev/showcase/csv-snip.png) 42 | 43 | ### JSON output 44 | 45 | ```json 46 | { 47 | "createdAt": "...", 48 | "packageManager": "...", 49 | "name": "...", 50 | "version": "...", 51 | "rootVulnerabilities": [...], 52 | "dependencyVulnerabilities": [...], 53 | "licenseUsage": {...}, 54 | "licenseIssues": [...], 55 | "metaIssues": [...], 56 | "errors": [...], 57 | } 58 | ``` 59 | 60 | ![Marking issues as resolved](https://user-images.githubusercontent.com/5381731/224849330-226ef881-ffbf-4819-ba32-e434c8358f60.png) 61 | 62 | ### Get Involved 63 | 64 | - Have a support question? [Post it here](https://github.com/sandworm-hq/sandworm-audit/discussions/categories/q-a). 65 | - Have a feature request? [Post it here](https://github.com/sandworm-hq/sandworm-audit/discussions/categories/ideas). 66 | - Did you find a security issue? [See SECURITY.md](SECURITY.md). 67 | - Did you find a bug? [Post an issue](https://github.com/sandworm-hq/sandworm-audit/issues/new/choose). 68 | - Want to write some code? See [CONTRIBUTING.md](CONTRIBUTING.md). 69 | 70 | ## Get Started 71 | 72 | > **Note** 73 | > Sandworm Audit requires Node 14.19+. 74 | 75 | Install `sandworm-audit` globally via your favorite package manager: 76 | 77 | ```bash 78 | npm install -g @sandworm/audit 79 | # or yarn global add @sandworm/audit 80 | # or pnpm add -g @sandworm/audit 81 | ``` 82 | 83 | Then, run `sandworm-audit` in the root directory of your application. Make sure there's a manifest and a lockfile. 84 | 85 | You can also directly run without installing via: 86 | 87 | ```bash 88 | npx @sandworm/audit@latest 89 | # or yarn dlx -p @sandworm/audit sandworm 90 | # or pnpm --package=@sandworm/audit dlx sandworm 91 | ``` 92 | 93 | Available options: 94 | 95 | ``` 96 | Options: 97 | -v, --version Show version number [boolean] 98 | --help Show help [boolean] 99 | -o, --output-path The path of the output directory, relative to the 100 | application path [string] [default: "sandworm"] 101 | -d, --include-dev Include dev dependencies[boolean] [default: false] 102 | --sv, --show-versions Show package versions in chart names 103 | [boolean] [default: false] 104 | -p, --path The path to the application to audit [string] 105 | --md, --max-depth Max depth to represent in charts [number] 106 | --ms, --min-severity Min issue severity to represent in charts [string] 107 | --lp, --license-policy Custom license policy JSON string [string] 108 | -f, --from Load data from "registry" or "disk" 109 | [string] [default: "registry"] 110 | --fo, --fail-on Fail policy JSON string [string] [default: "[]"] 111 | -s, --summary Print a summary of the audit results to the 112 | console [boolean] [default: true] 113 | --root-vulnerabilites Include vulnerabilities for the root project 114 | [boolean] [default: false] 115 | --skip-license-issues Skip scanning for license issues 116 | [boolean] [default: false] 117 | --skip-meta-issues Skip scanning for meta issues 118 | [boolean] [default: false] 119 | --skip-tree Don't output the dependency tree chart 120 | [boolean] [default: false] 121 | --force-tree Force build large dependency tree charts 122 | [boolean] [default: false] 123 | --skip-treemap Don't output the dependency treemap chart 124 | [boolean] [default: false] 125 | --skip-csv Don't output the dependency csv file 126 | [boolean] [default: false] 127 | --skip-report Don't output the report json file 128 | [boolean] [default: false] 129 | --skip-all Don't output any file [boolean] [default: false] 130 | --show-tips Show usage tips [boolean] [default: true] 131 | ``` 132 | 133 | ### Documentation 134 | 135 | > [Read the full docs here](https://docs.sandworm.dev/audit). 136 | 137 | ## Samples on Sandworm.dev 138 | 139 | - [Apollo Client](https://sandworm.dev/npm/package/apollo-client) 140 | - [AWS SDK](https://sandworm.dev/npm/package/aws-sdk) 141 | - [Express](https://sandworm.dev/npm/package/express) 142 | - [Mocha](https://sandworm.dev/npm/package/mocha) 143 | - [Mongoose](https://sandworm.dev/npm/package/mongoose) 144 | - [Nest.js](https://sandworm.dev/npm/package/@nestjs/cli) 145 | - [Redis](https://sandworm.dev/npm/package/redis) 146 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const {getDependencyVulnerabilities} = require('./issues/vulnerabilities'); 2 | const {getLicenseIssues, getLicenseUsage, getLicenseCategories} = require('./issues/license'); 3 | const {buildTree, buildTreemap} = require('./charts'); 4 | const {excludeResolved, validateResolvedIssues, allIssuesFromReport} = require('./issues/utils'); 5 | const csv = require('./charts/csv'); 6 | const {getMetaIssues} = require('./issues/meta'); 7 | const validateConfig = require('./validateConfig'); 8 | const getDependencyGraph = require('./graph'); 9 | const {addDependencyGraphData} = require('./graph/utils'); 10 | const {loadResolvedIssues} = require('./files'); 11 | const {getRegistryAudit, setupRegistries} = require('./registry'); 12 | 13 | const getReport = async ({ 14 | appPath, 15 | packageType, 16 | dependencyGraph, 17 | includeDev = false, 18 | licensePolicy, 19 | loadDataFrom = 'registry', 20 | maxDepth = 7, 21 | minDisplayedSeverity = 'high', 22 | onProgress = () => {}, 23 | output = ['tree', 'treemap', 'csv'], 24 | rootIsShell = false, 25 | skipLicenseIssues = false, 26 | skipMetaIssues = false, 27 | includeRootVulnerabilities = false, 28 | forceBuildLargeTrees = false, 29 | showVersions = false, 30 | width = 1500, 31 | } = {}) => { 32 | validateConfig({ 33 | appPath, 34 | dependencyGraph, 35 | licensePolicy, 36 | loadDataFrom, 37 | maxDepth, 38 | minDisplayedSeverity, 39 | onProgress, 40 | output, 41 | width, 42 | }); 43 | 44 | let errors = []; 45 | 46 | // Generate the dependency graph 47 | onProgress({type: 'start', stage: 'graph'}); 48 | await setupRegistries(appPath); 49 | const dGraph = 50 | dependencyGraph || 51 | (await getDependencyGraph(appPath, { 52 | loadDataFrom, 53 | rootIsShell, 54 | includeDev, 55 | packageType, 56 | onProgress: (progress) => onProgress({type: 'progress', stage: 'graph', progress}), 57 | })); 58 | const packageGraph = dGraph.root; 59 | errors = [...errors, ...(dGraph.errors || [])]; 60 | onProgress({type: 'end', stage: 'graph'}); 61 | 62 | // Get vulnerabilities 63 | onProgress({type: 'start', stage: 'vulnerabilities'}); 64 | let dependencyVulnerabilities; 65 | let rootVulnerabilities; 66 | let licenseUsage; 67 | let licenseIssues; 68 | let metaIssues; 69 | 70 | try { 71 | dependencyVulnerabilities = await getDependencyVulnerabilities({ 72 | appPath, 73 | packageManager: packageGraph.meta.packageManager, 74 | packageGraph, 75 | includeDev, 76 | onProgress: (message) => onProgress({type: 'update', stage: 'vulnerabilities', message}), 77 | }); 78 | 79 | if (!includeDev) { 80 | dependencyVulnerabilities = (dependencyVulnerabilities || []).filter((issue) => 81 | (issue?.findings?.sources || []).find(({flags}) => flags.prod), 82 | ); 83 | } 84 | } catch (error) { 85 | errors.push(error); 86 | } 87 | 88 | if (includeRootVulnerabilities && packageGraph.name && packageGraph.version) { 89 | try { 90 | rootVulnerabilities = await getRegistryAudit({ 91 | packageManager: packageGraph.meta.packageManager, 92 | packageName: packageGraph.name, 93 | packageVersion: packageGraph.version, 94 | packageGraph, 95 | includeDev, 96 | }); 97 | } catch (error) { 98 | errors.push(error); 99 | } 100 | } 101 | onProgress({type: 'end', stage: 'vulnerabilities'}); 102 | 103 | if (!skipLicenseIssues) { 104 | // Get license info and issues 105 | onProgress({type: 'start', stage: 'licenses'}); 106 | try { 107 | const {defaultCategories, userCategories} = getLicenseCategories(licensePolicy); 108 | licenseUsage = await getLicenseUsage({ 109 | dependencies: includeDev ? dGraph.all : dGraph.prodDependencies, 110 | defaultCategories, 111 | userCategories, 112 | }); 113 | licenseIssues = await getLicenseIssues({ 114 | licenseUsage, 115 | packageGraph, 116 | licensePolicy, 117 | includeDev, 118 | }); 119 | } catch (error) { 120 | errors.push(error); 121 | } 122 | onProgress({type: 'end', stage: 'licenses'}); 123 | } 124 | 125 | if (!skipMetaIssues) { 126 | // Get meta issues 127 | onProgress({type: 'start', stage: 'issues'}); 128 | try { 129 | metaIssues = await getMetaIssues({ 130 | dependencies: includeDev ? dGraph.all : dGraph.prodDependencies, 131 | packageGraph, 132 | workspace: dGraph.workspace, 133 | includeDev, 134 | }); 135 | } catch (error) { 136 | errors.push(error); 137 | } 138 | onProgress({type: 'end', stage: 'issues'}); 139 | } 140 | 141 | const SEVERITIES = ['critical', 'high', 'moderate', 'low']; 142 | const sortBySeverity = (a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity); 143 | const filteredIssues = (dependencyVulnerabilities || []) 144 | .concat(rootVulnerabilities || []) 145 | .concat(metaIssues || []) 146 | .concat(licenseIssues || []) 147 | .filter( 148 | ({severity}) => SEVERITIES.indexOf(severity) <= SEVERITIES.indexOf(minDisplayedSeverity), 149 | ) 150 | .sort(sortBySeverity); 151 | 152 | const options = { 153 | showVersions, 154 | width, 155 | maxDepth, 156 | issues: filteredIssues, 157 | includeDev, 158 | forceBuildLargeTrees, 159 | onProgress: (message) => onProgress({type: 'update', stage: 'chart', message}), 160 | }; 161 | 162 | // Generate charts 163 | const svgs = {}; 164 | if (output.includes('tree')) { 165 | onProgress({type: 'start', stage: 'tree'}); 166 | svgs.tree = await buildTree(packageGraph, options); 167 | onProgress({type: 'end', stage: 'tree'}); 168 | } 169 | 170 | if (output.includes('treemap')) { 171 | onProgress({type: 'start', stage: 'treemap'}); 172 | svgs.treemap = await buildTreemap(packageGraph, options); 173 | onProgress({type: 'end', stage: 'treemap'}); 174 | } 175 | 176 | // Generate CSV 177 | let csvData; 178 | let jsonData; 179 | if (output.includes('csv')) { 180 | onProgress({type: 'start', stage: 'csv'}); 181 | try { 182 | ({csvData, jsonData} = csv(dGraph.all)); 183 | } catch (error) { 184 | errors.push(error); 185 | } 186 | onProgress({type: 'end', stage: 'csv'}); 187 | } 188 | 189 | const allIssues = allIssuesFromReport({ 190 | dependencyVulnerabilities, 191 | rootVulnerabilities, 192 | licenseIssues, 193 | metaIssues, 194 | }); 195 | let resolvedIssues = loadResolvedIssues(appPath); 196 | let resolvedIssuesAlerts; 197 | 198 | try { 199 | // Doctor the resolved issues file 200 | resolvedIssuesAlerts = validateResolvedIssues(resolvedIssues, allIssues); 201 | } catch (error) { 202 | resolvedIssues = []; 203 | errors.push(error); 204 | } 205 | 206 | const allIssueCount = allIssues.length; 207 | const unresolvedIssues = { 208 | dependencyVulnerabilities: excludeResolved(dependencyVulnerabilities, resolvedIssues), 209 | rootVulnerabilities: excludeResolved(rootVulnerabilities, resolvedIssues), 210 | licenseIssues: excludeResolved(licenseIssues, resolvedIssues), 211 | metaIssues: excludeResolved(metaIssues, resolvedIssues), 212 | }; 213 | const allUnresolvedIssues = allIssuesFromReport(unresolvedIssues); 214 | const unresolvedIssuesCount = allUnresolvedIssues.length; 215 | const resolvedIssuesCount = allIssueCount - unresolvedIssuesCount; 216 | 217 | return { 218 | ...unresolvedIssues, 219 | dependencyGraph: dGraph, 220 | licenseUsage, 221 | resolvedIssues, 222 | resolvedIssuesAlerts, 223 | resolvedIssuesCount, 224 | svgs, 225 | csv: csvData, 226 | allDependencies: jsonData, 227 | name: packageGraph.name, 228 | version: packageGraph.version, 229 | errors, 230 | }; 231 | }; 232 | 233 | module.exports = { 234 | getReport, 235 | getDependencyGraph, 236 | addDependencyGraphData, 237 | }; 238 | -------------------------------------------------------------------------------- /src/issues/utils.js: -------------------------------------------------------------------------------- 1 | const semverSatisfies = require('semver/functions/satisfies'); 2 | const {aggregateDependencies} = require('../charts/utils'); 3 | const {UsageError} = require('../errors'); 4 | const {SEMVER_REGEXP} = require('../graph/utils'); 5 | const fromGitHub = require('./vulnerabilities/github'); 6 | 7 | const getPathsForPackage = (packageGraph, packageName, semver, includeDev) => { 8 | const parse = (node, currentPath = [], depth = 0, seenNodes = []) => { 9 | if (seenNodes.includes(node) || depth > 9) { 10 | return []; 11 | } 12 | 13 | const currentNodeValidatesSemver = 14 | node.name === packageName && semverSatisfies(node.version, semver); 15 | // For convenience, omit the root package from the path 16 | // unless we're explicitly searching for the root 17 | const newPath = 18 | depth === 0 && !currentNodeValidatesSemver 19 | ? [] 20 | : [...currentPath, {name: node.name, version: node.version, flags: node.flags}]; 21 | if (currentNodeValidatesSemver) { 22 | return [newPath]; 23 | } 24 | 25 | return aggregateDependencies(node, includeDev).reduce( 26 | (agg, subnode) => agg.concat(parse(subnode, newPath, depth + 1, [...seenNodes, node])), 27 | [], 28 | ); 29 | }; 30 | 31 | return parse(packageGraph); 32 | }; 33 | 34 | const includesPackage = (set, {name, version} = {}) => 35 | set.find(({name: pname, version: pversion}) => name === pname && version === pversion); 36 | 37 | const getAllPackagesFromPaths = (paths) => 38 | paths.reduce((agg, path) => [...agg, ...path.filter((data) => !includesPackage(agg, data))], []); 39 | 40 | const getRootPackagesFromPaths = (paths) => 41 | paths.reduce((agg, path) => (includesPackage(agg, path[0]) ? agg : [...agg, path[0]]), []); 42 | 43 | const getTargetPackagesFromPaths = (paths) => 44 | paths.reduce( 45 | (agg, path) => 46 | includesPackage(agg, path[path.length - 1]) ? agg : [...agg, path[path.length - 1]], 47 | [], 48 | ); 49 | 50 | const getDisplayPaths = (paths) => paths.map((path) => path.map(({name}) => name).join('>')); 51 | 52 | const getFindings = ({ 53 | packageGraph, 54 | packageName, 55 | range, 56 | allPathsAffected = true, 57 | includeDev = true, 58 | }) => { 59 | const allPaths = getPathsForPackage(packageGraph, packageName, range, includeDev); 60 | const affects = allPathsAffected 61 | ? getAllPackagesFromPaths(allPaths) 62 | : getTargetPackagesFromPaths(allPaths); 63 | const rootDependencies = getRootPackagesFromPaths(allPaths); 64 | const paths = getDisplayPaths(allPaths); 65 | // Paths can grow exponentially in complex dependency graphs 66 | // Only keep a maximum of 50 paths in the report 67 | paths.splice(50); 68 | const sources = getTargetPackagesFromPaths(allPaths); 69 | 70 | return { 71 | sources, 72 | affects, 73 | rootDependencies, 74 | paths, 75 | }; 76 | }; 77 | 78 | const reportFromNpmAdvisory = (advisory, packageGraph, includeDev) => ({ 79 | findings: getFindings({ 80 | packageGraph, 81 | packageName: advisory.module_name, 82 | range: advisory.vulnerable_versions, 83 | includeDev, 84 | }), 85 | githubAdvisoryId: advisory.github_advisory_id, 86 | id: advisory.id, 87 | title: advisory.title, 88 | url: advisory.url, 89 | severity: advisory.severity, 90 | name: advisory.module_name, 91 | range: advisory.vulnerable_versions, 92 | type: 'vulnerability', 93 | recommendation: advisory.recommendation, 94 | advisory, 95 | }); 96 | 97 | const reportFromComposerAdvisory = async (advisory, packageGraph, includeDev) => { 98 | // composer affected ranges look like "affectedVersions": ">=1.0.0,<1.44.7|>=2.0.0,<2.15.3|>=3.0.0,<3.4.3", 99 | // massage this to fit what semver expects 100 | const range = advisory.affectedVersions.replaceAll(',', ' ').replaceAll('|', ' || '); 101 | const report = { 102 | findings: getFindings({ 103 | packageGraph, 104 | packageName: advisory.packageName, 105 | range, 106 | includeDev, 107 | }), 108 | id: advisory.advisoryId, 109 | sources: advisory.sources, 110 | githubAdvisoryId: advisory.sources.find(({name}) => name === 'GitHub')?.remoteId, 111 | name: advisory.packageName, 112 | title: advisory.title, 113 | type: 'vulnerability', 114 | // overview missing here, 115 | url: advisory.link, 116 | severity: 'high', 117 | range, 118 | advisory, 119 | }; 120 | 121 | if (report.githubAdvisoryId) { 122 | try { 123 | const ghAdvisory = await fromGitHub(report.githubAdvisoryId); 124 | Object.assign(report, { 125 | severity: ghAdvisory.severity === 'medium' ? 'moderate' : ghAdvisory.severity, 126 | advisory: ghAdvisory, 127 | }); 128 | // eslint-disable-next-line no-empty 129 | } catch (error) {} 130 | } 131 | 132 | return report; 133 | }; 134 | 135 | const allIssuesFromReport = (report) => [ 136 | ...(report.rootVulnerabilities || []), 137 | ...(report.dependencyVulnerabilities || []), 138 | ...(report.licenseIssues || []), 139 | ...(report.metaIssues || []), 140 | ]; 141 | 142 | const makeSandwormIssueId = ({code, name, version, specifier}) => 143 | `SWRM-${code}-${name}-${version}${specifier ? `-${specifier}` : ''}`; 144 | 145 | const getUniqueIssueId = (issue) => issue.githubAdvisoryId || issue.sandwormIssueId || issue.id; 146 | 147 | const resolutionIdMatchesIssueId = (resolutionId, issueId) => { 148 | if (typeof resolutionId !== 'string' || typeof issueId !== 'string') { 149 | return false; 150 | } 151 | 152 | if (resolutionId === issueId) { 153 | return true; 154 | } 155 | 156 | if (resolutionId.includes('*')) { 157 | const [start, end] = resolutionId.split('*'); 158 | if (issueId.startsWith(start) && issueId.endsWith(end)) { 159 | const wildcardContent = issueId.replace(start, '').replace(end, ''); 160 | if (wildcardContent.match(SEMVER_REGEXP)) { 161 | return true; 162 | } 163 | } 164 | } 165 | 166 | return false; 167 | }; 168 | 169 | const excludeResolved = (issues = [], resolved = []) => { 170 | const filteredIssues = []; 171 | 172 | issues.forEach((issue) => { 173 | const issueId = getUniqueIssueId(issue); 174 | const matchingResolutions = resolved.filter(({id}) => resolutionIdMatchesIssueId(id, issueId)); 175 | const matchingResolvedPaths = matchingResolutions.reduce( 176 | (agg, {paths}) => agg.concat(paths), 177 | [], 178 | ); 179 | const unresolvedPaths = issue.findings.paths.filter( 180 | (path) => !matchingResolvedPaths.includes(path), 181 | ); 182 | 183 | if (unresolvedPaths.length > 0) { 184 | Object.assign(issue.findings, {paths: unresolvedPaths}); 185 | filteredIssues.push(issue); 186 | } 187 | }); 188 | 189 | return filteredIssues; 190 | }; 191 | 192 | const validateResolvedIssues = (resolvedIssues = [], currentIssues = []) => { 193 | if (!Array.isArray(resolvedIssues)) { 194 | throw new UsageError('Resolved issues must be array'); 195 | } 196 | return resolvedIssues.reduce((agg, resolvedIssue) => { 197 | if (!resolvedIssue.id || !resolvedIssue.paths || !resolvedIssue.notes) { 198 | throw new UsageError( 199 | 'Each resolved issue must have the following fields: "id", "paths", and "notes"', 200 | ); 201 | } 202 | if (!Array.isArray(resolvedIssue.paths)) { 203 | throw new UsageError('Issue paths must be array'); 204 | } 205 | 206 | const matchingIssues = currentIssues.filter((issue) => 207 | resolutionIdMatchesIssueId(resolvedIssue.id, getUniqueIssueId(issue)), 208 | ); 209 | 210 | if (matchingIssues.length === 0) { 211 | return [ 212 | ...agg, 213 | `Issue ${resolvedIssue.id} is not present in the latest audit, you can remove it from your resolution file`, 214 | ]; 215 | } 216 | 217 | return [ 218 | ...agg, 219 | ...resolvedIssue.paths 220 | .filter( 221 | (path) => 222 | !matchingIssues.reduce((ag, i) => [...ag, ...i.findings.paths], []).includes(path), 223 | ) 224 | .map( 225 | (path) => 226 | `Path ${path} for issue ${resolvedIssue.id} is not present in the latest audit, you can remove it from your resolution file`, 227 | ), 228 | ]; 229 | }, []); 230 | }; 231 | 232 | const isWorkspaceProject = (workspace, {name, version}) => 233 | (workspace?.workspaceProjects || []).find( 234 | ({name: projectName, version: projectVersion}) => 235 | projectName === name && projectVersion === version, 236 | ); 237 | 238 | module.exports = { 239 | getFindings, 240 | reportFromNpmAdvisory, 241 | reportFromComposerAdvisory, 242 | getUniqueIssueId, 243 | makeSandwormIssueId, 244 | excludeResolved, 245 | allIssuesFromReport, 246 | validateResolvedIssues, 247 | resolutionIdMatchesIssueId, 248 | isWorkspaceProject, 249 | }; 250 | -------------------------------------------------------------------------------- /src/issues/licenses.json: -------------------------------------------------------------------------------- 1 | { 2 | "categories": [{"name":"Network Protective","licenses":["AGPL-1.0","AGPL-1.0-only","AGPL-1.0-or-later","AGPL-3.0","AGPL-3.0-only","AGPL-3.0-or-later","CPAL-1.0","EUPL-1.0","EUPL-1.1","EUPL-1.2","RPL-1.1","RPL-1.5","SSPL-1.0"]},{"name":"Uncategorized","licenses":["AAL","Abstyles","Adobe-2006","Adobe-Glyph","ADSL","Afmparse","Aladdin","AMDPLPA","AML","AMPAS","ANTLR-PD","ANTLR-PD-fallback","APAFML","App-s2p","Arphic-1999","Baekmuk","Bahyph","Barr","Bitstream-Vera","blessing","BlueOak-1.0.0","Borceux","BSD-1-Clause","BSD-2-Clause-Patent","BSD-2-Clause-Views","BSL-1.0","BUSL-1.1","bzip2-1.0.5","bzip2-1.0.6","C-UDA-1.0","CAL-1.0","CAL-1.0-Combined-Work-Exception","Caldera","CC-BY-1.0","CC-BY-2.0","CC-BY-2.5","CC-BY-2.5-AU","CC-BY-3.0","CC-BY-3.0-AT","CC-BY-3.0-DE","CC-BY-3.0-IGO","CC-BY-3.0-NL","CC-BY-3.0-US","CC-BY-4.0","CC-BY-NC-1.0","CC-BY-NC-2.0","CC-BY-NC-2.5","CC-BY-NC-3.0","CC-BY-NC-3.0-DE","CC-BY-NC-4.0","CC-BY-NC-ND-1.0","CC-BY-NC-ND-2.0","CC-BY-NC-ND-2.5","CC-BY-NC-ND-3.0","CC-BY-NC-ND-3.0-DE","CC-BY-NC-ND-3.0-IGO","CC-BY-NC-ND-4.0","CC-BY-NC-SA-1.0","CC-BY-NC-SA-2.0","CC-BY-NC-SA-2.0-FR","CC-BY-NC-SA-2.0-UK","CC-BY-NC-SA-2.5","CC-BY-NC-SA-3.0","CC-BY-NC-SA-3.0-DE","CC-BY-NC-SA-3.0-IGO","CC-BY-NC-SA-4.0","CC-BY-ND-1.0","CC-BY-ND-2.0","CC-BY-ND-2.5","CC-BY-ND-3.0","CC-BY-ND-3.0-DE","CC-BY-ND-4.0","CC-BY-SA-1.0","CC-BY-SA-2.0","CC-BY-SA-2.0-UK","CC-BY-SA-2.1-JP","CC-BY-SA-2.5","CC-BY-SA-3.0","CC-BY-SA-3.0-AT","CC-BY-SA-3.0-DE","CC-BY-SA-4.0","CC-PDDC","CDL-1.0","CDLA-Permissive-1.0","CDLA-Permissive-2.0","CDLA-Sharing-1.0","CERN-OHL-1.1","CERN-OHL-1.2","CERN-OHL-P-2.0","CERN-OHL-S-2.0","CERN-OHL-W-2.0","checkmk","COIL-1.0","Community-Spec-1.0","copyleft-next-0.3.0","copyleft-next-0.3.1","CPOL-1.02","Crossword","CrystalStacker","Cube","curl","D-FSL-1.0","diffmark","DL-DE-BY-2.0","DOC","Dotseqn","DRL-1.0","dvipdfm","ECL-1.0","eCos-2.0","eGenix","Elastic-2.0","Entessa","EPICS","etalab-2.0","EUDatagrid","Eurosym","Fair","FDK-AAC","Frameworx-1.0","FreeBSD-DOC","FreeImage","FSFAP","FSFUL","FSFULLR","FSFULLRWD","GD","GFDL-1.1","GFDL-1.1-invariants-only","GFDL-1.1-invariants-or-later","GFDL-1.1-no-invariants-only","GFDL-1.1-no-invariants-or-later","GFDL-1.1-only","GFDL-1.1-or-later","GFDL-1.2","GFDL-1.2-invariants-only","GFDL-1.2-invariants-or-later","GFDL-1.2-no-invariants-only","GFDL-1.2-no-invariants-or-later","GFDL-1.2-only","GFDL-1.2-or-later","GFDL-1.3","GFDL-1.3-invariants-only","GFDL-1.3-invariants-or-later","GFDL-1.3-no-invariants-only","GFDL-1.3-no-invariants-or-later","GFDL-1.3-only","GFDL-1.3-or-later","Giftware","GL2PS","Glide","Glulxe","GLWTPL","gnuplot","HaskellReport","Hippocratic-2.1","HPND","HPND-sell-variant","HTMLTIDY","IBM-pibs","IJG","ImageMagick","Imlib2","Info-ZIP","Intel","Intel-ACPI","Jam","JPNIC","Knuth-CTAN","LAL-1.2","LAL-1.3","Latex2e","Leptonica","LGPLLR","Libpng","libpng-2.0","libselinux-1.0","libtiff","libutil-David-Nugent","LiLiQ-P-1.1","LiLiQ-R-1.1","LiLiQ-Rplus-1.1","Linux-man-pages-copyleft","Linux-OpenIB","LPL-1.0","LPL-1.02","LZMA-SDK-9.11-to-9.20","LZMA-SDK-9.22","MakeIndex","Minpack","MirOS","MIT-Modern-Variant","MIT-open-group","MITNFA","mpi-permissive","mpich2","mplus","MS-LPL","MTLL","MulanPSL-1.0","MulanPSL-2.0","Multics","Mup","NAIST-2003","NBPL-1.0","NCGL-UK-2.0","NCSA","Net-SNMP","NetCDF","Newsletr","NGPL","NICTA-1.0","NIST-PD","NIST-PD-fallback","NLOD-1.0","NLOD-2.0","Noweb","NPL-1.0","NPL-1.1","NPOSL-3.0","NRL","NTP","NTP-0","Nunit","O-UDA-1.0","OCCT-PL","OCLC-2.0","ODC-By-1.0","OFL-1.0","OFL-1.0-no-RFN","OFL-1.0-RFN","OFL-1.1","OFL-1.1-no-RFN","OFL-1.1-RFN","OGC-1.0","OGDL-Taiwan-1.0","OGL-Canada-2.0","OGL-UK-1.0","OGL-UK-2.0","OGL-UK-3.0","OGTSL","OML","OPL-1.0","OPUBL-1.0","OSET-PL-2.1","Parity-6.0.0","Parity-7.0.0","Plexus","PolyForm-Noncommercial-1.0.0","PolyForm-Small-Business-1.0.0","PostgreSQL","PSF-2.0","psfrag","psutils","Python-2.0.1","Qhull","Rdisc","RPSL-1.0","RSA-MD","Saxpath","SCEA","SchemeReport","Sendmail-8.23","SGI-B-1.0","SGI-B-1.1","SGI-B-2.0","SHL-0.5","SHL-0.51","SimPL-2.0","SMLNJ","SMPPL","SNIA","Spencer-86","Spencer-94","Spencer-99","SSH-OpenSSH","SSH-short","StandardML-NJ","SWL","TAPR-OHL-1.0","TCP-wrappers","TMate","TORQUE-1.1","TOSL","TU-Berlin-1.0","TU-Berlin-2.0","UCL-1.0","Unicode-DFS-2015","Unicode-DFS-2016","Unicode-TOU","UPL-1.0","VOSTROM","VSL-1.0","Wsuipa","wxWindows","X11-distribute-modifications-variant","Xerox","Xnet","xpp","XSkat","Zed","Zend-2.0"]},{"name":"Permissive","licenses":["0BSD","AFL-1.1","AFL-1.2","AFL-2.0","AFL-2.1","AFL-3.0","Apache-1.0","Apache-1.1","Apache-2.0","APSL-1.0","Artistic-2.0","Beerware","BSD-2-Clause","BSD-2-Clause-FreeBSD","BSD-2-Clause-NetBSD","BSD-3-Clause","BSD-3-Clause-Attribution","BSD-3-Clause-Clear","BSD-3-Clause-LBNL","BSD-3-Clause-Modification","BSD-3-Clause-No-Military-License","BSD-3-Clause-No-Nuclear-License","BSD-3-Clause-No-Nuclear-License-2014","BSD-3-Clause-No-Nuclear-Warranty","BSD-3-Clause-Open-MPI","BSD-4-Clause","BSD-4-Clause-Shortened","BSD-4-Clause-UC","BSD-Protection","BSD-Source-Code","CECILL-1.0","CECILL-1.1","CECILL-2.0","CECILL-2.1","CECILL-B","CNRI-Jython","CNRI-Python","CNRI-Python-GPL-Compatible","Condor-1.1","DSDP","ECL-2.0","EFL-1.0","EFL-2.0","FTL","ICU","iMatix","ISC","JasPer-2.0","JSON","MIT","MIT-0","MIT-advertising","MIT-CMU","MIT-enna","MIT-feh","Naumen","NLPL","OLDAP-1.1","OLDAP-1.2","OLDAP-1.3","OLDAP-1.4","OLDAP-2.0","OLDAP-2.0.1","OLDAP-2.1","OLDAP-2.2","OLDAP-2.2.1","OLDAP-2.2.2","OLDAP-2.3","OLDAP-2.4","OLDAP-2.5","OLDAP-2.6","OLDAP-2.7","OLDAP-2.8","OpenSSL","PHP-3.0","PHP-3.01","Python-2.0","Ruby","Sendmail","TCL","W3C","W3C-19980720","W3C-20150513","WTFPL","X11","XFree86-1.1","xinetd","Zlib","zlib-acknowledgement","ZPL-1.1","ZPL-2.0","ZPL-2.1"]},{"name":"Strongly Protective","licenses":["APL-1.0","CPL-1.0","EPL-1.0","EPL-2.0","GPL-1.0","GPL-1.0+","GPL-1.0-only","GPL-1.0-or-later","GPL-2.0","GPL-2.0+","GPL-2.0-only","GPL-2.0-or-later","GPL-2.0-with-autoconf-exception","GPL-2.0-with-bison-exception","GPL-2.0-with-classpath-exception","GPL-2.0-with-font-exception","GPL-2.0-with-GCC-exception","GPL-3.0","GPL-3.0+","GPL-3.0-only","GPL-3.0-or-later","GPL-3.0-with-autoconf-exception","GPL-3.0-with-GCC-exception","IPA","MS-RL","ODbL-1.0","OSL-1.0","OSL-1.1","OSL-2.0","OSL-2.1","OSL-3.0","QPL-1.0","Vim"]},{"name":"Public Domain","licenses":["CC0-1.0","PDDL-1.0","SAX-PD","Unlicense"]},{"name":"Weakly Protective","licenses":["APSL-1.1","APSL-1.2","APSL-2.0","Artistic-1.0","Artistic-1.0-cl8","Artistic-1.0-Perl","BitTorrent-1.0","BitTorrent-1.1","CATOSL-1.1","CDDL-1.0","CDDL-1.1","CECILL-C","ClArtistic","CUA-OPL-1.0","ErlPL-1.1","gSOAP-1.3b","Interbase-1.0","IPL-1.0","LGPL-2.0","LGPL-2.0+","LGPL-2.0-only","LGPL-2.0-or-later","LGPL-2.1","LGPL-2.1+","LGPL-2.1-only","LGPL-2.1-or-later","LGPL-3.0","LGPL-3.0+","LGPL-3.0-only","LGPL-3.0-or-later","LPPL-1.0","LPPL-1.1","LPPL-1.2","LPPL-1.3a","LPPL-1.3c","Motosoto","MPL-1.0","MPL-1.1","MPL-2.0","MPL-2.0-no-copyleft-exception","MS-PL","NASA-1.3","Nokia","NOSL","RHeCos-1.1","RSCPL","SISSL","SISSL-1.2","Sleepycat","SPL-1.0","SugarCRM-1.1.3","Watcom-1.0","YPL-1.0","YPL-1.1","Zimbra-1.3","Zimbra-1.4"]}], 3 | "osiApproved": ["0BSD","AAL","AFL-1.1","AFL-1.2","AFL-2.0","AFL-2.1","AFL-3.0","AGPL-3.0","AGPL-3.0-only","AGPL-3.0-or-later","Apache-1.1","Apache-2.0","APL-1.0","APSL-1.0","APSL-1.1","APSL-1.2","APSL-2.0","Artistic-1.0","Artistic-1.0-cl8","Artistic-1.0-Perl","Artistic-2.0","BSD-1-Clause","BSD-2-Clause","BSD-2-Clause-Patent","BSD-3-Clause","BSD-3-Clause-LBNL","BSL-1.0","CAL-1.0","CAL-1.0-Combined-Work-Exception","CATOSL-1.1","CDDL-1.0","CECILL-2.1","CERN-OHL-P-2.0","CERN-OHL-S-2.0","CERN-OHL-W-2.0","CNRI-Python","CPAL-1.0","CPL-1.0","CUA-OPL-1.0","ECL-1.0","ECL-2.0","EFL-1.0","EFL-2.0","Entessa","EPL-1.0","EPL-2.0","EUDatagrid","EUPL-1.1","EUPL-1.2","Fair","Frameworx-1.0","GPL-2.0","GPL-2.0+","GPL-2.0-only","GPL-2.0-or-later","GPL-3.0","GPL-3.0+","GPL-3.0-only","GPL-3.0-or-later","GPL-3.0-with-GCC-exception","HPND","Intel","IPA","IPL-1.0","ISC","Jam","LGPL-2.0","LGPL-2.0+","LGPL-2.0-only","LGPL-2.0-or-later","LGPL-2.1","LGPL-2.1+","LGPL-2.1-only","LGPL-2.1-or-later","LGPL-3.0","LGPL-3.0+","LGPL-3.0-only","LGPL-3.0-or-later","LiLiQ-P-1.1","LiLiQ-R-1.1","LiLiQ-Rplus-1.1","LPL-1.0","LPL-1.02","LPPL-1.3c","MirOS","MIT","MIT-0","MIT-Modern-Variant","Motosoto","MPL-1.0","MPL-1.1","MPL-2.0","MPL-2.0-no-copyleft-exception","MS-PL","MS-RL","MulanPSL-2.0","Multics","NASA-1.3","Naumen","NCSA","NGPL","Nokia","NPOSL-3.0","NTP","OCLC-2.0","OFL-1.1","OFL-1.1-no-RFN","OFL-1.1-RFN","OGTSL","OLDAP-2.8","OSET-PL-2.1","OSL-1.0","OSL-2.0","OSL-2.1","OSL-3.0","PHP-3.0","PHP-3.01","PostgreSQL","Python-2.0","QPL-1.0","RPL-1.1","RPL-1.5","RPSL-1.0","RSCPL","SimPL-2.0","SISSL","Sleepycat","SPL-1.0","UCL-1.0","Unicode-DFS-2016","Unlicense","UPL-1.0","VSL-1.0","W3C","Watcom-1.0","wxWindows","Xnet","Zlib","ZPL-2.0","ZPL-2.1"], 4 | "deprecated": ["AGPL-1.0","AGPL-3.0","BSD-2-Clause-FreeBSD","BSD-2-Clause-NetBSD","bzip2-1.0.5","eCos-2.0","GFDL-1.1","GFDL-1.2","GFDL-1.3","GPL-1.0","GPL-1.0+","GPL-2.0","GPL-2.0+","GPL-2.0-with-autoconf-exception","GPL-2.0-with-bison-exception","GPL-2.0-with-classpath-exception","GPL-2.0-with-font-exception","GPL-2.0-with-GCC-exception","GPL-3.0","GPL-3.0+","GPL-3.0-with-autoconf-exception","GPL-3.0-with-GCC-exception","LGPL-2.0","LGPL-2.0+","LGPL-2.1","LGPL-2.1+","LGPL-3.0","LGPL-3.0+","Nunit","StandardML-NJ","wxWindows"] 5 | } -------------------------------------------------------------------------------- /src/issues/license.js: -------------------------------------------------------------------------------- 1 | const licenseCatalog = require('./licenses.json'); 2 | const {getFindings, makeSandwormIssueId} = require('./utils'); 3 | 4 | const DEFAULT_LICENSE_CATEGORIES = [ 5 | 'Public Domain', 6 | 'Permissive', 7 | 'Weakly Protective', 8 | 'Strongly Protective', 9 | 'Network Protective', 10 | 'Uncategorized', 11 | 'Invalid', 12 | ]; 13 | 14 | const DEFAULT_POLICY = { 15 | high: ['cat:Network Protective', 'cat:Strongly Protective'], 16 | moderate: ['cat:Weakly Protective'], 17 | }; 18 | 19 | const isSpdxExpression = (license) => { 20 | if (!license || license === 'N/A') { 21 | return false; 22 | } 23 | 24 | return !!(license.match(/ or /i) || license.match(/ and /i) || license.match(/ with /i)); 25 | }; 26 | 27 | const getLicenseCategories = (licensePolicy = DEFAULT_POLICY) => { 28 | const defaultCategories = licenseCatalog.categories; 29 | const userCategories = licensePolicy.categories || []; 30 | const filteredUserCategories = []; 31 | 32 | userCategories.forEach(({name, licenses}) => { 33 | if (name !== 'Invalid' && DEFAULT_LICENSE_CATEGORIES.includes(name)) { 34 | defaultCategories.forEach((dc) => { 35 | // eslint-disable-next-line no-param-reassign 36 | dc.licenses = dc.licenses.filter((dcl) => !licenses.includes(dcl)); 37 | }); 38 | const targetCategory = defaultCategories.find(({name: targetName}) => name === targetName); 39 | targetCategory.licenses = [...targetCategory.licenses, ...licenses]; 40 | } else { 41 | filteredUserCategories.push({name, licenses}); 42 | } 43 | }); 44 | 45 | return { 46 | defaultCategories, 47 | userCategories: filteredUserCategories, 48 | }; 49 | }; 50 | 51 | const getCategoriesForLicense = ({license, defaultCategories, userCategories}) => { 52 | if (!license || license === 'N/A') { 53 | return {defaultCategory: 'N/A', userCategories: []}; 54 | } 55 | 56 | let defaultCategory; 57 | 58 | if (isSpdxExpression(license)) { 59 | // Parse simple AND/OR SPDX expressions 60 | if ((license.match(/\s/g) || []).length === 2) { 61 | let expressionLicenses; 62 | let condition; 63 | 64 | if (license.match(/ or /i)) { 65 | condition = 'or'; 66 | expressionLicenses = license.replace(/[()]/g, '').split(/ or /i); 67 | } else if (license.match(/ and /i)) { 68 | condition = 'and'; 69 | expressionLicenses = license.replace(/[()]/g, '').split(/ and /i); 70 | } 71 | 72 | if (expressionLicenses) { 73 | const {defaultCategory: cat1} = getCategoriesForLicense({ 74 | license: expressionLicenses[0], 75 | defaultCategories, 76 | userCategories, 77 | }); 78 | const {defaultCategory: cat2} = getCategoriesForLicense({ 79 | license: expressionLicenses[1], 80 | defaultCategories, 81 | userCategories, 82 | }); 83 | 84 | if ([cat1, cat2].includes('Invalid')) { 85 | defaultCategory = 'Invalid'; 86 | } else { 87 | const aggregateExpressionType = 88 | DEFAULT_LICENSE_CATEGORIES[ 89 | condition === 'or' 90 | ? Math.min( 91 | DEFAULT_LICENSE_CATEGORIES.indexOf(cat1), 92 | DEFAULT_LICENSE_CATEGORIES.indexOf(cat2), 93 | ) 94 | : Math.max( 95 | DEFAULT_LICENSE_CATEGORIES.indexOf(cat1), 96 | DEFAULT_LICENSE_CATEGORIES.indexOf(cat2), 97 | ) 98 | ]; 99 | 100 | defaultCategory = aggregateExpressionType; 101 | } 102 | } 103 | } else { 104 | defaultCategory = 'Expression'; 105 | } 106 | } 107 | 108 | if (!defaultCategory) { 109 | defaultCategory = 110 | defaultCategories.find(({licenses}) => licenses.includes(license))?.name || 'Invalid'; 111 | } 112 | 113 | const selectedUserCategories = 114 | userCategories.filter(({licenses}) => licenses.includes(license)).map((c) => c.name) || []; 115 | 116 | return {defaultCategory, userCategories: selectedUserCategories}; 117 | }; 118 | 119 | const getLicenseUsage = ({dependencies = [], defaultCategories, userCategories}) => { 120 | const licenseUsage = dependencies.reduce((agg, {name, version, license}) => { 121 | const licenseString = license || 'N/A'; 122 | 123 | const licenseData = agg.find(({string}) => string === licenseString); 124 | 125 | if (!licenseData) { 126 | return [ 127 | ...agg, 128 | { 129 | string: licenseString, 130 | meta: { 131 | categories: getCategoriesForLicense({ 132 | license: licenseString, 133 | defaultCategories, 134 | userCategories, 135 | }), 136 | isSpdxExpression: isSpdxExpression(licenseString), 137 | }, 138 | dependencies: [{name, version}], 139 | }, 140 | ]; 141 | } 142 | 143 | licenseData.dependencies.push({name, version}); 144 | return agg; 145 | }, []); 146 | 147 | return licenseUsage.sort((a, b) => b.dependencies.length - a.dependencies.length); 148 | }; 149 | 150 | const getLicenseIssues = ({ 151 | licenseUsage, 152 | packageGraph, 153 | licensePolicy = DEFAULT_POLICY, 154 | includeDev, 155 | }) => { 156 | const issues = []; 157 | 158 | licenseUsage.forEach(({string, meta, dependencies}) => { 159 | const defaultCategory = meta?.categories?.defaultCategory; 160 | const userCategories = meta?.categories?.userCategories || []; 161 | 162 | if (string === 'N/A') { 163 | issues.push({ 164 | severity: 'critical', 165 | title: 'Package has no specified license', 166 | shortTitle: 'No license specified', 167 | recommendation: 'Check the package code and files for license information', 168 | dependencies, 169 | sandwormIssueCode: 100, 170 | }); 171 | } else if (string === 'UNLICENSED') { 172 | issues.push({ 173 | severity: 'critical', 174 | title: 'Package is explicitly not available for use under any terms', 175 | shortTitle: 'Not licensed for use', 176 | recommendation: 'Use another package that is licensed for use', 177 | dependencies, 178 | sandwormIssueCode: 101, 179 | }); 180 | } else if (!isSpdxExpression(string)) { 181 | if (!licenseCatalog.osiApproved.includes(string)) { 182 | issues.push({ 183 | severity: 'low', 184 | title: `Package uses a license that is not OSI approved ("${string}")`, 185 | shortTitle: 'License not OSI approved', 186 | recommendation: 'Read and validate the license terms', 187 | dependencies, 188 | sandwormIssueCode: 102, 189 | }); 190 | } 191 | if (licenseCatalog.deprecated.includes(string)) { 192 | issues.push({ 193 | severity: 'low', 194 | title: `Package uses a deprecated license ("${string}")`, 195 | shortTitle: 'License is deprecated', 196 | dependencies, 197 | sandwormIssueCode: 103, 198 | }); 199 | } 200 | } 201 | 202 | if ((!defaultCategory || defaultCategory === 'Uncategorized') && userCategories.length === 0) { 203 | issues.push({ 204 | severity: 'high', 205 | title: `Package uses an atypical license ("${string}")`, 206 | shortTitle: 'Atypical license', 207 | recommendation: 'Read and validate the license terms', 208 | dependencies, 209 | sandwormIssueCode: 104, 210 | }); 211 | } else if (defaultCategory === 'Invalid') { 212 | issues.push({ 213 | severity: 'high', 214 | title: `Package uses an invalid SPDX license ("${string}")`, 215 | shortTitle: 'Invalid SPDX license', 216 | recommendation: 'Validate that the package complies with your license policy', 217 | dependencies, 218 | sandwormIssueCode: 105, 219 | }); 220 | } else if (defaultCategory === 'Expression') { 221 | issues.push({ 222 | severity: 'high', 223 | title: `Package uses a custom license expression: \`${string}\``, 224 | shortTitle: 'Custom license expression', 225 | recommendation: 'Validate that the license expression complies with your license policy', 226 | dependencies, 227 | sandwormIssueCode: 106, 228 | }); 229 | } 230 | 231 | const allCategoryStrings = [defaultCategory, ...userCategories].map((c) => `cat:${c}`); 232 | 233 | Object.entries(licensePolicy).forEach(([config, includes]) => { 234 | if (['critical', 'high', 'moderate', 'low'].includes(config)) { 235 | if (includes.includes(string)) { 236 | issues.push({ 237 | severity: config, 238 | title: `Package uses a problematic license ("${string}")`, 239 | shortTitle: 'Problematic license', 240 | recommendation: 'Validate that the package complies with your license policy', 241 | dependencies, 242 | sandwormIssueCode: 150, 243 | }); 244 | } else if (includes.find((c) => allCategoryStrings.includes(c))) { 245 | issues.push({ 246 | severity: config, 247 | title: `Package uses a problematic ${defaultCategory} license ("${string}")`, 248 | shortTitle: 'Problematic license', 249 | recommendation: 'Validate that the package complies with your license policy', 250 | dependencies, 251 | sandwormIssueCode: 151, 252 | }); 253 | } 254 | } 255 | }); 256 | }); 257 | 258 | return issues.reduce( 259 | (agg, issue) => 260 | agg.concat( 261 | issue.dependencies.map(({name, version}) => ({ 262 | name, 263 | version, 264 | ...issue, 265 | dependencies: undefined, // this field was just a crutch 266 | sandwormIssueId: makeSandwormIssueId({ 267 | code: issue.sandwormIssueCode, 268 | name, 269 | version, 270 | specifier: issue.sandwormIssueSpecifier, 271 | }), 272 | findings: getFindings({ 273 | packageGraph, 274 | packageName: name, 275 | range: version, 276 | includeDev, 277 | }), 278 | type: 'license', 279 | })), 280 | ), 281 | [], 282 | ); 283 | }; 284 | 285 | module.exports = { 286 | getLicenseCategories, 287 | getCategoriesForLicense, 288 | getLicenseUsage, 289 | getLicenseIssues, 290 | }; 291 | -------------------------------------------------------------------------------- /src/charts/utils.js: -------------------------------------------------------------------------------- 1 | let globalUidIndex = 0; 2 | 3 | const getUid = (type) => { 4 | const middle = type || `${Math.random().toString(16).slice(2)}`; 5 | const uid = `O-${middle}-${globalUidIndex}`; 6 | globalUidIndex += 1; 7 | 8 | return {id: uid, href: `url(#${uid})`}; 9 | }; 10 | 11 | const moduleNamePrefix = { 12 | optional: '🅞 ', 13 | peer: '🅟 ', 14 | dev: '🅓 ', 15 | bundled: '🅑 ', 16 | }; 17 | 18 | const getModuleName = (d, showVersion = true) => 19 | `${moduleNamePrefix[d.data.rel] || ''}${d.data.name}${showVersion ? `@${d.data.version}` : ''}`; 20 | 21 | const humanFileSize = (size) => { 22 | const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); 23 | return `${(size / 1024 ** i).toFixed(2) * 1} ${['B', 'kB', 'MB', 'GB', 'TB'][i]}`; 24 | }; 25 | 26 | const getAncestors = (d) => 27 | d 28 | .ancestors() 29 | .reverse() 30 | .map((dd) => getModuleName(dd)); 31 | 32 | const groupByDepth = (root, maxDepth = Infinity) => { 33 | const map = []; 34 | const iterate = (node) => { 35 | const {depth, x0, x1, y0, y1} = node; 36 | if (x0 === x1 || y0 === y1) { 37 | return; 38 | } 39 | if (depth > maxDepth) { 40 | return; 41 | } 42 | let existingNodes = map[depth]; 43 | if (!existingNodes) { 44 | existingNodes = []; 45 | map[depth] = existingNodes; 46 | } 47 | existingNodes.push(node); 48 | if (node.children) { 49 | node.children.forEach((child) => iterate(child)); 50 | } 51 | }; 52 | iterate(root); 53 | return map; 54 | }; 55 | 56 | const addSandwormLogo = (svg, x, y, width) => { 57 | const sandwormLink = svg 58 | .append('a') 59 | .attr('id', 'sandworm-logo') 60 | .attr('href', 'https://sandworm.dev') 61 | .attr('target', '_blank'); 62 | 63 | sandwormLink 64 | .append('rect') 65 | .attr('x', x) 66 | .attr('y', y) 67 | .attr('width', width) 68 | .attr('height', 40) 69 | .attr('fill', '#333'); 70 | 71 | sandwormLink 72 | .append('image') 73 | .attr( 74 | 'href', 75 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAANcAAAA8CAMAAADCIN/GAAAC+lBMVEUAAAD/////wGH////////////////////////////////+3o3////////////////////83Iz////////////////////////////////////////////+sEX/2YT/////////////////////////qz3/pTP/oSz////////////////////////////////////////////+vVn/0nj/////////////////pzX/y2//////sUb/uVL/3Yr/13/+xGX/tEr/ozD/qTn/sUb/tk3/ozL/2IP/ozD/zG//pDL/03r/1IL/3oz/ulP/2YP/zHD/xWT/2IH2vmH/qDj/pjX/3In/tEn/tUr/xGP/3ov/3oz/zHD/3In/1oD/3Yv/3Yr/0Xb/ozD/oi//tEv/pTL/qz3/v1z/4I//3or///8AAAC+TgD/1H3/uVH/bQH/1n//vFX/03r/znP/qTj/2ob/vlr/rT//2IH/pzb8agL/0Xf/ozD/r0L/qzzDUQH/45D/yWr/sUT/5ZL/agH/wV7/t03/w2L/s0j/7Jb/xmb/6ZT/2YT/oi3/3Ii7TgD/3Yr/y27/pTP/bwn/tUr/4I6xm2HGVQf/znD/8Zq5ombWvHftZQru0YXmyoD/0XT9s0v01oj/2IPewnv/qTD/3IOOfE2JNwPCqmv/rjH+cBBRAQDQtnOFdEh/b0QhHQ4NCQPhXgKvSAKjQQFvHgD/2nqijFX/vEduaEH/sDk/NxzBVQy2SwH/zWbptmCok1z/xViYhlN3aUH/t0BqXDi3UxAZFAk4AAD/1XrKsG/SqV6lkFrknDtfVjR5XiwDKx0AGg+jSg71aAOBJgBuCgBgCgD/6o7/yF//wFDQlkFYTCmgaR9iSRvWXAYZAAD/54z/4YLctWfznzOKYiZNRSUsOiItJhTqwnDBmFDtqEijgUO+hTRGUDLWiyuxdidSPBKxTxCXQQnOVwaVKgB6CAD/956Udz+reTBxViRQFgHyynfBn12YcjNsQBKegHpSAAAAanRSTlMABQb4/Prz+wrK3P2kQeR4VfzQsQ8bFfXUvmZiKf748FzshTsi+vr4k6tOR4s3L7iXcTL7+MSdfmqB+o0M+KM3+/fbWkszJSLt42tmEeLGwn5ZGPrFknIa2tnKt6mFSkXbybWtpaSZmNPPHVa/iAAADmRJREFUaN7VmmdcFEcYxpejc4oc4NERaUpXxIKxt8SYaHrvPTsoCkQFTO50VTwuBhU5OBCQgIhSEhFRAbuJvcTeNZb0ZklPfr887yx3NJP4wQ/co95N29n57/vOuzNzCh2kUNDnvS888fywSZOemfTssOcnDH+AVwgWLE41/KFJcSsunjhx4uLFEzqdUad8adiEey2aDCN/YMLLSZcurZg82d8kVY7eqHtw2IuWTDbkofHrLu2Oi4tLmjx56tSZM99/5513PvhApVLqjTnPvmiZYBjzw+PXrUuOi0tPSkqaDDCQffj++yBbtEil1Oq0w+4VLE8K4YHniosXD05OTi5Ol8lmrgBZM9giJchUEwTL0/Dx62YPXpyXlwylg2zqzD++nLliJkwGMJlM0g2zOF98eHbe4NmLIZksafeX9dfY9s9hM8wymSxHqTQ+84hlgU3Imz1iNtQMljS4nrG6tbtY+R8wGXxRBstRGR+813LAFMKETZkjMjM5GCl98xlW95PmyOqf2KHPW8BAptI9aDEWUwgPb8oAFiSDFe9l7LNcTdayxMRlx9mfMhjIZIu9NUSwCCmEsfMyRmRkZHAwKG8v2/WJJiErYVliWuLqlQBDwG82mRJgzwqWIIUw5OmT8+ZlkAgsM2/zYWBlJSQQFwfbu2KyGQzRw/i8ZXjim5v6zSOZwA4ywpLtNRdgx9iXAAMZ90WYTDu884MphDHTp/ebPp2DEdnsr9hnwDJxAWwZq8HiwwQGX9Q9I3R2KQTFxJPAMoNlPtpQ2QgmiHMBLFtDniivqsBFYE90doMhFm6dNm1aC1imbC5Nbm5j47LVq1dng2115YakqWaTYSGsf/CBTg82cevQNmAHdzUmaBqb1t6qrDx262bCmuzsuTDY5zOTWoEt0nd6g405NWXKlGlmsozNbKVG89PRC+zahg3fbfiOsaM3VmOG1U9tXuG/T3HRX/+2w90DU7u4BHS921yvbR1qBoNOnma1uSsZ215QqC3K16qK8g+xY8tW3zqQvpv2ZCaTLcp54W5xWQkRoih6UuIu6smRU4bON4OB6xv2SR3bVrpFK+mLDJLWWFBRcIidbzr85e50gJl80Z9W9neLq6toLTreZa7Rp0bOnw8wE9m8Kxf+Zvk5xi0VWm1pfY5WVVOiK9ywayX7HFwtYP4XX7pd5LCC2mSF2zRpm7tDLnT87x0h3b7Za6dGLm0DdpAxg1GpLarJkQrKVbqKfZJKp2xgbG8S3202b6P93/ngxY73bv76Xwu1SRCX7f9y3Xl//FPx1PqRS5cuNYGB6xzLN+zRK6XqPcaCaknaUCpJqn0V1WzvuuRmsMkc7MQTQkdFR0REtwxPHR2rbt+ie1SUe6tscKyvyV7/KffooGj3tkW+Qd3NtbFRavDwRDAvGee3AAKYiexbVqNX1eRLuoIDKsMXJRXVWn3h9gpjKTu3eXcxyExg/ic6TrBBTiLk1E0mC4rpKYo93LoHOPX3RLTr6+noGe4eY4eyPu78mVKZtWjvHED2chWCPB1dYxyoONDT05tcK9jL1dHVFwVRYehK7OkWJJDCnfo7hfu6osDd17O/U6QQiFo7dBrcBQlbVxeK8uv95rSATfv6N/ZxkZSj3L5NKmLQF6xcKmrYo1dJWxj7qjg92eyL/hcntZ9evcVm9XfAuPsiYUOjccRHgFwb5okyFHqqCczBi+eQtbcBV3cPJLsKDoK7M4VHJHxQ4IyG3agr3nIgWWUQEm7OsHFPqyAkvcLkbsIi7JCzx79AQbgfXCaw5dP2H2asRpIkfU71gTPA+pQh4DdU6bSSrpRdYL8kE1g6B/O/hP1l+2jtIXrQg+2DvA/luDAAEY8wUrS2l/P21qKbAHmJ1qgi0fyyErqg5T2Cu+BC+ViBCuwwRmDhm2QHMN61h9yTswCu5j5sQSbL2kYMF+LBBQFs+fIp+1ldIyvP31NUYiySoT5mH7Fqo7akIN/wXeV1gBWbwCav8G936jYQ9+2iDnbx7E9Ty45wvCJ7O+GOnOseeqg2boFhNhi1RyyckGqcB0S64XHbi44OQgAG5YprB4iwH3jUTugjisbuIdq69XazpScFV/RGsT280stN6MoN1D+wix2+bUTrLoFe9By9hMfXh7z7LsDANf9rVpmdwIqKDOXlhmoAQSD7qMFQX11foTKwhOvsm+Jks8WmDm/LFQiuAfh2CIYLDUTvNt6UczPZCzjiIP64AeojOHiKoKGpH+6BJuACh2gfJXQHDvzSSgjnnLBaD7FnBNp1dUYqjHPZil640p24rLl7hNujextq1Yf4hDfAxcH8lvr9eGFZ9tHvlHqppKCGTEXi3/lFOZKuiq29sZJ9lW4+XmzLZQX3sfcQnSIjwGIFH7PH/Sjl4IwbyX4oxtCsERxR0E0IAqa1L1pYwdHARYZCxUCYjasrZqQdsmqaSS482rmQmdSCN8o9EE4IlbxTTZ26kbcQqq8HCIU3doTMmAEyvzlLf2XnsYXcoNXqdT8Xfgo35ML3tp+NOq2+lK3NbTx+eDMs1hwW29kLXZNpbPuDzMoTN4wQ3ImjN0rJDzGoSGRp2tiCi4bfHzkUBcMhiSscXDGgEx2daPK74sJYwdfeXnRCAKWA4kQl4OoBT6MScFHEgfhD6EZBtLszcT0OLgLzW7CfrU1MA5eysLTCsNNsLiLbll9VqixgazWaTzDFIIDFJSW153K4x5bcnbzNCgOzCZKj+cAWrkAqaebyoagmt1D3EImLTCs6R3nCbQeCOcqOl0bjcld3gXM4YoYFERdMQyWtuPpwLsHEFb8j5L33iGzOFXYkMS376KF92+r3GbZ91MoPdxq2bNm2rxxcWblYTxUvJrC43XEdT+uD+zpS1LKJFlx5cHeQzWjbistsLxd61clPPYqvN5DoA4t79RCt1b54y8VQM/TpgS6D+YXBPRBRfO+I635wEdic/awWhzTZaw+VaLVanX6nKW7gu+pnScrR57NaTRYM9k0ePxCOWzf+kXZUsYQR5EQ3oJhNoRuKAM/tuHx7iDawLCmGz69mtxL5W8AVKaSjBSSB0dvsa04Od8Q1ZmNKynvQnB8YP3zKZaWSUlIawMPBPv2CsZ1FklYplX+cAOXWXXs0b3Eecb2saBs3osWYYEEOXwNBgznjFe2uDvAwc9m04uor04h91ergMHgvuKjOCY1sYWgKJfiDNwYl7WzEAb5q3wEiDyR3xDUuNTWFyGacq0uUj2i26LSlNeWlWyjEf4HgcbWg4oChRFdy7VgujnI0n7G9eYsBNvjSc+0WfhSlY/p4UZCOoJeuB4btTK9pW1OcN3NZg4u/lzBSZxHi9kJVb3rNOneH98FcHmiFMndP0Q5doCd7mAszzQdpM5d9m7iBIv6aEIaM2hhKYJdZLT+jyT56De+vAmy7WIO8kNqp16kM1aUVbC24ZEekM/zB6x5qtwx1hvPLr3s3wnTGmEFmI9rfnstB6IvGdqgHuWk9HwQaecxeGKd1NG8f25N3ZU1LyyjkfWCv/+Ci94IgvLoxNDUldcb37Lx8praG7SzQ6nNKDuwpPHNgG8sv2ZAvaXWFhsO/Z2nAldC46yCddQ/OK36hfdBwE2WFqXnWS84NGNCyPozk4wxDqhtR+ICdL5Rb9stOSLrwOiS8mj3c18u08CRQYRAeVpiJi4IPie4ykHORi2CBCK7U1I3fMvkMNDGt7nChvkRXv0Wnr6nS1VQZC88UalU6A7txvglkWbnHro4gruLxHX9+6BoYFuPWOxy987/h94RRzndQgA8mXpS3j3es3Mw7wNtX9t2+A9zCuqkFHx9vXEUK8vYJcKeEu0+A3Jx3FRGJniPlnqnDQUHNzm++MgqF0fxt4+Id4IONSmhqaOqSjT8gyhPVmtxP2NVC455yrVS4vcRoMGj1Bdu1CIY/Zaet0TQhcBz96FEYbPC65zru7tptF/9Td9akY893rFc2hi5ZknIWXKDSZFFk2F66r1CrLSrP0Vft0+ZIFRV72MrsuXNRn9CEN9jm2Zn4beLh9uYyb1dNOROeVXOq42bdfAEKOtSZy8zVHaso+S+p0atCl8xKOXshjVMhlAOM5eslfZVBry2sL9HrS64Ci2ZfYuKR8xrONaL46c5+MDpk1KpQcLHzuU1ERWC1v7PqAim/VCfpDYUqHLTVEhaUNjcxG1z4caL4oU6OhcgBrrLvGeI4BbwsCuZZOEA8sDO/oKqqfOchVrcmEURpaaBKTGtiBzMyR2wa39l/jVXAYGW9Zi35voHV1Wo0QKNontv42XHGVVm7LDuRbAWo7OwbleyvfvhxYlPnN5dCGF22cNassstnGTt+vUmTS8pquv47a/j19BW2q3bNkWyuI2tqK9mZ0xkZ8+ZtelrR6bmgV1f1WrhwSdn+sz8ydqHyeN3xShjq3A/7Fyz1O33lMHCP3rx589Yuxg6e7ndyOn4DzBxjAVgK4cn7ZgFsVlnq5e9/uPL3jz+e++3st/tD5izArmz+0q9P/3rw3NWGM7/99e3X07biF8B5/Ta9aQFYBDZmIURkKSmpqZcvh6bQFnpGCLabfgvoBG45xI+Dp+DgtN+miYJlSCHcX0ZY0BJ5fU/i5wNz/JrRppCIrd/JR58ULEbxqxaayJZwNg42Qz7SWbCcJB8ID93ab6xgQYovW4g5ZuZqA9bqpHvo1qFjLWJytYAtbAVm2m22Ixs6dOtTFoaFhWKvsl69uCv+C9jykVO2TnzS0rAUwrjH4Isg42AdfXHk8lPz4y3uvx/yAcfftwpkZl80gyHgj/Q7dWriWAvE4kMe93ivVfDG0HaTLCTk3fXrnxqNJhaIhVETWfyoslVls0IhDhYCzdix/t3HRg+xSGOR5IEPGf36qFmrIMLauHHHjh0hj8WPFSzVWK01ZOz9j78y6j5o1GOvx48ZJ1ia/gFGpDo5x60N7QAAAABJRU5ErkJggg==', 76 | ) 77 | .attr('x', x + 5) 78 | .attr('y', y + 5) 79 | .attr('height', 30) 80 | .attr('opacity', 0.8); 81 | }; 82 | 83 | const addLicenseData = (node) => 84 | node 85 | .filter((d) => d.data.license) 86 | .append('license') 87 | .attr('name', (d) => d.data.license); 88 | 89 | const aggregateDependencies = (node, includeDev = false) => [ 90 | ...Object.values(node.dependencies || {}), 91 | ...Object.values(node.optionalDependencies || {}), 92 | ...Object.values(node.peerDependencies || {}), 93 | ...Object.values(node.bundledDependencies || {}), 94 | ...(includeDev ? Object.values(node.devDependencies || {}) : []), 95 | ]; 96 | 97 | const aggregateDependenciesWithType = (node, includeDev = false) => [ 98 | ...Object.values(node.dependencies || {}).map((dep) => ['prod', dep]), 99 | ...Object.values(node.optionalDependencies || {}).map((dep) => ['optional', dep]), 100 | ...Object.values(node.peerDependencies || {}).map((dep) => ['peer', dep]), 101 | ...Object.values(node.bundledDependencies || {}).map((dep) => ['bundled', dep]), 102 | ...(includeDev ? Object.values(node.devDependencies || {}).map((dep) => ['dev', dep]) : []), 103 | ]; 104 | 105 | const processGraph = (node, options = {}, depth = 0, history = [], rel = null) => { 106 | const {maxDepth = Infinity, includeDev = false, postprocess = (n) => n} = options; 107 | const dependencies = 108 | depth >= maxDepth 109 | ? [] 110 | : aggregateDependenciesWithType(node, includeDev) 111 | .filter(([, n]) => !history.includes(n)) 112 | .map(([r, n]) => processGraph(n, options, depth + 1, [...history, node], r)); 113 | 114 | return postprocess({ 115 | name: node.name, 116 | version: node.version, 117 | license: node.license, 118 | size: node.size, 119 | rel, 120 | children: dependencies, 121 | }); 122 | }; 123 | 124 | const getReportsForNode = (d, issues) => 125 | issues.filter(({findings}) => 126 | findings.affects.find(({name, version}) => d.data.name === name && d.data.version === version), 127 | ); 128 | 129 | const getIssueLevel = (d, issues) => { 130 | const reports = getReportsForNode(d, issues); 131 | if (reports.length) { 132 | if (reports.find(({name}) => d.data.name === name)) { 133 | return 'direct'; 134 | } 135 | return 'indirect'; 136 | } 137 | 138 | return 'none'; 139 | }; 140 | 141 | const addIssues = (node, issues) => 142 | node 143 | .filter((d) => getReportsForNode(d, issues)) 144 | .append('issues') 145 | .selectAll('issue') 146 | .data((d) => getReportsForNode(d, issues)) 147 | .join('issue') 148 | .attr('title', (d) => d.shortTitle || d.title) 149 | .attr('url', (d) => d.url) 150 | .attr('severity', (d) => d.severity); 151 | 152 | module.exports = { 153 | getModuleName, 154 | getUid, 155 | humanFileSize, 156 | getAncestors, 157 | groupByDepth, 158 | addSandwormLogo, 159 | addIssues, 160 | addLicenseData, 161 | aggregateDependencies, 162 | aggregateDependenciesWithType, 163 | processGraph, 164 | getReportsForNode, 165 | getIssueLevel, 166 | }; 167 | -------------------------------------------------------------------------------- /src/cli/cmds/audit.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs/promises'); 2 | const path = require('path'); 3 | const { 4 | files: {loadConfig}, 5 | } = require('@sandworm/utils'); 6 | const semver = require('semver'); 7 | 8 | const {getReport} = require('../..'); 9 | const onProgress = require('../progress'); 10 | const {getIssueCounts, failIfRequested} = require('../utils'); 11 | const summary = require('../summary'); 12 | const logger = require('../logger'); 13 | const checkUpdates = require('../checkUpdates'); 14 | const {outputFilenames, loadManifest} = require('../../files'); 15 | const handleCrash = require('../handleCrash'); 16 | const tip = require('../tips'); 17 | const {UsageError} = require('../../errors'); 18 | 19 | exports.command = ['audit', '*']; 20 | exports.desc = "Security & License Compliance For Your App's Dependencies 🪱"; 21 | exports.builder = (yargs) => 22 | yargs 23 | .option('o', { 24 | alias: 'output-path', 25 | demandOption: false, 26 | default: 'sandworm', 27 | describe: 'The path of the output directory, relative to the application path', 28 | type: 'string', 29 | }) 30 | .option('d', { 31 | alias: 'include-dev', 32 | demandOption: false, 33 | default: false, 34 | describe: 'Include dev dependencies', 35 | type: 'boolean', 36 | }) 37 | .option('sv', { 38 | alias: 'show-versions', 39 | demandOption: false, 40 | default: false, 41 | describe: 'Show package versions in chart names', 42 | type: 'boolean', 43 | }) 44 | .option('p', { 45 | alias: 'path', 46 | demandOption: false, 47 | describe: 'The path to the application to audit', 48 | type: 'string', 49 | }) 50 | .option('pt', { 51 | alias: 'package-type', 52 | demandOption: false, 53 | describe: 'The type of package to search for at the given path', 54 | type: 'string', 55 | }) 56 | .option('md', { 57 | alias: 'max-depth', 58 | demandOption: false, 59 | describe: 'Max depth to represent in charts', 60 | type: 'number', 61 | }) 62 | .option('ms', { 63 | alias: 'min-severity', 64 | demandOption: false, 65 | describe: 'Min issue severity to represent in charts', 66 | type: 'string', 67 | }) 68 | .option('lp', { 69 | alias: 'license-policy', 70 | demandOption: false, 71 | describe: 'Custom license policy JSON string', 72 | type: 'string', 73 | }) 74 | .option('f', { 75 | alias: 'from', 76 | demandOption: false, 77 | default: 'registry', 78 | describe: 'Load data from "registry" or "disk"', 79 | type: 'string', 80 | }) 81 | .option('fo', { 82 | alias: 'fail-on', 83 | demandOption: false, 84 | default: '[]', 85 | describe: 'Fail policy JSON string', 86 | type: 'string', 87 | }) 88 | .option('s', { 89 | alias: 'summary', 90 | demandOption: false, 91 | default: true, 92 | describe: 'Print a summary of the audit results to the console', 93 | type: 'boolean', 94 | }) 95 | .option('rs', { 96 | alias: 'root-is-shell', 97 | demandOption: false, 98 | default: false, 99 | describe: 'Root project is a shell with a single dependency', 100 | hidden: true, 101 | type: 'boolean', 102 | }) 103 | .option('root-vulnerabilites', { 104 | demandOption: false, 105 | default: false, 106 | describe: 'Include vulnerabilities for the root project', 107 | type: 'boolean', 108 | }) 109 | .option('skip-license-issues', { 110 | demandOption: false, 111 | default: false, 112 | describe: 'Skip scanning for license issues', 113 | type: 'boolean', 114 | }) 115 | .option('skip-meta-issues', { 116 | demandOption: false, 117 | default: false, 118 | describe: 'Skip scanning for meta issues', 119 | type: 'boolean', 120 | }) 121 | .option('skip-tree', { 122 | demandOption: false, 123 | default: false, 124 | describe: "Don't output the dependency tree chart", 125 | type: 'boolean', 126 | }) 127 | .option('force-tree', { 128 | demandOption: false, 129 | default: false, 130 | describe: 'Force build large dependency tree charts', 131 | type: 'boolean', 132 | }) 133 | .option('skip-treemap', { 134 | demandOption: false, 135 | default: false, 136 | describe: "Don't output the dependency treemap chart", 137 | type: 'boolean', 138 | }) 139 | .option('skip-csv', { 140 | demandOption: false, 141 | default: false, 142 | describe: "Don't output the dependency csv file", 143 | type: 'boolean', 144 | }) 145 | .option('skip-report', { 146 | demandOption: false, 147 | default: false, 148 | describe: "Don't output the report json file", 149 | type: 'boolean', 150 | }) 151 | .option('skip-all', { 152 | demandOption: false, 153 | default: false, 154 | describe: "Don't output any file", 155 | type: 'boolean', 156 | }) 157 | .option('show-tips', { 158 | demandOption: false, 159 | default: true, 160 | describe: 'Show usage tips', 161 | type: 'boolean', 162 | }); 163 | 164 | exports.handler = async (argv) => { 165 | const appPath = argv.path || process.cwd(); 166 | 167 | try { 168 | if (semver.lt(process.versions.node, '14.19.0')) { 169 | throw new UsageError( 170 | `Sandworm requires Node >=14.19.0 (currently on ${process.versions.node})`, 171 | ); 172 | } 173 | 174 | let isOutdated = false; 175 | 176 | (async () => { 177 | isOutdated = await checkUpdates(); 178 | })(); 179 | 180 | logger.logCliHeader(); 181 | const {default: ora} = await import('ora'); 182 | const fileConfig = loadConfig(appPath)?.audit || {}; 183 | const skipOutput = 184 | typeof fileConfig.skipAll !== 'undefined' ? !!fileConfig.skipAll : argv.skipAll; 185 | 186 | if (argv.licensePolicy) { 187 | try { 188 | JSON.parse(argv.licensePolicy); 189 | } catch (error) { 190 | throw new UsageError('The provided license policy is not valid JSON'); 191 | } 192 | } 193 | if (argv.failOn) { 194 | try { 195 | JSON.parse(argv.failOn); 196 | } catch (error) { 197 | throw new UsageError('The provided fail policy is not valid JSON'); 198 | } 199 | } 200 | 201 | const configuration = { 202 | appPath, 203 | packageType: argv.packageType, 204 | includeDev: 205 | typeof fileConfig.includeDev !== 'undefined' ? fileConfig.includeDev : argv.includeDev, 206 | skipLicenseIssues: 207 | typeof fileConfig.skipLicenseIssues !== 'undefined' 208 | ? fileConfig.skipLicenseIssues 209 | : argv.skipLicenseIssues, 210 | skipMetaIssues: 211 | typeof fileConfig.skipMetaIssues !== 'undefined' 212 | ? fileConfig.skipMetaIssues 213 | : argv.skipMetaIssues, 214 | showVersions: 215 | typeof fileConfig.showVersions !== 'undefined' 216 | ? fileConfig.showVersions 217 | : argv.showVersions, 218 | rootIsShell: 219 | typeof fileConfig.rootIsShell !== 'undefined' ? fileConfig.rootIsShell : argv.rootIsShell, 220 | includeRootVulnerabilities: 221 | typeof fileConfig.includeRootVulnerabilities !== 'undefined' 222 | ? fileConfig.includeRootVulnerabilities 223 | : argv.rootVulnerabilities, 224 | maxDepth: fileConfig.maxDepth || argv.maxDepth, 225 | licensePolicy: 226 | fileConfig.licensePolicy || (argv.licensePolicy && JSON.parse(argv.licensePolicy)), 227 | minDisplayedSeverity: fileConfig.minDisplayedSeverity || argv.minSeverity, 228 | loadDataFrom: fileConfig.loadDataFrom || argv.from, 229 | forceBuildLargeTrees: 230 | typeof fileConfig.forceBuildLargeTrees !== 'undefined' 231 | ? fileConfig.forceBuildLargeTrees 232 | : argv.forceTree, 233 | output: skipOutput 234 | ? [] 235 | : [ 236 | !(typeof fileConfig.skipTree !== 'undefined' ? !!fileConfig.skipTree : argv.skipTree) && 237 | 'tree', 238 | !(typeof fileConfig.skipTreemap !== 'undefined' 239 | ? !!fileConfig.skipTreemap 240 | : argv.skipTreemap) && 'treemap', 241 | !(typeof fileConfig.skipCsv !== 'undefined' ? !!fileConfig.skipCsv : argv.skipCsv) && 242 | 'csv', 243 | ].filter((o) => o), 244 | onProgress: onProgress({ora}), 245 | }; 246 | 247 | // ***************** 248 | // Perform the audit 249 | // ***************** 250 | const { 251 | dependencyGraph, 252 | svgs, 253 | csv, 254 | dependencyVulnerabilities, 255 | rootVulnerabilities, 256 | licenseUsage, 257 | licenseIssues, 258 | metaIssues, 259 | resolvedIssues, 260 | resolvedIssuesAlerts, 261 | resolvedIssuesCount, 262 | name, 263 | version, 264 | errors, 265 | } = await getReport(configuration); 266 | 267 | // ******************** 268 | // Write output to disk 269 | // ******************** 270 | const outputSpinner = ora('Writing Output Files').start(); 271 | const filenames = outputFilenames(name, version); 272 | const outputPath = path.join(appPath, fileConfig.outputPath || argv.outputPath); 273 | await fs.mkdir(outputPath, {recursive: true}); 274 | 275 | // Write charts 276 | await Object.keys(svgs).reduce(async (agg, chartType) => { 277 | await agg; 278 | 279 | if (svgs[chartType]) { 280 | const chartPath = path.join(outputPath, filenames[chartType]); 281 | await fs.writeFile(chartPath, svgs[chartType]); 282 | } 283 | }, Promise.resolve()); 284 | 285 | // Write CSV 286 | if (csv) { 287 | const csvOutputPath = path.join(outputPath, filenames.dependenciesCsv); 288 | await fs.writeFile(csvOutputPath, csv); 289 | } 290 | 291 | // Write JSON report 292 | const shouldWriteReport = 293 | !skipOutput && 294 | !(typeof fileConfig.skipReport !== 'undefined' ? !!fileConfig.skipReport : argv.skipReport); 295 | if (shouldWriteReport) { 296 | const {version: sandwormVersion} = await loadManifest(path.join(__dirname, '../../..')); 297 | delete configuration.appPath; 298 | const report = { 299 | name, 300 | version, 301 | createdAt: Date.now(), 302 | system: { 303 | sandwormVersion, 304 | nodeVersion: process.versions.node, 305 | ...(dependencyGraph.root.meta || {}), 306 | }, 307 | configuration, 308 | rootVulnerabilities, 309 | dependencyVulnerabilities, 310 | licenseUsage, 311 | licenseIssues, 312 | metaIssues, 313 | resolvedIssues, 314 | errors: errors.map((e) => e.message || e), 315 | }; 316 | const reportOutputPath = path.join(outputPath, filenames.json); 317 | await fs.writeFile(reportOutputPath, JSON.stringify(report, null, 2)); 318 | } 319 | 320 | outputSpinner.succeed(shouldWriteReport ? 'Report written to disk' : 'Report done'); 321 | 322 | // *************** 323 | // Display results 324 | // *************** 325 | const issuesByType = { 326 | root: rootVulnerabilities, 327 | dependencies: dependencyVulnerabilities, 328 | licenses: licenseIssues, 329 | meta: metaIssues, 330 | }; 331 | const {issueCountsByType, issueCountsBySeverity, totalIssueCount} = 332 | getIssueCounts(issuesByType); 333 | 334 | logger.log(''); 335 | if (resolvedIssuesCount > 0) { 336 | logger.logColor( 337 | logger.colors.GREEN, 338 | `🙌 ${resolvedIssuesCount} ${ 339 | resolvedIssuesCount === 1 ? 'issue' : 'issues' 340 | } already resolved`, 341 | ); 342 | } 343 | if (totalIssueCount > 0) { 344 | const displayableIssueCount = Object.entries(issueCountsBySeverity).filter( 345 | ([, count]) => count > 0, 346 | ); 347 | 348 | logger.logColor( 349 | logger.colors.CYAN, 350 | `⚠️ Identified ${displayableIssueCount 351 | .map(([severity, count]) => `${count} ${severity} severity`) 352 | .join(', ')} issues`, 353 | ); 354 | 355 | if (argv.summary) { 356 | summary(issuesByType); 357 | } 358 | } else { 359 | logger.logColor(logger.colors.CYAN, '✅ Zero issues identified'); 360 | } 361 | 362 | if (resolvedIssuesAlerts?.length) { 363 | logger.log(''); 364 | resolvedIssuesAlerts.forEach((alert) => { 365 | logger.log(`⚠️ ${alert}`); 366 | }); 367 | } 368 | 369 | logger.log(''); 370 | if (errors.length === 0) { 371 | logger.log('✨ Done'); 372 | } else { 373 | logger.log('✨ Done, but with errors:'); 374 | errors.forEach((error) => { 375 | logger.logColor(logger.colors.RED, `❌ ${error.stack || error}`); 376 | }); 377 | logger.logColor(logger.colors.RED, '❌ Failing because of errors'); 378 | process.exit(1); 379 | } 380 | 381 | // ***************** 382 | // Fail if requested 383 | // ***************** 384 | const failOn = fileConfig.failOn || (argv.failOn && JSON.parse(argv.failOn)); 385 | 386 | if (failOn) { 387 | failIfRequested({failOn, issueCountsByType}); 388 | } 389 | 390 | // ********************* 391 | // Outdated notification 392 | // ********************* 393 | if (isOutdated) { 394 | logger.log( 395 | `🔔 ${logger.colors.BG_CYAN}${logger.colors.BLACK}%s${logger.colors.RESET}\n`, 396 | 'New version available! Run "npm i -g @sandworm/audit" to update.', 397 | ); 398 | } else if (typeof fileConfig.showTips !== 'undefined' ? fileConfig.showTips : argv.showTips) { 399 | logger.log(tip()); 400 | } 401 | 402 | process.exit(); 403 | } catch (error) { 404 | await handleCrash(error, appPath); 405 | } 406 | }; 407 | -------------------------------------------------------------------------------- /src/graph/utils.js: -------------------------------------------------------------------------------- 1 | const semverLib = require('semver'); 2 | const {aggregateDependenciesWithType} = require('../charts/utils'); 3 | 4 | const SEMVER_REGEXP = 5 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?/; 6 | 7 | const parseDependencyString = (depstring) => { 8 | const parts = depstring.split('@'); 9 | let semver = parts.pop(); 10 | let name; 11 | let localName; 12 | 13 | if (parts.length === 1) { 14 | if (semver.startsWith('/')) { 15 | // Parses the pnpm format execa@/safe-execa/0.1.3 16 | [, name, semver] = semver.split('/'); 17 | [localName] = parts; 18 | } else { 19 | // Parses js-sdsl@^4.1.4 20 | [name] = parts; 21 | } 22 | } else if (parts.length === 2) { 23 | if (semver.startsWith('/')) { 24 | // Parses the pnpm format @zkochan/js-yaml@/js-yaml/0.0.6 25 | [, name, semver] = semver.split('/'); 26 | [, localName] = parts; 27 | localName = `@${localName}`; 28 | } else if (parts[1] === '/') { 29 | // Parses the pnpm format js-yaml@/@zkochan/js-yaml/0.0.6 30 | const semverParts = semver.split('/'); 31 | name = `@${semverParts[0]}/${semverParts[1]}`; 32 | [, , semver] = semverParts; 33 | [localName] = parts; 34 | } else if (parts[0] === '') { 35 | // @pnpm/constant@6.1.0 36 | name = `@${parts[1]}`; 37 | } else { 38 | // execa@npm:safe-execa@^0.1.1 39 | [localName] = parts; 40 | name = parts[1].replace('npm:', ''); 41 | } 42 | } else if (parts.length === 3) { 43 | if (parts[2] === '/') { 44 | // Parses the pnpm format @test/pck@/@test/pkg/0.0.6 45 | const semverParts = semver.split('/'); 46 | name = `@${semverParts[0]}/${semverParts[1]}`; 47 | [, , semver] = semverParts; 48 | [, localName] = parts; 49 | localName = `@${localName}`; 50 | } else { 51 | // js-yaml@npm:@zkochan/js-yaml@^0.0.6 52 | [localName] = parts; 53 | name = `@${parts[2]}`; 54 | } 55 | } else if (parts.length === 4) { 56 | // @pnpm/constant@npm:@test/constant@6.1.0 57 | localName = `@${parts[1]}`; 58 | name = `@${parts[3]}`; 59 | } 60 | 61 | return {name, semver, localName: localName || name}; 62 | }; 63 | 64 | const makeNode = (data) => ({ 65 | ...data, 66 | flags: {}, 67 | dependencies: {}, 68 | devDependencies: {}, 69 | optionalDependencies: {}, 70 | peerDependencies: {}, 71 | bundledDependencies: {}, 72 | parents: { 73 | dependencies: {}, 74 | devDependencies: {}, 75 | optionalDependencies: {}, 76 | peerDependencies: {}, 77 | bundledDependencies: {}, 78 | }, 79 | }); 80 | 81 | const processDependenciesForPackage = ({ 82 | dependencies, 83 | newPackage, 84 | allPackages, 85 | placeholders, 86 | altTilde = false, 87 | }) => { 88 | Object.entries({ 89 | ...(dependencies.dependencies && {dependencies: dependencies.dependencies}), 90 | ...(dependencies.devDependencies && {devDependencies: dependencies.devDependencies}), 91 | ...(dependencies.optionalDependencies && { 92 | optionalDependencies: dependencies.optionalDependencies, 93 | }), 94 | ...(dependencies.peerDependencies && {peerDependencies: dependencies.peerDependencies}), 95 | ...(dependencies.bundledDependencies && { 96 | bundledDependencies: dependencies.bundledDependencies, 97 | }), 98 | }).forEach(([dependencyType, dependencyObject]) => { 99 | Object.entries(dependencyObject || {}).forEach( 100 | ([originalDependencyName, originalSemverRule]) => { 101 | const {name: dependencyName, semver: semverRule} = parseDependencyString( 102 | // In some cases, pnpm appends metadata to the version 103 | // Ex lockfile v5: `rollup-plugin-terser: 7.0.2_rollup@2.79.1` 104 | // Ex lockfile v5: `workbox-webpack-plugin: 6.1.5_fa2917c6d78243729a500a2a8fe6cdc5` 105 | // Ex lockfile v6: `ajv-formats: 2.1.1(ajv@8.12.0)` 106 | `${originalDependencyName}@${originalSemverRule.split('_')[0].split('(')[0]}`, 107 | ); 108 | 109 | let processedSemverRule = semverRule; 110 | // By default, semver expands ~1.2 to 1.2.x 111 | // But composer expands it to >=1.2 <2.0.0 112 | // Setting altTilde to true enables support for the latter 113 | if (altTilde) { 114 | const pattern = semverRule.match(/~(\d+)\.(\d+)/); 115 | if (pattern) { 116 | processedSemverRule = `>=${pattern[1]}.${pattern[2]} <${pattern[1] + 1}.0.0`; 117 | } 118 | } 119 | 120 | // Composer allows rules like "^3.4|^4.4|^5.0" 121 | // Semver needs us to expand all single | to || 122 | processedSemverRule = processedSemverRule.replace(/(? 126 | pkg.name === dependencyName && semverLib.satisfies(pkg.version, processedSemverRule), 127 | ); 128 | 129 | if (dependencyPackage) { 130 | // eslint-disable-next-line no-param-reassign 131 | newPackage[dependencyType][dependencyPackage.name] = dependencyPackage; 132 | dependencyPackage.parents[dependencyType][newPackage.name] = newPackage; 133 | } else { 134 | placeholders.push({ 135 | dependencyName, 136 | semverRule: processedSemverRule, 137 | targetPackage: newPackage, 138 | dependencyType, 139 | }); 140 | } 141 | }, 142 | ); 143 | }); 144 | }; 145 | 146 | const processPlaceholders = ({newPackage, placeholders}) => { 147 | let newVersion = newPackage.version; 148 | if (newVersion && newVersion.startsWith('v')) { 149 | newVersion = newVersion.slice(1); 150 | } 151 | placeholders 152 | .filter(({dependencyName, semverRule}) => { 153 | let placeholderSemverRule = semverRule; 154 | if (placeholderSemverRule.startsWith('v')) { 155 | placeholderSemverRule = placeholderSemverRule.slice(1); 156 | } 157 | return ( 158 | newPackage.name === dependencyName && 159 | (newVersion === placeholderSemverRule || 160 | semverLib.satisfies(newVersion, placeholderSemverRule)) 161 | ); 162 | }) 163 | .forEach((placeholder) => { 164 | // eslint-disable-next-line no-param-reassign 165 | placeholder.targetPackage[placeholder.dependencyType][newPackage.name] = newPackage; 166 | // eslint-disable-next-line no-param-reassign 167 | newPackage.parents[placeholder.dependencyType][placeholder.targetPackage.name] = 168 | placeholder.targetPackage; 169 | placeholders.splice(placeholders.indexOf(placeholder), 1); 170 | }); 171 | }; 172 | 173 | const postProcessGraph = ({root, processedNodes = [], flags = {}, depth = 0}) => { 174 | if (!root) { 175 | return root; 176 | } 177 | 178 | Object.assign(root.flags, flags); 179 | 180 | if (!processedNodes.includes(root)) { 181 | // eslint-disable-next-line no-param-reassign 182 | processedNodes.push(root); 183 | 184 | Object.keys(root).forEach((key) => { 185 | const value = root[key]; 186 | if ( 187 | key !== 'flags' && 188 | (value === undefined || 189 | (Object.getPrototypeOf(value) === Object.prototype && Object.keys(value).length === 0)) 190 | ) { 191 | // eslint-disable-next-line no-param-reassign 192 | delete root[key]; 193 | } 194 | }); 195 | 196 | if (depth === 0) { 197 | Object.values(root.dependencies || {}).forEach((dep) => { 198 | // eslint-disable-next-line no-param-reassign 199 | dep.flags.prod = true; 200 | }); 201 | } 202 | 203 | // Flags may mutate during the recursive processing 204 | // Make a copy of the flags before we start 205 | const rootFlags = {...root.flags}; 206 | aggregateDependenciesWithType(root, true).forEach(([rel, dep]) => { 207 | const newFlags = { 208 | ...(rootFlags.prod && {prod: true}), 209 | ...((rootFlags.dev || rel === 'dev') && {dev: true}), 210 | ...((rootFlags.optional || rel === 'optional') && {optional: true}), 211 | ...((rootFlags.peer || rel === 'peer') && {peer: true}), 212 | ...((rootFlags.bundled || rel === 'bundled') && {bundled: true}), 213 | }; 214 | return postProcessGraph({ 215 | root: dep, 216 | processedNodes, 217 | flags: newFlags, 218 | depth: depth + 1, 219 | }); 220 | }); 221 | } 222 | 223 | return root; 224 | }; 225 | 226 | const normalizeLicense = (rawLicense) => { 227 | let license; 228 | let licenseData = rawLicense; 229 | 230 | if (!rawLicense) { 231 | return rawLicense; 232 | } 233 | 234 | try { 235 | licenseData = JSON.parse(licenseData); 236 | // eslint-disable-next-line no-empty 237 | } catch (error) {} 238 | 239 | if (typeof licenseData === 'string') { 240 | // Standard SPDX field 241 | license = licenseData; 242 | } else if (Array.isArray(licenseData)) { 243 | // Some older npm packages use an array 244 | // { 245 | // "licenses" : [ 246 | // {"type": "MIT", "url": "..."}, 247 | // {"type": "Apache-2.0", "url": "..."} 248 | // ] 249 | // } 250 | // Composer packages use string arrays 251 | if (licenseData.length === 1) { 252 | const onlyLicense = licenseData[0]; 253 | license = 254 | typeof onlyLicense === 'string' ? onlyLicense : onlyLicense.type || onlyLicense.name; 255 | } else { 256 | license = `(${licenseData 257 | .map((licenseItem) => { 258 | if (typeof licenseItem === 'string') { 259 | return licenseItem; 260 | } 261 | return licenseItem.type || licenseItem.name; 262 | }) 263 | .join(' OR ')})`; 264 | } 265 | } else if (typeof licenseData === 'object' && licenseData !== null) { 266 | // Some older npm packages use an object 267 | // { 268 | // "license" : { 269 | // "type" : "ISC", 270 | // "url" : "..." 271 | // } 272 | // } 273 | license = licenseData.type || licenseData.name; 274 | } 275 | 276 | return license; 277 | }; 278 | 279 | const addDependencyGraphData = async ({ 280 | root, 281 | processedNodes = [], 282 | packageData = [], 283 | packageManager, 284 | loadDataFrom = false, 285 | includeDev = false, 286 | getRegistryData, 287 | onProgress, 288 | }) => { 289 | if (!root) { 290 | return root; 291 | } 292 | 293 | let errors = []; 294 | 295 | if (!processedNodes.includes(root)) { 296 | // eslint-disable-next-line no-param-reassign 297 | processedNodes.push(root); 298 | 299 | let currentPackageData = packageData.find( 300 | ({name, version}) => root.name === name && root.version === version, 301 | ); 302 | const shouldLoadMetadata = includeDev || root.flags.prod; 303 | 304 | if (!currentPackageData && loadDataFrom === 'registry' && shouldLoadMetadata) { 305 | try { 306 | currentPackageData = await getRegistryData(packageManager, root.name, root.version); 307 | } catch (e) { 308 | errors.push(e); 309 | } 310 | } 311 | 312 | if (shouldLoadMetadata) { 313 | onProgress?.(); 314 | } 315 | 316 | if (currentPackageData) { 317 | const license = normalizeLicense(currentPackageData.licenses || currentPackageData.license); 318 | 319 | Object.assign(root, { 320 | ...(currentPackageData.relativePath && {relativePath: currentPackageData.relativePath}), 321 | ...(currentPackageData.engines && {engines: currentPackageData.engines}), 322 | ...(currentPackageData.size && {size: currentPackageData.size}), 323 | ...(currentPackageData.deprecated && {deprecated: currentPackageData.deprecated}), 324 | ...(currentPackageData.repository && {repository: currentPackageData.repository}), 325 | ...(currentPackageData.bugs && {bugs: currentPackageData.bugs}), 326 | ...(currentPackageData.scripts && {scripts: currentPackageData.scripts}), 327 | ...(currentPackageData.published && {published: currentPackageData.published}), 328 | ...(currentPackageData.dependencies && { 329 | originalDependencies: currentPackageData.dependencies, 330 | }), 331 | ...(currentPackageData.author && {author: currentPackageData.author}), 332 | ...(currentPackageData.maintainers && { 333 | maintainers: currentPackageData.maintainers, 334 | }), 335 | // eslint-disable-next-line no-underscore-dangle 336 | ...(currentPackageData._npmUser && {publisher: currentPackageData._npmUser}), 337 | ...(currentPackageData['dist-tags'] && { 338 | latestVersion: currentPackageData['dist-tags'].latest, 339 | }), 340 | ...(license && {license}), 341 | }); 342 | } 343 | 344 | await Promise.all( 345 | [ 346 | ...Object.values(root.dependencies || {}), 347 | ...Object.values(root.devDependencies || {}), 348 | ...Object.values(root.optionalDependencies || {}), 349 | ...Object.values(root.peerDependencies || {}), 350 | ...Object.values(root.bundledDependencies || {}), 351 | ].map(async (dep) => { 352 | errors = errors.concat( 353 | await addDependencyGraphData({ 354 | root: dep, 355 | processedNodes, 356 | packageData, 357 | packageManager, 358 | loadDataFrom, 359 | includeDev, 360 | getRegistryData, 361 | onProgress, 362 | }), 363 | ); 364 | }), 365 | ); 366 | } 367 | 368 | return errors; 369 | }; 370 | 371 | const seedNodes = ({initialNodes, allPackages, placeholders, altTilde}) => { 372 | initialNodes.forEach((nodeManifest) => { 373 | const node = makeNode({ 374 | name: nodeManifest.name, 375 | version: nodeManifest.version, 376 | engines: nodeManifest.engines, 377 | }); 378 | 379 | processDependenciesForPackage({ 380 | dependencies: nodeManifest, 381 | newPackage: node, 382 | allPackages, 383 | placeholders, 384 | altTilde, 385 | }); 386 | 387 | processPlaceholders({newPackage: node, placeholders}); 388 | 389 | allPackages.push(node); 390 | }); 391 | }; 392 | 393 | module.exports = { 394 | SEMVER_REGEXP, 395 | makeNode, 396 | parseDependencyString, 397 | processDependenciesForPackage, 398 | processPlaceholders, 399 | postProcessGraph, 400 | addDependencyGraphData, 401 | normalizeLicense, 402 | seedNodes, 403 | }; 404 | --------------------------------------------------------------------------------