sdc-check
Version:
Small tool to inform you about potential risks in your project dependencies list
253 lines (224 loc) • 8.72 kB
JavaScript
import { ParseLockfile, ValidateHost } from 'lockfile-lint-api'
import { readFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import pacote from 'pacote'
import { omit, pick } from './utils.js'
const INSTALL_HOOKS = ['install', 'preinstall', 'postinstall', 'preuninstall', 'postuninstall']
/**
* @typedef {import('./metrics.js').getMetrics} getMetrics
* @type getMetrics
*/
export async function getMetrics(nssOutput, config, rootDir) {
let findings = []
try {
let rootPackageJson = JSON.parse(
(await readFile(join(rootDir, 'package.json'), 'utf-8')) || '{}'
)
for (let [name, dependencyData] of Object.entries(nssOutput.dependencies)) {
if (
dependencyData.metadata.publishedCount === 0 ||
(rootPackageJson.name === name && rootPackageJson.private === 'true')
) {
// looks like this is a root directory of a project when running in internal-mode
continue
}
for (let [version, versionData] of Object.entries(dependencyData.versions)) {
let packageManifestObject = await pacote.manifest(`${name}@${version}`, {
fullMetadata: true
})
let packument = await pacote.packument(`${name}@${version}`, {
fullMetadata: true
})
findings.push({
metrics: {
...gatherMetricsFromNodeSecScanner(
dependencyData,
versionData,
config,
packageManifestObject
),
...gatherSourceCodeRepositoryMetric(dependencyData, packageManifestObject),
...gatherDangerousShellCommandsMetric(packageManifestObject),
...(await gatherReleaseActivityMetrics(packument, version, config)),
...gatherOsScriptsMetric(versionData)
},
package: name,
version
})
}
}
} catch (error) {
/* eslint-disable no-console */
console.error('\nERROR: Could not gather metrics')
throw error
/* eslint-enable no-console */
}
return findings
}
/**
* @typedef {import('./metrics.js').gatherMetricsFromNodeSecScanner} gatherMetricsFromNodeSecScanner
* @type gatherMetricsFromNodeSecScanner
*/
export function gatherMetricsFromNodeSecScanner(
dependencyData,
versionData,
config,
packageManifestObject
) {
let decisionMakers =
dependencyData.metadata.publishers.length + dependencyData.metadata.maintainers.length
let obfuscatedCodeWarns = versionData.warnings.filter(warn => warn.kind === 'obfuscated-code')
return {
hasInstallScripts: {
result: versionData.flags.some(flag => flag === 'hasScript'),
value: JSON.stringify(pick(INSTALL_HOOKS, packageManifestObject.scripts || {}))
},
hasObfuscatedCode: {
result: obfuscatedCodeWarns.length > 0,
value: obfuscatedCodeWarns
// @ts-expect-error Bad type inference
.map(warn => `${warn.value}-${warn.file}`)
.join(', ')
},
hasTooManyDecisionMakers: {
result: decisionMakers > config.limitOfDecisionMakers,
value: decisionMakers
},
isPackageUnmaintained: {
result: dependencyData.metadata.hasReceivedUpdateInOneYear === false,
value: new Date(dependencyData.metadata.lastUpdateAt).toISOString().slice(0, 10)
}
}
}
/**
* @typedef {import('./metrics.js').gatherSourceCodeRepositoryMetric} gatherSourceCodeRepositoryMetric
* @type gatherSourceCodeRepositoryMetric
*/
export function gatherSourceCodeRepositoryMetric(dependencyData, packageManifestObject) {
let hasNotSourceCodeRefInHomepage =
typeof dependencyData.metadata.homepage !== 'string' ||
(!dependencyData.metadata.homepage.includes('github') &&
!dependencyData.metadata.homepage.includes('gitlab'))
let hasNotSourceCodeRefInRepository =
typeof packageManifestObject.repository !== 'object' ||
typeof packageManifestObject.repository.url !== 'string' ||
(!packageManifestObject.repository.url.includes('github') &&
!packageManifestObject.repository.url.includes('gitlab'))
return {
noSourceCodeRepository: {
result: hasNotSourceCodeRefInHomepage && hasNotSourceCodeRefInRepository,
value: hasNotSourceCodeRefInHomepage && hasNotSourceCodeRefInRepository
}
}
}
/**
* @typedef {import('./metrics.js').gatherDangerousShellCommandsMetric} gatherDangerousShellCommandsMetric
* @type gatherDangerousShellCommandsMetric
*/
export function gatherDangerousShellCommandsMetric(packageManifestObject) {
let NET_TOOLS = ['wget', 'curl', 'Invoke-WebRequest', 'System.Net.WebClient', 'bitsadmin']
let PERMISSIONS_TOOLS = ['cacls', 'takeown', 'chown', 'chmod']
let TOOLS = [...NET_TOOLS, ...PERMISSIONS_TOOLS]
let scripts = packageManifestObject.scripts || {}
let scriptsContent = Object.values(scripts)
.join(' ')
.split(' ')
.map(command => command.toLowerCase().trim())
let result = scriptsContent.some(word => TOOLS.some(tool => tool.toLowerCase() === word))
return {
scriptsHaveDangerousShellCommands: {
result,
value: result ? JSON.stringify(scripts) : ''
}
}
}
/**
* @typedef {import('./metrics.js').gatherReleaseActivityMetrics} gatherReleaseActivityMetrics
* @type gatherReleaseActivityMetrics
*/
export function gatherReleaseActivityMetrics(packument, version, config) {
try {
if (!packument || typeof packument.time !== 'object' || !packument.time) {
return {
packageReleasedAfterLongPeriodOfInactivity: { result: false, value: '' },
packageVersionIsTooNew: { result: false, value: '' }
}
}
let releaseDates = Object.values(omit(['modified', 'created'], packument.time))
let previousVersionIndex = releaseDates.findIndex(date => date === packument.time[version]) - 1
let versionReleaseDate = new Date(packument.time[version].slice(0, 10))
let limitDateOfEarliestRelease = new Date(
new Date().setDate(new Date().getDate() - config.daysBeforeUpgradeToNewVersion)
)
if (releaseDates.length === 1 || !releaseDates[previousVersionIndex]) {
// if only one version exists
// or dependecy points to first version
return {
packageReleasedAfterLongPeriodOfInactivity: { result: false, value: '' },
packageVersionIsTooNew: {
result: versionReleaseDate > limitDateOfEarliestRelease,
value: versionReleaseDate.toISOString().slice(0, 10)
}
}
}
let limitDateOfLongestInactivityPeriod = new Date(
new Date(versionReleaseDate.valueOf()).setMonth(
versionReleaseDate.getMonth() - config.monthsOfInactivityAllowed
)
)
// @ts-expect-error generic types unavailable in js so we have to use unknown. 'releaseDates' will be string[], not unknown[]
let previousReleaseDate = new Date(releaseDates[previousVersionIndex].slice(0, 10))
return {
packageReleasedAfterLongPeriodOfInactivity: {
result: previousReleaseDate < limitDateOfLongestInactivityPeriod,
value: previousReleaseDate.toISOString().slice(0, 10)
},
packageVersionIsTooNew: {
result: versionReleaseDate > limitDateOfEarliestRelease,
value: versionReleaseDate.toISOString().slice(0, 10)
}
}
} catch (error) {
/* eslint-disable no-console */
console.error('ERROR: Could not process package packument to get activity metrics')
/* eslint-enable no-console */
throw error
}
}
/**
* @typedef {import('./metrics.js').gatherLockFileSafetyMetric} gatherLockFileSafetyMetric
* @type gatherLockFileSafetyMetric
*/
export async function gatherLockFileSafetyMetric(lockfilePath) {
if (!lockfilePath) {
return { object: {}, type: 'success' }
}
try {
let lockfileType = lockfilePath.includes('yarn') ? 'yarn' : 'npm'
let parser = new ParseLockfile({ lockfilePath, lockfileType })
let lockfile = parser.parseSync()
let validator = new ValidateHost({ packages: lockfile.object })
return validator.validate([lockfileType], { emptyHostname: true })
} catch (error) {
/* eslint-disable no-console */
console.error('ERROR: Could not process the validation of lockfile')
/* eslint-enable no-console */
throw error
}
}
/**
* @typedef {import('./metrics.js').gatherOsScriptsMetric} gatherOsScriptsMetric
* @type gatherOsScriptsMetric
*/
export function gatherOsScriptsMetric(versionData) {
let shellFileExtensions = ['.sh', '.bash', '.bat', '.cmd']
let shellFiles = versionData.composition.files.filter(file =>
shellFileExtensions.some(ext => extname(file) === ext)
)
return {
hasOsScripts: {
result: shellFiles.length > 0,
value: shellFiles.length > 0 ? shellFiles : []
}
}
}