diff --git a/HTML_REPORT_0.png b/HTML_REPORT_0.png index 13d5680..fa18b02 100644 Binary files a/HTML_REPORT_0.png and b/HTML_REPORT_0.png differ diff --git a/HTML_REPORT_3_1.png b/HTML_REPORT_3_1.png new file mode 100644 index 0000000..2e375c8 Binary files /dev/null and b/HTML_REPORT_3_1.png differ diff --git a/HTML_REPORT_5.png b/HTML_REPORT_5.png new file mode 100644 index 0000000..c5de2d8 Binary files /dev/null and b/HTML_REPORT_5.png differ diff --git a/HTML_REPORT_6.png b/HTML_REPORT_6.png new file mode 100644 index 0000000..efede9a Binary files /dev/null and b/HTML_REPORT_6.png differ diff --git a/README.md b/README.md index cd08a72..2552eaf 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,14 @@ Or using `npx`: ![HTML_REPORT_3](HTML_REPORT_3.png) +![HTML_REPORT_3_1](HTML_REPORT_3_1.png) + ![HTML_REPORT_4](HTML_REPORT_4.png) +![HTML_REPORT_5](HTML_REPORT_5.png) + +![HTML_REPORT_6](HTML_REPORT_6.png) + --- ## Contributing diff --git a/package.json b/package.json index b3ffcc2..0722140 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-health-meter", - "version": "2.0.0", + "version": "2.1.0", "description": "", "main": "src/index.js", "type": "module", diff --git a/src/commons/AuditUtils.js b/src/commons/AuditUtils.js index e9f30db..72951d4 100644 --- a/src/commons/AuditUtils.js +++ b/src/commons/AuditUtils.js @@ -1,3 +1,5 @@ +import fs from 'fs-extra'; +import path from 'path'; import crypto from 'crypto'; import glob from 'globby'; import unixify from 'unixify'; @@ -59,6 +61,108 @@ const isExcludedFile = (filePath) => ( false ); +/** + * This asynchronous function reads a file and returns its content as a string. + * + * @param {string} filePath - The path to the file to be read. + * @returns {Promise} A promise that resolves to a string containing the file content, or null if an error occurs. + * + * @example + * const content = await getAuditedFileContent('/path/to/file'); + * print(content); // Logs the content of the file. + */ +const getFileContent = async (filePath) => { + try { + const fileStreamReader = fs.createReadStream(filePath); + + const chunks = []; + + for await (const chunk of fileStreamReader) { + chunks.push(Buffer.from(chunk)); + } + + return Buffer.concat(chunks)?.toString('utf-8')?.trim(); + } catch (e) { + return null; + } +}; + +/** + * This asynchronous function checks if a file contains a specific anti-pattern. + * + * @param {string} antiPattern - The anti-pattern to check for in the file. + * @param {string} source - The file to check for the anti-pattern. + * @returns {Promise} A promise that resolves to a boolean indicating whether the file contains the anti-pattern. + * If an error occurs, it logs the error message and returns false. + * + * @example + * const result = await isNonCompliantFile('anti-pattern', '/path/to/file'); + * print(result); // Logs true if the file contains the anti-pattern, false otherwise. + */ +const isNonCompliantFile = async (antiPattern, source) => { + try { + if (!antiPattern?.length || !source?.length) { + return false; + } + + return source?.includes(antiPattern); + } catch (error) { + AppLogger.info(`[AuditUtils - isNonCompliantFile] error: ${error.message}`); + return false; + } +}; + +/** + * Finds the common base path among a list of files. + * @param {string[]} files - The list of file paths. + * @returns {string} - Returns the common base path. + */ +function findCommonBase(files) { + AppLogger.info(`[AuditUtils - findCommonBase] files: ${files?.length}`); + + if (!files + || files.length === 0 + || files.length === 1) { + return ''; + } + + const lastSlash = files[0].lastIndexOf(path.sep); + AppLogger.info(`[AuditUtils - findCommonBase] lastSlash: ${lastSlash}`); + + if (!lastSlash) { + return ''; + } + + const first = files[0].substr(0, lastSlash + 1); + AppLogger.info(`[AuditUtils - findCommonBase] first: ${first}`); + + let prefixlen = first.length; + AppLogger.info(`[AuditUtils - findCommonBase] prefixlen: ${prefixlen}`); + + /** + * Handles the prefixing of a file. + * @param {string} file - The file to handle. + */ + function handleFilePrefixing(file) { + + AppLogger.info(`[AuditUtils - findCommonBase] file: ${file}`); + + for (let i = prefixlen; i > 0; i--) { + if (file.substr(0, i) === first.substr(0, i)) { + prefixlen = i; + return; + } + } + prefixlen = 0; + } + + files.forEach(handleFilePrefixing); + + AppLogger.info(`[AuditUtils - findCommonBase] prefixlen: ${prefixlen}`); + + return first.substr(0, prefixlen); +} + /** * Converts a pattern to a file. * @param {string} pattern - The pattern to convert. @@ -67,10 +171,17 @@ const isExcludedFile = (filePath) => ( const patternToFile = (pattern) => glob.sync(unixify(pattern)); /** - * Retrieves all files from a specified directory. + * This function retrieves files from a given source directory. + * + * @param {string} srcDir - The source directory from which to retrieve files. + * @returns {Object} An object containing an array of files and the base path. + * If an error occurs, it returns an object with an empty files array and null basePath. + * @throws Will log the error message if one occurs. * - * @param {string} srcDir - The source directory to retrieve files from. - * @returns {Array|null} An array of files from the source directory, or null if the source directory is not specified or an error occurs. + * @example + * const result = getFiles('/path/to/source/directory'); + * print(result.files); // Logs the array of files. + * print(result.basePath); // Logs the base path. */ const getFiles = (srcDir) => { try{ @@ -90,11 +201,88 @@ const getFiles = (srcDir) => { AppLogger.info(`[AuditUtils - parseFiles] files: ${files?.length}`); - return files; + const basePath = findCommonBase(files); + + return ({ + files, + basePath + }); }catch (error) { AppLogger.info(`[AuditUtils - parseFiles] error: ${error.message}`); + return ({ + files: [], + basePath: null + }); + } +}; + +/** + * Parses a file and returns relevant information. + * + * @param {string} file - The path to the file. + * @param {string} basePath - The base path for all files. + * @param {Object} options - The options for parsing. + * @param {RegExp} [options.exclude] - A regular expression for files to exclude. + * @param {boolean} [options.noempty] - Whether to skip empty lines. + * @returns {Object|null} An object containing the file information, or null if the file is excluded or not a JavaScript/TypeScript file. + */ +const parseFile = (file, basePath, options) => { + AppLogger.info(`[AuditUtils - parseFile] file: ${file}`); + AppLogger.info(`[AuditUtils - parseFile] basePath: ${basePath}`); + AppLogger.info(`[AuditUtils - parseFile] options: ${options}`); + + const mockPattern = /.*?(Mock).(js|jsx|ts|tsx)$/ig; + const testPattern = /.*?(Test).(js|jsx|ts|tsx)$/ig; + const nodeModulesPattern = /node_modules/g; + const targetModulesPattern = /target/g; + + if (file && ( + (options.exclude && file.match(options.exclude)) || + file.match(targetModulesPattern) || + file.match(mockPattern) || + file.match(testPattern) || + file.match(nodeModulesPattern) + )) { + AppLogger.info(`[AuditUtils - parseFile] excluded file: ${file}`); + return null; + } + + if (!file.match(/\.(js|jsx|ts|tsx)$/)) { + return null; + } + + AppLogger.info(`[AuditUtils - parseFile] matched file: ${file}`); + + const fileShort = file.replace(basePath, ''); + const fileSafe = fileShort.replace(/[^a-zA-Z0-9]/g, '_'); + + AppLogger.info(`[AuditUtils - parseFile] fileShort: ${fileShort}`); + AppLogger.info(`[AuditUtils - parseFile] fileSafe: ${fileSafe}`); + + let source = fs.readFileSync(file).toString(); + const trimmedSource = source.trim(); + + if (!trimmedSource) { return null; } + + // if skip empty line option + if (options.noempty) { + source = source.replace(/^\s*[\r\n]/gm, ''); + } + + // if begins with shebang + if (source[0] === '#' && source[1] === '!') { + source = `//${source}`; + } + + return ({ + file, + fileSafe, + fileShort, + source, + options, + }); }; /** @@ -117,6 +305,9 @@ const AuditUtils = { isExcludedFile, getFiles, generateHash, + parseFile, + getFileContent, + isNonCompliantFile, }; export default AuditUtils; diff --git a/src/index.js b/src/index.js index 20b5172..deaef1a 100755 --- a/src/index.js +++ b/src/index.js @@ -7,8 +7,10 @@ import AppLogger from './commons/AppLogger.js'; import CodeComplexityAuditor from './kernel/complexity/CodeComplexityAuditor.js'; import CodeCouplingAuditor from './kernel/coupling/CodeCouplingAuditor.js'; import CodeDuplicationAuditor from './kernel/duplication/CodeDuplicationAuditor.js'; +import CodeSecurityAuditor from './kernel/security/CodeSecurityAuditor.js'; import CodeComplexityUtils from './kernel/complexity/CodeComplexityUtils.js'; import CodeCouplingUtils from './kernel/coupling/CodeCouplingUtils.js'; +import CodeSecurityUtils from './kernel/security/CodeSecurityUtils.js'; /** * Parses command line arguments. @@ -46,6 +48,8 @@ if(!srcDir || !outputDir){ process.exit(-1); } +AppLogger.info('***** Code audit start *****'); + /** * Cleaning workspace */ @@ -112,3 +116,23 @@ CodeDuplicationAuditor.startAudit( `${outputDir}/code-duplication-audit`, format ); + +/** + * Starts the code security audit. + * @type {Object} + */ +const codeSecurityAnalysisResult = await CodeSecurityAuditor.startAudit(srcDir, {}); + +/** + * Writes the audit result to files. + */ +CodeSecurityUtils + .writeCodeSecurityAuditToFile({ + codeSecurityOptions: { + outputDir: `${outputDir}/code-security-audit`, + fileFormat: format, // html or json + }, + codeSecurityAnalysisResult, + }); + +AppLogger.info('***** Code audit finished successfully *****'); diff --git a/src/kernel/complexity/CodeComplexityConfig.js b/src/kernel/complexity/CodeComplexityConfig.js index 14f4a0d..d9312c0 100644 --- a/src/kernel/complexity/CodeComplexityConfig.js +++ b/src/kernel/complexity/CodeComplexityConfig.js @@ -270,20 +270,29 @@ const formatCodeComplexityHtmlReport = (summary, helpMessages, reportsByFile) => + + + +

