Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pnpm-lock.yaml
# Various CLI Output Files
herodevs.report.json
herodevs.sbom.json
herodevs.openvex.json
bom.json
sbom.json
cdx.json
Expand Down
73 changes: 70 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ $ npm install -g @herodevs/cli
$ hd COMMAND
running command...
$ hd (--version|-v)
@herodevs/cli/2.0.6 darwin-arm64 node-v24.14.1
@herodevs/cli/2.0.6 darwin-arm64 node-v24.15.0
$ hd --help [COMMAND]
USAGE
$ hd COMMAND
Expand All @@ -102,6 +102,7 @@ USAGE
* [`hd tracker init`](#hd-tracker-init)
* [`hd tracker run`](#hd-tracker-run)
* [`hd update [CHANNEL]`](#hd-update-channel)
* [`hd vex`](#hd-vex)

### `hd auth login`

Expand Down Expand Up @@ -177,10 +178,10 @@ USAGE
FLAGS
-c, --csv Output in CSV format
-d, --directory=<value> Directory to search
-e, --afterDate=<value> [default: 2025-04-23] Start date (format: yyyy-MM-dd)
-e, --afterDate=<value> [default: 2025-05-05] Start date (format: yyyy-MM-dd)
-m, --months=<value> [default: 12] The number of months of git history to review. Cannot be used along beforeDate
and afterDate
-s, --beforeDate=<value> [default: 2026-04-23] End date (format: yyyy-MM-dd)
-s, --beforeDate=<value> [default: 2026-05-05] End date (format: yyyy-MM-dd)
-s, --save Save the committers report as herodevs.committers.<output>
-x, --exclude=<value>... Path Exclusions (eg -x="./src/bin" -x="./dist")
--json Output to JSON format
Expand Down Expand Up @@ -348,6 +349,72 @@ EXAMPLES
```

_See code: [@oclif/plugin-update](https://github.com/oclif/plugin-update/blob/4.7.32/src/commands/update.ts)_

### `hd vex`

Download and filter the HeroDevs VEX statement

```
USAGE
$ hd vex [--json] [-f <value>] [-p <value>...] [-v <value>...] [--status
affected|not_affected|fixed|under_investigation...] [-e <value>...] [-s] [-o <value>]

FLAGS
-e, --exclude-package=<value>... Glob pattern matched against product PURLs to exclude (repeatable, e.g.
--exclude-package "pkg:npm/lodash*"). Removes statements where any product matches.
-f, --file=<value> Path to a CycloneDX or SPDX 2.3 SBOM; filters VEX entries to packages present in the
SBOM
-o, --output=<value> Save VEX statement to a custom path (defaults to herodevs.openvex.json when not
provided)
-p, --package=<value>... Glob pattern matched against product PURLs (repeatable, e.g. --package
"pkg:npm/lodash*"). Keeps statements where any product matches.
-s, --save Save VEX statement to herodevs.openvex.json in the current directory
-v, --vuln=<value>... Glob pattern matched against vulnerability IDs (repeatable, e.g. --vuln
"CVE-2021-*")
--status=<option>... Filter by VEX analysis status (repeatable)
<options: affected|not_affected|fixed|under_investigation>

GLOBAL FLAGS
--json Format output as json.

DESCRIPTION
Download and filter the HeroDevs VEX statement

EXAMPLES
Download the full HeroDevs VEX statement

$ hd vex

Filter to packages present in an SBOM

$ hd vex --file /path/to/sbom.json

Filter by vulnerability ID pattern

$ hd vex --vuln "CVE-2021-*"

Filter by package PURL pattern

$ hd vex --package "pkg:npm/express*"

Filter by status

$ hd vex --status not_affected

Save filtered VEX to a file

$ hd vex --file sbom.json --save

Exclude packages matching a PURL pattern

$ hd vex --exclude-package "pkg:npm/lodash*"

Combine multiple filters (AND logic)

$ hd vex --file sbom.json --vuln "CVE-*" --status affected
```

_See code: [src/commands/vex/index.ts](https://github.com/herodevs/cli/blob/v2.0.6/src/commands/vex/index.ts)_
<!-- commandsstop -->

## CI/CD Usage
Expand Down
164 changes: 164 additions & 0 deletions src/commands/vex/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Command, Flags } from '@oclif/core';
import { filenamePrefix } from '../../config/constants.ts';
import { track } from '../../service/analytics.svc.ts';
import { readSbomFromFile, saveArtifactToFile } from '../../service/file.svc.ts';
import { getErrorMessage } from '../../service/log.svc.ts';
import { applyVexFilters, fetchVexStatement, type OpenVexDocument } from '../../service/vex.svc.ts';

export default class Vex extends Command {
static override description = 'Download and filter the HeroDevs VEX statement';
static enableJsonFlag = true;
static override examples = [
{
description: 'Download the full HeroDevs VEX statement',
command: '<%= config.bin %> <%= command.id %>',
},
{
description: 'Filter to packages present in an SBOM',
command: '<%= config.bin %> <%= command.id %> --file /path/to/sbom.json',
},
{
description: 'Filter by vulnerability ID pattern',
command: '<%= config.bin %> <%= command.id %> --vuln "CVE-2021-*"',
},
{
description: 'Filter by package PURL pattern',
command: '<%= config.bin %> <%= command.id %> --package "pkg:npm/express*"',
},
{
description: 'Filter by status',
command: '<%= config.bin %> <%= command.id %> --status not_affected',
},
{
description: 'Save filtered VEX to a file',
command: '<%= config.bin %> <%= command.id %> --file sbom.json --save',
},
{
description: 'Exclude packages matching a PURL pattern',
command: '<%= config.bin %> <%= command.id %> --exclude-package "pkg:npm/lodash*"',
},
{
description: 'Combine multiple filters (AND logic)',
command: '<%= config.bin %> <%= command.id %> --file sbom.json --vuln "CVE-*" --status affected',
},
];

static override flags = {
file: Flags.string({
char: 'f',
description: 'Path to a CycloneDX or SPDX 2.3 SBOM; filters VEX entries to packages present in the SBOM',
}),
package: Flags.string({
char: 'p',
description:
'Glob pattern matched against product PURLs (repeatable, e.g. --package "pkg:npm/lodash*"). Keeps statements where any product matches.',
multiple: true,
}),
vuln: Flags.string({
char: 'v',
description: 'Glob pattern matched against vulnerability IDs (repeatable, e.g. --vuln "CVE-2021-*")',
multiple: true,
}),
status: Flags.string({
description: 'Filter by VEX analysis status (repeatable)',
multiple: true,
options: ['affected', 'not_affected', 'fixed', 'under_investigation'],
}),
'exclude-package': Flags.string({
char: 'e',
description:
'Glob pattern matched against product PURLs to exclude (repeatable, e.g. --exclude-package "pkg:npm/lodash*"). Removes statements where any product matches.',
multiple: true,
}),
save: Flags.boolean({
char: 's',
default: false,
description: `Save VEX statement to ${filenamePrefix}.openvex.json in the current directory`,
}),
output: Flags.string({
char: 'o',
description: `Save VEX statement to a custom path (defaults to ${filenamePrefix}.openvex.json when not provided)`,
}),
};

public async run(): Promise<OpenVexDocument> {
const { flags } = await this.parse(Vex);

track('CLI VEX Download Started', (context) => ({
command: context.command,
command_flags: context.command_flags,
}));

let vex: OpenVexDocument;
try {
vex = await fetchVexStatement();
} catch (error) {
const message = getErrorMessage(error);
track('CLI VEX Download Failed', () => ({ error: message }));
this.error(`Failed to fetch VEX statement. ${message}`);
}

const hasFilters =
flags.file ||
flags.package?.length ||
flags.vuln?.length ||
flags.status?.length ||
flags['exclude-package']?.length;

if (hasFilters) {
const sbom = flags.file ? this.loadSbom(flags.file) : undefined;
vex = applyVexFilters(vex, {
sbom,
packagePatterns: flags.package,
vulnPatterns: flags.vuln,
statuses: flags.status,
excludePackagePatterns: flags['exclude-package'],
});
}

track('CLI VEX Download Completed', (context) => ({
command: context.command,
command_flags: context.command_flags,
statement_count: vex.statements.length,
filtered: Boolean(hasFilters),
}));

let outputPath = flags.output;
if (flags.output && !flags.save) {
this.warn('--output requires --save to write the file. Run again with --save to create the file.');
outputPath = undefined;
}

const shouldSave = flags.save;
if (shouldSave) {
const savedPath = this.saveVex(vex, outputPath);
this.log(`VEX statement saved to ${savedPath}`);
}

if (!this.jsonEnabled()) {
this.log(JSON.stringify(vex, null, 2));
}

return vex;
}

private loadSbom(filePath: string) {
try {
return readSbomFromFile(filePath);
} catch (error) {
const message = getErrorMessage(error);
track('CLI Error Encountered', () => ({ error: message }));
this.error(message);
}
}

private saveVex(vex: OpenVexDocument, outputPath?: string): string {
try {
return saveArtifactToFile(process.cwd(), { kind: 'vex', payload: vex, outputPath });
} catch (error) {
const message = getErrorMessage(error);
track('CLI Error Encountered', () => ({ error: message }));
this.error(message);
}
}
}
1 change: 1 addition & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const config = {
graphqlPath: process.env.GRAPHQL_PATH || '/graphql',
analyticsUrl: process.env.ANALYTICS_URL || 'https://apps.herodevs.com/api/eol/track',
eolLogInUrl: process.env.EOL_LOG_IN_URL || 'https://apps.herodevs.com/eol/cli-logged-in',
vexStatementsUrl: process.env.VEX_STATEMENTS_URL || 'https://apps.herodevs.com/api/ontology/vex/statements',
concurrentPageRequests,
pageSize,
ciTokenFromEnv: process.env.HD_CI_CREDENTIAL?.trim() || undefined,
Expand Down
6 changes: 4 additions & 2 deletions src/service/file.svc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,19 @@ export function validateDirectory(dirPath: string): void {
}
}

type SaveArtifactKind = 'sbom' | 'sbomTrimmed' | 'report';
type SaveArtifactKind = 'sbom' | 'sbomTrimmed' | 'report' | 'vex';

type SaveArtifactRequest =
| { kind: 'sbom'; payload: CdxBom; outputPath?: string }
| { kind: 'sbomTrimmed'; payload: CdxBom }
| { kind: 'report'; payload: EolReport; outputPath?: string };
| { kind: 'report'; payload: EolReport; outputPath?: string }
| { kind: 'vex'; payload: object; outputPath?: string };

const artifactFilenames: Record<SaveArtifactKind, string> = {
sbom: `${filenamePrefix}.sbom.json`,
sbomTrimmed: `${filenamePrefix}.sbom-trimmed.json`,
report: `${filenamePrefix}.report.json`,
vex: `${filenamePrefix}.openvex.json`,
};

/**
Expand Down
Loading
Loading