diff --git a/.github/workflows/repotests.yml b/.github/workflows/repotests.yml index 0df8536fce..eaedb975c3 100644 --- a/.github/workflows/repotests.yml +++ b/.github/workflows/repotests.yml @@ -51,6 +51,7 @@ jobs: env: CI: true - uses: swift-actions/setup-swift@v1 + if: matrix.os == 'ubuntu-latest' - uses: actions/checkout@v3 with: repository: 'ShiftLeftSecurity/shiftleft-java-example' @@ -136,6 +137,7 @@ jobs: # mv *.hpi jenkins # CDXGEN_DEBUG_MODE=debug bin/cdxgen.js -p -r -t jenkins jenkins -o bomresults/bom-jenkins.json --validate ls -ltr bomresults + shell: bash - name: repotests 1.4 run: | bin/cdxgen.js -p -r -t java repotests/shiftleft-java-example -o bomresults/bom-java.json --generate-key-and-sign --spec-version 1.4 @@ -147,6 +149,7 @@ jobs: FETCH_LICENSE=0 bin/cdxgen.js -p -r repotests/Goatly.NET -o bomresults/bom-csharp3.json --validate --spec-version 1.4 FETCH_LICENSE=true bin/cdxgen.js -p -r -t python repotests/DjanGoat -o bomresults/bom-python.json --validate --spec-version 1.4 bin/cdxgen.js -p -r -t php repotests/Vulnerable-Web-Application -o bomresults/bom-php.json --validate --spec-version 1.4 + shell: bash - name: denotests if: github.ref == 'refs/heads/master' && matrix.os == 'ubuntu-latest' run: | diff --git a/README.md b/README.md index 528e7391bd..7ffc7179fd 100644 --- a/README.md +++ b/README.md @@ -443,6 +443,50 @@ const bomNSData = await createBom(filePath, options); const dbody = await submitBom(args, bomNSData.bomJson); ``` +## Interactive mode + +`cdxi` is a new interactive REPL server to interactively create, import and search an SBoM. All the exported functions from cdxgen and node.js could be used in this mode. In addition, several custom commands are defined. + +### Custom commands + +| Command | Description | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| .create | Create an SBoM from a path | +| .import | Import an existing SBoM from a path. Any SBoM in CycloneDX format is supported. | +| .search | Search the given string in the components name, group, purl and description | +| .sort | Sort the components based on the given attribute. Eg: .sort name to sort by name. Accepts full jsonata [order by](http://docs.jsonata.org/path-operators#order-by-) clause too. Eg: `.sort components^(>name)` | +| .query | Pass a raw query in [jsonata](http://docs.jsonata.org/) format | +| .print | Print the SBoM as a table | +| .tree | Print the dependency tree if available | +| .validate | Validate the SBoM | +| .exit | To exit the shell | +| .save | To save the modified SBoM to a new file | +| .update | Update components based on query expression. Use syntax `\| query \| new object \|`. See example. | + +### Sample REPL usage + +Start the REPL server. + +```shell +cdxi +``` + +Below are some example commands to create an SBoM for a spring application and perform searches and queries. + +``` +.create /mnt/work/vuln-spring +.print +.search spring +.query components[name ~> /spring/ and scope = "required"] +.sort name +.sort components^(>name) +.update | components[name ~> /spring/] | {'publisher': "foo"} | +``` + +### REPL History + +Repl history will get persisted under `$HOME/.config/.cdxgen` directory. To override this location, use the environment variable `CDXGEN_REPL_HISTORY`. + ## Node.js >= 20 permission model Refer to the [permissions document](./docs/PERMISSIONS.md) diff --git a/bin/repl.js b/bin/repl.js new file mode 100644 index 0000000000..073b1e0fdb --- /dev/null +++ b/bin/repl.js @@ -0,0 +1,311 @@ +import repl from "node:repl"; +import jsonata from "jsonata"; +import fs from "node:fs"; +import { join } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import process from "node:process"; + +import { createBom } from "../index.js"; +import { validateBom } from "../validator.js"; +import { printTable, printDependencyTree } from "../display.js"; + +const options = { + useColors: true, + breakEvalOnSigint: true, + preview: true, + prompt: "↝ ", + ignoreUndefined: true, + useGlobal: true +}; + +const cdxArt = ` ██████╗██████╗ ██╗ ██╗ +██╔════╝██╔══██╗╚██╗██╔╝ +██║ ██║ ██║ ╚███╔╝ +██║ ██║ ██║ ██╔██╗ +╚██████╗██████╔╝██╔╝ ██╗ + ╚═════╝╚═════╝ ╚═╝ ╚═╝ +`; + +console.log("\n" + cdxArt); + +// The current sbom is stored here +let sbom = undefined; + +let historyFile = undefined; +const historyConfigDir = join(homedir(), ".config", ".cdxgen"); +if (!process.env.CDXGEN_REPL_HISTORY && !fs.existsSync(historyConfigDir)) { + try { + fs.mkdirSync(historyConfigDir, { recursive: true }); + historyFile = join(historyConfigDir, ".repl_history"); + } catch (e) { + // ignore + } +} else { + historyFile = join(historyConfigDir, ".repl_history"); +} + +export const importSbom = (sbomOrPath) => { + if (sbomOrPath && sbomOrPath.endsWith(".json") && fs.existsSync(sbomOrPath)) { + try { + sbom = JSON.parse(fs.readFileSync(sbomOrPath, "utf-8")); + console.log(`✅ SBoM imported successfully from ${sbomOrPath}`); + } catch (e) { + console.log( + `⚠ Unable to import the SBoM from ${sbomOrPath} due to ${e}` + ); + } + } else { + console.log(`⚠ ${sbomOrPath} is invalid.`); + } +}; +// Load any sbom passed from the command line +if (process.argv.length > 2) { + importSbom(process.argv[process.argv.length - 1]); + console.log("💭 Type .print to view the SBoM as a table"); +} else if (fs.existsSync("bom.json")) { + // If the current directory has a bom.json load it + importSbom("bom.json"); +} else { + console.log("💭 Use .create to create an SBoM for the given path."); + console.log("💭 Use .import to import an existing SBoM."); + console.log("💭 Type .exit or press ctrl+d to close."); +} + +const cdxgenRepl = repl.start(options); +if (historyFile) { + cdxgenRepl.setupHistory( + process.env.CDXGEN_REPL_HISTORY || historyFile, + (err) => { + if (err) { + console.log( + "⚠ REPL history would not be persisted for this session. Set the environment variable CDXGEN_REPL_HISTORY to specify a custom history file" + ); + } + } + ); +} +cdxgenRepl.defineCommand("create", { + help: "create an SBoM for the given path", + async action(sbomOrPath) { + this.clearBufferedCommand(); + const tempDir = fs.mkdtempSync(join(tmpdir(), "cdxgen-repl-")); + const bomFile = join(tempDir, "bom.json"); + const bomNSData = await createBom(sbomOrPath, { + multiProject: true, + installDeps: true, + output: bomFile + }); + if (bomNSData) { + sbom = bomNSData.bomJson; + console.log("✅ SBoM imported successfully."); + console.log("💭 Type .print to view the SBoM as a table"); + } else { + console.log("SBoM was not generated successfully"); + } + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("import", { + help: "import an existing SBoM", + action(sbomOrPath) { + this.clearBufferedCommand(); + importSbom(sbomOrPath); + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("exit", { + help: "exit", + action() { + this.close(); + } +}); +cdxgenRepl.defineCommand("sbom", { + help: "show the current sbom", + action() { + if (sbom) { + console.log(sbom); + } else { + console.log( + "⚠ No SBoM is loaded. Use .import command to import an existing SBoM" + ); + } + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("search", { + help: "search the current sbom", + async action(searchStr) { + if (sbom) { + if (searchStr) { + try { + if (!searchStr.includes("~>")) { + searchStr = `components[group ~> /${searchStr}/i or name ~> /${searchStr}/i or description ~> /${searchStr}/i or publisher ~> /${searchStr}/i or purl ~> /${searchStr}/i]`; + } + const expression = jsonata(searchStr); + let components = await expression.evaluate(sbom); + if (!components) { + console.log("No results found!"); + } else { + printTable({ components, dependencies: [] }); + } + } catch (e) { + console.log(e); + } + } else { + console.log( + "⚠ Specify the search string. Eg: .search " + ); + } + } else { + console.log( + "⚠ No SBoM is loaded. Use .import command to import an existing SBoM" + ); + } + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("sort", { + help: "sort the current sbom based on the attribute", + async action(sortStr) { + if (sbom) { + if (sortStr) { + try { + if (!sortStr.includes("^")) { + sortStr = `components^(${sortStr})`; + } + const expression = jsonata(sortStr); + let components = await expression.evaluate(sbom); + if (!components) { + console.log("No results found!"); + } else { + printTable({ components, dependencies: [] }); + // Store the sorted list in memory + if (components.length === sbom.components.length) { + sbom.components = components; + } + } + } catch (e) { + console.log(e); + } + } else { + console.log("⚠ Specify the attribute to sort by. Eg: .sort name"); + } + } else { + console.log( + "⚠ No SBoM is loaded. Use .import command to import an existing SBoM" + ); + } + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("query", { + help: "query the current sbom", + async action(querySpec) { + if (sbom) { + if (querySpec) { + try { + const expression = jsonata(querySpec); + console.log(await expression.evaluate(sbom)); + } catch (e) { + console.log(e); + } + } else { + console.log( + "⚠ Specify the search specification in jsonata format. Eg: .query metadata.component" + ); + } + } else { + console.log( + "⚠ No SBoM is loaded. Use .import command to import an existing SBoM" + ); + } + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("print", { + help: "print the current sbom as a table", + action() { + if (sbom) { + printTable(sbom); + } else { + console.log( + "⚠ No SBoM is loaded. Use .import command to import an existing SBoM" + ); + } + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("tree", { + help: "display the dependency tree", + action() { + if (sbom) { + printDependencyTree(sbom); + } else { + console.log( + "⚠ No SBoM is loaded. Use .import command to import an existing SBoM" + ); + } + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("validate", { + help: "validate the sbom", + action() { + if (sbom) { + const result = validateBom(sbom); + if (result) { + console.log("SBoM is valid!"); + } + } else { + console.log( + "⚠ No SBoM is loaded. Use .import command to import an existing SBoM" + ); + } + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("save", { + help: "save the sbom to a new file", + action(saveToFile) { + if (sbom) { + if (!saveToFile) { + saveToFile = "bom.json"; + } + fs.writeFileSync(saveToFile, JSON.stringify(sbom, null, 2)); + console.log(`SBoM saved successfully to ${saveToFile}`); + } else { + console.log( + "⚠ No SBoM is loaded. Use .import command to import an existing SBoM" + ); + } + this.displayPrompt(); + } +}); +cdxgenRepl.defineCommand("update", { + help: "update the sbom components based on the given query", + async action(updateSpec) { + if (sbom) { + if (!updateSpec) { + return; + } + if (!updateSpec.startsWith("|")) { + updateSpec = "|" + updateSpec; + } + if (!updateSpec.endsWith("|")) { + updateSpec = updateSpec + "|"; + } + updateSpec = "$ ~> " + updateSpec; + const expression = jsonata(updateSpec); + const newSbom = await expression.evaluate(sbom); + if (newSbom && newSbom.components.length <= sbom.components.length) { + sbom = newSbom; + } + console.log("SBoM updated successfully."); + } else { + console.log( + "⚠ No SBoM is loaded. Use .import command to import an existing SBoM" + ); + } + this.displayPrompt(); + } +}); diff --git a/display.js b/display.js index ed931646f6..01f73a9a30 100644 --- a/display.js +++ b/display.js @@ -13,6 +13,9 @@ const MAX_TREE_DEPTH = 3; export const printTable = (bomJson) => { const data = [["Group", "Name", "Version", "Scope"]]; + if (!bomJson || !bomJson.components) { + return; + } for (const comp of bomJson.components) { data.push([comp.group || "", comp.name, comp.version, comp.scope || ""]); } diff --git a/docker.js b/docker.js index 8d517b46e1..fcb7f77252 100644 --- a/docker.js +++ b/docker.js @@ -24,7 +24,7 @@ import { x } from "tar"; import { spawnSync } from "node:child_process"; import { DEBUG_MODE } from "./utils.js"; -const isWin = _platform() === "win32"; +export const isWin = _platform() === "win32"; let dockerConn = undefined; let isPodman = false; diff --git a/docker.test.js b/docker.test.js index 5d64d4b578..41d95b30db 100644 --- a/docker.test.js +++ b/docker.test.js @@ -3,14 +3,17 @@ import { parseImageName, getImage, removeImage, - exportImage + exportImage, + isWin } from "./docker.js"; import { expect, test } from "@jest/globals"; test("docker connection", async () => { - const dockerConn = await getConnection(); - expect(dockerConn); -}); + if (!(isWin && process.env.CI === "true")) { + const dockerConn = await getConnection(); + expect(dockerConn); + } +}, 120000); test("parseImageName tests", () => { expect(parseImageName("debian")).toEqual({ @@ -59,7 +62,7 @@ test("parseImageName tests", () => { digest: "5d008306a7c5d09ba0161a3408fa3839dc2c9dd991ffb68adecc1040399fe9e1", platform: "" }); -}); +}, 120000); test("docker getImage", async () => { const imageData = await getImage("hello-world:latest"); diff --git a/package-lock.json b/package-lock.json index 42eadc975f..07b79b01f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cyclonedx/cdxgen", - "version": "9.3.3", + "version": "9.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cyclonedx/cdxgen", - "version": "9.3.3", + "version": "9.4.0", "license": "Apache-2.0", "dependencies": { "@babel/parser": "^7.22.10", @@ -34,7 +34,8 @@ "yargs": "^17.7.2" }, "bin": { - "cdxgen": "bin/cdxgen.js" + "cdxgen": "bin/cdxgen.js", + "cdxi": "bin/repl.js" }, "devDependencies": { "caxa": "^3.0.1", @@ -50,7 +51,8 @@ "@cyclonedx/cdxgen-plugins-bin": "^1.2.0", "body-parser": "^1.20.2", "compression": "^1.7.4", - "connect": "^3.7.0" + "connect": "^3.7.0", + "jsonata": "^2.0.3" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -6286,6 +6288,15 @@ "node": ">=6" } }, + "node_modules/jsonata": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-2.0.3.tgz", + "integrity": "sha512-Up2H81MUtjqI/dWwWX7p4+bUMfMrQJVMN/jW6clFMTiYP528fBOBNtRu944QhKTs3+IsVWbgMeUTny5fw2VMUA==", + "optional": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", diff --git a/package.json b/package.json index f5c755824f..5067ca24f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cyclonedx/cdxgen", - "version": "9.3.3", + "version": "9.4.0", "description": "Creates CycloneDX Software Bill-of-Materials (SBOM) from source or container image", "homepage": "http://github.com/cyclonedx/cdxgen", "author": "Prabhu Subramanian ", @@ -31,13 +31,14 @@ "type": "module", "exports": "./index.js", "bin": { - "cdxgen": "./bin/cdxgen.js" + "cdxgen": "./bin/cdxgen.js", + "cdxi": "./bin/repl.js" }, "scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --inject-globals false", "watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch --inject-globals false", - "lint": "eslint *.js *.test.js bin/cdxgen.js", - "pretty": "prettier --write *.js data/*.json bin/cdxgen.js --trailing-comma=none" + "lint": "eslint *.js *.test.js bin/*.js", + "pretty": "prettier --write *.js data/*.json bin/*.js --trailing-comma=none" }, "engines": { "node": ">=16" @@ -79,7 +80,8 @@ "@cyclonedx/cdxgen-plugins-bin": "^1.2.0", "body-parser": "^1.20.2", "compression": "^1.7.4", - "connect": "^3.7.0" + "connect": "^3.7.0", + "jsonata": "^2.0.3" }, "files": [ "*.js",