Code Security Analysis

+ + +
+
+ + + + + ${tableHeaders} + + + + ${tableRows} + +
Analysis Details
+
+
+ + + + `; +}; + +const CodeSecurityConfig = { + SECURITY_ANTI_PATTERNS_TOKENS, + formatSecurityAuditReport, + formatCodeSecurityHtmlReport, +}; + +export default CodeSecurityConfig; \ No newline at end of file diff --git a/src/kernel/security/CodeSecurityUtils.js b/src/kernel/security/CodeSecurityUtils.js new file mode 100644 index 0000000..e42d131 --- /dev/null +++ b/src/kernel/security/CodeSecurityUtils.js @@ -0,0 +1,200 @@ +import path from 'path'; +import fs from 'fs-extra'; +import AppLogger from '../../commons/AppLogger.js'; +import AuditUtils from '../../commons/AuditUtils.js'; +import CodeSecurityConfig from './CodeSecurityConfig.js'; + +const { + getFiles, + parseFile, + isNonCompliantFile, +} = AuditUtils; + +const { + SECURITY_ANTI_PATTERNS_TOKENS, + formatSecurityAuditReport, + formatCodeSecurityHtmlReport, +} = CodeSecurityConfig; + +/** + * Inspects the source directory. + * @param {Object} params - The parameters for the inspection. + * @returns {Promise<*[]>} - Returns an object containing the overview report. + */ +const inspectDirectory = async ({ + srcDir, + options, +}) => { + try { + AppLogger.info(`[CodeSecurityUtils - inspectDirectory] srcDir: ${srcDir}`); + + const { + files, + basePath + } = getFiles(srcDir); + AppLogger.info(`[CodeSecurityUtils - inspectDirectory] files: ${files?.length}`); + AppLogger.info(`[CodeSecurityUtils - inspectDirectory] basePath: ${basePath}`); + + if(!files?.length){ + return []; + } + + const securityAuditReports = []; + + for (const file of files) { + const report = parseFile(file, basePath, options); + if(!report){ + continue; + } + + const { + source, + } = report; + AppLogger.info(`[CodeSecurityUtils - inspectDirectory] source: ${source?.length}`); + if(!source?.length){ + continue; + } + + for (const antiPattern of SECURITY_ANTI_PATTERNS_TOKENS) { + const nonCompliantStatus = await isNonCompliantFile(antiPattern, source); + AppLogger.info(`[CodeSecurityUtils - inspectDirectory] nonCompliantStatus: ${nonCompliantStatus}`); + + if (nonCompliantStatus !== true) { + continue; + } + + const securityAuditReport = formatSecurityAuditReport({ + fileName: file, + antiPattern, + }); + + AppLogger.info(`[CodeSecurityUtils - inspectDirectory] securityAuditReport: ${Object.keys(securityAuditReport || {}).join(',')}`); + if (!securityAuditReport) { + continue; + } + + securityAuditReports.push(securityAuditReport); + } + } + + return securityAuditReports; + } catch (error) { + return []; + } +}; + +/** + * Groups code Security reports by file. + * @param {Array} reports - The reports to group. + * @returns {Object} - Returns an object with the reports grouped by file. + */ +const groupCodeSecurityReportsByFile = (reports) => reports?.reduce((acc, report) => ({ + ...acc, + [report.file]: [ + ...(acc[report.file] || []), + report, + ], +}), {}); + +/** + * Formats the audit reports. + * @param {Array} auditReports - The audit reports to format. + * @param {string} fileFormat - The format of the file. + * @returns {string} - Returns a string with the formatted reports. + */ +const formatCodeSecurityAuditReports = ({ + auditReports, + fileFormat, +}) => { + const reportsByFile = groupCodeSecurityReportsByFile(auditReports); + + if(fileFormat === 'json'){ + return JSON.stringify({ + reports: reportsByFile, + }, null, 2); + } + + if(fileFormat === 'html'){ + return formatCodeSecurityHtmlReport(reportsByFile); + } + + return ''; +}; + +/** + * This function writes the results of a code security audit to a file. + * + * @param {Object} codeSecurityOptions - The options for the code security audit. + * @param {string} codeSecurityOptions.outputDir - The directory where the output file will be written. + * @param {string} codeSecurityOptions.fileFormat - The format of the output file. + * @param {Array} codeSecurityAnalysisResult - The results of the code security audit. + * @returns {boolean} Returns true if the file was successfully written, false otherwise. + * @throws Will log the error message if an error occurs. + * + * @example + * const result = writeCodeSecurityAuditToFile({ + * codeSecurityOptions: { + * outputDir: '/path/to/output/directory', + * fileFormat: 'json' + * }, + * codeSecurityAnalysisResult: [...] + * }); + * console.log(result); // Logs true if the file was successfully written, false otherwise. + */ +const writeCodeSecurityAuditToFile = ({ + codeSecurityOptions, + codeSecurityAnalysisResult, +}) => { + try{ + const { + outputDir, + fileFormat, + } = codeSecurityOptions || {}; + + AppLogger.info(`[CodeSecurityUtils - writeCodeSecurityAuditToFile] outputDir: ${outputDir}`); + AppLogger.info(`[CodeSecurityUtils - writeCodeSecurityAuditToFile] fileFormat: ${fileFormat}`); + + if(!outputDir?.length){ + return false; + } + + const codeSecurityAuditOutputFileName = `CodeSecurityReport.${fileFormat || 'json'}`; + AppLogger.info(`[CodeSecurityUtils - writeCodeSecurityAuditToFile] codeSecurityAuditOutputFileName: ${codeSecurityAuditOutputFileName}`); + + const codeSecurityAuditOutputFile = path.join(outputDir, codeSecurityAuditOutputFileName); + AppLogger.info(`[CodeSecurityUtils - writeCodeSecurityAuditToFile] codeSecurityAuditOutputFile: ${codeSecurityAuditOutputFile}`); + + if(fs.existsSync(codeSecurityAuditOutputFile)){ + fs.rmSync(codeSecurityAuditOutputFile); + } else { + fs.mkdirSync(outputDir, { + recursive: true + }); + } + + const formattedCodeSecurityAuditReports = formatCodeSecurityAuditReports({ + auditReports: codeSecurityAnalysisResult, + fileFormat, + }); + + AppLogger.info(`[CodeSecurityUtils - writeCodeSecurityAuditToFile] formattedCodeSecurityAuditReports: ${formattedCodeSecurityAuditReports?.length}`); + + if(!formattedCodeSecurityAuditReports?.length){ + return false; + } + + fs.writeFileSync(codeSecurityAuditOutputFile, formattedCodeSecurityAuditReports); + + return true; + } catch (error) { + AppLogger.info(`[CodeSecurityUtils - writeCodeSecurityAuditToFile] error: ${error.message}`); + return false; + } +}; + +const CodeSecurityUtils = { + inspectDirectory, + writeCodeSecurityAuditToFile, +}; + +export default CodeSecurityUtils; \ No newline at end of file