Skip to content
Closed
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 dist/index.js

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions src/__snapshots__/reporter.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`reporter renders Cargo tree failures in vulnerability issues 1`] = `
"
> Test advisory

| Details | |
| ------------------- | ---------------------------------------------- |
| Package | \`rustls-webpki\` |
| Version | \`0.101.7\` |
| URL | [https://example.com/advisory](https://example.com/advisory) |
| Date | 2024-01-01 |

A test advisory description.

## Cargo tree

Could not generate the Cargo tree with \`cargo tree -e features -i rustls-webpki\`: package ID specification did not match any packages

See [advisory page](https://rustsec.org/advisories/RUSTSEC-2024-0001.html) for additional details.
"
`;

exports[`reporter renders Cargo trees in check reports 1`] = `
"
## Vulnerabilities

### [RUSTSEC-2024-0001](https://rustsec.org/advisories/RUSTSEC-2024-0001.html)

> Test advisory

| Details | |
| ------------------- | ---------------------------------------------- |
| Package | \`rustls-webpki\` |
| Version | \`0.101.7\` |
| URL | [https://example.com/advisory](https://example.com/advisory) |
| Date | 2024-01-01 |

A test advisory description.

#### Cargo tree

\`\`\`text
rustls-webpki v0.101.7
├── rustls v0.21.12
│ └── example-app v0.1.0
└── rustls feature "webpki"
└── rustls feature "default"
\`\`\`


"
`;

exports[`reporter renders Cargo trees in vulnerability issues 1`] = `
"
> Test advisory

| Details | |
| ------------------- | ---------------------------------------------- |
| Package | \`rustls-webpki\` |
| Version | \`0.101.7\` |
| URL | [https://example.com/advisory](https://example.com/advisory) |
| Date | 2024-01-01 |

A test advisory description.

## Cargo tree

\`\`\`text
rustls-webpki v0.101.7
├── rustls v0.21.12
│ └── example-app v0.1.0
└── rustls feature "webpki"
└── rustls feature "default"
\`\`\`

See [advisory page](https://rustsec.org/advisories/RUSTSEC-2024-0001.html) for additional details.
"
`;
10 changes: 10 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ export interface VulnerabilitiesInfo {
list: Vulnerability[];
}

export interface DependencyTree {
command: string;
output?: string;
error?: string;
}

export interface DependencyTrees {
[packageName: string]: DependencyTree;
}

export interface Vulnerability {
advisory: Advisory;
package: Package;
Expand Down
266 changes: 266 additions & 0 deletions src/main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
const mockCargoCall = jest.fn();
const mockFindOrInstall = jest.fn();
const mockReportIssues = jest.fn();
const mockReportCheck = jest.fn();
const mockSetFailed = jest.fn();

jest.mock('@actions/core', () => ({
debug: jest.fn(),
endGroup: jest.fn(),
info: jest.fn(),
setFailed: mockSetFailed,
startGroup: jest.fn(),
warning: jest.fn(),
}));

jest.mock('@actions/github', () => ({
context: {
eventName: 'schedule',
repo: {
owner: 'owner',
repo: 'repo',
},
},
}));

jest.mock('@clechasseur/rs-actions-core', () => ({
Cargo: {
get: jest.fn(async () => ({
call: mockCargoCall,
findOrInstall: mockFindOrInstall,
})),
},
}));

jest.mock('./input', () => ({
get: jest.fn(() => ({
ignore: undefined,
token: 'github-token',
workingDirectory: 'crate',
})),
}));

jest.mock('./reporter', () => ({
reportCheck: mockReportCheck,
reportIssues: mockReportIssues,
}));

const vulnerableAuditReport = {
database: {
'advisory-count': 2,
'last-commit': 'abc123',
'last-updated': '2024-01-01',
},
lockfile: {
'dependency-count': 3,
},
vulnerabilities: {
found: true,
count: 2,
list: [
{
advisory: {
id: 'RUSTSEC-2024-0001',
package: 'rustls-webpki',
title: 'First advisory',
description: 'first',
informational: undefined,
url: 'https://example.com/first',
date: '2024-01-01',
},
package: {
name: 'rustls-webpki',
version: '0.101.7',
},
versions: {
patched: [],
unaffected: [],
},
},
{
advisory: {
id: 'RUSTSEC-2024-0002',
package: 'rustls-webpki',
title: 'Second advisory',
description: 'second',
informational: undefined,
url: 'https://example.com/second',
date: '2024-01-02',
},
package: {
name: 'rustls-webpki',
version: '0.101.7',
},
versions: {
patched: [],
unaffected: [],
},
},
],
},
warnings: [],
};

const cleanAuditReport = {
database: {
'advisory-count': 0,
'last-commit': 'abc123',
'last-updated': '2024-01-01',
},
lockfile: {
'dependency-count': 3,
},
vulnerabilities: {
found: false,
count: 0,
list: [],
},
warnings: [],
};

let auditReport = vulnerableAuditReport;
let cargoTreeExitCode = 0;

async function importMain(): Promise<void> {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit fragile, importing main.ts right now has side effects.

Doing something like this:

  if (require.main === module) {
      main();
  }

...might break the runtime usage (IDK js module loading that well) but we could also just split into an inner entrypoint file.

await jest.isolateModulesAsync(async () => {
await import('./main');
});
}

async function waitForExpect(assertion: () => void): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}

