Skip to content

Commit

Permalink
feat(github-actions): support npm for audit action
Browse files Browse the repository at this point in the history
  • Loading branch information
fpaul-1A committed Oct 17, 2024
1 parent b512051 commit 03e4ceb
Show file tree
Hide file tree
Showing 23 changed files with 1,563 additions and 220 deletions.
2 changes: 1 addition & 1 deletion tools/github-actions/audit/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module.exports = {
'parserOptions': {
'tsconfigRootDir': __dirname,
'project': [
'tsconfig.json',
'tsconfig.build.json',
'tsconfig.eslint.json'
],
'sourceType': 'module'
Expand Down
4 changes: 2 additions & 2 deletions tools/github-actions/audit/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ inputs:
required: false
default: 'true'
recursive:
description: 'Audit transitive dependencies as well'
description: 'Audit transitive dependencies as well (Yarn Berry only)'
required: false
default: 'true'
environment:
description: 'Which environments to cover'
description: 'Which environments to cover ("production" or "development")'
required: false
default: production
outputs:
Expand Down
9 changes: 9 additions & 0 deletions tools/github-actions/audit/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const getJestGlobalConfig = require('../../../jest.config.ut').getJestGlobalConfig;

/** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */
module.exports = {
...getJestGlobalConfig(),
projects: [
'<rootDir>/testing/jest.config.ut.js'
]
};
10 changes: 8 additions & 2 deletions tools/github-actions/audit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
"main": "tmp/main.js",
"scripts": {
"nx": "nx",
"build": "tsc",
"package": "ncc build --source-map --license LICENSE.txt --out packaged-action"
"build": "tsc -b tsconfig.build.json",
"package": "ncc build --source-map --license LICENSE.txt --out packaged-action",
"test": "jest"
},
"keywords": [
"github-actions",
Expand All @@ -32,6 +33,7 @@
"@o3r/eslint-config-otter": "workspace:~",
"@o3r/eslint-plugin": "workspace:~",
"@stylistic/eslint-plugin-ts": "~2.4.0",
"@types/jest": "~29.5.2",
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.14.1",
Expand All @@ -43,7 +45,11 @@
"eslint-plugin-jsdoc": "~48.11.0",
"eslint-plugin-prefer-arrow": "~1.2.3",
"eslint-plugin-unicorn": "^54.0.0",
"jest": "~29.7.0",
"jest-junit": "~16.0.0",
"jsonc-eslint-parser": "~2.4.0",
"ts-jest": "~29.2.0",
"tslib": "^2.6.2",
"typescript": "~5.5.4"
},
"engines": {
Expand Down
299 changes: 223 additions & 76 deletions tools/github-actions/audit/packaged-action/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion tools/github-actions/audit/packaged-action/index.js.map

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions tools/github-actions/audit/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
"tools/github-actions/audit/package.json"
]
}
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "tools/github-actions/audit/jest.config.js"
}
}
},
"tags": []
Expand Down
7 changes: 2 additions & 5 deletions tools/github-actions/audit/readme.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
# Yarn Audit action

## Pre-requisite
This task requires yarn2+ installed.
# Audit action

## Overview
This GitHub action runs the ```yarn npm audit``` with the given command parameters, and generates a markdown report out of the json result.
This GitHub action runs the `npm audit`, or `yarn npm audit` with the given command parameters, and generates a markdown report out of the json result.
The action will fail and throw an error if it finds vulnerabilities of at least the specified input severity.

## Task options
Expand Down
158 changes: 38 additions & 120 deletions tools/github-actions/audit/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,137 +1,55 @@
import * as core from '@actions/core';
import {getExecOutput} from '@actions/exec';
import { getExecOutput } from '@actions/exec';
import type { Severity } from 'audit-types';
import * as fs from 'node:fs';
import * as os from 'node:os';
import type { GitHubAdvisoryId, NPMAuditReportV1, Severity } from 'audit-types';
type Audit = NPMAuditReportV1.Audit;
type Advisory = NPMAuditReportV1.Advisory;

/**
* Severities supported by yarn npm audit from the lowest to the highest criticality
*/
const severities: Severity[] = ['info', 'low', 'moderate', 'high', 'critical'];
const colors = ['', 'green', 'yellow', 'orange', 'red'];

/**
* Interface to describe Yarn 4+ npm audit response.
* It is not yet covered by audit-types
*/
interface Yarn4AuditResponse {
value: string;
children: {
// eslint-disable-next-line @typescript-eslint/naming-convention
ID: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
Issue: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
Severity: Severity;
// eslint-disable-next-line @typescript-eslint/naming-convention
'Vulnerable Versions': string;
// eslint-disable-next-line @typescript-eslint/naming-convention
'Tree Versions': string[];
// eslint-disable-next-line @typescript-eslint/naming-convention
Dependents: string[];
};
}

