Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates

version: 2
updates:
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'weekly'
allow:
- dependency-type: 'production'
38 changes: 38 additions & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# This workflow will run tests using node and then publish a package to the
# npm registry when a release is created.
# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages

name: NPM Package

on:
release:
types: [created]

permissions:
id-token: write
contents: read
actions: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24.x
- run: npm ci
- run: npm test

publish-npm:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24.x
registry-url: https://registry.npmjs.org/
- run: npm i -g npm@11
- run: npm ci
- run: npm publish
26 changes: 26 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Run Tests
on:
pull_request:
branches:
- '**'
push:
branches:
- main
permissions:
contents: read
actions: read
jobs:
test:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [22.x, 24.x]

steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/node_modules/*
/dist/*
/build
/coverage
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/dist
/coverage
tests/fixtures/invalid.json
/css
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright [yyyy] [name of copyright owner]
Copyright 2026 Bundesamt für Sicherheit in der Informationstechnik (BSI)

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
85 changes: 83 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,83 @@
# cli
A cli to access features of secvisogram via command line.
# @secvisogram/cli

A command-line interface for rendering [CSAF](https://oasis-open.github.io/csaf-documentation/)
(Common Security Advisory Framework) documents (versions 2.0 and 2.1) to
HTML, without needing the [Secvisogram](https://github.com/secvisogram/secvisogram)
web app - see [issue #606](https://github.com/secvisogram/secvisogram/issues/606).

It uses [`@secvisogram/html-template`](https://github.com/secvisogram/html-template)
for the actual rendering, and is a thin wrapper around it.

## Installation

```sh
npm install -g @secvisogram/cli
```

## Usage

```sh
secvisogram-render render <input-file.json> [-o <output-file.html>]
secvisogram-render --help
secvisogram-render --version
```

- `<input-file.json>` - path to a CSAF 2.0 or 2.1 JSON document.
- `--output, -o <output-file.html>` - path to write the rendered HTML to.
If omitted, the HTML is written to stdout instead.
- `--help, -h` - print usage information. Works both on its own
(`secvisogram-render --help`) and after a command
(`secvisogram-render render --help`).
- `--version, -v` - print the installed version of `@secvisogram/cli`.

### Examples

Render to stdout:

```sh
secvisogram-render render advisory.json
```

Render to a file:

```sh
secvisogram-render render advisory.json -o advisory.html
```

The CSAF version (`2.0` or `2.1`) is read from the document's
`document.csaf_version` field; there's no separate flag to select it.
Any other value (or a missing field) is rejected with an error.

The output is a single, self-contained HTML file/string - all CSS is
inlined into `<style>` tags, so it works fully offline and can be opened
directly from disk (`file://...`) without any other files alongside it.

## Exit codes

The CLI exits with `1` and prints a message prefixed with `error:` to
stderr (instead of a raw stack trace) for:

- a missing or unreadable input file
- invalid JSON in the input file (a leading UTF-8 byte-order-mark, if
present, is stripped automatically before parsing)
- an unsupported or missing `document.csaf_version`
- a failure while writing the output file
- an unknown or missing command

On success, it exits with `0`.

## Known limitations

- Only the two bundled templates (for CSAF 2.0 and 2.1) can be used;
there's currently no way to supply a custom template (tracked as a
follow-up to [issue #606](https://github.com/secvisogram/secvisogram/issues/606),
which originally requested this).

## Development

```sh
git clone https://github.com/secvisogram/cli
cd cli
npm install
npm test # type-check, prettier --check, and run the test suite
```
211 changes: 211 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
#!/usr/bin/env node

import {
enrichDocumentV2_0,
enrichDocumentV2_1,
HTMLTemplate2_0,
HTMLTemplate2_1,
renderMarkdown,
} from '@secvisogram/html-template'
import { readFile, writeFile } from 'node:fs/promises'
import { basename } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'

const args = parseArgs({
allowPositionals: true,
strict: false,
options: {
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
},
})

const [cmd] = args.positionals
// Pass through everything after the command token, unmodified. We can't
// just filter process.argv.slice(2) for values equal to `cmd` (as before) -
// that would also strip any option *value* that happens to equal the
// command name, e.g. `render -o render` would lose the "render" value for
// `-o`. Since `cmd` is always args.positionals[0], and parseArgs guarantees
// positionals appear in argv in the same relative order they were given,
// the command token is simply the first occurrence of `cmd` in argv.
const cmdIndex = process.argv.slice(2).indexOf(cmd)
const argv = process.argv.slice(2).filter((_, i) => i !== cmdIndex)

if (args.values.version) {
console.log(await readOwnVersion())
} else if (args.values.help) {
// Checked before dispatching to a subcommand, so `--help`/`-h` works the
// same whether given as `secvisogram-render --help` or
// `secvisogram-render render --help`.
renderHelp()
} else if (cmd === 'render') {
await render(argv)
} else if (!cmd) {
console.error('error: missing command')
renderHelp()
process.exitCode = 1
} else {
console.error(`unknown command: ${cmd}`)
renderHelp()
process.exitCode = 1
}

/**
* Reads this package's own version from its package.json, since a plain
* JSON import would need import attribute syntax that isn't available
* consistently across the Node versions this CLI supports.
*/
async function readOwnVersion() {
const packageJsonPath = fileURLToPath(
new URL('../package.json', import.meta.url),
)
const raw = await readFile(packageJsonPath, 'utf8')
return /** @type {{ version: string }} */ (JSON.parse(raw)).version
}

