@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.
Files changed (255) hide show
  1. package/README.md +9 -6
  2. package/bin/cdxgen.js +90 -26
  3. package/bin/evinse.js +18 -1
  4. package/bin/repl.js +86 -0
  5. package/bin/sign.js +45 -15
  6. package/bin/tracebom.js +5 -1
  7. package/bin/validate.js +84 -18
  8. package/bin/verify.js +155 -86
  9. package/data/helpers/init.gradle +46 -21
  10. package/data/remediations.json +61 -0
  11. package/data/rules/dependency-sources.yaml +67 -0
  12. package/data/rules/scala-evidence.yaml +115 -0
  13. package/lib/audit/index.js +38 -6
  14. package/lib/cli/index.js +113 -54
  15. package/lib/cli/jsBom.js +50 -29
  16. package/lib/cli/jvmBom.js +665 -138
  17. package/lib/cli/managedBom.js +320 -200
  18. package/lib/cli/nativeBom.js +139 -30
  19. package/lib/core/activity.js +12 -0
  20. package/lib/core/env.js +17 -9
  21. package/lib/core/fs.js +3 -2
  22. package/lib/core/httpClient.js +159 -24
  23. package/lib/core/pluginRun.js +184 -0
  24. package/lib/core/processTree.js +100 -0
  25. package/lib/core/runState.js +39 -0
  26. package/lib/core/toml.js +72 -0
  27. package/lib/ecosystems/asarutils.js +4 -2
  28. package/lib/ecosystems/bunutils.js +5 -7
  29. package/lib/ecosystems/cargoCache.js +231 -8
  30. package/lib/ecosystems/cmakeResolver.js +10 -21
  31. package/lib/ecosystems/cppBuildContext.js +631 -0
  32. package/lib/ecosystems/cppEvidence.js +337 -22
  33. package/lib/ecosystems/cppIncludeResolver.js +294 -0
  34. package/lib/ecosystems/denoutils.js +93 -66
  35. package/lib/ecosystems/ecosystems.js +1967 -788
  36. package/lib/ecosystems/golem.js +28 -12
  37. package/lib/ecosystems/gradleutils.js +314 -30
  38. package/lib/ecosystems/jsEvidence.js +85 -24
  39. package/lib/ecosystems/kosi.js +21 -4
  40. package/lib/ecosystems/npmutils.js +64 -0
  41. package/lib/ecosystems/parsers-dotnet.js +567 -151
  42. package/lib/ecosystems/parsers-elm.js +46 -28
  43. package/lib/ecosystems/parsers-gleam.js +3 -4
  44. package/lib/ecosystems/parsers-go.js +324 -61
  45. package/lib/ecosystems/parsers-js.js +92 -26
  46. package/lib/ecosystems/parsers-julia.js +2 -3
  47. package/lib/ecosystems/parsers-jvm.js +204 -12
  48. package/lib/ecosystems/parsers-misc.js +182 -55
  49. package/lib/ecosystems/parsers-python.js +141 -10
  50. package/lib/ecosystems/parsers-rust.js +4 -4
  51. package/lib/ecosystems/parsers-terraform.js +1573 -19
  52. package/lib/ecosystems/pythonutils.js +102 -6
  53. package/lib/ecosystems/remote/huggingface.js +3 -0
  54. package/lib/ecosystems/rubyutils.js +67 -17
  55. package/lib/ecosystems/rusi.js +22 -9
  56. package/lib/ecosystems/sbtutils.js +243 -187
  57. package/lib/ecosystems/scalacliutils.js +342 -0
  58. package/lib/ecosystems/scalasem.js +2618 -0
  59. package/lib/ecosystems/terraformRegistry.js +288 -0
  60. package/lib/ecosystems/utils.js +2 -0
  61. package/lib/ecosystems/vendoredCode.js +156 -0
  62. package/lib/evinser/evinser.js +578 -69
  63. package/lib/helpers/agenticToolutils.js +2 -2
  64. package/lib/helpers/bomSigner.js +576 -128
  65. package/lib/inventory/aiCollector.js +4 -1
  66. package/lib/inventory/analyzer.js +110 -127
  67. package/lib/inventory/atomUtils.js +171 -48
  68. package/lib/inventory/auditCategories.js +7 -0
  69. package/lib/inventory/buildToolRateLimit.js +144 -0
  70. package/lib/inventory/cacheCommands.js +2 -1
  71. package/lib/inventory/cmakeBuildDirs.js +254 -0
  72. package/lib/inventory/deps.js +501 -124
  73. package/lib/inventory/dosai.js +248 -44
  74. package/lib/inventory/dosaiParsers.js +310 -15
  75. package/lib/inventory/dynamic.js +52 -6
  76. package/lib/inventory/evidenceUtils.js +154 -109
  77. package/lib/inventory/fetchBatch.js +574 -38
  78. package/lib/inventory/fetchDiskCache.js +597 -0
  79. package/lib/inventory/forgeEnricher.js +14 -5
  80. package/lib/inventory/jvmLocalRepos.js +917 -0
  81. package/lib/inventory/mcpConfigParser.js +2 -3
  82. package/lib/inventory/parentSelection.js +146 -0
  83. package/lib/inventory/rustFormulationParser.js +3 -4
  84. package/lib/inventory/scalaCoords.js +153 -0
  85. package/lib/inventory/source.js +8 -8
  86. package/lib/inventory/spdx.js +101 -0
  87. package/lib/inventory/traceRunner.js +88 -7
  88. package/lib/inventory/tracedCrypto.js +294 -0
  89. package/lib/managers/docker.js +33 -32
  90. package/lib/managers/oci.js +24 -2
  91. package/lib/parsers/cmakeCache.js +69 -0
  92. package/lib/parsers/cmakeLists.js +16 -2
  93. package/lib/parsers/cmakePresets.js +400 -0
  94. package/lib/parsers/compileCommands.js +557 -0
  95. package/lib/parsers/strictJson.js +322 -0
  96. package/lib/parsers/terraformConfig.js +1073 -0
  97. package/lib/parsers/terraformSource.js +652 -0
  98. package/lib/server/server.js +23 -0
  99. package/lib/stages/postgen/introspection/markers.js +1 -1
  100. package/lib/stages/postgen/postgen.js +15 -1
  101. package/lib/stages/postgen/specVersionCompat.js +32 -0
  102. package/lib/validator/index.js +25 -10
  103. package/lib/validator/reporters/json.js +1 -0
  104. package/package.json +21 -21
  105. package/types/lib/audit/index.d.ts.map +1 -1
  106. package/types/lib/cli/index.d.ts.map +1 -1
  107. package/types/lib/cli/jsBom.d.ts.map +1 -1
  108. package/types/lib/cli/jvmBom.d.ts +24 -1
  109. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  110. package/types/lib/cli/managedBom.d.ts +7 -4
  111. package/types/lib/cli/managedBom.d.ts.map +1 -1
  112. package/types/lib/cli/nativeBom.d.ts +3 -1
  113. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  114. package/types/lib/core/activity.d.ts.map +1 -1
  115. package/types/lib/core/env.d.ts +9 -0
  116. package/types/lib/core/env.d.ts.map +1 -1
  117. package/types/lib/core/fs.d.ts.map +1 -1
  118. package/types/lib/core/httpClient.d.ts +13 -8
  119. package/types/lib/core/httpClient.d.ts.map +1 -1
  120. package/types/lib/core/pluginRun.d.ts +97 -0
  121. package/types/lib/core/pluginRun.d.ts.map +1 -0
  122. package/types/lib/core/processTree.d.ts +36 -0
  123. package/types/lib/core/processTree.d.ts.map +1 -0
  124. package/types/lib/core/runState.d.ts +29 -0
  125. package/types/lib/core/runState.d.ts.map +1 -0
  126. package/types/lib/core/toml.d.ts +20 -0
  127. package/types/lib/core/toml.d.ts.map +1 -0
  128. package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
  129. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  130. package/types/lib/ecosystems/cargoCache.d.ts +12 -0
  131. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
  132. package/types/lib/ecosystems/cmakeResolver.d.ts +5 -3
  133. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  134. package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
  135. package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
  136. package/types/lib/ecosystems/cppEvidence.d.ts +18 -2
  137. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  138. package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
  139. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
  140. package/types/lib/ecosystems/denoutils.d.ts +9 -3
  141. package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
  142. package/types/lib/ecosystems/ecosystems.d.ts +62 -13
  143. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  144. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  145. package/types/lib/ecosystems/gradleutils.d.ts +50 -0
  146. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  147. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  148. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  149. package/types/lib/ecosystems/npmutils.d.ts +15 -0
  150. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  151. package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
  152. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  153. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
  154. package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
  155. package/types/lib/ecosystems/parsers-go.d.ts +44 -2
  156. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  157. package/types/lib/ecosystems/parsers-js.d.ts +6 -1
  158. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  159. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
  160. package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
  161. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  162. package/types/lib/ecosystems/parsers-misc.d.ts +3 -1
  163. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  164. package/types/lib/ecosystems/parsers-python.d.ts +53 -3
  165. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  166. package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
  167. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
  168. package/types/lib/ecosystems/pythonutils.d.ts +27 -2
  169. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  170. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  171. package/types/lib/ecosystems/rubyutils.d.ts +21 -1
  172. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  173. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  174. package/types/lib/ecosystems/sbtutils.d.ts +88 -2
  175. package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
  176. package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
  177. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
  178. package/types/lib/ecosystems/scalasem.d.ts +311 -0
  179. package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
  180. package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
  181. package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
  182. package/types/lib/ecosystems/utils.d.ts +2 -2
  183. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  184. package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
  185. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
  186. package/types/lib/evinser/evinser.d.ts +75 -8
  187. package/types/lib/evinser/evinser.d.ts.map +1 -1
  188. package/types/lib/helpers/bomSigner.d.ts +80 -4
  189. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  190. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  191. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  192. package/types/lib/inventory/atomUtils.d.ts +82 -10
  193. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  194. package/types/lib/inventory/auditCategories.d.ts +3 -0
  195. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  196. package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
  197. package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
  198. package/types/lib/inventory/cacheCommands.d.ts +2 -1
  199. package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
  200. package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
  201. package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
  202. package/types/lib/inventory/deps.d.ts +59 -1
  203. package/types/lib/inventory/deps.d.ts.map +1 -1
  204. package/types/lib/inventory/dosai.d.ts +57 -8
  205. package/types/lib/inventory/dosai.d.ts.map +1 -1
  206. package/types/lib/inventory/dosaiParsers.d.ts +53 -7
  207. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  208. package/types/lib/inventory/dynamic.d.ts +10 -0
  209. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  210. package/types/lib/inventory/evidenceUtils.d.ts +19 -4
  211. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  212. package/types/lib/inventory/fetchBatch.d.ts +83 -14
  213. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  214. package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
  215. package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
  216. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  217. package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
  218. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
  219. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  220. package/types/lib/inventory/parentSelection.d.ts +34 -0
  221. package/types/lib/inventory/parentSelection.d.ts.map +1 -0
  222. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  223. package/types/lib/inventory/scalaCoords.d.ts +80 -0
  224. package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
  225. package/types/lib/inventory/source.d.ts.map +1 -1
  226. package/types/lib/inventory/spdx.d.ts +9 -0
  227. package/types/lib/inventory/spdx.d.ts.map +1 -1
  228. package/types/lib/inventory/traceRunner.d.ts +12 -0
  229. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  230. package/types/lib/inventory/tracedCrypto.d.ts +42 -0
  231. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
  232. package/types/lib/managers/docker.d.ts.map +1 -1
  233. package/types/lib/managers/oci.d.ts +3 -1
  234. package/types/lib/managers/oci.d.ts.map +1 -1
  235. package/types/lib/parsers/cmakeCache.d.ts +16 -0
  236. package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
  237. package/types/lib/parsers/cmakeLists.d.ts +2 -1
  238. package/types/lib/parsers/cmakeLists.d.ts.map +1 -1
  239. package/types/lib/parsers/cmakePresets.d.ts +78 -0
  240. package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
  241. package/types/lib/parsers/compileCommands.d.ts +115 -0
  242. package/types/lib/parsers/compileCommands.d.ts.map +1 -0
  243. package/types/lib/parsers/strictJson.d.ts +46 -0
  244. package/types/lib/parsers/strictJson.d.ts.map +1 -0
  245. package/types/lib/parsers/terraformConfig.d.ts +70 -0
  246. package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
  247. package/types/lib/parsers/terraformSource.d.ts +66 -0
  248. package/types/lib/parsers/terraformSource.d.ts.map +1 -0
  249. package/types/lib/server/server.d.ts.map +1 -1
  250. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  251. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  252. package/types/lib/validator/index.d.ts +8 -0
  253. package/types/lib/validator/index.d.ts.map +1 -1
  254. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  255. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