throw lastError;
}

describe('main reporting flow', () => {
beforeEach(() => {
mockCargoCall.mockReset();
mockFindOrInstall.mockReset();
mockReportIssues.mockReset();
mockReportCheck.mockReset();
mockSetFailed.mockReset();
auditReport = vulnerableAuditReport;
cargoTreeExitCode = 0;

mockCargoCall.mockImplementation(async (args, options) => {
if (args[0] === 'audit') {
options.listeners.stdout(Buffer.from(JSON.stringify(auditReport)));
return 1;
}

if (args[0] === 'tree') {
if (cargoTreeExitCode !== 0) {
options.listeners.stderr(
Buffer.from(
'package ID specification did not match any packages',
),
);
return cargoTreeExitCode;
}

options.listeners.stdout(
Buffer.from(
`rustls-webpki v0.101.7
├── rustls v0.21.12
└── rustls feature "webpki"
└── rustls feature "default"`,
),
);
return 0;
}

throw new Error(`Unexpected cargo command: ${args.join(' ')}`);
});
});

it('passes one feature-aware Cargo tree per vulnerable package to scheduled issues', async () => {
await importMain();

await waitForExpect(() => {
expect(mockReportIssues).toHaveBeenCalled();
});

const treeCalls = mockCargoCall.mock.calls.filter(
([args]) => args[0] === 'tree',
);
expect(treeCalls).toHaveLength(1);
expect(treeCalls[0][0]).toEqual([
'tree',
'-e',
'features',
'-i',
'rustls-webpki',
]);
expect(treeCalls[0][1]).toEqual(
expect.objectContaining({
cwd: 'crate',
ignoreReturnCode: true,
}),
);

expect(mockReportIssues).toHaveBeenCalledWith(
'github-token',
vulnerableAuditReport.vulnerabilities.list,
[],
{
'rustls-webpki': {
command: 'cargo tree -e features -i rustls-webpki',
output: expect.stringContaining('rustls feature "webpki"'),
},
},
);
expect(mockReportCheck).not.toHaveBeenCalled();
expect(mockSetFailed).not.toHaveBeenCalled();
});

it('passes Cargo tree errors to scheduled issues without failing the action', async () => {
cargoTreeExitCode = 101;

await importMain();

await waitForExpect(() => {
expect(mockReportIssues).toHaveBeenCalled();
});

expect(mockReportIssues).toHaveBeenCalledWith(
'github-token',
vulnerableAuditReport.vulnerabilities.list,
[],
{
'rustls-webpki': {
command: 'cargo tree -e features -i rustls-webpki',
error: 'package ID specification did not match any packages',
},
},
);
expect(mockSetFailed).not.toHaveBeenCalled();
});

it('does not collect Cargo trees when there is nothing to report', async () => {
auditReport = cleanAuditReport;

await importMain();

await waitForExpect(() => {
expect(mockCargoCall).toHaveBeenCalled();
});

const treeCalls = mockCargoCall.mock.calls.filter(
([args]) => args[0] === 'tree',
);
expect(treeCalls).toHaveLength(0);
expect(mockReportIssues).not.toHaveBeenCalled();
expect(mockReportCheck).not.toHaveBeenCalled();
expect(mockSetFailed).not.toHaveBeenCalled();
});
});
Loading
Loading