audit-export
Version:
Pretty export your npm audit output as an offline accessible html page
556 lines (493 loc) • 15.5 kB
JavaScript
const fs = require("fs-extra");
const ejs = require("ejs");
const packageJson = require("../package.json");
// biome-ignore lint/style/useNodejsImportProtocol: The "node:" protocol doesn't work on NodeJS v10 and v12, so it's not added to make the compatibility possible.
const os = require("os");
// biome-ignore lint/style/useNodejsImportProtocol: The "node:" protocol doesn't work on NodeJS v10 and v12, so it's not added to make the compatibility possible.
const child_process = require("child_process");
const OUTPUT_FILE_NAME = "audit-report.html";
const OPTIONS = {
path: undefined,
title: "NPM Audit Report",
open: false,
};
const HELP_TEXT =
'\n Usage:\n\n $ npm audit --json | audit-export [--path <output_path>] [--title <report_title>] [--open]\n\n Supported package managers:\n $ npm/pnpm/yarn audit --json | audit-export\n\n Parameters:\n --path Output file or directory path (default: ./audit-report.html)\n --title HTML report title (default: NPM Audit Report)\n --open Automatically open the report in your default browser\n\n Examples:\n $ npm audit --json | audit-export\n $ npm audit --json | audit-export --path ./reports/security.html\n $ npm audit --json | audit-export --title "Project Security Report" --open\n';
/**
* Processes the input data and writes it to the specified file or folder.
* @param {object} options - App options
* @param {string} inputData - Data read from stdin.
*/
function processInput(options, inputData) {
const finalPath = getFinalPath(options.path);
writeIfFolderExists(options, finalPath, inputData);
if (options.open) {
openReport(finalPath);
}
}
/**
* Constructs the final path based on the folder and file paths.
* @param {string} path - Path to the folder and/or file.
* @returns {string} - The final path to write the output.
*/
function getFinalPath(path) {
if (path.includes(".html")) return path;
return join([path, OUTPUT_FILE_NAME]);
}
/**
* Checks if the provided folder exists and calls the writeOutput function.
* @param {object} options - App options
* @param {string} finalPath - Final path to write the output.
* @param {string} data - Data to be written to the file.
*/
function writeIfFolderExists(options, finalPath, data) {
const folderPath = finalPath.replace(/[/\\][^/\\]+\.html$/, ""); // Remove the file name part
fs.access(folderPath, fs.constants.F_OK, (err) => {
if (err) {
console.error("Error: The provided folder does not exist.");
process.exit(1);
}
writeOutput(options, finalPath, data);
});
}
/**
* Writes the given data to the specified file.
* @param {object} options - App options
* @param {string} path - Path to the file.
* @param {string} data - Data to be written to the file.
*/
function writeOutput(options, path, data) {
const output = generateHtmlTemplateContent(options, data);
fs.writeFile(path, output, (err) => {
if (err) {
console.error("Error: Unable to write to file.", err);
process.exit(1);
}
console.log("Audit exported successfully!");
process.exit(0);
});
}
/**
* Generates HTML template content based on the provided data.
* @param {object} options - App options
* @param {string} data - JSON string containing vulnerability data.
* @returns {string} - HTML content generated from the template.
*/
function generateHtmlTemplateContent(options, data) {
// Handling yarn audit data - that is jsonl, not json
let tool;
let jsonData;
try {
jsonData = JSON.parse(data);
tool = data.advisories
? "pnpm"
: // biome-ignore lint/complexity/useOptionalChain: Old NodeJS versions don't support optional chaining
(Object.values(jsonData)[0][0] || {}).id
? "bun"
: "npm";
} catch (_error) {
// Data couldn't be parsed as JSON, so it's a jsonl file
jsonData = JSON.parse(
`[${data
.split("\n")
.filter((line) => line.trim())
.join(",")}]`,
);
tool = "yarn";
}
const TEMPLATE = fs.readFileSync(join([__dirname, "template.ejs"]), "utf-8");
const vulnerabilities = getVulnerabilities(jsonData);
const packageJson = fs.readFileSync(
join([__dirname, "..", "package.json"]),
"utf-8",
);
const templateData = {
npmReportTitle: options.title,
vulnsFound: vulnerabilities.length,
vulnerableDependencies: [
...new Set(vulnerabilities.map((vuln) => vuln.package)),
].length,
currentDate: getCurrentDate(),
totals: {
critical: countVulnerabilities(vulnerabilities, "critical"),
high: countVulnerabilities(vulnerabilities, "high"),
moderate: countVulnerabilities(vulnerabilities, "moderate"),
low: countVulnerabilities(vulnerabilities, "low"),
info: countVulnerabilities(vulnerabilities, "info"),
},
vulnerabilities: vulnerabilities,
version: JSON.parse(packageJson).version || "Unknown",
packageLockLocation: join([process.cwd(), "package-lock.json"]),
tool: tool,
};
return ejs.render(TEMPLATE, templateData);
}
/**
* Counts the number of vulnerabilities with the specified severity.
* @param {Array} vulnerabilities - Array of vulnerability objects.
* @param {string} severity - Severity level to count.
* @returns {number} - Number of vulnerabilities with the specified severity.
*/
function countVulnerabilities(vulnerabilities, severity) {
return vulnerabilities.filter((vuln) => vuln.severity === severity).length;
}
/**
* Extracts and processes vulnerability data from the provided JSON data.
* @param {string} data - JSON string containing vulnerability data.
* @returns {Array} - Array of processed vulnerability objects.
*/
function getVulnerabilities(data) {
let allVulns = [];
if (Array.isArray(data)) {
allVulns = getVulnerabilitiesFromYarnAudit(data);
} else if (data.vulnerabilities) {
allVulns = getVulnerabilitiesFromNpmAudit(data);
} else if (data.advisories) {
allVulns = getVulnerabilitiesFromPnpmAudit(data);
// biome-ignore lint/complexity/useOptionalChain: Old NodeJS versions don't support optional chaining
} else if ((Object.values(data)[0][0] || {}).id) {
allVulns = getVulnerabilitiesFromBunAudit(data);
}
return deduplicateEntries(
allVulns.map((vuln) => processVulnerability(vuln)).filter((vuln) => vuln),
);
}
/**
* Extracts and processes vulnerability data from bun audit report.
*
* @param {Object} data - The bun audit report data.
* @returns {Array} - An array of processed vulnerability objects.
*/
function getVulnerabilitiesFromBunAudit(data) {
const allVulns = [];
for (const [name, vulnerabilities] of Object.entries(data)) {
for (const vuln of vulnerabilities) {
allVulns.push({
...vuln,
name,
});
}
}
return allVulns;
}
/**
* Extracts and processes vulnerability data from npm audit report.
*
* @param {Object} data - The npm audit report data.
* @returns {Array} - An array of processed vulnerability objects.
*/
function getVulnerabilitiesFromNpmAudit(data) {
const allVulns = [];
for (const pkg in data.vulnerabilities) {
for (const vulnerability of data.vulnerabilities[pkg].via) {
if (typeof vulnerability !== "string") {
allVulns.push({
...vulnerability,
isDirect: data.vulnerabilities[pkg].isDirect,
fixAvailable: data.vulnerabilities[pkg].fixAvailable,
});
}
}
}
return allVulns;
}
/**
* Extracts and processes vulnerability data from pnpm audit report.
*
* @param {Object} data - The pnpm audit report data.
* @returns {Array} - An array of processed vulnerability objects.
*/
function getVulnerabilitiesFromPnpmAudit(data) {
const allVulns = [];
for (const vuln in data.advisories) {
const vulnerability = data.advisories[vuln];
allVulns.push({
...vulnerability,
fixAvailable:
vulnerability.recommendation && vulnerability.patched_versions,
isDirect: vulnerability.findings.some((finding) =>
finding.paths.some((path) => (path.match(/>/g) || []).length === 1),
),
});
}
return allVulns;
}
/**
* Extracts and processes vulnerability data from yarn audit report.
*
* @param {Array} data - The yarn audit report data.
* @returns {Array} - An array of processed vulnerability objects.
*/
function getVulnerabilitiesFromYarnAudit(data) {
const allVulns = [];
for (const arrayItem of data) {
if (arrayItem.type === "auditAdvisory") {
const vulnerability = arrayItem.data.advisory;
allVulns.push({
...vulnerability,
fixAvailable:
vulnerability.recommendation && vulnerability.patched_versions,
isDirect: vulnerability.findings.some((finding) =>
finding.paths.some((path) => (path.match(/>/g) || []).length === 1),
),
});
}
}
return allVulns;
}
/**
* Processes a single vulnerability object and returns a standardized format.
* @param {Object} vuln - Raw vulnerability object.
* @returns {Object} - Processed vulnerability object.
*/
function processVulnerability(vuln) {
const tags = [];
const cvesAndCwes = (vuln.cwe || [])
.concat(vuln.cves || [])
.filter((cwe) => cwe)
.join(", ");
if ("fixAvailable" in vuln) {
tags.push(vuln.fixAvailable ? "Fix Available" : "No Fix");
}
if ("isDirect" in vuln) {
tags.push(vuln.isDirect ? "Direct" : "Indirect");
}
if (vuln.title) {
return {
link: vuln.url,
name: vuln.title,
tags: tags,
package: vuln.name || vuln.module_name,
severity: vuln.severity,
severity_number: getNumberOfSeverity(vuln.severity),
cwes: cvesAndCwes,
};
}
}
/**
* Removes duplicate entries from an array of objects based on their keys and values.
*
* @param {Array} array - The array of objects to deduplicate.
* @returns {Array} - A new array with duplicate entries removed.
*/
function deduplicateEntries(array) {
return array.filter((item, index, self) => {
return (
index ===
self.findIndex((other) => {
const keys1 = Object.keys(item);
const keys2 = Object.keys(other);
if (keys1.length !== keys2.length) return false;
return keys1.every((key) => {
const val1 = item[key];
const val2 = other[key];
// Compare arrays
if (Array.isArray(val1) && Array.isArray(val2)) {
return (
val1.length === val2.length && val1.every((v, i) => v === val2[i])
);
}
return val1 === val2;
});
})
);
});
}
/**
* Turn a string severity into the correspondent number
* @param {string} severity - Severity string name
* @returns {number} - Number of the severity, from 0 to 5
*/
function getNumberOfSeverity(severity) {
switch (severity) {
case "critical":
return 5;
case "high":
return 4;
case "moderate":
return 3;
case "low":
return 2;
case "info":
return 1;
case "none":
return 0;
default:
return 0;
}
}
/**
* Joins array elements into a path string based on the operating system.
* @param {Array} paths - Array of path elements.
* @returns {string} - The joined path string.
*/
function join(paths) {
if (os.platform() === "win32") {
return paths.join("\\");
}
return paths.join("/");
}
/**
* Gets the current date in a formatted string.
* @returns {string} - Formatted current date string.
*/
function getCurrentDate() {
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const d = new Date();
return `${checkNumLength(str(d.getDate()))} of ${months[d.getMonth()]}, ${d.getFullYear()} - ${checkNumLength(str(d.getHours()))}:${checkNumLength(str(d.getMinutes()))}:${checkNumLength(str(d.getSeconds()))}`;
}
/**
* Converts a value to a string.
* @param {any} string - Value to be converted to a string.
* @returns {string} - The converted string.
*/
function str(string) {
return string.toString();
}
/**
* Ensures a two-digit format for numbers by adding a leading zero if necessary.
* @param {string} number - Number to check and possibly add a leading zero.
* @returns {string} - Number with a leading zero if necessary.
*/
function checkNumLength(number) {
if (number.length === 2) return number;
return `0${number}`;
}
/**
* Opens the generated report in the default browser.
* @param {string} finalPath - Final path of the generated report.
*/
function openReport(finalPath) {
const start =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
child_process.exec(`${start} "${finalPath}"`);
}
/**
* Processes command line arguments passed to the script.
* If a parameter is provided, it invokes the processParameter function to handle it.
* If folder or file parameters are provided as positional arguments, it assigns them to the OPTIONS object.
*/
function processArgument() {
const args = process.argv.slice(2);
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg.startsWith("--")) {
const consumed = processParameter(arg, args, index);
if (consumed) index++; // Skip next argument if it was consumed as a value
} else if (!OPTIONS.path) {
OPTIONS.path = arg;
}
}
}
/**
* Processes a single command line parameter.
* Determines the parameter and its value if applicable, then performs the appropriate action.
*
* @param {string} arg - The command line argument to process.
* @param {string[]} args - The array of command line arguments.
* @param {number} index - The index of the current argument in the args array.
* @returns {boolean} - Whether the next argument was consumed as a value.
*/
function processParameter(arg, args, index) {
let param;
let value;
let consumedNext = false;
const argumentName = arg.slice(2);
if (arg.includes("=")) {
[param, value] = argumentName.split(/=(.+)/);
} else if (
args[index + 1] &&
!args[index + 1].startsWith("--") &&
argumentName !== "help" &&
argumentName !== "version"
) {
param = argumentName;
value = args[index + 1];
consumedNext = true;
} else if (
argumentName !== "help" &&
argumentName !== "version" &&
argumentName !== "open"
) {
console.error(`Error: Missing value for parameter '${argumentName}'.`);
process.exit(1);
} else {
param = argumentName;
}
switch (param) {
case "path":
case "title":
handleParameter(param, value);
break;
case "open":
OPTIONS.open = true;
break;
case "help":
showMessageAndExit(HELP_TEXT);
break;
case "version":
showMessageAndExit(`v${packageJson.version}`);
break;
default:
console.error(`Error: Unknown parameter '${param}'.`);
process.exit(1);
}
return consumedNext;
}
/**
* Handles a parameter with its corresponding value.
* Assigns the value to the OPTIONS object if it is required.
*
* @param {string} param - The parameter to handle.
* @param {string} value - The value associated with the parameter.
*/
function handleParameter(param, value) {
if (!value) {
console.error(`Error: ${param} parameter requires a value.`);
process.exit(1);
}
OPTIONS[param] = value;
}
/**
* Displays the help text and exits the script with a success status code.
*/
function showMessageAndExit(msg) {
console.log(msg);
process.exit(0);
}
if (process.argv.length > 2) {
processArgument();
}
if (!OPTIONS.path) {
OPTIONS.path = process.cwd();
}
// Set encoding for stdin
process.stdin.setEncoding("utf8");
// Support chunked input
let inputData = "";
// Read data from stdin
process.stdin.on("data", (chunk) => {
inputData += chunk;
});
// Read data from stdin
process.stdin.on("end", () => {
processInput(OPTIONS, inputData);
});