@cdxgen/cdxgen 13.3.0 → 13.4.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 +9 -6
- package/bin/cdxgen.js +90 -26
- package/bin/evinse.js +18 -1
- package/bin/repl.js +86 -0
- package/bin/sign.js +45 -15
- package/bin/tracebom.js +5 -1
- package/bin/validate.js +84 -18
- package/bin/verify.js +155 -86
- package/data/helpers/init.gradle +46 -21
- package/data/remediations.json +61 -0
- package/data/rules/dependency-sources.yaml +67 -0
- package/data/rules/scala-evidence.yaml +115 -0
- package/lib/audit/index.js +38 -6
- package/lib/cli/index.js +113 -54
- package/lib/cli/jsBom.js +50 -29
- package/lib/cli/jvmBom.js +665 -138
- package/lib/cli/managedBom.js +320 -200
- package/lib/cli/nativeBom.js +139 -30
- package/lib/core/activity.js +12 -0
- package/lib/core/env.js +17 -9
- package/lib/core/fs.js +3 -2
- package/lib/core/httpClient.js +159 -24
- package/lib/core/pluginRun.js +184 -0
- package/lib/core/processTree.js +100 -0
- package/lib/core/runState.js +39 -0
- package/lib/core/toml.js +72 -0
- package/lib/ecosystems/asarutils.js +4 -2
- package/lib/ecosystems/bunutils.js +5 -7
- package/lib/ecosystems/cargoCache.js +231 -8
- package/lib/ecosystems/cmakeResolver.js +10 -21
- package/lib/ecosystems/cppBuildContext.js +631 -0
- package/lib/ecosystems/cppEvidence.js +337 -22
- package/lib/ecosystems/cppIncludeResolver.js +294 -0
- package/lib/ecosystems/denoutils.js +93 -66
- package/lib/ecosystems/ecosystems.js +1967 -788
- package/lib/ecosystems/golem.js +28 -12
- package/lib/ecosystems/gradleutils.js +314 -30
- package/lib/ecosystems/jsEvidence.js +85 -24
- package/lib/ecosystems/kosi.js +21 -4
- package/lib/ecosystems/npmutils.js +64 -0
- package/lib/ecosystems/parsers-dotnet.js +567 -151
- package/lib/ecosystems/parsers-elm.js +46 -28
- package/lib/ecosystems/parsers-gleam.js +3 -4
- package/lib/ecosystems/parsers-go.js +324 -61
- package/lib/ecosystems/parsers-js.js +92 -26
- package/lib/ecosystems/parsers-julia.js +2 -3
- package/lib/ecosystems/parsers-jvm.js +204 -12
- package/lib/ecosystems/parsers-misc.js +182 -55
- package/lib/ecosystems/parsers-python.js +141 -10
- package/lib/ecosystems/parsers-rust.js +4 -4
- package/lib/ecosystems/parsers-terraform.js +1573 -19
- package/lib/ecosystems/pythonutils.js +102 -6
- package/lib/ecosystems/remote/huggingface.js +3 -0
- package/lib/ecosystems/rubyutils.js +67 -17
- package/lib/ecosystems/rusi.js +22 -9
- package/lib/ecosystems/sbtutils.js +243 -187
- package/lib/ecosystems/scalacliutils.js +342 -0
- package/lib/ecosystems/scalasem.js +2618 -0
- package/lib/ecosystems/terraformRegistry.js +288 -0
- package/lib/ecosystems/utils.js +2 -0
- package/lib/ecosystems/vendoredCode.js +156 -0
- package/lib/evinser/evinser.js +578 -69
- package/lib/helpers/agenticToolutils.js +2 -2
- package/lib/helpers/bomSigner.js +576 -128
- package/lib/inventory/aiCollector.js +4 -1
- package/lib/inventory/analyzer.js +110 -127
- package/lib/inventory/atomUtils.js +171 -48
- package/lib/inventory/auditCategories.js +7 -0
- package/lib/inventory/buildToolRateLimit.js +144 -0
- package/lib/inventory/cacheCommands.js +2 -1
- package/lib/inventory/cmakeBuildDirs.js +254 -0
- package/lib/inventory/deps.js +501 -124
- package/lib/inventory/dosai.js +248 -44
- package/lib/inventory/dosaiParsers.js +310 -15
- package/lib/inventory/dynamic.js +52 -6
- package/lib/inventory/evidenceUtils.js +154 -109
- package/lib/inventory/fetchBatch.js +574 -38
- package/lib/inventory/fetchDiskCache.js +597 -0
- package/lib/inventory/forgeEnricher.js +14 -5
- package/lib/inventory/jvmLocalRepos.js +917 -0
- package/lib/inventory/mcpConfigParser.js +2 -3
- package/lib/inventory/parentSelection.js +146 -0
- package/lib/inventory/rustFormulationParser.js +3 -4
- package/lib/inventory/scalaCoords.js +153 -0
- package/lib/inventory/source.js +8 -8
- package/lib/inventory/spdx.js +101 -0
- package/lib/inventory/traceRunner.js +88 -7
- package/lib/inventory/tracedCrypto.js +294 -0
- package/lib/managers/docker.js +33 -32
- package/lib/managers/oci.js +24 -2
- package/lib/parsers/cmakeCache.js +69 -0
- package/lib/parsers/cmakeLists.js +16 -2
- package/lib/parsers/cmakePresets.js +400 -0
- package/lib/parsers/compileCommands.js +557 -0
- package/lib/parsers/strictJson.js +322 -0
- package/lib/parsers/terraformConfig.js +1073 -0
- package/lib/parsers/terraformSource.js +652 -0
- package/lib/server/server.js +23 -0
- package/lib/stages/postgen/introspection/markers.js +1 -1
- package/lib/stages/postgen/postgen.js +15 -1
- package/lib/stages/postgen/specVersionCompat.js +32 -0
- package/lib/validator/index.js +25 -10
- package/lib/validator/reporters/json.js +1 -0
- package/package.json +21 -21
- package/types/lib/audit/index.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts +24 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts +7 -4
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts +3 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/activity.d.ts.map +1 -1
- package/types/lib/core/env.d.ts +9 -0
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/httpClient.d.ts +13 -8
- package/types/lib/core/httpClient.d.ts.map +1 -1
- package/types/lib/core/pluginRun.d.ts +97 -0
- package/types/lib/core/pluginRun.d.ts.map +1 -0
- package/types/lib/core/processTree.d.ts +36 -0
- package/types/lib/core/processTree.d.ts.map +1 -0
- package/types/lib/core/runState.d.ts +29 -0
- package/types/lib/core/runState.d.ts.map +1 -0
- package/types/lib/core/toml.d.ts +20 -0
- package/types/lib/core/toml.d.ts.map +1 -0
- package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
- package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
- package/types/lib/ecosystems/cargoCache.d.ts +12 -0
- package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
- package/types/lib/ecosystems/cmakeResolver.d.ts +5 -3
- package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
- package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
- package/types/lib/ecosystems/cppEvidence.d.ts +18 -2
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
- package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
- package/types/lib/ecosystems/denoutils.d.ts +9 -3
- package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts +62 -13
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/golem.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +50 -0
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/kosi.d.ts.map +1 -1
- package/types/lib/ecosystems/npmutils.d.ts +15 -0
- package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-go.d.ts +44 -2
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +6 -1
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts +3 -1
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts +53 -3
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
- package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +27 -2
- package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
- package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +21 -1
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts.map +1 -1
- package/types/lib/ecosystems/sbtutils.d.ts +88 -2
- package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
- package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
- package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
- package/types/lib/ecosystems/scalasem.d.ts +311 -0
- package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
- package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
- package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
- package/types/lib/ecosystems/utils.d.ts +2 -2
- package/types/lib/ecosystems/utils.d.ts.map +1 -1
- package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
- package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
- package/types/lib/evinser/evinser.d.ts +75 -8
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +80 -4
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/inventory/aiCollector.d.ts.map +1 -1
- package/types/lib/inventory/analyzer.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +82 -10
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/auditCategories.d.ts +3 -0
- package/types/lib/inventory/auditCategories.d.ts.map +1 -1
- package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
- package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
- package/types/lib/inventory/cacheCommands.d.ts +2 -1
- package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
- package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
- package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
- package/types/lib/inventory/deps.d.ts +59 -1
- package/types/lib/inventory/deps.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +57 -8
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dosaiParsers.d.ts +53 -7
- package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
- package/types/lib/inventory/dynamic.d.ts +10 -0
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts +19 -4
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts +83 -14
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
- package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
- package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
- package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
- package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
- package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/parentSelection.d.ts +34 -0
- package/types/lib/inventory/parentSelection.d.ts.map +1 -0
- package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
- package/types/lib/inventory/scalaCoords.d.ts +80 -0
- package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/spdx.d.ts +9 -0
- package/types/lib/inventory/spdx.d.ts.map +1 -1
- package/types/lib/inventory/traceRunner.d.ts +12 -0
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- package/types/lib/inventory/tracedCrypto.d.ts +42 -0
- package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +3 -1
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/parsers/cmakeCache.d.ts +16 -0
- package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
- package/types/lib/parsers/cmakeLists.d.ts +2 -1
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -1
- package/types/lib/parsers/cmakePresets.d.ts +78 -0
- package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
- package/types/lib/parsers/compileCommands.d.ts +115 -0
- package/types/lib/parsers/compileCommands.d.ts.map +1 -0
- package/types/lib/parsers/strictJson.d.ts +46 -0
- package/types/lib/parsers/strictJson.d.ts.map +1 -0
- package/types/lib/parsers/terraformConfig.d.ts +70 -0
- package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
- package/types/lib/parsers/terraformSource.d.ts +66 -0
- package/types/lib/parsers/terraformSource.d.ts.map +1 -0
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +8 -0
- package/types/lib/validator/index.d.ts.map +1 -1
- package/types/lib/validator/reporters/json.d.ts.map +1 -1
- package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
package/lib/helpers/bomSigner.js
CHANGED
|
@@ -14,9 +14,36 @@ const JSF_ALGORITHMS = new Map([
|
|
|
14
14
|
["PS256", { keyType: "rsa-pss", hash: "sha256" }],
|
|
15
15
|
["PS384", { keyType: "rsa-pss", hash: "sha384" }],
|
|
16
16
|
["PS512", { keyType: "rsa-pss", hash: "sha512" }],
|
|
17
|
-
[
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
[
|
|
18
|
+
"ES256",
|
|
19
|
+
{
|
|
20
|
+
keyType: "ec",
|
|
21
|
+
hash: "sha256",
|
|
22
|
+
curve: "prime256v1",
|
|
23
|
+
order:
|
|
24
|
+
0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
"ES384",
|
|
29
|
+
{
|
|
30
|
+
keyType: "ec",
|
|
31
|
+
hash: "sha384",
|
|
32
|
+
curve: "secp384r1",
|
|
33
|
+
order:
|
|
34
|
+
0xffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973n,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
"ES512",
|
|
39
|
+
{
|
|
40
|
+
keyType: "ec",
|
|
41
|
+
hash: "sha512",
|
|
42
|
+
curve: "secp521r1",
|
|
43
|
+
order:
|
|
44
|
+
0x01fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa51868783bf2f966b7fcc0148f709a5d03bb5c9b8899c47aebb6fb71e91386409n,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
20
47
|
["Ed25519", { keyType: "ed25519", hash: null }],
|
|
21
48
|
["Ed448", { keyType: "ed448", hash: null }],
|
|
22
49
|
["HS256", { keyType: "secret", hash: "sha256" }],
|
|
@@ -28,6 +55,49 @@ const SIGNATURE_MODES = ["replace", "signers", "chain"];
|
|
|
28
55
|
|
|
29
56
|
const NESTED_TARGETS = ["components", "services", "annotations"];
|
|
30
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Largest number of entries accepted in a `signers` or `chain` signature.
|
|
60
|
+
* Every entry is checked against the whole signed object, so the bound keeps
|
|
61
|
+
* the cost of verifying a document proportional to its size.
|
|
62
|
+
*/
|
|
63
|
+
export const MAX_SIGNATURE_ENTRIES = 32;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Members the CycloneDX 1.4 to 1.7 and 2.0 schemas define at the root of a
|
|
67
|
+
* BOM. A root signature is only accepted on an object made of these members,
|
|
68
|
+
* so a signed component, service, or annotation cannot pass as a signed BOM.
|
|
69
|
+
*/
|
|
70
|
+
export const BOM_ROOT_MEMBERS = Object.freeze([
|
|
71
|
+
"$schema",
|
|
72
|
+
"bomFormat",
|
|
73
|
+
"specFormat",
|
|
74
|
+
"specVersion",
|
|
75
|
+
"serialNumber",
|
|
76
|
+
"version",
|
|
77
|
+
"metadata",
|
|
78
|
+
"components",
|
|
79
|
+
"services",
|
|
80
|
+
"externalReferences",
|
|
81
|
+
"dependencies",
|
|
82
|
+
"compositions",
|
|
83
|
+
"vulnerabilities",
|
|
84
|
+
"annotations",
|
|
85
|
+
"formulation",
|
|
86
|
+
"declarations",
|
|
87
|
+
"definitions",
|
|
88
|
+
"citations",
|
|
89
|
+
"properties",
|
|
90
|
+
"signature",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const BOM_ROOT_MEMBER_SET = new Set(BOM_ROOT_MEMBERS);
|
|
94
|
+
|
|
95
|
+
// Members that identify a BOM. Components, services, and annotations never hold
|
|
96
|
+
// them, so a nested object carrying one is refused for signing and verification.
|
|
97
|
+
const BOM_IDENTITY_MEMBERS = ["bomFormat", "specFormat", "specVersion"];
|
|
98
|
+
|
|
99
|
+
const MAX_LISTED_MEMBERS = 10;
|
|
100
|
+
|
|
31
101
|
// DER layouts tried when checking whether raw bytes are really a key.
|
|
32
102
|
const DER_KEY_LAYOUTS = [
|
|
33
103
|
[crypto.createPublicKey, "spki"],
|
|
@@ -59,6 +129,31 @@ function isPlainObject(value) {
|
|
|
59
129
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
60
130
|
}
|
|
61
131
|
|
|
132
|
+
function listMembers(names) {
|
|
133
|
+
const shown = names.slice(0, MAX_LISTED_MEMBERS).map(displayValue);
|
|
134
|
+
if (names.length > MAX_LISTED_MEMBERS) {
|
|
135
|
+
shown.push(`and ${names.length - MAX_LISTED_MEMBERS} more`);
|
|
136
|
+
}
|
|
137
|
+
return shown.join(", ");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A BOM names its format with bomFormat (CycloneDX 1.x) or specFormat (2.0).
|
|
141
|
+
function hasBomIdentity(bom) {
|
|
142
|
+
return (
|
|
143
|
+
(bom.bomFormat === "CycloneDX" || bom.specFormat === "CycloneDX") &&
|
|
144
|
+
((typeof bom.specVersion === "string" && bom.specVersion !== "") ||
|
|
145
|
+
typeof bom.specVersion === "number")
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function unexpectedRootMembers(bom) {
|
|
150
|
+
return Object.keys(bom).filter((name) => !BOM_ROOT_MEMBER_SET.has(name));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function bomIdentityMembers(item) {
|
|
154
|
+
return BOM_IDENTITY_MEMBERS.filter((name) => Object.hasOwn(item, name));
|
|
155
|
+
}
|
|
156
|
+
|
|
62
157
|
/**
|
|
63
158
|
* Deterministic JSON canonicalizer following RFC 8785 (JCS), which JSF uses to
|
|
64
159
|
* produce the signed data. Values are serialized the way JSON.stringify would
|
|
@@ -88,6 +183,44 @@ function canonicalize(value) {
|
|
|
88
183
|
return `{${members.join(",")}}`;
|
|
89
184
|
}
|
|
90
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Returns a function that produces the UTF-8 bytes of the canonical form of
|
|
188
|
+
* `{ ...content, signature }` for any signature value, canonicalizing the
|
|
189
|
+
* content members only once. Canonical members are ordered by name, so the
|
|
190
|
+
* `signature` member always sits between the members whose names sort before
|
|
191
|
+
* it and those that sort after it.
|
|
192
|
+
*
|
|
193
|
+
* @param {Object} content - The signed object without its signature member
|
|
194
|
+
* @returns {function(Object): Buffer} - Signed bytes for the given signature member
|
|
195
|
+
*/
|
|
196
|
+
function signedViewSerializer(content) {
|
|
197
|
+
if (typeof content.toJSON === "function") {
|
|
198
|
+
return (signature) =>
|
|
199
|
+
Buffer.from(canonicalize({ ...content, signature }), "utf8");
|
|
200
|
+
}
|
|
201
|
+
const before = [];
|
|
202
|
+
const after = [];
|
|
203
|
+
for (const name of Object.keys(content).sort()) {
|
|
204
|
+
const member = canonicalize(content[name]);
|
|
205
|
+
if (member !== undefined) {
|
|
206
|
+
(name < "signature" ? before : after).push(
|
|
207
|
+
`${JSON.stringify(name)}:${member}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const head = Buffer.from(
|
|
212
|
+
before.length ? `{${before.join(",")},` : "{",
|
|
213
|
+
"utf8",
|
|
214
|
+
);
|
|
215
|
+
const tail = Buffer.from(after.length ? `,${after.join(",")}}` : "}", "utf8");
|
|
216
|
+
return (signature) =>
|
|
217
|
+
Buffer.concat([
|
|
218
|
+
head,
|
|
219
|
+
Buffer.from(`"signature":${canonicalize(signature)}`, "utf8"),
|
|
220
|
+
tail,
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
223
|
+
|
|
91
224
|
function getAlgorithm(alg) {
|
|
92
225
|
const spec = typeof alg === "string" ? JSF_ALGORITHMS.get(alg) : undefined;
|
|
93
226
|
if (!spec) {
|
|
@@ -308,16 +441,33 @@ function signatureOptions(key, spec) {
|
|
|
308
441
|
return options;
|
|
309
442
|
}
|
|
310
443
|
|
|
311
|
-
|
|
312
|
-
|
|
444
|
+
/**
|
|
445
|
+
* Returns the ECDSA signature (r || s) with s in the lower half of the curve
|
|
446
|
+
* order. (r, s) and (r, n - s) both verify, so emitting only the low form gives
|
|
447
|
+
* each signature a single encoding.
|
|
448
|
+
*/
|
|
449
|
+
function toLowS(signature, order) {
|
|
450
|
+
const size = signature.length / 2;
|
|
451
|
+
const s = BigInt(`0x${signature.subarray(size).toString("hex")}`);
|
|
452
|
+
if (s <= order >> 1n) {
|
|
453
|
+
return signature;
|
|
454
|
+
}
|
|
455
|
+
const lowS = Buffer.from(
|
|
456
|
+
(order - s).toString(16).padStart(size * 2, "0"),
|
|
457
|
+
"hex",
|
|
458
|
+
);
|
|
459
|
+
return Buffer.concat([signature.subarray(0, size), lowS]);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function computeSignature(data, key, spec) {
|
|
313
463
|
if (spec.keyType === "secret") {
|
|
314
464
|
return crypto.createHmac(spec.hash, key).update(data).digest();
|
|
315
465
|
}
|
|
316
|
-
|
|
466
|
+
const signature = crypto.sign(spec.hash, data, signatureOptions(key, spec));
|
|
467
|
+
return spec.order ? toLowS(signature, spec.order) : signature;
|
|
317
468
|
}
|
|
318
469
|
|
|
319
|
-
function signatureMatches(
|
|
320
|
-
const data = Buffer.from(canonicalData, "utf8");
|
|
470
|
+
function signatureMatches(data, key, spec, signature) {
|
|
321
471
|
if (spec.keyType === "secret") {
|
|
322
472
|
const expected = crypto.createHmac(spec.hash, key).update(data).digest();
|
|
323
473
|
return (
|
|
@@ -385,6 +535,15 @@ function readSignatureEntries(signature) {
|
|
|
385
535
|
}
|
|
386
536
|
const form = hasSigners ? "signers" : "chain";
|
|
387
537
|
const { [form]: entries, ...wrapper } = signature;
|
|
538
|
+
const wrapperMembers = Object.keys(wrapper);
|
|
539
|
+
if (wrapperMembers.includes("excludes")) {
|
|
540
|
+
throw new Error("Signatures that use JSF excludes are not supported.");
|
|
541
|
+
}
|
|
542
|
+
if (wrapperMembers.length) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
`A ${form} signature may only hold the ${form} array, but it also holds ${listMembers(wrapperMembers)}.`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
388
547
|
if (
|
|
389
548
|
!Array.isArray(entries) ||
|
|
390
549
|
!entries.length ||
|
|
@@ -392,6 +551,11 @@ function readSignatureEntries(signature) {
|
|
|
392
551
|
) {
|
|
393
552
|
throw new Error(`The ${form} array must contain signature objects.`);
|
|
394
553
|
}
|
|
554
|
+
if (entries.length > MAX_SIGNATURE_ENTRIES) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`The ${form} array has ${entries.length} entries; at most ${MAX_SIGNATURE_ENTRIES} are supported.`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
395
559
|
return { form, entries, wrapper };
|
|
396
560
|
}
|
|
397
561
|
|
|
@@ -401,26 +565,26 @@ function withoutValue(entry) {
|
|
|
401
565
|
}
|
|
402
566
|
|
|
403
567
|
/**
|
|
404
|
-
* Builds the
|
|
568
|
+
* Builds the signature member that is signed for entry `index`.
|
|
405
569
|
*
|
|
406
570
|
* Following JSF, only the `value` of the entry being processed is removed.
|
|
407
571
|
* Other `signers` are removed entirely, while a `chain` entry keeps every
|
|
408
572
|
* earlier entry (including its value) and drops the later ones.
|
|
409
573
|
*/
|
|
410
|
-
function
|
|
574
|
+
function jsfSignedSignature(form, entries, wrapper, index) {
|
|
411
575
|
if (form === "single") {
|
|
412
|
-
return
|
|
576
|
+
return withoutValue(entries[index]);
|
|
413
577
|
}
|
|
414
578
|
const kept =
|
|
415
579
|
form === "signers"
|
|
416
580
|
? [withoutValue(entries[index])]
|
|
417
581
|
: [...entries.slice(0, index), withoutValue(entries[index])];
|
|
418
|
-
return { ...
|
|
582
|
+
return { ...wrapper, [form]: kept };
|
|
419
583
|
}
|
|
420
584
|
|
|
421
|
-
function
|
|
422
|
-
const
|
|
423
|
-
|
|
585
|
+
function candidateSignatures(parsed, index) {
|
|
586
|
+
const signatures = [
|
|
587
|
+
jsfSignedSignature(parsed.form, parsed.entries, parsed.wrapper, index),
|
|
424
588
|
];
|
|
425
589
|
// Appending a signers or chain entry to a single signature moves that
|
|
426
590
|
// signature into the new array. It was signed in the single signature form,
|
|
@@ -430,9 +594,29 @@ function candidateViews(content, parsed, index) {
|
|
|
430
594
|
(parsed.form === "signers" || (parsed.form === "chain" && index === 0)) &&
|
|
431
595
|
!Object.keys(parsed.wrapper).length
|
|
432
596
|
) {
|
|
433
|
-
|
|
597
|
+
signatures.push(jsfSignedSignature("single", parsed.entries, {}, index));
|
|
434
598
|
}
|
|
435
|
-
return
|
|
599
|
+
return signatures;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Parses a node's signature and returns a function that builds the signed bytes
|
|
604
|
+
* of an entry. The node content is canonicalized once, on first use.
|
|
605
|
+
*
|
|
606
|
+
* @param {Object} node - A signed BOM, component, service, or annotation
|
|
607
|
+
* @returns {{ parsed: Object, signedData: function(number): Buffer[] }}
|
|
608
|
+
*/
|
|
609
|
+
function prepareSignedNode(node) {
|
|
610
|
+
const parsed = readSignatureEntries(node.signature);
|
|
611
|
+
const { signature: _signature, ...content } = node;
|
|
612
|
+
let serialize;
|
|
613
|
+
return {
|
|
614
|
+
parsed,
|
|
615
|
+
signedData(index) {
|
|
616
|
+
serialize ??= signedViewSerializer(content);
|
|
617
|
+
return candidateSignatures(parsed, index).map(serialize);
|
|
618
|
+
},
|
|
619
|
+
};
|
|
436
620
|
}
|
|
437
621
|
|
|
438
622
|
function decodeSignatureValue(value) {
|
|
@@ -446,14 +630,14 @@ function decodeSignatureValue(value) {
|
|
|
446
630
|
return signature;
|
|
447
631
|
}
|
|
448
632
|
|
|
449
|
-
|
|
450
|
-
|
|
633
|
+
/**
|
|
634
|
+
* Checks one signature entry with one key. `signedData` returns the bytes the
|
|
635
|
+
* entry may cover; it is only called once the entry and key are compatible.
|
|
636
|
+
*/
|
|
637
|
+
function verifyEntry(entry, key, signedData) {
|
|
451
638
|
const spec = getAlgorithm(entry.algorithm);
|
|
452
639
|
const signature = decodeSignatureValue(entry.value);
|
|
453
|
-
if (
|
|
454
|
-
Object.hasOwn(entry, "excludes") ||
|
|
455
|
-
Object.hasOwn(parsed.wrapper, "excludes")
|
|
456
|
-
) {
|
|
640
|
+
if (Object.hasOwn(entry, "excludes")) {
|
|
457
641
|
throw new Error("Signatures that use JSF excludes are not supported.");
|
|
458
642
|
}
|
|
459
643
|
assertKeyMatchesAlgorithm(key, entry.algorithm, spec);
|
|
@@ -467,13 +651,117 @@ function verifyEntry(content, parsed, index, key) {
|
|
|
467
651
|
);
|
|
468
652
|
}
|
|
469
653
|
}
|
|
470
|
-
return
|
|
471
|
-
signatureMatches(
|
|
654
|
+
return signedData().some((data) =>
|
|
655
|
+
signatureMatches(data, key, spec, signature),
|
|
472
656
|
);
|
|
473
657
|
}
|
|
474
658
|
|
|
475
659
|
function mismatchReason(entry) {
|
|
476
|
-
return `The ${entry.algorithm} signature does not verify with this key: the signed content changed, or a different key made it.`;
|
|
660
|
+
return `The ${displayValue(entry.algorithm)} signature does not verify with this key: the signed content changed, or a different key made it.`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Checks every entry of a node's signature against the trusted keys.
|
|
665
|
+
*
|
|
666
|
+
* @param {Object} node - The signed object
|
|
667
|
+
* @param {crypto.KeyObject[]} keys - Loaded verification keys
|
|
668
|
+
* @param {Object} [options]
|
|
669
|
+
* @param {boolean} [options.requireAll] - The node verifies only when every entry does
|
|
670
|
+
* @param {boolean} [options.requireEveryKey] - With requireAll, every key must also verify at least one entry
|
|
671
|
+
* @param {boolean} [options.firstMatch] - Stop at the first entry that verifies
|
|
672
|
+
* @returns {{ form: string|undefined, verified: boolean, match: Object|null, reasons: string[], entries: Object[], keysMatched: boolean[] }}
|
|
673
|
+
* `reasons` explains a signature that could not be read; each entry carries its own reasons.
|
|
674
|
+
* `keysMatched[i]` tells whether `keys[i]` verified at least one entry.
|
|
675
|
+
*/
|
|
676
|
+
function checkNode(node, keys, options = {}) {
|
|
677
|
+
const result = {
|
|
678
|
+
form: undefined,
|
|
679
|
+
verified: false,
|
|
680
|
+
match: null,
|
|
681
|
+
reasons: [],
|
|
682
|
+
entries: [],
|
|
683
|
+
keysMatched: keys.map(() => false),
|
|
684
|
+
};
|
|
685
|
+
let prepared;
|
|
686
|
+
try {
|
|
687
|
+
prepared = prepareSignedNode(node);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
result.reasons.push(err.message);
|
|
690
|
+
return result;
|
|
691
|
+
}
|
|
692
|
+
const { parsed } = prepared;
|
|
693
|
+
result.form = parsed.form;
|
|
694
|
+
for (const [index, entry] of parsed.entries.entries()) {
|
|
695
|
+
const reasons = [];
|
|
696
|
+
let verified = false;
|
|
697
|
+
// The signed bytes of one entry are built at most once and released before
|
|
698
|
+
// the next entry, so memory stays proportional to the node size.
|
|
699
|
+
let data;
|
|
700
|
+
const signedData = () => {
|
|
701
|
+
data ??= prepared.signedData(index);
|
|
702
|
+
return data;
|
|
703
|
+
};
|
|
704
|
+
for (const [keyIndex, key] of keys.entries()) {
|
|
705
|
+
try {
|
|
706
|
+
if (verifyEntry(entry, key, signedData)) {
|
|
707
|
+
verified = true;
|
|
708
|
+
result.keysMatched[keyIndex] = true;
|
|
709
|
+
if (!options.requireAll) {
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
reasons.push(mismatchReason(entry));
|
|
715
|
+
} catch (err) {
|
|
716
|
+
reasons.push(err.message);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
result.entries.push({
|
|
720
|
+
index,
|
|
721
|
+
algorithm: entry.algorithm,
|
|
722
|
+
keyId: entry.keyId,
|
|
723
|
+
verified,
|
|
724
|
+
reasons: verified ? [] : [...new Set(reasons)],
|
|
725
|
+
});
|
|
726
|
+
if (verified && !result.match) {
|
|
727
|
+
result.match = entry;
|
|
728
|
+
if (options.firstMatch) {
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
result.verified = options.requireAll
|
|
734
|
+
? result.entries.length === parsed.entries.length &&
|
|
735
|
+
result.entries.every((entry) => entry.verified) &&
|
|
736
|
+
(!options.requireEveryKey || result.keysMatched.every(Boolean))
|
|
737
|
+
: Boolean(result.match);
|
|
738
|
+
return result;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function entryReasons(result) {
|
|
742
|
+
if (result.reasons.length) {
|
|
743
|
+
return result.reasons;
|
|
744
|
+
}
|
|
745
|
+
return result.entries.flatMap((entry) =>
|
|
746
|
+
entry.reasons.map((reason) =>
|
|
747
|
+
result.form === "single"
|
|
748
|
+
? reason
|
|
749
|
+
: `${result.form}[${entry.index}]: ${reason}`,
|
|
750
|
+
),
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function nestedLabel(field, index, item) {
|
|
755
|
+
let name = index;
|
|
756
|
+
if (isPlainObject(item)) {
|
|
757
|
+
name =
|
|
758
|
+
item["bom-ref"] ||
|
|
759
|
+
item.name ||
|
|
760
|
+
(Array.isArray(item.subjects) && item.subjects.length
|
|
761
|
+
? item.subjects
|
|
762
|
+
: index);
|
|
763
|
+
}
|
|
764
|
+
return `${field}[${index}] '${displayValue(name)}'`;
|
|
477
765
|
}
|
|
478
766
|
|
|
479
767
|
function signatureLayout(target, mode) {
|
|
@@ -486,13 +774,17 @@ function signatureLayout(target, mode) {
|
|
|
486
774
|
};
|
|
487
775
|
}
|
|
488
776
|
const parsed = readSignatureEntries(existing);
|
|
489
|
-
if (parsed.form
|
|
490
|
-
return { form: mode, entries: parsed.entries, wrapper: {} };
|
|
491
|
-
}
|
|
492
|
-
if (parsed.form !== mode) {
|
|
777
|
+
if (parsed.form !== "single" && parsed.form !== mode) {
|
|
493
778
|
throw new Error("Cannot mix signature chains and multi-signers.");
|
|
494
779
|
}
|
|
495
|
-
|
|
780
|
+
if (parsed.entries.length >= MAX_SIGNATURE_ENTRIES) {
|
|
781
|
+
throw new Error(
|
|
782
|
+
`Cannot append another ${mode} entry: a signature holds at most ${MAX_SIGNATURE_ENTRIES} entries.`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
return parsed.form === "single"
|
|
786
|
+
? { form: mode, entries: parsed.entries, wrapper: {} }
|
|
787
|
+
: parsed;
|
|
496
788
|
}
|
|
497
789
|
|
|
498
790
|
function addSignature(target, key, spec, template, layout) {
|
|
@@ -500,22 +792,27 @@ function addSignature(target, key, spec, template, layout) {
|
|
|
500
792
|
const entry = structuredClone(template);
|
|
501
793
|
const entries = [...layout.entries, entry];
|
|
502
794
|
const index = entries.length - 1;
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
layout.form,
|
|
506
|
-
entries,
|
|
507
|
-
layout.wrapper,
|
|
508
|
-
index,
|
|
509
|
-
);
|
|
510
|
-
entry.value = computeSignature(canonicalize(view), key, spec).toString(
|
|
511
|
-
"base64url",
|
|
795
|
+
const signedData = signedViewSerializer(content)(
|
|
796
|
+
jsfSignedSignature(layout.form, entries, layout.wrapper, index),
|
|
512
797
|
);
|
|
798
|
+
entry.value = computeSignature(signedData, key, spec).toString("base64url");
|
|
513
799
|
target.signature =
|
|
514
800
|
layout.form === "single"
|
|
515
801
|
? entry
|
|
516
802
|
: { ...layout.wrapper, [layout.form]: entries };
|
|
517
803
|
}
|
|
518
804
|
|
|
805
|
+
function signatureTemplate(algorithm, publicKey, keyId) {
|
|
806
|
+
const template = { algorithm };
|
|
807
|
+
if (publicKey) {
|
|
808
|
+
template.publicKey = publicKey;
|
|
809
|
+
}
|
|
810
|
+
if (keyId !== null && keyId !== undefined && keyId !== "") {
|
|
811
|
+
template.keyId = String(keyId);
|
|
812
|
+
}
|
|
813
|
+
return template;
|
|
814
|
+
}
|
|
815
|
+
|
|
519
816
|
/**
|
|
520
817
|
* Applies JSF signatures to the BOM and, optionally, its components, services,
|
|
521
818
|
* and annotations.
|
|
@@ -525,11 +822,18 @@ function addSignature(target, key, spec, template, layout) {
|
|
|
525
822
|
* skipped by default, because changing them would invalidate the existing root
|
|
526
823
|
* signature; explicitly requesting nested signing in that case is an error.
|
|
527
824
|
*
|
|
825
|
+
* The BOM root must carry `bomFormat` (or `specFormat`) and `specVersion`, and
|
|
826
|
+
* only members that CycloneDX defines at the root. A nested element that
|
|
827
|
+
* carries `bomFormat`, `specFormat`, or `specVersion` is refused. Verification
|
|
828
|
+
* rejects signatures over either shape.
|
|
829
|
+
*
|
|
528
830
|
* @param {Object} bomJson - CycloneDX BOM Object
|
|
529
831
|
* @param {Object} options - Signing options
|
|
530
832
|
* @param {string|Buffer|Object|crypto.KeyObject} options.privateKey - Private key (PEM or KeyObject), or the shared secret for HS* algorithms
|
|
531
833
|
* @param {string} [options.algorithm] - JSF algorithm identifier (default RS512). It must match the key type.
|
|
532
834
|
* @param {Object} [options.publicKeyJwk] - JWK of the signing key's public key to embed
|
|
835
|
+
* @param {boolean} [options.embedPublicKey] - Embed the public key derived from the signing key when publicKeyJwk is not given. Ignored for HS* algorithms and for keys without a JWK form, such as rsa-pss.
|
|
836
|
+
* @param {boolean} [options.embedPublicKeyNested] - Also embed the public key in nested signatures (default true)
|
|
533
837
|
* @param {string} [options.keyId] - Key ID to embed
|
|
534
838
|
* @param {string} [options.mode] - 'replace' (default), 'signers', or 'chain'
|
|
535
839
|
* @param {boolean} [options.signComponents] - Sign each component
|
|
@@ -542,6 +846,7 @@ export function signBom(bomJson, options = {}) {
|
|
|
542
846
|
privateKey,
|
|
543
847
|
algorithm = "RS512",
|
|
544
848
|
publicKeyJwk = null,
|
|
849
|
+
embedPublicKeyNested = true,
|
|
545
850
|
keyId = null,
|
|
546
851
|
mode = "replace",
|
|
547
852
|
} = options;
|
|
@@ -554,16 +859,37 @@ export function signBom(bomJson, options = {}) {
|
|
|
554
859
|
`Unsupported signature mode: ${mode}. Use one of ${SIGNATURE_MODES.join(", ")}.`,
|
|
555
860
|
);
|
|
556
861
|
}
|
|
862
|
+
if (!isPlainObject(bomJson)) {
|
|
863
|
+
throw new Error("The BOM must be a JSON object.");
|
|
864
|
+
}
|
|
865
|
+
if (!hasBomIdentity(bomJson)) {
|
|
866
|
+
throw new Error(
|
|
867
|
+
'Only a CycloneDX BOM can be signed: its root needs bomFormat (or specFormat) "CycloneDX" and a specVersion.',
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
const unexpected = unexpectedRootMembers(bomJson);
|
|
871
|
+
if (unexpected.length) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`The BOM has members that CycloneDX does not define at the root of a BOM: ${listMembers(unexpected)}. Remove them before signing.`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
557
876
|
const spec = getAlgorithm(algorithm);
|
|
558
877
|
const signingKey = loadSigningKey(privateKey, algorithm, spec);
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
878
|
+
let publicKey = embeddablePublicKey(publicKeyJwk, signingKey, algorithm);
|
|
879
|
+
if (!publicKey && options.embedPublicKey && signingKey.type !== "secret") {
|
|
880
|
+
try {
|
|
881
|
+
publicKey = crypto.createPublicKey(signingKey).export({ format: "jwk" });
|
|
882
|
+
} catch {
|
|
883
|
+
// Some key types, such as rsa-pss, have no JWK form and are not embedded.
|
|
884
|
+
publicKey = undefined;
|
|
885
|
+
}
|
|
566
886
|
}
|
|
887
|
+
const rootTemplate = signatureTemplate(algorithm, publicKey, keyId);
|
|
888
|
+
const nestedTemplate = signatureTemplate(
|
|
889
|
+
algorithm,
|
|
890
|
+
embedPublicKeyNested ? publicKey : undefined,
|
|
891
|
+
keyId,
|
|
892
|
+
);
|
|
567
893
|
|
|
568
894
|
const appending = mode !== "replace" && bomJson.signature !== undefined;
|
|
569
895
|
const requested = {
|
|
@@ -586,17 +912,24 @@ export function signBom(bomJson, options = {}) {
|
|
|
586
912
|
`Cannot sign ${field} while appending a ${mode} signature, because that changes content covered by the existing root signature. Sign the root only (for example, --no-sign-${field}).`,
|
|
587
913
|
);
|
|
588
914
|
}
|
|
589
|
-
for (const item of items) {
|
|
590
|
-
if (isPlainObject(item)) {
|
|
591
|
-
|
|
915
|
+
for (const [index, item] of items.entries()) {
|
|
916
|
+
if (!isPlainObject(item)) {
|
|
917
|
+
continue;
|
|
592
918
|
}
|
|
919
|
+
const identity = bomIdentityMembers(item);
|
|
920
|
+
if (identity.length) {
|
|
921
|
+
throw new Error(
|
|
922
|
+
`Cannot sign ${nestedLabel(field, index, item)}: it carries ${identity.join(", ")}, which only the root of a BOM may hold.`,
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
plan.push([item, signatureLayout(item, mode)]);
|
|
593
926
|
}
|
|
594
927
|
}
|
|
595
928
|
const rootLayout = signatureLayout(bomJson, mode);
|
|
596
929
|
for (const [item, layout] of plan) {
|
|
597
|
-
addSignature(item, signingKey, spec,
|
|
930
|
+
addSignature(item, signingKey, spec, nestedTemplate, layout);
|
|
598
931
|
}
|
|
599
|
-
addSignature(bomJson, signingKey, spec,
|
|
932
|
+
addSignature(bomJson, signingKey, spec, rootTemplate, rootLayout);
|
|
600
933
|
return bomJson;
|
|
601
934
|
}
|
|
602
935
|
|
|
@@ -609,6 +942,9 @@ export function signBom(bomJson, options = {}) {
|
|
|
609
942
|
* embedded publicKey (if any) is the verification key, and its value covers the
|
|
610
943
|
* node content plus the signature metadata as JSF defines.
|
|
611
944
|
*
|
|
945
|
+
* This checks one object only. To verify a BOM, use {@link verifyBomSignatures}
|
|
946
|
+
* or {@link verifyBom}, which also require the signed root to be a BOM.
|
|
947
|
+
*
|
|
612
948
|
* @param {Object} node - The BOM or granular object to verify
|
|
613
949
|
* @param {string|Buffer|Object|crypto.KeyObject} publicKey - Verification key. Strings and Buffers are parsed as public keys; use loadSharedSecret() for HMAC.
|
|
614
950
|
* @param {Object} [options]
|
|
@@ -620,35 +956,189 @@ export function verifyNode(node, publicKey, options = {}) {
|
|
|
620
956
|
return false;
|
|
621
957
|
}
|
|
622
958
|
const key = loadVerificationKey(publicKey);
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
parsed = readSignatureEntries(node.signature);
|
|
627
|
-
} catch (err) {
|
|
628
|
-
reasons?.push(err.message);
|
|
629
|
-
return false;
|
|
959
|
+
const result = checkNode(node, [key], { firstMatch: true });
|
|
960
|
+
if (result.match) {
|
|
961
|
+
return result.match;
|
|
630
962
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
963
|
+
if (Array.isArray(options.reasons)) {
|
|
964
|
+
options.reasons.push(...entryReasons(result));
|
|
965
|
+
}
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Verifies the signatures of a BOM against one or more trusted keys and reports
|
|
971
|
+
* the outcome for every signature entry.
|
|
972
|
+
*
|
|
973
|
+
* The BOM verifies when:
|
|
974
|
+
* - its root carries `bomFormat` (or `specFormat`) "CycloneDX" and
|
|
975
|
+
* `specVersion`, and only members that CycloneDX defines at the root of a BOM;
|
|
976
|
+
* - its root signature verifies, meaning at least one entry verifies with one
|
|
977
|
+
* of the keys. With `requireAll`, every entry must verify with one of the
|
|
978
|
+
* keys, and every key must verify at least one entry;
|
|
979
|
+
* - with `deep`, every signed component, service, and annotation verifies the
|
|
980
|
+
* same way and carries none of `bomFormat`, `specFormat`, and `specVersion`.
|
|
981
|
+
*
|
|
982
|
+
* A BOM without a root signature fails unless `allowNestedOnly` is set. Then
|
|
983
|
+
* every component, service, and annotation must carry a signature that
|
|
984
|
+
* verifies, and `notices` lists the members that no signature covers.
|
|
985
|
+
*
|
|
986
|
+
* Parse untrusted BOM text with `parseStrictJson()` from
|
|
987
|
+
* `lib/parsers/strictJson.js` before calling this function, so that the
|
|
988
|
+
* verified values are the values every JSON parser reads.
|
|
989
|
+
*
|
|
990
|
+
* @param {Object} bom - CycloneDX BOM Object
|
|
991
|
+
* @param {Array<string|Buffer|Object|crypto.KeyObject>|string|Buffer|Object|crypto.KeyObject} keys - Trusted verification keys (see verifyNode)
|
|
992
|
+
* @param {Object} [options]
|
|
993
|
+
* @param {boolean} [options.deep] - Also verify nested signatures (default true)
|
|
994
|
+
* @param {boolean} [options.allowNestedOnly] - Accept a BOM without a root signature when every nested element is signed
|
|
995
|
+
* @param {boolean} [options.requireAll] - Require every entry of each signature to verify, and every key to verify an entry of the root signature
|
|
996
|
+
* @returns {{ verified: boolean, errors: string[], notices: string[], root: Object|null, nested: Object[] }}
|
|
997
|
+
* `root` and each `nested` item hold `label`, `form`, `verified`, `match` (the first verified entry),
|
|
998
|
+
* `reasons` (why the signature could not be read), `entries` (`index`, `algorithm`, `keyId`, `verified`, `reasons`),
|
|
999
|
+
* and `keysMatched` (whether each key verified an entry).
|
|
1000
|
+
*/
|
|
1001
|
+
export function verifyBomSignatures(bom, keys, options = {}) {
|
|
1002
|
+
const deep = options.deep !== false;
|
|
1003
|
+
const allowNestedOnly = Boolean(options.allowNestedOnly);
|
|
1004
|
+
const requireAll = Boolean(options.requireAll);
|
|
1005
|
+
const trustedKeys = (Array.isArray(keys) ? keys : [keys]).map((key) =>
|
|
1006
|
+
loadVerificationKey(key),
|
|
1007
|
+
);
|
|
1008
|
+
if (!trustedKeys.length) {
|
|
1009
|
+
throw new Error("At least one verification key is required.");
|
|
1010
|
+
}
|
|
1011
|
+
const report = {
|
|
1012
|
+
verified: false,
|
|
1013
|
+
errors: [],
|
|
1014
|
+
notices: [],
|
|
1015
|
+
root: null,
|
|
1016
|
+
nested: [],
|
|
1017
|
+
};
|
|
1018
|
+
if (!isPlainObject(bom)) {
|
|
1019
|
+
report.errors.push("The BOM must be a JSON object.");
|
|
1020
|
+
return report;
|
|
1021
|
+
}
|
|
1022
|
+
if (!hasBomIdentity(bom)) {
|
|
1023
|
+
report.errors.push(
|
|
1024
|
+
'The signed object is not a CycloneDX BOM: its root needs bomFormat (or specFormat) "CycloneDX" and a specVersion.',
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
const unexpected = unexpectedRootMembers(bom);
|
|
1028
|
+
if (unexpected.length) {
|
|
1029
|
+
report.errors.push(
|
|
1030
|
+
`The BOM has members that CycloneDX does not define at the root of a BOM: ${listMembers(unexpected)}.`,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
const nestedOnly = bom.signature === undefined;
|
|
1034
|
+
if (!nestedOnly) {
|
|
1035
|
+
report.root = {
|
|
1036
|
+
label: "BOM",
|
|
1037
|
+
...checkNode(bom, trustedKeys, { requireAll, requireEveryKey: true }),
|
|
1038
|
+
};
|
|
1039
|
+
const unmatched = report.root.keysMatched.flatMap((matched, index) =>
|
|
1040
|
+
matched ? [] : [`#${index + 1}`],
|
|
1041
|
+
);
|
|
1042
|
+
if (requireAll && report.root.form && unmatched.length) {
|
|
1043
|
+
report.errors.push(
|
|
1044
|
+
`No entry of the root signature verifies with key ${unmatched.join(", ")}, so a signature expected from that key is missing.`,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
} else if (!allowNestedOnly) {
|
|
1048
|
+
report.errors.push(
|
|
1049
|
+
"The BOM has no root signature, so no signature covers the BOM as a whole.",
|
|
1050
|
+
);
|
|
1051
|
+
} else if (!deep) {
|
|
1052
|
+
report.errors.push(
|
|
1053
|
+
"The BOM has no root signature, and nested signatures were not checked.",
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
if (deep) {
|
|
1057
|
+
let signedCount = 0;
|
|
1058
|
+
for (const field of NESTED_TARGETS) {
|
|
1059
|
+
const items = bom[field];
|
|
1060
|
+
if (items === undefined) {
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if (!Array.isArray(items)) {
|
|
1064
|
+
if (nestedOnly) {
|
|
1065
|
+
report.errors.push(`${field} is not an array of signed objects.`);
|
|
1066
|
+
}
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
const unsigned = [];
|
|
1070
|
+
for (const [index, item] of items.entries()) {
|
|
1071
|
+
const label = nestedLabel(field, index, item);
|
|
1072
|
+
if (!isPlainObject(item) || item.signature === undefined) {
|
|
1073
|
+
unsigned.push(label);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
signedCount++;
|
|
1077
|
+
const identity = bomIdentityMembers(item);
|
|
1078
|
+
if (identity.length) {
|
|
1079
|
+
report.errors.push(
|
|
1080
|
+
`${label} carries ${identity.join(", ")}, which only the root of a BOM may hold.`,
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
report.nested.push({
|
|
1084
|
+
label,
|
|
1085
|
+
...checkNode(item, trustedKeys, { requireAll }),
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
if (nestedOnly && unsigned.length) {
|
|
1089
|
+
report.errors.push(
|
|
1090
|
+
`${unsigned.length} of ${items.length} ${field} are not signed, starting with ${unsigned[0]}.`,
|
|
1091
|
+
);
|
|
638
1092
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1093
|
+
}
|
|
1094
|
+
if (nestedOnly && allowNestedOnly) {
|
|
1095
|
+
if (!signedCount) {
|
|
1096
|
+
report.errors.push("The BOM has no signatures.");
|
|
1097
|
+
}
|
|
1098
|
+
const uncovered = Object.keys(bom).filter(
|
|
1099
|
+
(name) => !NESTED_TARGETS.includes(name),
|
|
1100
|
+
);
|
|
1101
|
+
report.notices.push(
|
|
1102
|
+
uncovered.length
|
|
1103
|
+
? `Without a root signature, no signature covers ${listMembers(uncovered)}, or which components, services, and annotations the BOM lists and in what order.`
|
|
1104
|
+
: "Without a root signature, no signature covers which components, services, and annotations the BOM lists or in what order.",
|
|
1105
|
+
);
|
|
642
1106
|
}
|
|
643
1107
|
}
|
|
644
|
-
|
|
645
|
-
|
|
1108
|
+
report.verified =
|
|
1109
|
+
!report.errors.length &&
|
|
1110
|
+
(report.root ? report.root.verified : allowNestedOnly && deep) &&
|
|
1111
|
+
report.nested.every((item) => item.verified);
|
|
1112
|
+
return report;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Flattens a {@link verifyBomSignatures} report into the reasons it did not
|
|
1117
|
+
* verify: policy errors first, then each rejected root entry, then each
|
|
1118
|
+
* rejected nested entry prefixed with the element it belongs to.
|
|
1119
|
+
*
|
|
1120
|
+
* @param {Object} report - Result of verifyBomSignatures
|
|
1121
|
+
* @returns {string[]} - Reasons, empty when the report verified
|
|
1122
|
+
*/
|
|
1123
|
+
export function signatureReportReasons(report) {
|
|
1124
|
+
const reasons = [...report.errors];
|
|
1125
|
+
if (report.root && !report.root.verified) {
|
|
1126
|
+
reasons.push(...entryReasons(report.root));
|
|
1127
|
+
}
|
|
1128
|
+
for (const item of report.nested) {
|
|
1129
|
+
if (!item.verified) {
|
|
1130
|
+
reasons.push(
|
|
1131
|
+
...entryReasons(item).map((reason) => `${item.label}: ${reason}`),
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
return reasons;
|
|
646
1136
|
}
|
|
647
1137
|
|
|
648
1138
|
/**
|
|
649
1139
|
* Verifies a BOM's top-level signature, as well as nested components, services,
|
|
650
|
-
* and annotations. Returns the root match only if the
|
|
651
|
-
*
|
|
1140
|
+
* and annotations, with one key. Returns the root match only if the BOM verifies
|
|
1141
|
+
* as {@link verifyBomSignatures} defines with `deep` enabled.
|
|
652
1142
|
*
|
|
653
1143
|
* @param {Object} bom - CycloneDX BOM Object
|
|
654
1144
|
* @param {string|Buffer|Object|crypto.KeyObject} publicKey - Verification key (see verifyNode)
|
|
@@ -656,44 +1146,25 @@ export function verifyNode(node, publicKey, options = {}) {
|
|
|
656
1146
|
* @returns {boolean|Object} - Signature object if valid. False otherwise.
|
|
657
1147
|
*/
|
|
658
1148
|
export function verifyBom(bom, publicKey, options = {}) {
|
|
659
|
-
if (!bom
|
|
1149
|
+
if (!isPlainObject(bom) || !bom.signature) {
|
|
660
1150
|
return false;
|
|
661
1151
|
}
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (!rootMatch) {
|
|
666
|
-
return false;
|
|
1152
|
+
const report = verifyBomSignatures(bom, [publicKey]);
|
|
1153
|
+
if (report.verified) {
|
|
1154
|
+
return report.root.match;
|
|
667
1155
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
continue;
|
|
671
|
-
}
|
|
672
|
-
for (const [index, item] of bom[field].entries()) {
|
|
673
|
-
if (!item?.signature) {
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
const nestedReasons = [];
|
|
677
|
-
if (!verifyNode(item, key, { ...options, reasons: nestedReasons })) {
|
|
678
|
-
const name = displayValue(
|
|
679
|
-
item["bom-ref"] || item.name || item.subject || index,
|
|
680
|
-
);
|
|
681
|
-
for (const reason of nestedReasons) {
|
|
682
|
-
reasons?.push(`${field}[${index}] '${name}': ${reason}`);
|
|
683
|
-
}
|
|
684
|
-
return false;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
1156
|
+
if (Array.isArray(options.reasons)) {
|
|
1157
|
+
options.reasons.push(...signatureReportReasons(report));
|
|
687
1158
|
}
|
|
688
|
-
return
|
|
1159
|
+
return false;
|
|
689
1160
|
}
|
|
690
1161
|
|
|
691
1162
|
/**
|
|
692
1163
|
* Checks every signature entry of a node against a set of trusted keys. An
|
|
693
1164
|
* entry is verified when one of the keys verifies that entry.
|
|
694
1165
|
*
|
|
695
|
-
* `cdx-sign` uses this before appending
|
|
696
|
-
*
|
|
1166
|
+
* `cdx-sign` uses this before appending a `signers` or `chain` entry, to check
|
|
1167
|
+
* that the content still carries the earlier signatures.
|
|
697
1168
|
*
|
|
698
1169
|
* @param {Object} node - The BOM or granular object whose signature is checked
|
|
699
1170
|
* @param {Array<string|Buffer|Object|crypto.KeyObject>} [keys] - Trusted verification keys (see verifyNode)
|
|
@@ -704,40 +1175,17 @@ export function checkSignatureEntries(node, keys = []) {
|
|
|
704
1175
|
return [];
|
|
705
1176
|
}
|
|
706
1177
|
const trustedKeys = keys.map((key) => loadVerificationKey(key));
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
parsed = readSignatureEntries(node.signature);
|
|
710
|
-
} catch (err) {
|
|
1178
|
+
const result = checkNode(node, trustedKeys);
|
|
1179
|
+
if (result.reasons.length) {
|
|
711
1180
|
return [
|
|
712
1181
|
{
|
|
713
1182
|
index: 0,
|
|
714
1183
|
algorithm: undefined,
|
|
715
1184
|
keyId: undefined,
|
|
716
1185
|
verified: false,
|
|
717
|
-
reasons:
|
|
1186
|
+
reasons: result.reasons,
|
|
718
1187
|
},
|
|
719
1188
|
];
|
|
720
1189
|
}
|
|
721
|
-
|
|
722
|
-
return parsed.entries.map((entry, index) => {
|
|
723
|
-
const reasons = [];
|
|
724
|
-
const verified = trustedKeys.some((key) => {
|
|
725
|
-
try {
|
|
726
|
-
if (verifyEntry(content, parsed, index, key)) {
|
|
727
|
-
return true;
|
|
728
|
-
}
|
|
729
|
-
reasons.push(mismatchReason(entry));
|
|
730
|
-
} catch (err) {
|
|
731
|
-
reasons.push(err.message);
|
|
732
|
-
}
|
|
733
|
-
return false;
|
|
734
|
-
});
|
|
735
|
-
return {
|
|
736
|
-
index,
|
|
737
|
-
algorithm: entry.algorithm,
|
|
738
|
-
keyId: entry.keyId,
|
|
739
|
-
verified,
|
|
740
|
-
reasons: verified ? [] : [...new Set(reasons)],
|
|
741
|
-
};
|
|
742
|
-
});
|
|
1190
|
+
return result.entries;
|
|
743
1191
|
}
|