/**
* @param {string[]} argv
*/
async function render(argv) {
let args
try {
args = parseArgs({
args: argv,
allowPositionals: true,
options: {
output: {
type: 'string',
short: 'o',
},
},
})
} catch (/** @type {any} */ err) {
console.error(`error: ${err.message}`)
renderHelp()
process.exitCode = 1
return
}
const inputPath = args.positionals[0]
const outputPath = args.values.output

if (!inputPath) {
console.error('error: missing required argument <input-file.json>')
renderHelp()
process.exitCode = 1
return
}

let raw
try {
raw = await readFile(inputPath, 'utf8')
} catch (/** @type {any} */ err) {
if (err.code === 'ENOENT') {
console.error(`error: input file not found: ${inputPath}`)
} else {
console.error(
`error: could not read input file: ${inputPath}\n${err.message}`,
)
}
process.exitCode = 1
return
}

// Strip a leading UTF-8 byte-order-mark (BOM), if present, so JSON.parse
// doesn't choke on it.
const withoutBom = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw

let csafDoc
try {
csafDoc = JSON.parse(withoutBom)
} catch (/** @type {any} */ err) {
console.error(`error: invalid JSON in ${inputPath}: ${err.message}`)
process.exitCode = 1
return
}

// JSON.parse accepts any JSON value at the top level (e.g. `null`, `42`,
// `"a string"`, `[1, 2, 3]`), not just objects, so this must be checked
// explicitly - a CSAF document has to be an object to have a
// `.document.csaf_version` field at all.
if (
typeof csafDoc !== 'object' ||
csafDoc === null ||
Array.isArray(csafDoc)
) {
console.error(
`error: invalid CSAF document in ${inputPath}: expected a JSON object at the top level, got ${
csafDoc === null
? 'null'
: Array.isArray(csafDoc)
? 'an array'
: typeof csafDoc
}`,
)
process.exitCode = 1
return
}

const version = csafDoc.document?.csaf_version
let html

try {
if (version === '2.0') {
const { document: enrichedDoc } = enrichDocumentV2_0(csafDoc)
const parsedDoc = renderMarkdown(enrichedDoc)
html = HTMLTemplate2_0({ document: parsedDoc })
} else if (version === '2.1') {
const { document: enrichedDoc } = enrichDocumentV2_1(csafDoc)
const parsedDoc = renderMarkdown(enrichedDoc)
html = HTMLTemplate2_1({ document: parsedDoc })
} else {
console.error(
`error: unsupported or missing csaf_version: ${JSON.stringify(version)}. Expected "2.0" or "2.1".`,
)
process.exitCode = 1
return
}
} catch (/** @type {any} */ err) {
console.error(`error: failed to render document: ${err.message}`)
process.exitCode = 1
return
}

try {
if (outputPath) {
await writeFile(outputPath, html, 'utf8')
} else {
process.stdout.write(html)
}
} catch (/** @type {any} */ err) {
console.error(
`error: could not write output file: ${outputPath}\n${err.message}`,
)
process.exitCode = 1
return
}
}

function renderHelp() {
const bin = basename(process.argv[1])
console.log(`
usage: ${bin} <command> [options]

commands:
render [-o <output-file.html>] <input-file.json>
Render a CSAF 2.0 or 2.1 JSON document to HTML.

<input-file.json>
Path to the CSAF JSON file

--output, -o <output-file.html>
Path to write the HTML output (default: stdout)

options:
--help, -h
Show this help message

--version, -v
Show the installed version of ${bin}
`)
}
Loading
Loading