/**
* Description of an advisory as it will be displayed in the action summary
*/
interface OtterAdvisory {
severity: Severity;
overview: string;
moduleName: string;
}
import * as path from 'node:path';
import {
computeNpmReport, computeYarn1Report,
computeYarn3Report,
computeYarn4Report,
OtterAdvisory,
OtterAuditReport, OtterAuditReporter,
severities
} from './reports';

/**
* Data to build the action report summary
*/
interface OtterAuditReport {
nbVulnerabilities: number;
errors: OtterAdvisory[];
warnings: OtterAdvisory[];
highestSeverityFound?: Severity;
}

/**
* Format the response from yarn 4 npm audit in a common interface that will be used to build the
* report summary
* @param response
* @param severityThreshold
*/
function computeYarn4Report(response: string, severityThreshold: Severity): OtterAuditReport {
core.info('Computing Report for Yarn 4');
const reports = response.split('\n').filter(a => !!a);
const severityThresholdIndex = severities.indexOf(severityThreshold);
return reports.reduce((currentReport, currentVulnerability) => {
const vulnerabilityReport: Yarn4AuditResponse = JSON.parse(currentVulnerability);
const vulnerabilitySeverity = vulnerabilityReport.children.Severity || 'info';
const severityIndex = severities.indexOf(vulnerabilitySeverity);
if (severityIndex >= severityThresholdIndex) {
currentReport.errors.push({
severity: vulnerabilitySeverity,
moduleName: vulnerabilityReport.value,
overview: vulnerabilityReport.children.Issue
});
} else {
currentReport.warnings.push({
severity: vulnerabilitySeverity,
moduleName: vulnerabilityReport.value,
overview: `This issue affects versions ${vulnerabilityReport.children['Vulnerable Versions']}. ${vulnerabilityReport.children.Issue}`
});
}
currentReport.highestSeverityFound = severities.indexOf(currentReport.highestSeverityFound || 'info') <= severities.indexOf(vulnerabilitySeverity) ?
vulnerabilitySeverity : currentReport.highestSeverityFound;
currentReport.nbVulnerabilities += 1;
return currentReport;
}, {nbVulnerabilities: 0, errors: [], warnings: []} as OtterAuditReport);
}

/**
* Format the response from yarn 3 npm audit in a common interface that will be used to build the
* report summary
* @param response
* @param severityThreshold
*/
function computeYarn3Report(response: string, severityThreshold: Severity): OtterAuditReport {
core.info('Computing Report for Yarn 3');
const reportJson = JSON.parse(response) as Audit;
core.debug(response);
const nbVulnerabilities = Object.values(reportJson.metadata.vulnerabilities as { [key: string]: number } || {}).reduce((acc, curr) => acc + curr, 0);
let highestSeverityFound: Severity | undefined;
for (let index = severities.length; index >= 0; index--) {
const severity: Severity = severities[index];
if (reportJson.metadata.vulnerabilities[severity] > 0) {
highestSeverityFound = severity;
break;
}
}
return Object.values(reportJson.advisories as Readonly<Record<GitHubAdvisoryId, Advisory>>)
.reduce<OtterAuditReport>((currentVulnerabilities, advisory: Advisory) => {
core.info(`${severities.indexOf(severityThreshold)} - ${severities.indexOf(advisory.severity)}`);
if (severities.indexOf(severityThreshold) <= severities.indexOf(advisory.severity)) {
currentVulnerabilities.errors.push({severity: advisory.severity, overview: advisory.overview, moduleName: advisory.module_name});
} else {
currentVulnerabilities.warnings.push({severity: advisory.severity, overview: advisory.overview, moduleName: advisory.module_name});
}
return currentVulnerabilities;
}, {errors: [], warnings: [], nbVulnerabilities, highestSeverityFound} as OtterAuditReport);
}
const colors = ['', 'green', 'yellow', 'orange', 'red'];

