@cyclonedx/cdxgen 12.1.4 → 12.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -39
- package/bin/cdxgen.js +181 -90
- package/bin/evinse.js +4 -4
- package/bin/repl.js +3 -3
- package/bin/sign.js +102 -0
- package/bin/validate.js +233 -0
- package/bin/verify.js +69 -28
- package/data/queries.json +1 -1
- package/data/rules/ci-permissions.yaml +186 -0
- package/data/rules/dependency-sources.yaml +123 -0
- package/data/rules/package-integrity.yaml +135 -0
- package/data/rules/vscode-extensions.yaml +228 -0
- package/lib/cli/index.js +484 -440
- package/lib/evinser/db.js +137 -0
- package/lib/{helpers → evinser}/db.poku.js +2 -6
- package/lib/evinser/evinser.js +5 -18
- package/lib/evinser/swiftsem.js +1 -1
- package/lib/helpers/bomSigner.js +312 -0
- package/lib/helpers/bomSigner.poku.js +156 -0
- package/lib/helpers/caxa.js +1 -1
- package/lib/helpers/ciParsers/azurePipelines.js +295 -0
- package/lib/helpers/ciParsers/azurePipelines.poku.js +253 -0
- package/lib/helpers/ciParsers/circleCi.js +286 -0
- package/lib/helpers/ciParsers/circleCi.poku.js +230 -0
- package/lib/helpers/ciParsers/common.js +24 -0
- package/lib/helpers/ciParsers/githubActions.js +636 -0
- package/lib/helpers/ciParsers/githubActions.poku.js +802 -0
- package/lib/helpers/ciParsers/gitlabCi.js +213 -0
- package/lib/helpers/ciParsers/gitlabCi.poku.js +247 -0
- package/lib/helpers/ciParsers/jenkins.js +181 -0
- package/lib/helpers/ciParsers/jenkins.poku.js +197 -0
- package/lib/helpers/depsUtils.js +203 -0
- package/lib/helpers/depsUtils.poku.js +150 -0
- package/lib/helpers/display.js +429 -14
- package/lib/helpers/envcontext.js +23 -8
- package/lib/helpers/formulationParsers.js +351 -0
- package/lib/helpers/logger.js +14 -0
- package/lib/helpers/protobom.js +9 -9
- package/lib/helpers/pythonutils.js +305 -0
- package/lib/helpers/pythonutils.poku.js +469 -0
- package/lib/helpers/utils.js +970 -528
- package/lib/helpers/utils.poku.js +139 -256
- package/lib/helpers/versutils.js +202 -0
- package/lib/helpers/versutils.poku.js +315 -0
- package/lib/helpers/vsixutils.js +1061 -0
- package/lib/helpers/vsixutils.poku.js +2247 -0
- package/lib/managers/binary.js +19 -19
- package/lib/managers/docker.js +108 -1
- package/lib/managers/oci.js +10 -0
- package/lib/managers/piptree.js +4 -10
- package/lib/parsers/npmrc.js +92 -0
- package/lib/parsers/npmrc.poku.js +528 -0
- package/lib/server/openapi.yaml +1 -10
- package/lib/server/server.js +58 -16
- package/lib/server/server.poku.js +123 -144
- package/lib/stages/postgen/annotator.js +1 -1
- package/lib/stages/postgen/auditBom.js +197 -0
- package/lib/stages/postgen/auditBom.poku.js +378 -0
- package/lib/stages/postgen/postgen.js +54 -1
- package/lib/stages/postgen/postgen.poku.js +90 -1
- package/lib/stages/postgen/ruleEngine.js +369 -0
- package/lib/stages/pregen/envAudit.js +299 -0
- package/lib/stages/pregen/envAudit.poku.js +572 -0
- package/lib/stages/pregen/pregen.js +12 -8
- package/lib/third-party/arborist/lib/deepest-nesting-target.js +1 -1
- package/lib/third-party/arborist/lib/node.js +3 -3
- package/lib/third-party/arborist/lib/shrinkwrap.js +1 -1
- package/lib/third-party/arborist/lib/tree-check.js +1 -1
- package/lib/{helpers/validator.js → validator/bomValidator.js} +107 -47
- package/lib/validator/complianceEngine.js +241 -0
- package/lib/validator/complianceEngine.poku.js +168 -0
- package/lib/validator/complianceRules.js +1610 -0
- package/lib/validator/complianceRules.poku.js +328 -0
- package/lib/validator/index.js +222 -0
- package/lib/validator/index.poku.js +144 -0
- package/lib/validator/reporters/annotations.js +121 -0
- package/lib/validator/reporters/console.js +149 -0
- package/lib/validator/reporters/index.js +41 -0
- package/lib/validator/reporters/json.js +37 -0
- package/lib/validator/reporters/sarif.js +184 -0
- package/lib/validator/reporters.poku.js +150 -0
- package/package.json +8 -8
- package/types/bin/sign.d.ts +3 -0
- package/types/bin/sign.d.ts.map +1 -0
- package/types/bin/validate.d.ts +3 -0
- package/types/bin/validate.d.ts.map +1 -0
- package/types/helpers/utils.d.ts +0 -1
- package/types/lib/cli/index.d.ts +49 -52
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/evinser/db.d.ts +34 -0
- package/types/lib/evinser/db.d.ts.map +1 -0
- package/types/lib/evinser/evinser.d.ts +63 -16
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +27 -0
- package/types/lib/helpers/bomSigner.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/azurePipelines.d.ts +17 -0
- package/types/lib/helpers/ciParsers/azurePipelines.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/circleCi.d.ts +17 -0
- package/types/lib/helpers/ciParsers/circleCi.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/common.d.ts +11 -0
- package/types/lib/helpers/ciParsers/common.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/githubActions.d.ts +34 -0
- package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/gitlabCi.d.ts +17 -0
- package/types/lib/helpers/ciParsers/gitlabCi.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/jenkins.d.ts +17 -0
- package/types/lib/helpers/ciParsers/jenkins.d.ts.map +1 -0
- package/types/lib/helpers/depsUtils.d.ts +21 -0
- package/types/lib/helpers/depsUtils.d.ts.map +1 -0
- package/types/lib/helpers/display.d.ts +111 -11
- package/types/lib/helpers/display.d.ts.map +1 -1
- package/types/lib/helpers/envcontext.d.ts +19 -7
- package/types/lib/helpers/envcontext.d.ts.map +1 -1
- package/types/lib/helpers/formulationParsers.d.ts +50 -0
- package/types/lib/helpers/formulationParsers.d.ts.map +1 -0
- package/types/lib/helpers/logger.d.ts +15 -1
- package/types/lib/helpers/logger.d.ts.map +1 -1
- package/types/lib/helpers/protobom.d.ts +2 -2
- package/types/lib/helpers/pythonutils.d.ts +18 -0
- package/types/lib/helpers/pythonutils.d.ts.map +1 -0
- package/types/lib/helpers/utils.d.ts +532 -128
- package/types/lib/helpers/utils.d.ts.map +1 -1
- package/types/lib/helpers/versutils.d.ts +8 -0
- package/types/lib/helpers/versutils.d.ts.map +1 -0
- package/types/lib/helpers/vsixutils.d.ts +130 -0
- package/types/lib/helpers/vsixutils.d.ts.map +1 -0
- package/types/lib/managers/docker.d.ts +12 -31
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +11 -1
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/managers/piptree.d.ts.map +1 -1
- package/types/lib/parsers/npmrc.d.ts +26 -0
- package/types/lib/parsers/npmrc.d.ts.map +1 -0
- package/types/lib/server/server.d.ts +21 -2
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/auditBom.d.ts +20 -0
- package/types/lib/stages/postgen/auditBom.d.ts.map +1 -0
- package/types/lib/stages/postgen/postgen.d.ts +8 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/ruleEngine.d.ts +18 -0
- package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -0
- package/types/lib/stages/pregen/envAudit.d.ts +8 -0
- package/types/lib/stages/pregen/envAudit.d.ts.map +1 -0
- package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
- package/types/lib/{helpers/validator.d.ts → validator/bomValidator.d.ts} +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -0
- package/types/lib/validator/complianceEngine.d.ts +66 -0
- package/types/lib/validator/complianceEngine.d.ts.map +1 -0
- package/types/lib/validator/complianceRules.d.ts +70 -0
- package/types/lib/validator/complianceRules.d.ts.map +1 -0
- package/types/lib/validator/index.d.ts +70 -0
- package/types/lib/validator/index.d.ts.map +1 -0
- package/types/lib/validator/reporters/annotations.d.ts +31 -0
- package/types/lib/validator/reporters/annotations.d.ts.map +1 -0
- package/types/lib/validator/reporters/console.d.ts +30 -0
- package/types/lib/validator/reporters/console.d.ts.map +1 -0
- package/types/lib/validator/reporters/index.d.ts +21 -0
- package/types/lib/validator/reporters/index.d.ts.map +1 -0
- package/types/lib/validator/reporters/json.d.ts +11 -0
- package/types/lib/validator/reporters/json.d.ts.map +1 -0
- package/types/lib/validator/reporters/sarif.d.ts +16 -0
- package/types/lib/validator/reporters/sarif.d.ts.map +1 -0
- package/lib/helpers/db.js +0 -162
- package/types/helpers/db.d.ts +0 -35
- package/types/helpers/db.d.ts.map +0 -1
- package/types/lib/helpers/db.d.ts +0 -35
- package/types/lib/helpers/db.d.ts.map +0 -1
- package/types/lib/helpers/validator.d.ts.map +0 -1
- package/types/managers/binary.d.ts +0 -37
- package/types/managers/binary.d.ts.map +0 -1
- package/types/managers/docker.d.ts +0 -56
- package/types/managers/docker.d.ts.map +0 -1
- package/types/managers/oci.d.ts +0 -2
- package/types/managers/oci.d.ts.map +0 -1
- package/types/managers/piptree.d.ts +0 -2
- package/types/managers/piptree.d.ts.map +0 -1
- package/types/server/server.d.ts +0 -34
- package/types/server/server.d.ts.map +0 -1
- package/types/stages/postgen/annotator.d.ts +0 -27
- package/types/stages/postgen/annotator.d.ts.map +0 -1
- package/types/stages/postgen/postgen.d.ts +0 -51
- package/types/stages/postgen/postgen.d.ts.map +0 -1
- package/types/stages/pregen/pregen.d.ts +0 -59
- package/types/stages/pregen/pregen.d.ts.map +0 -1
package/bin/repl.js
CHANGED
|
@@ -22,8 +22,8 @@ import {
|
|
|
22
22
|
} from "../lib/helpers/display.js";
|
|
23
23
|
import { readBinary } from "../lib/helpers/protobom.js";
|
|
24
24
|
import { getTmpDir } from "../lib/helpers/utils.js";
|
|
25
|
-
import { validateBom } from "../lib/helpers/validator.js";
|
|
26
25
|
import { getBomWithOras } from "../lib/managers/oci.js";
|
|
26
|
+
import { validateBom } from "../lib/validator/bomValidator.js";
|
|
27
27
|
|
|
28
28
|
const options = {
|
|
29
29
|
useColors: true,
|
|
@@ -594,7 +594,7 @@ cdxgenRepl.defineCommand("osinfocategories", {
|
|
|
594
594
|
cdxgenRepl.defineCommand("licenses", {
|
|
595
595
|
help: "visualize license distribution",
|
|
596
596
|
async action() {
|
|
597
|
-
if (!sbom
|
|
597
|
+
if (!sbom?.components) {
|
|
598
598
|
console.log("⚠ No SBOM loaded.");
|
|
599
599
|
this.displayPrompt();
|
|
600
600
|
return;
|
|
@@ -649,7 +649,7 @@ cdxgenRepl.defineCommand("inspect", {
|
|
|
649
649
|
cdxgenRepl.defineCommand("tagcloud", {
|
|
650
650
|
help: "generate a text/tag cloud based on component descriptions and tags",
|
|
651
651
|
action() {
|
|
652
|
-
if (!sbom
|
|
652
|
+
if (!sbom?.components) {
|
|
653
653
|
console.log("⚠ No SBOM loaded.");
|
|
654
654
|
this.displayPrompt();
|
|
655
655
|
return;
|
package/bin/sign.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
|
|
6
|
+
import yargs from "yargs";
|
|
7
|
+
import { hideBin } from "yargs/helpers";
|
|
8
|
+
|
|
9
|
+
import { signBom } from "../lib/helpers/bomSigner.js";
|
|
10
|
+
import { retrieveCdxgenVersion, safeExistsSync } from "../lib/helpers/utils.js";
|
|
11
|
+
|
|
12
|
+
const _yargs = yargs(hideBin(process.argv));
|
|
13
|
+
|
|
14
|
+
const args = _yargs
|
|
15
|
+
.option("input", {
|
|
16
|
+
alias: "i",
|
|
17
|
+
default: "bom.json",
|
|
18
|
+
description: "Input SBOM json to sign.",
|
|
19
|
+
})
|
|
20
|
+
.option("output", {
|
|
21
|
+
alias: "o",
|
|
22
|
+
description: "Output signed SBOM json. Defaults to overwriting input.",
|
|
23
|
+
})
|
|
24
|
+
.option("private-key", {
|
|
25
|
+
alias: "k",
|
|
26
|
+
demandOption: true,
|
|
27
|
+
description: "Private key in PEM format.",
|
|
28
|
+
})
|
|
29
|
+
.option("algorithm", {
|
|
30
|
+
alias: "a",
|
|
31
|
+
default: "RS512",
|
|
32
|
+
description: "JSF Signature Algorithm (e.g., RS512, ES256, Ed25519).",
|
|
33
|
+
})
|
|
34
|
+
.option("mode", {
|
|
35
|
+
alias: "m",
|
|
36
|
+
default: "replace",
|
|
37
|
+
choices: ["replace", "signers", "chain"],
|
|
38
|
+
description:
|
|
39
|
+
"Signature mode. Use 'signers' for multi-signing, 'chain' for sequential chaining.",
|
|
40
|
+
})
|
|
41
|
+
.option("key-id", {
|
|
42
|
+
description:
|
|
43
|
+
"Optional identifier for the key (keyId) to embed in the signature block.",
|
|
44
|
+
})
|
|
45
|
+
.option("sign-components", {
|
|
46
|
+
type: "boolean",
|
|
47
|
+
default: true,
|
|
48
|
+
description:
|
|
49
|
+
"Sign granular components. Disable (--no-sign-components) when appending multi-signatures.",
|
|
50
|
+
})
|
|
51
|
+
.option("sign-services", {
|
|
52
|
+
type: "boolean",
|
|
53
|
+
default: true,
|
|
54
|
+
description:
|
|
55
|
+
"Sign granular services. Disable (--no-sign-services) when appending multi-signatures.",
|
|
56
|
+
})
|
|
57
|
+
.option("sign-annotations", {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
default: true,
|
|
60
|
+
description:
|
|
61
|
+
"Sign granular annotations. Disable (--no-sign-annotations) when appending multi-signatures.",
|
|
62
|
+
})
|
|
63
|
+
.scriptName("cdx-sign")
|
|
64
|
+
.version(retrieveCdxgenVersion())
|
|
65
|
+
.help()
|
|
66
|
+
.wrap(Math.min(120, yargs().terminalWidth())).argv;
|
|
67
|
+
|
|
68
|
+
if (!safeExistsSync(args.input)) {
|
|
69
|
+
console.error(`Input file '${args.input}' not found.`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!safeExistsSync(args.privateKey)) {
|
|
74
|
+
console.error(`Private key file '${args.privateKey}' not found.`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const bomJson = JSON.parse(fs.readFileSync(args.input, "utf8"));
|
|
80
|
+
const privateKey = fs.readFileSync(args.privateKey, "utf8");
|
|
81
|
+
|
|
82
|
+
const signedBom = signBom(bomJson, {
|
|
83
|
+
privateKey,
|
|
84
|
+
algorithm: args.algorithm,
|
|
85
|
+
keyId: args.keyId,
|
|
86
|
+
mode: args.mode,
|
|
87
|
+
signComponents: args.signComponents,
|
|
88
|
+
signServices: args.signServices,
|
|
89
|
+
signAnnotations: args.signAnnotations,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const outputPath = args.output || args.input;
|
|
93
|
+
fs.writeFileSync(outputPath, JSON.stringify(signedBom, null, null));
|
|
94
|
+
|
|
95
|
+
console.log(`Successfully signed BOM and saved to '${outputPath}'`);
|
|
96
|
+
console.log(
|
|
97
|
+
`Mode: ${args.mode} | Algorithm: ${args.algorithm}${args.keyId ? ` | KeyId: ${args.keyId}` : ""}`,
|
|
98
|
+
);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error("SBOM signing failed:", error.message);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
package/bin/validate.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cdx-validate CLI — structural, deep, and compliance validation for
|
|
5
|
+
* CycloneDX SBOMs.
|
|
6
|
+
*
|
|
7
|
+
* Exit codes:
|
|
8
|
+
* 0 — all checks pass (or no findings >= --fail-severity).
|
|
9
|
+
* 1 — configuration error (bad input, missing file, unknown reporter).
|
|
10
|
+
* 2 — schema validation failed (in --strict mode).
|
|
11
|
+
* 3 — compliance findings at or above --fail-severity.
|
|
12
|
+
* 4 — signature verification was requested and failed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import process from "node:process";
|
|
18
|
+
|
|
19
|
+
import yargs from "yargs";
|
|
20
|
+
import { hideBin } from "yargs/helpers";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
dirNameStr,
|
|
24
|
+
retrieveCdxgenVersion,
|
|
25
|
+
safeExistsSync,
|
|
26
|
+
safeMkdirSync,
|
|
27
|
+
} from "../lib/helpers/utils.js";
|
|
28
|
+
import { getBomWithOras } from "../lib/managers/oci.js";
|
|
29
|
+
import { shouldFail, validateBomAdvanced } from "../lib/validator/index.js";
|
|
30
|
+
import { render as renderReport } from "../lib/validator/reporters/index.js";
|
|
31
|
+
|
|
32
|
+
const _yargs = yargs(hideBin(process.argv));
|
|
33
|
+
|
|
34
|
+
const args = _yargs
|
|
35
|
+
.option("input", {
|
|
36
|
+
alias: "i",
|
|
37
|
+
default: "bom.json",
|
|
38
|
+
description: "Input SBOM JSON file or OCI reference.",
|
|
39
|
+
})
|
|
40
|
+
.option("platform", {
|
|
41
|
+
description:
|
|
42
|
+
"Platform to use when resolving an OCI reference (passed to oras).",
|
|
43
|
+
})
|
|
44
|
+
.option("report", {
|
|
45
|
+
alias: "r",
|
|
46
|
+
default: "console",
|
|
47
|
+
choices: ["console", "json", "sarif", "annotations"],
|
|
48
|
+
description: "Output format.",
|
|
49
|
+
})
|
|
50
|
+
.option("report-file", {
|
|
51
|
+
alias: "o",
|
|
52
|
+
description:
|
|
53
|
+
"Write the report to this file. Defaults to stdout. Required for the 'annotations' reporter when you want to save the annotated BOM.",
|
|
54
|
+
})
|
|
55
|
+
.option("schema", {
|
|
56
|
+
type: "boolean",
|
|
57
|
+
default: true,
|
|
58
|
+
description:
|
|
59
|
+
"Run the CycloneDX JSON-schema validation. Pass --no-schema to skip.",
|
|
60
|
+
})
|
|
61
|
+
.option("deep", {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
default: true,
|
|
64
|
+
description:
|
|
65
|
+
"Run the deep purl/ref/metadata checks from lib/helpers/bomValidator.js. Pass --no-deep to skip.",
|
|
66
|
+
})
|
|
67
|
+
.option("benchmark", {
|
|
68
|
+
alias: "b",
|
|
69
|
+
type: "string",
|
|
70
|
+
description:
|
|
71
|
+
"Comma-separated list of compliance benchmark aliases to include in the scorecards (scvs, scvs-l1, scvs-l2, scvs-l3, cra). Defaults to all.",
|
|
72
|
+
})
|
|
73
|
+
.option("categories", {
|
|
74
|
+
type: "string",
|
|
75
|
+
description:
|
|
76
|
+
"Comma-separated list of compliance rule categories to evaluate (compliance-scvs, compliance-cra). Defaults to all.",
|
|
77
|
+
})
|
|
78
|
+
.option("min-severity", {
|
|
79
|
+
type: "string",
|
|
80
|
+
default: "info",
|
|
81
|
+
choices: ["info", "low", "medium", "high", "critical"],
|
|
82
|
+
description:
|
|
83
|
+
"Drop findings below this severity from the output (benchmark scoring is unaffected).",
|
|
84
|
+
})
|
|
85
|
+
.option("fail-severity", {
|
|
86
|
+
type: "string",
|
|
87
|
+
default: "high",
|
|
88
|
+
choices: ["info", "low", "medium", "high", "critical"],
|
|
89
|
+
description:
|
|
90
|
+
"Exit with code 3 when any failing finding is at or above this severity.",
|
|
91
|
+
})
|
|
92
|
+
.option("include-manual", {
|
|
93
|
+
type: "boolean",
|
|
94
|
+
default: true,
|
|
95
|
+
description:
|
|
96
|
+
"Include non-automatable manual-review findings in the output. Pass --no-include-manual to hide them.",
|
|
97
|
+
})
|
|
98
|
+
.option("include-pass", {
|
|
99
|
+
type: "boolean",
|
|
100
|
+
default: false,
|
|
101
|
+
description:
|
|
102
|
+
"Include passing findings in the output (useful for audits). Defaults to false.",
|
|
103
|
+
})
|
|
104
|
+
.option("public-key", {
|
|
105
|
+
description:
|
|
106
|
+
"Path to a PEM public key. When set, cdx-validate also verifies the BOM signature.",
|
|
107
|
+
})
|
|
108
|
+
.option("require-signature", {
|
|
109
|
+
type: "boolean",
|
|
110
|
+
default: false,
|
|
111
|
+
description:
|
|
112
|
+
"Exit non-zero (4) when --public-key is provided but signature verification fails.",
|
|
113
|
+
})
|
|
114
|
+
.option("strict", {
|
|
115
|
+
type: "boolean",
|
|
116
|
+
default: false,
|
|
117
|
+
description:
|
|
118
|
+
"Treat a failing schema or deep validation as a non-zero exit (code 2).",
|
|
119
|
+
})
|
|
120
|
+
.completion("completion", "Generate bash/zsh completion")
|
|
121
|
+
.epilogue("for documentation, visit https://cdxgen.github.io/cdxgen")
|
|
122
|
+
.scriptName("cdx-validate")
|
|
123
|
+
.version(retrieveCdxgenVersion())
|
|
124
|
+
.help()
|
|
125
|
+
.wrap(Math.min(120, yargs().terminalWidth())).argv;
|
|
126
|
+
|
|
127
|
+
function loadBom(input, platform) {
|
|
128
|
+
if (safeExistsSync(input)) {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(fs.readFileSync(input, "utf8"));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error(`Failed to parse ${input}: ${err.message}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
input.includes(":") ||
|
|
138
|
+
input.includes("docker") ||
|
|
139
|
+
input.includes("ghcr")
|
|
140
|
+
) {
|
|
141
|
+
const bom = getBomWithOras(input, platform);
|
|
142
|
+
if (bom) return bom;
|
|
143
|
+
}
|
|
144
|
+
console.error(`Input '${input}' is not a readable SBOM.`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function loadPublicKey(path) {
|
|
150
|
+
if (!path) return null;
|
|
151
|
+
if (!safeExistsSync(path)) {
|
|
152
|
+
console.error(`Public key '${path}' not found.`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
return fs.readFileSync(path, "utf8");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function splitCsv(value) {
|
|
159
|
+
if (!value) return undefined;
|
|
160
|
+
return value
|
|
161
|
+
.split(",")
|
|
162
|
+
.map((v) => v.trim())
|
|
163
|
+
.filter(Boolean);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function writeOrPrint(content, outputPath) {
|
|
167
|
+
if (!outputPath) {
|
|
168
|
+
process.stdout.write(`${content}\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const parent = dirname(outputPath);
|
|
172
|
+
if (parent && !safeExistsSync(parent)) {
|
|
173
|
+
safeMkdirSync(parent, { recursive: true });
|
|
174
|
+
}
|
|
175
|
+
fs.writeFileSync(outputPath, content);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const bomJson = loadBom(args.input, args.platform);
|
|
179
|
+
const publicKeyStr = loadPublicKey(args.publicKey);
|
|
180
|
+
|
|
181
|
+
const report = validateBomAdvanced(bomJson, {
|
|
182
|
+
schema: args.schema,
|
|
183
|
+
deep: args.deep,
|
|
184
|
+
benchmarks: splitCsv(args.benchmark),
|
|
185
|
+
categories: splitCsv(args.categories),
|
|
186
|
+
minSeverity: args.minSeverity,
|
|
187
|
+
includeManual: args.includeManual,
|
|
188
|
+
includePass: args.includePass,
|
|
189
|
+
publicKey: publicKeyStr || undefined,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
let output;
|
|
193
|
+
try {
|
|
194
|
+
const { version: pkgVersion } = JSON.parse(
|
|
195
|
+
fs.readFileSync(join(dirNameStr, "package.json"), "utf8"),
|
|
196
|
+
);
|
|
197
|
+
output = renderReport(args.report, report, {
|
|
198
|
+
bomJson,
|
|
199
|
+
toolName: "cdx-validate",
|
|
200
|
+
toolVersion: pkgVersion,
|
|
201
|
+
pretty: true,
|
|
202
|
+
});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(err.message);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
writeOrPrint(output, args.reportFile);
|
|
209
|
+
|
|
210
|
+
const { shouldFail: fail, reason } = shouldFail(report, {
|
|
211
|
+
failSeverity: args.failSeverity,
|
|
212
|
+
strict: args.strict,
|
|
213
|
+
requireSignature: Boolean(args.requireSignature && publicKeyStr),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (report.signatureVerified === false && args.requireSignature) {
|
|
217
|
+
console.error(
|
|
218
|
+
`cdx-validate: signature verification failed — ${report.signatureDetails?.error || "no matching key"}.`,
|
|
219
|
+
);
|
|
220
|
+
process.exit(4);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (args.strict && report.schemaValid === false) {
|
|
224
|
+
console.error("cdx-validate: schema validation failed.");
|
|
225
|
+
process.exit(2);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (fail) {
|
|
229
|
+
console.error(`cdx-validate: ${reason}`);
|
|
230
|
+
process.exit(3);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
process.exit(0);
|
package/bin/verify.js
CHANGED
|
@@ -4,10 +4,10 @@ import fs from "node:fs";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
|
|
7
|
-
import jws from "jws";
|
|
8
7
|
import yargs from "yargs";
|
|
9
8
|
import { hideBin } from "yargs/helpers";
|
|
10
9
|
|
|
10
|
+
import { verifyNode } from "../lib/helpers/bomSigner.js";
|
|
11
11
|
import {
|
|
12
12
|
dirNameStr,
|
|
13
13
|
retrieveCdxgenVersion,
|
|
@@ -32,6 +32,12 @@ const args = _yargs
|
|
|
32
32
|
default: "public.key",
|
|
33
33
|
description: "Public key in PEM format. Default public.key",
|
|
34
34
|
})
|
|
35
|
+
.option("deep", {
|
|
36
|
+
type: "boolean",
|
|
37
|
+
default: true,
|
|
38
|
+
description:
|
|
39
|
+
"Strictly verify all nested component, service, and annotation signatures against the provided public key. Pass --no-deep to verify only the root signature.",
|
|
40
|
+
})
|
|
35
41
|
.completion("completion", "Generate bash/zsh completion")
|
|
36
42
|
.epilogue("for documentation, visit https://cdxgen.github.io/cdxgen")
|
|
37
43
|
.scriptName("cdx-verify")
|
|
@@ -78,49 +84,84 @@ function getBom(args) {
|
|
|
78
84
|
}
|
|
79
85
|
return undefined;
|
|
80
86
|
}
|
|
87
|
+
|
|
81
88
|
const bomJson = getBom(args);
|
|
89
|
+
|
|
82
90
|
if (!bomJson) {
|
|
83
91
|
console.log(`${args.input} is invalid!`);
|
|
84
92
|
process.exit(1);
|
|
85
93
|
}
|
|
94
|
+
|
|
86
95
|
if (bomJson && !safeExistsSync(args.publicKey)) {
|
|
87
96
|
console.log("Public key for signature verification is missing!");
|
|
88
97
|
process.exit(1);
|
|
89
98
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
|
|
100
|
+
const publicKeyStr = fs.readFileSync(args.publicKey, "utf8");
|
|
101
|
+
|
|
102
|
+
let rootMatch = null;
|
|
103
|
+
if (bomJson.signature) {
|
|
104
|
+
rootMatch = verifyNode(bomJson, publicKeyStr);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const verifyNested = args.deep || !bomJson.signature;
|
|
108
|
+
let hasInvalidNested = false;
|
|
109
|
+
let checkedNested = 0;
|
|
110
|
+
|
|
111
|
+
if (verifyNested) {
|
|
112
|
+
for (const comp of bomJson.components || []) {
|
|
113
|
+
if (comp.signature) {
|
|
114
|
+
checkedNested++;
|
|
115
|
+
if (!verifyNode(comp, publicKeyStr)) {
|
|
116
|
+
console.log(
|
|
117
|
+
`Component '${comp["bom-ref"] || comp.name}' signature is invalid!`,
|
|
118
|
+
);
|
|
119
|
+
hasInvalidNested = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const svc of bomJson.services || []) {
|
|
124
|
+
if (svc.signature) {
|
|
125
|
+
checkedNested++;
|
|
126
|
+
if (!verifyNode(svc, publicKeyStr)) {
|
|
127
|
+
console.log(
|
|
128
|
+
`Service '${svc["bom-ref"] || svc.name}' signature is invalid!`,
|
|
129
|
+
);
|
|
130
|
+
hasInvalidNested = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const ann of bomJson.annotations || []) {
|
|
135
|
+
if (ann.signature) {
|
|
136
|
+
checkedNested++;
|
|
137
|
+
if (!verifyNode(ann, publicKeyStr)) {
|
|
138
|
+
console.log(
|
|
139
|
+
`Annotation '${ann["bom-ref"] || ann.subject}' signature is invalid!`,
|
|
140
|
+
);
|
|
141
|
+
hasInvalidNested = true;
|
|
142
|
+
}
|
|
103
143
|
}
|
|
104
144
|
}
|
|
105
145
|
}
|
|
106
|
-
|
|
146
|
+
|
|
147
|
+
if (hasInvalidNested) {
|
|
148
|
+
console.log("One or more nested signatures are invalid!");
|
|
107
149
|
process.exit(1);
|
|
108
150
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
bomSignature,
|
|
117
|
-
bomJson.signature.algorithm,
|
|
118
|
-
fs.readFileSync(args.publicKey, "utf8"),
|
|
119
|
-
);
|
|
120
|
-
if (validationResult) {
|
|
121
|
-
console.log("Signature is valid!");
|
|
151
|
+
|
|
152
|
+
if (bomJson.signature) {
|
|
153
|
+
if (rootMatch) {
|
|
154
|
+
const identifier = rootMatch.keyId
|
|
155
|
+
? `KeyId: '${rootMatch.keyId}'`
|
|
156
|
+
: `Algorithm: '${rootMatch.algorithm}'`;
|
|
157
|
+
console.log(`✓ Signature is valid! (Matched ${identifier})`);
|
|
122
158
|
} else {
|
|
123
159
|
console.log("BOM signature is invalid!");
|
|
124
160
|
process.exit(1);
|
|
125
161
|
}
|
|
162
|
+
} else if (checkedNested > 0 && !hasInvalidNested) {
|
|
163
|
+
console.log(`✓ ${checkedNested} nested signature(s) are valid!`);
|
|
164
|
+
} else {
|
|
165
|
+
console.log("No valid signatures found to verify!");
|
|
166
|
+
process.exit(1);
|
|
126
167
|
}
|
package/data/queries.json
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"vscode_extensions": {
|
|
28
28
|
"query": "select vscode_extensions.* from users join vscode_extensions using (uid);",
|
|
29
29
|
"description": "Lists all vscode extensions.",
|
|
30
|
-
"purlType": "
|
|
30
|
+
"purlType": "vscode-extension",
|
|
31
31
|
"componentType": "application"
|
|
32
32
|
},
|
|
33
33
|
"deb_packages": {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# CI/CD Permission Security Rules
|
|
2
|
+
# Category: ci-permission
|
|
3
|
+
# Evaluates GitHub Actions, GitLab CI, Azure Pipelines, etc. for privilege risks
|
|
4
|
+
|
|
5
|
+
- id: CI-001
|
|
6
|
+
name: "Unpinned action in write-permission workflow"
|
|
7
|
+
description: "GitHub Actions referenced by tag/branch in workflows with write permissions pose supply chain risk"
|
|
8
|
+
severity: high
|
|
9
|
+
category: ci-permission
|
|
10
|
+
condition: |
|
|
11
|
+
components[
|
|
12
|
+
$prop($, 'cdx:github:action:isShaPinned') = 'false'
|
|
13
|
+
and (
|
|
14
|
+
$prop($, 'cdx:github:workflow:hasWritePermissions') = 'true'
|
|
15
|
+
or $prop($, 'cdx:github:job:hasWritePermissions') = 'true'
|
|
16
|
+
)
|
|
17
|
+
]
|
|
18
|
+
location: |
|
|
19
|
+
{
|
|
20
|
+
"bomRef": $."bom-ref",
|
|
21
|
+
"purl": purl,
|
|
22
|
+
"file": $prop($, 'cdx:github:workflow:file')
|
|
23
|
+
}
|
|
24
|
+
message: "Unpinned GitHub Action '{{ $prop($, 'cdx:github:action:uses') }}' in workflow with write permissions"
|
|
25
|
+
mitigation: "Pin action to full SHA: actions/setup-node@abc123def456..."
|
|
26
|
+
evidence: |
|
|
27
|
+
{
|
|
28
|
+
"pinningType": $prop($, 'cdx:github:action:versionPinningType'),
|
|
29
|
+
"workflowTriggers": $prop($, 'cdx:github:workflow:triggers'),
|
|
30
|
+
"jobName": $prop($, 'cdx:github:job:name')
|
|
31
|
+
}
|
|
32
|
+
- id: CI-002
|
|
33
|
+
name: "OIDC token issuance to non-official action"
|
|
34
|
+
description: "Workflows granting id-token:write to third-party actions may enable token exfiltration"
|
|
35
|
+
severity: high
|
|
36
|
+
category: ci-permission
|
|
37
|
+
condition: |
|
|
38
|
+
components[
|
|
39
|
+
$prop($, 'cdx:github:workflow:hasIdTokenWrite') = 'true'
|
|
40
|
+
and $prop($, 'cdx:actions:isOfficial') = 'false'
|
|
41
|
+
]
|
|
42
|
+
location: |
|
|
43
|
+
{ "bomRef": $."bom-ref", "purl": purl }
|
|
44
|
+
message: "Workflow grants OIDC token access to non-official action '{{ $prop($, 'cdx:github:action:uses') }}'"
|
|
45
|
+
mitigation: "Restrict id-token scope or use only verified/official actions for OIDC operations"
|
|
46
|
+
evidence: |
|
|
47
|
+
{
|
|
48
|
+
"isVerified": $prop($, 'cdx:actions:isVerified'),
|
|
49
|
+
"actionGroup": group
|
|
50
|
+
}
|
|
51
|
+
- id: CI-003
|
|
52
|
+
name: "Action pinned to mutable tag"
|
|
53
|
+
description: "GitHub Actions pinned to tags (vs SHA) can change behavior unexpectedly if tag is moved"
|
|
54
|
+
severity: medium
|
|
55
|
+
category: ci-permission
|
|
56
|
+
condition: |
|
|
57
|
+
components[
|
|
58
|
+
$prop($, 'cdx:github:action:versionPinningType') = 'tag'
|
|
59
|
+
]
|
|
60
|
+
location: |
|
|
61
|
+
{ "bomRef": $."bom-ref", "purl": purl }
|
|
62
|
+
message: "GitHub Action '{{ $prop($, 'cdx:github:action:uses') }}' pinned to mutable tag (not SHA)"
|
|
63
|
+
mitigation: "Consider pinning to full commit SHA for immutability: actions/setup-node@<full-sha>"
|
|
64
|
+
evidence: |
|
|
65
|
+
{
|
|
66
|
+
"currentVersion": version,
|
|
67
|
+
"isOfficial": $prop($, 'cdx:actions:isOfficial')
|
|
68
|
+
}
|
|
69
|
+
- id: CI-004
|
|
70
|
+
name: "Workflow uses pull_request_target trigger"
|
|
71
|
+
description: "pull_request_target can execute code in the context of the base branch, risking secret exposure"
|
|
72
|
+
severity: medium
|
|
73
|
+
category: ci-permission
|
|
74
|
+
# Check workflows (not components) for this trigger pattern
|
|
75
|
+
condition: |
|
|
76
|
+
formulation.workflows[
|
|
77
|
+
$hasProp($, 'cdx:github:workflow:triggers')
|
|
78
|
+
and $nullSafeProp($, 'cdx:github:workflow:triggers') ~> $trim() ~> $contains('pull_request_target')
|
|
79
|
+
]
|
|
80
|
+
location: |
|
|
81
|
+
{
|
|
82
|
+
"bomRef": $."bom-ref",
|
|
83
|
+
"file": $prop($, 'cdx:github:workflow:file')
|
|
84
|
+
}
|
|
85
|
+
message: "Workflow '{{ $prop($, 'cdx:github:workflow:name') }}' uses pull_request_target trigger"
|
|
86
|
+
mitigation: "Ensure workflow does not checkout or run code from the PR head; use pull_request with careful permissions"
|
|
87
|
+
evidence: |
|
|
88
|
+
{
|
|
89
|
+
"triggers": $prop($, 'cdx:github:workflow:triggers'),
|
|
90
|
+
"hasWritePermissions": $prop($, 'cdx:github:workflow:hasWritePermissions')
|
|
91
|
+
}
|
|
92
|
+
- id: CI-005
|
|
93
|
+
name: "Checkout step persists credentials unnecessarily"
|
|
94
|
+
description: "actions/checkout with persist-credentials=true (default) exposes GITHUB_TOKEN to subsequent steps"
|
|
95
|
+
severity: medium
|
|
96
|
+
category: ci-permission
|
|
97
|
+
condition: |
|
|
98
|
+
components[
|
|
99
|
+
$contains($nullSafeProp($, 'cdx:github:action:uses'), 'actions/checkout')
|
|
100
|
+
and $propBool($, 'cdx:github:checkout:persistCredentials') != false
|
|
101
|
+
and (
|
|
102
|
+
$propBool($, 'cdx:github:workflow:hasWritePermissions') = true
|
|
103
|
+
or $propBool($, 'cdx:github:job:hasWritePermissions') = true
|
|
104
|
+
)
|
|
105
|
+
]
|
|
106
|
+
location: |
|
|
107
|
+
{
|
|
108
|
+
"bomRef": $."bom-ref",
|
|
109
|
+
"purl": purl,
|
|
110
|
+
"file": $prop($, 'cdx:github:workflow:file')
|
|
111
|
+
}
|
|
112
|
+
message: "Checkout step uses persist-credentials=true in privileged workflow; consider persist-credentials: false;"
|
|
113
|
+
mitigation: "Add persist-credentials: false to checkout steps that don't require git push operations"
|
|
114
|
+
evidence: |
|
|
115
|
+
{
|
|
116
|
+
"persistCredentials": $prop($, 'cdx:github:checkout:persistCredentials'),
|
|
117
|
+
"workflowPermissions": $prop($, 'cdx:github:workflow:hasWritePermissions')
|
|
118
|
+
}
|
|
119
|
+
- id: CI-006
|
|
120
|
+
name: "Cache usage in untrusted trigger context"
|
|
121
|
+
description: "GitHub Actions cache can be poisoned when used in workflows triggered by untrusted input (e.g., pull_request from forks)"
|
|
122
|
+
severity: high
|
|
123
|
+
category: ci-permission
|
|
124
|
+
condition: |
|
|
125
|
+
components[
|
|
126
|
+
$nullSafeProp($, 'cdx:github:action:uses') ~> $contains('actions/cache')
|
|
127
|
+
and $nullSafeProp($, 'cdx:github:workflow:triggers') ~> $contains('pull_request')
|
|
128
|
+
]
|
|
129
|
+
location: |
|
|
130
|
+
{
|
|
131
|
+
"bomRef": $."bom-ref",
|
|
132
|
+
"purl": purl,
|
|
133
|
+
"file": $prop($, 'cdx:github:workflow:file')
|
|
134
|
+
}
|
|
135
|
+
message: "Cache action used in pull_request-triggered workflow; cache key '{{ $prop($, 'cdx:github:cache:key') }}' may be writable by untrusted code"
|
|
136
|
+
mitigation: "Scope cache keys to PR-specific values, validate restored cache contents, or avoid caching in untrusted workflows"
|
|
137
|
+
evidence: |
|
|
138
|
+
{
|
|
139
|
+
"cacheKey": $prop($, 'cdx:github:cache:key'),
|
|
140
|
+
"cachePath": $prop($, 'cdx:github:cache:path'),
|
|
141
|
+
"triggers": $prop($, 'cdx:github:workflow:triggers')
|
|
142
|
+
}
|
|
143
|
+
- id: CI-007
|
|
144
|
+
name: "Script injection via untrusted context interpolation"
|
|
145
|
+
description: "Direct interpolation of github.event.* or inputs.* into run: blocks enables command injection"
|
|
146
|
+
severity: critical
|
|
147
|
+
category: ci-permission
|
|
148
|
+
condition: |
|
|
149
|
+
formulation.components[
|
|
150
|
+
$prop($, 'cdx:github:step:hasUntrustedInterpolation') = 'true'
|
|
151
|
+
and $prop($, 'cdx:github:step:type') = 'run'
|
|
152
|
+
]
|
|
153
|
+
location: |
|
|
154
|
+
{
|
|
155
|
+
"bomRef": $."bom-ref",
|
|
156
|
+
"file": $prop($, 'cdx:github:workflow:file')
|
|
157
|
+
}
|
|
158
|
+
message: "Untrusted input interpolated into shell command: variables '{{ $prop($, 'cdx:github:step:interpolatedVars') }}'"
|
|
159
|
+
mitigation: "Use intermediate environment variables: env: TITLE: ${{ github.event.pull_request.title }} then reference $TITLE in run:"
|
|
160
|
+
evidence: |
|
|
161
|
+
{
|
|
162
|
+
"interpolatedVars": $prop($, 'cdx:github:step:interpolatedVars'),
|
|
163
|
+
"stepCommand": $prop($, 'cdx:github:step:command')
|
|
164
|
+
}
|
|
165
|
+
- id: CI-008
|
|
166
|
+
name: "High-risk trigger with write permissions"
|
|
167
|
+
description: "Triggers like pull_request_target, issue_comment, or workflow_run combined with write permissions enable privilege escalation"
|
|
168
|
+
severity: high
|
|
169
|
+
category: ci-permission
|
|
170
|
+
condition: |
|
|
171
|
+
formulation.workflows[
|
|
172
|
+
$prop($, 'cdx:github:workflow:hasHighRiskTrigger') = 'true'
|
|
173
|
+
and $prop($, 'cdx:github:workflow:hasWritePermissions') = 'true'
|
|
174
|
+
]
|
|
175
|
+
location: |
|
|
176
|
+
{
|
|
177
|
+
"bomRef": $."bom-ref",
|
|
178
|
+
"file": $prop($, 'cdx:github:workflow:file')
|
|
179
|
+
}
|
|
180
|
+
message: "Workflow '{{ $prop($, 'cdx:github:workflow:name') }}' uses high-risk trigger with write permissions"
|
|
181
|
+
mitigation: "Use pull_request instead of pull_request_target; require manual approval for issue_comment triggers; restrict permissions per job"
|
|
182
|
+
evidence: |
|
|
183
|
+
{
|
|
184
|
+
"triggers": $prop($, 'cdx:github:workflow:triggers'),
|
|
185
|
+
"hasWritePermissions": $prop($, 'cdx:github:workflow:hasWritePermissions')
|
|
186
|
+
}
|