Skip to content

Commit f12775c

Browse files
authored
feat: allow users to save purls in csv format (#102)
* feat: allow purls to be outputted in csv format * fix: ensure only sbom flags are passed to command Previously, all flags from the 'parent' commands were getting passed to the sbom commands. This threw an error if the parent command, e.g. 'report purls', has a command that doesn't exist in the sbom command. * feat: improve csv output formatting * chore: add purls.svc tests
1 parent 7720b98 commit f12775c

File tree

11 files changed

+134
-32
lines changed

11 files changed

+134
-32
lines changed

.gitignore

+4-3
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
node_modules
88
oclif.manifest.json
99

10-
11-
1210
yarn.lock
1311
pnpm-lock.yaml
1412

15-
**/tsconfig.tsbuildinfo
13+
**/tsconfig.tsbuildinfo
14+
15+
# cli-generated files
16+
nes.**.**

README.md

+8-7
Original file line numberDiff line numberDiff line change
@@ -383,15 +383,14 @@ Generate a list of purls from a sbom
383383

384384
```
385385
USAGE
386-
$ hd report purls [--json] [-f <value>] [-d <value>] [-s]
386+
$ hd report purls [-f <value>] [-d <value>] [-s] [-o json|csv]
387387
388388
FLAGS
389-
-d, --dir=<value> The directory to scan in order to create a cyclonedx sbom
390-
-f, --file=<value> The file path of an existing cyclonedx sbom to scan for EOL
391-
-s, --save Save the list of purls as nes.purls.json
392-
393-
GLOBAL FLAGS
394-
--json Format output as json.
389+
-d, --dir=<value> The directory to scan in order to create a cyclonedx sbom
390+
-f, --file=<value> The file path of an existing cyclonedx sbom to scan for EOL
391+
-o, --output=<option> [default: json] The output format of the list of purls
392+
<options: json|csv>
393+
-s, --save Save the list of purls as nes.purls.<output>
395394
396395
DESCRIPTION
397396
Generate a list of purls from a sbom
@@ -402,6 +401,8 @@ EXAMPLES
402401
$ hd report purls --file=path/to/sbom.json
403402
404403
$ hd report purls --dir=./my-project --save
404+
405+
$ hd report purls --dir=./my-project --save --output=csv
405406
```
406407

407408
_See code: [src/commands/report/purls.ts](https://github.com/herodevs/cli/blob/v2.0.0/src/commands/report/purls.ts)_

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"build": "shx rm -rf dist && tsc -b",
1212
"ci": "biome ci",
1313
"ci:fix": "biome check --write",
14+
"clean": "shx rm -rf dist && npm run clean:files && shx rm -rf node_modules",
15+
"clean:files": "shx rm -f nes.purls.csv nes.purls.json nes.sbom.json",
1416
"dev": "npm run build && ./bin/dev.js",
1517
"format": "biome format --write",
1618
"lint": "biome lint --write",

src/commands/report/purls.ts

+18-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import fs from 'node:fs';
22
import path from 'node:path';
33
import { Command, Flags, ux } from '@oclif/core';
44

5-
import { type Sbom, extractPurls } from '../../service/eol/eol.svc.ts';
5+
import type { Sbom } from '../../service/eol/eol.svc.ts';
6+
import { extractPurls, getPurlOutput } from '../../service/purls.svc.ts';
67
import SbomScan from '../scan/sbom.ts';
78

89
export default class ReportPurls extends Command {
@@ -12,6 +13,7 @@ export default class ReportPurls extends Command {
1213
'<%= config.bin %> <%= command.id %> --dir=./my-project',
1314
'<%= config.bin %> <%= command.id %> --file=path/to/sbom.json',
1415
'<%= config.bin %> <%= command.id %> --dir=./my-project --save',
16+
'<%= config.bin %> <%= command.id %> --save --output=csv',
1517
];
1618
static override flags = {
1719
file: Flags.string({
@@ -25,16 +27,23 @@ export default class ReportPurls extends Command {
2527
save: Flags.boolean({
2628
char: 's',
2729
default: false,
28-
description: 'Save the list of purls as nes.purls.json',
30+
description: 'Save the list of purls as nes.purls.<output>',
31+
}),
32+
output: Flags.string({
33+
char: 'o',
34+
options: ['json', 'csv'],
35+
default: 'json',
36+
description: 'The format of the saved file (when using --save)',
2937
}),
3038
};
3139

3240
public async run(): Promise<string[]> {
3341
const { flags } = await this.parse(ReportPurls);
34-
const { dir: _dirFlag, file: _fileFlag, save } = flags;
42+
const { dir: _dirFlag, file: _fileFlag, save, output } = flags;
3543

36-
// Load the SBOM
37-
const sbomCommand = new SbomScan(this.argv, this.config);
44+
// Load the SBOM: Only pass the file, dir, and save flags to SbomScan
45+
const sbomArgs = SbomScan.getSbomArgs(flags);
46+
const sbomCommand = new SbomScan(sbomArgs, this.config);
3847
const sbom: Sbom = await sbomCommand.run();
3948

4049
// Extract purls from SBOM
@@ -50,9 +59,11 @@ export default class ReportPurls extends Command {
5059

5160
// Save if requested
5261
if (save) {
53-
const outputPath = path.join(_dirFlag || process.cwd(), 'nes.purls.json');
5462
try {
55-
fs.writeFileSync(outputPath, JSON.stringify(purls, null, 2));
63+
const outputPath = path.join(_dirFlag || process.cwd(), `nes.purls.${output}`);
64+
const purlOutput = getPurlOutput(purls, output);
65+
fs.writeFileSync(outputPath, purlOutput);
66+
5667
this.log(`\nPurls saved to ${outputPath}`);
5768
} catch (error: unknown) {
5869
const errorMessage = error && typeof error === 'object' && 'message' in error ? error.message : 'Unknown error';

src/commands/scan/eol.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ export default class ScanEol extends Command {
3232
const { flags } = await this.parse(ScanEol);
3333
const { dir: _dirFlag, file: _fileFlag } = flags;
3434

35-
// Load the SBOM
36-
const sbomCommand = new SbomScan(this.argv, this.config);
35+
// Load the SBOM: Only pass the file, dir, and save flags to SbomScan
36+
const sbomArgs = SbomScan.getSbomArgs(flags);
37+
const sbomCommand = new SbomScan(sbomArgs, this.config);
3738
const sbom: Sbom = await sbomCommand.run();
3839

3940
// Scan the SBOM for EOL information

src/commands/scan/sbom.ts

+12
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ export default class ScanSbom extends Command {
2727
}),
2828
};
2929

30+
static getSbomArgs(flags: Record<string, string>): string[] {
31+
const { dir, file, save } = flags ?? {};
32+
33+
const sbomArgs = [];
34+
35+
if (file) sbomArgs.push('--file', file);
36+
if (dir) sbomArgs.push('--dir', dir);
37+
if (save) sbomArgs.push('--save');
38+
39+
return sbomArgs;
40+
}
41+
3042
getScanOptions() {
3143
// intentionally provided for mocking
3244
return {};

src/service/eol/eol.svc.ts

+1-8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getDaysEolFromEolAt, getStatusFromComponent } from '../line.ts';
33
import type { Line } from '../line.ts';
44
import type { ComponentStatus, ScanResult } from '../nes/modules/sbom.ts';
55
import { NesApolloClient } from '../nes/nes.client.ts';
6+
import { extractPurls } from '../purls.svc.ts';
67
import { createBomFromDir } from './cdx.svc.ts';
78
import type { Sbom, ScanOptions } from './eol.types.ts';
89

@@ -43,14 +44,6 @@ export async function scanForEol(sbom: Sbom) {
4344
return { purls, scan };
4445
}
4546

46-
/**
47-
* Translate an SBOM to a list of purls for api request.
48-
*/
49-
export async function extractPurls(sbom: Sbom): Promise<string[]> {
50-
const { components: comps } = sbom;
51-
return comps.map((c) => c.purl) ?? [];
52-
}
53-
5447
/**
5548
* Uses the purls from the sbom to run the scan.
5649
*/

src/service/purls.svc.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Sbom } from './eol/eol.types.ts';
2+
3+
/**
4+
* Formats a value for CSV output by wrapping it in quotes if it contains commas.
5+
* This ensures that values containing commas aren't split into multiple columns
6+
* when the CSV is opened in a spreadsheet application.
7+
*/
8+
export function formatCsvValue(value: string): string {
9+
// If the value contains a comma, wrap it in quotes to preserve it as a single cell
10+
return value.includes(',') ? `"${value}"` : value;
11+
}
12+
13+
/**
14+
* Converts an array of PURLs into either CSV or JSON format.
15+
* For CSV output, adds a header row with "purl" and formats values to preserve commas.
16+
* For JSON output, returns a properly indented JSON string.
17+
*/
18+
export function getPurlOutput(purls: string[], output: string): string {
19+
switch (output) {
20+
case 'csv':
21+
return ['purl', ...purls].map(formatCsvValue).join('\n');
22+
default:
23+
return JSON.stringify(purls, null, 2);
24+
}
25+
}
26+
27+
/**
28+
* Translate an SBOM to a list of purls for api request.
29+
*/
30+
export async function extractPurls(sbom: Sbom): Promise<string[]> {
31+
const { components: comps } = sbom;
32+
return comps.map((c) => c.purl) ?? [];
33+
}

test/commands/scan/eol.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { afterEach, beforeEach, describe, it } from 'node:test';
55
import { runCommand } from '@oclif/test';
66
import * as sinon from 'sinon';
77

8-
import { default as EolScan } from '../../../src/commands/scan/eol.ts';
98
import { default as SbomScan } from '../../../src/commands/scan/sbom.ts';
10-
import { type Sbom, cdxgen, extractPurls, prepareRows } from '../../../src/service/eol/eol.svc.ts';
9+
import { type Sbom, cdxgen, prepareRows } from '../../../src/service/eol/eol.svc.ts';
1110
import type { CdxCreator } from '../../../src/service/eol/eol.types.ts';
1211
import { type ScanResponseReport, type ScanResult, buildScanResult } from '../../../src/service/nes/modules/sbom.ts';
12+
import { extractPurls } from '../../../src/service/purls.svc.ts';
1313
import { FetchMock } from '../../utils/mocks/fetch.mock.ts';
1414
import { InquirerMock } from '../../utils/mocks/ui.mock.ts';
1515

test/service/line.test.ts

-3
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ describe('line', () => {
108108
status: 'EOL',
109109
daysEol: 30,
110110
info: { isEol: true, eolAt: new Date() },
111-
evidence: '',
112111
};
113112
const result = formatLine(line, 0, context);
114113
assert(result.name.includes('['));
@@ -123,7 +122,6 @@ describe('line', () => {
123122
status: 'OK',
124123
daysEol: null,
125124
info: { isEol: false, eolAt: null },
126-
evidence: '',
127125
};
128126
const result = formatLine(line, 0, context);
129127
assert(result.name.includes('OK'));
@@ -136,7 +134,6 @@ describe('line', () => {
136134
status: 'LTS',
137135
daysEol: 30,
138136
info: { isEol: false, eolAt: new Date() },
139-
evidence: '',
140137
};
141138
const result = formatLine(line, 0, context);
142139
assert(result.name.includes('LTS'));

test/service/purls.svc.test.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import assert from 'node:assert';
2+
import { describe, it } from 'node:test';
3+
import { formatCsvValue, getPurlOutput } from '../../src/service/purls.svc.ts';
4+
5+
describe('getPurlOutput', () => {
6+
describe('json output', () => {
7+
it('should format purls as JSON with proper indentation', () => {
8+
const purls = ['pkg:npm/[email protected]', 'pkg:npm/[email protected]'];
9+
const result = getPurlOutput(purls, 'json');
10+
const expected = JSON.stringify(purls, null, 2);
11+
assert.strictEqual(result, expected);
12+
});
13+
14+
it('should handle empty array', () => {
15+
const purls: string[] = [];
16+
const result = getPurlOutput(purls, 'json');
17+
assert.strictEqual(result, '[]');
18+
});
19+
});
20+
21+
describe('csv output', () => {
22+
it('should format purls with header', () => {
23+
const purls = ['pkg:npm/[email protected]', 'pkg:npm/[email protected]'];
24+
const result = getPurlOutput(purls, 'csv');
25+
const expected = 'purl\npkg:npm/[email protected]\npkg:npm/[email protected]';
26+
assert.strictEqual(result, expected);
27+
});
28+
29+
it('should handle empty array', () => {
30+
const purls: string[] = [];
31+
const result = getPurlOutput(purls, 'csv');
32+
assert.strictEqual(result, 'purl');
33+
});
34+
});
35+
});
36+
37+
describe('formatCsvValue', () => {
38+
it('should return value unchanged when no commas present', () => {
39+
const value = 'pkg:npm/[email protected]';
40+
assert.strictEqual(formatCsvValue(value), value);
41+
});
42+
43+
it('should wrap value in quotes when comma present', () => {
44+
const value = 'pkg:npm/[email protected],beta';
45+
assert.strictEqual(formatCsvValue(value), '"pkg:npm/[email protected],beta"');
46+
});
47+
48+
it('should handle empty string', () => {
49+
assert.strictEqual(formatCsvValue(''), '');
50+
});
51+
});

0 commit comments

Comments
 (0)