async function run(): Promise<void> {

try {
const cwd = process.env.GITHUB_WORKSPACE!;
const packageManager = fs.existsSync(path.resolve(cwd, 'yarn.lock')) ? 'yarn' : 'npm';
const severityConfig = core.getInput('severity') as Severity;
const allWorkspaces = core.getInput('allWorkspaces') === 'true';
const recursive = core.getInput('recursive') === 'true';
const environment = core.getInput('environment');
const versionOutput = await getExecOutput('yarn --version', [], {cwd: process.env.GITHUB_WORKSPACE});
const version = Number.parseInt(versionOutput.stdout.split('.')[0], 10);
const command = `yarn npm audit --environment ${environment} ${allWorkspaces ? '--all ' : ''}${recursive ? '--recursive ' : ''}--json`;
let auditReporter: OtterAuditReporter;
let auditCommand: string;
if (packageManager === 'yarn') {
const versionOutput = await getExecOutput('yarn --version', [], {cwd: process.env.GITHUB_WORKSPACE});
const version = Number.parseInt(versionOutput.stdout.split('.')[0], 10);
auditCommand = version <= 1 ?
`yarn audit ${environment === 'production' ? '--groups "dependencies peerDependencies" ' : ''}--json` :
`yarn npm audit --environment ${environment} ${allWorkspaces ? '--all ' : ''}${recursive ? '--recursive ' : ''}--json`;
auditReporter = version >= 4 ? computeYarn4Report : version >= 2 ? computeYarn3Report : computeYarn1Report;
} else {
auditCommand = `npm audit ${allWorkspaces ? '--workspaces --include-root-workspace ' : ''}--json`;
auditReporter = computeNpmReport;
}

const {stdout: report, stderr: err} = await getExecOutput(command, [], {cwd: process.env.GITHUB_WORKSPACE, ignoreReturnCode: true});
const {stdout: report, stderr: err} = await getExecOutput(auditCommand, [], {
cwd,
ignoreReturnCode: true,
env: {
...process.env,
// eslint-disable-next-line @typescript-eslint/naming-convention
NODE_ENV: environment
}
});
core.warning(err);
core.setOutput('reportJSON', report);
const reportData: OtterAuditReport = version >= 4 ? computeYarn4Report(report, severityConfig) : computeYarn3Report(report, severityConfig);
const reportData: OtterAuditReport = auditReporter(report, severityConfig);

if (!reportData.highestSeverityFound) {
core.info('No vulnerability detected.');
Expand Down
57 changes: 57 additions & 0 deletions tools/github-actions/audit/src/reports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { computeNpmReport, computeYarn1Report, computeYarn3Report, computeYarn4Report } from './reports';

jest.mock('@actions/core');

const MOCK_FOLDER = join(__dirname, '..', 'testing', 'mocks');

describe('github-action audit', () => {
describe('using NPM', () => {
it('should compute the npm report in v1', async () => {
const response = await readFile(join(MOCK_FOLDER, 'npm-v1-audit.json'), 'utf8');
const report = computeNpmReport(response, 'high');
expect(report.errors.length).toBe(1);
expect(report.warnings.length).toBe(1);
expect(report.highestSeverityFound).toBe('critical');
expect(report.nbVulnerabilities).toBe(2);
});

it('should compute the npm report in v2', async () => {
const response = await readFile(join(MOCK_FOLDER, 'npm-v2-audit.json'), 'utf8');
const report = computeNpmReport(response, 'high');
expect(report.errors.length).toBe(3);
expect(report.warnings.length).toBe(32);
expect(report.highestSeverityFound).toBe('critical');
expect(report.nbVulnerabilities).toBe(35);
});
});
describe('using Yarn', () => {
it('should compute the yarn report in v1', async () => {
const response = await readFile(join(MOCK_FOLDER, 'yarn-v1-audit.jsonl'), 'utf8');
const report = computeYarn1Report(response, 'high');
expect(report.errors.length).toBe(1);
expect(report.warnings.length).toBe(4);
expect(report.highestSeverityFound).toBe('critical');
expect(report.nbVulnerabilities).toBe(5);
});

it('should compute the yarn report in v3', async () => {
const response = await readFile(join(MOCK_FOLDER, 'yarn-v3-audit.json'), 'utf8');
const report = computeYarn3Report(response, 'high');
expect(report.errors.length).toBe(1);
expect(report.warnings.length).toBe(1);
expect(report.highestSeverityFound).toBe('critical');
expect(report.nbVulnerabilities).toBe(2);
});

it('should compute the yarn report in v4', async () => {
const response = await readFile(join(MOCK_FOLDER, 'yarn-v4-audit.jsonl'), 'utf8');
const report = computeYarn4Report(response, 'high');
expect(report.errors.length).toBe(1);
expect(report.warnings.length).toBe(4);
expect(report.highestSeverityFound).toBe('critical');
expect(report.nbVulnerabilities).toBe(5);
});
});
});
Loading

0 comments on commit 03e4ceb

Please sign in to comment.