Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(github-actions): support npm for audit action #2297

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")'
vscaiceanu-1a marked this conversation as resolved.
Show resolved Hide resolved
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'
]
};
13 changes: 10 additions & 3 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 @@ -16,7 +17,8 @@
],
"dependencies": {
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1"
"@actions/exec": "^1.1.1",
"tslib": "^2.6.2"
},
"peerDependencies": {
"audit-types": "~0.6.0"
Expand All @@ -32,6 +34,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 +46,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
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;
vscaiceanu-1a marked this conversation as resolved.
Show resolved Hide resolved
} 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
Loading