@@ -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
- ["ES256", { keyType: "ec", hash: "sha256", curve: "prime256v1" }],
18
- ["ES384", { keyType: "ec", hash: "sha384", curve: "secp384r1" }],
19
- ["ES512", { keyType: "ec", hash: "sha512", curve: "secp521r1" }],
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
- function computeSignature(canonicalData, key, spec) {
312
- const data = Buffer.from(canonicalData, "utf8");
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
- return crypto.sign(spec.hash, data, signatureOptions(key, spec));
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(canonicalData, key, spec, signature) {
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 object whose canonical form is signed for entry `index`.
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 jsfSignedView(content, form, entries, wrapper, index) {
574
+ function jsfSignedSignature(form, entries, wrapper, index) {
411
575
  if (form === "single") {
412
- return { ...content, signature: withoutValue(entries[index]) };
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 { ...content, signature: { ...wrapper, [form]: kept } };
582
+ return { ...wrapper, [form]: kept };
419
583
  }
420
584
 
421
- function candidateViews(content, parsed, index) {
422
- const views = [
423
- jsfSignedView(content, parsed.form, parsed.entries, parsed.wrapper, index),
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
- views.push(jsfSignedView(content, "single", parsed.entries, {}, index));
597
+ signatures.push(jsfSignedSignature("single", parsed.entries, {}, index));
434
598
  }
435
- return views;
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
- function verifyEntry(content, parsed, index, key) {
450
- const entry = parsed.entries[index];
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 candidateViews(content, parsed, index).some((view) =>
471
- signatureMatches(canonicalize(view), key, spec, signature),
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 === "single") {
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
- return parsed;
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 view = jsfSignedView(
504
- content,
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
- const template = { algorithm };
560
- const publicKey = embeddablePublicKey(publicKeyJwk, signingKey, algorithm);
561
- if (publicKey) {
562
- template.publicKey = publicKey;
563
- }
564
- if (keyId !== null && keyId !== undefined && keyId !== "") {
565
- template.keyId = String(keyId);
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
- plan.push([item, signatureLayout(item, mode)]);
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, template, layout);
930
+ addSignature(item, signingKey, spec, nestedTemplate, layout);
598
931
  }
599
- addSignature(bomJson, signingKey, spec, template, rootLayout);
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 reasons = Array.isArray(options.reasons) ? options.reasons : undefined;
624
- let parsed;
625
- try {
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
- const { signature: _signature, ...content } = node;
632
- const rejected = [];
633
- for (let index = 0; index < parsed.entries.length; index++) {
634
- const label = parsed.form === "single" ? "" : `${parsed.form}[${index}]: `;
635
- try {
636
- if (verifyEntry(content, parsed, index, key)) {
637
- return parsed.entries[index];
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
- rejected.push(`${label}${mismatchReason(parsed.entries[index])}`);
640
- } catch (err) {
641
- rejected.push(`${label}${err.message}`);
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
- reasons?.push(...rejected);
645
- return false;
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 root signature is valid
651
- * AND every signed nested element is valid for the same key.
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?.signature) {
1149
+ if (!isPlainObject(bom) || !bom.signature) {
660
1150
  return false;
661
1151
  }
662
- const key = loadVerificationKey(publicKey);
663
- const reasons = Array.isArray(options.reasons) ? options.reasons : undefined;
664
- const rootMatch = verifyNode(bom, key, options);
665
- if (!rootMatch) {
666
- return false;
1152
+ const report = verifyBomSignatures(bom, [publicKey]);
1153
+ if (report.verified) {
1154
+ return report.root.match;
667
1155
  }
668
- for (const field of NESTED_TARGETS) {
669
- if (!Array.isArray(bom[field])) {
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 rootMatch;
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 to a signature chain, because the new
696
- * chain entry vouches for every earlier entry.
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
- let parsed;
708
- try {
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: [err.message],
1186
+ reasons: result.reasons,
718
1187
  },
719
1188
  ];
720
1189
  }
721
- const { signature: _signature, ...content } = node;
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
  }