@cdxgen/cdxgen 13.4.0 → 13.6.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 -9
- package/bin/cdxgen-secure.js +15 -0
- package/bin/cdxgen.js +29 -5
- package/bin/evinse.js +8 -0
- package/bin/hbom.js +15 -7
- package/bin/repl.js +29 -0
- package/bin/validate.js +6 -0
- package/data/cbomosdb-queries.json +13 -1
- package/data/component-tags.json +8 -0
- package/data/license-aliases.json +24 -2
- package/data/queries-darwin.json +25 -1
- package/data/queries-win.json +19 -1
- package/data/queries.json +41 -0
- package/data/remediations.json +407 -1
- package/data/rules/ai-bom.yaml +1 -1
- package/data/rules/brotli-decoder.yaml +107 -0
- package/data/rules/cbom-security.yaml +235 -4
- package/data/rules/go-binaries.yaml +46 -0
- package/data/rules/golem-go.yaml +80 -0
- package/data/rules/hbom-compliance.yaml +40 -22
- package/data/rules/hbom-performance.yaml +7 -1
- package/data/rules/host-topology.yaml +1 -1
- package/data/rules/obom-runtime.yaml +31 -2
- package/data/rules/php-runtime.yaml +143 -0
- package/data/rules/vscode-extensions.yaml +75 -0
- package/data/spdx-license-list.json +121 -7
- package/data/spdx-licenses.json +69 -0
- package/data/spdx.schema.json +845 -2
- package/lib/audit/index.js +14 -0
- package/lib/cli/bomAssembly.js +120 -6
- package/lib/cli/cliOptions.js +110 -3
- package/lib/cli/index.js +182 -6
- package/lib/cli/jsBom.js +153 -64
- package/lib/cli/jvmBom.js +363 -66
- package/lib/cli/managedBom.js +853 -87
- package/lib/cli/nativeBom.js +267 -33
- package/lib/core/boundedFile.js +344 -0
- package/lib/core/brotliProbe.js +182 -0
- package/lib/core/env.js +38 -8
- package/lib/core/fs.js +40 -19
- package/lib/core/httpClient.js +172 -46
- package/lib/core/lockPaths.js +38 -0
- package/lib/core/paths.js +24 -1
- package/lib/core/processTree.js +5 -0
- package/lib/core/projectConfig.js +1 -0
- package/lib/core/projectTooling.js +97 -0
- package/lib/core/propertySanitizer.js +72 -0
- package/lib/core/toolRegistry.js +170 -0
- package/lib/ecosystems/cppEvidence.js +263 -106
- package/lib/ecosystems/cppIncludeResolver.js +16 -5
- package/lib/ecosystems/ecosystems.js +25 -10
- package/lib/ecosystems/gemHostProbe.js +301 -0
- package/lib/ecosystems/golem.js +550 -6
- package/lib/ecosystems/gradleutils.js +858 -244
- package/lib/ecosystems/parsers-bazel.js +107 -6
- package/lib/ecosystems/parsers-dotnet.js +117 -18
- package/lib/ecosystems/parsers-go.js +1090 -367
- package/lib/ecosystems/parsers-js.js +505 -22
- package/lib/ecosystems/parsers-jvm.js +259 -17
- package/lib/ecosystems/parsers-misc.js +438 -64
- package/lib/ecosystems/parsers-python.js +100 -12
- package/lib/ecosystems/pythonutils.js +128 -2
- package/lib/ecosystems/rubyutils.js +544 -34
- package/lib/ecosystems/rusi.js +10 -0
- package/lib/ecosystems/scalacliutils.js +4 -1
- package/lib/ecosystems/scalasem.js +798 -151
- package/lib/ecosystems/vendoredCode.js +237 -3
- package/lib/ecosystems/venvProbe.js +360 -0
- package/lib/evinser/evinser.js +472 -61
- package/lib/evinser/swiftsem.js +68 -5
- package/lib/helpers/agenticToolutils.js +413 -93
- package/lib/helpers/vsixutils.js +1574 -315
- package/lib/inventory/agentSkillSpec.js +17 -0
- package/lib/inventory/apkInstalledDb.js +243 -0
- package/lib/inventory/atomUtils.js +110 -0
- package/lib/inventory/auditCategories.js +3 -3
- package/lib/inventory/brotliDecoder.js +142 -0
- package/lib/inventory/buildToolRateLimit.js +1 -1
- package/lib/inventory/cacheDir.js +52 -9
- package/lib/inventory/cbomutils.js +56 -7
- package/lib/inventory/certificates.js +2371 -0
- package/lib/inventory/communityAiConfigParser.js +4 -9
- package/lib/inventory/deps.js +41 -4
- package/lib/inventory/display.js +200 -3
- package/lib/inventory/dosai.js +639 -63
- package/lib/inventory/dotnetRuntime.js +217 -0
- package/lib/inventory/dynamic.js +45 -3
- package/lib/inventory/envcontext.js +475 -18
- package/lib/inventory/evidenceUtils.js +447 -23
- package/lib/inventory/fetchBatch.js +1 -1
- package/lib/inventory/formulationParsers.js +32 -3
- package/lib/inventory/goBuildInfo.js +140 -0
- package/lib/inventory/gtfobins.js +13 -0
- package/lib/inventory/hbom.js +67 -7
- package/lib/inventory/hbomAnalysis.js +16 -0
- package/lib/inventory/hostRuntimeProbe.js +686 -0
- package/lib/inventory/hostTopology.js +21 -0
- package/lib/inventory/jvmLocalRepos.js +9 -2
- package/lib/inventory/jvmToolEnv.js +29 -0
- package/lib/inventory/licenseEnhancer.js +12 -0
- package/lib/inventory/mavenCentralMirror.js +534 -0
- package/lib/inventory/nodeBuildInfo.js +218 -0
- package/lib/inventory/nodeHostProbe.js +202 -0
- package/lib/inventory/osPackagePurl.js +244 -0
- package/lib/inventory/osPackageResolver.js +271 -87
- package/lib/inventory/osinfo.js +79 -31
- package/lib/inventory/osqueryTransform.js +114 -5
- package/lib/inventory/phpBuildInfo.js +438 -0
- package/lib/inventory/phpHostProbe.js +185 -0
- package/lib/inventory/rootfsCerts.js +426 -0
- package/lib/inventory/rubyBuildInfo.js +972 -0
- package/lib/inventory/rubyHostProbe.js +218 -0
- package/lib/inventory/scalaCoords.js +4 -3
- package/lib/inventory/scalaDedupe.js +440 -0
- package/lib/inventory/source.js +256 -5
- package/lib/inventory/spdx.js +144 -0
- package/lib/inventory/traceRunner.js +5 -1
- package/lib/inventory/tracedCrypto.js +129 -0
- package/lib/managers/binary.js +866 -186
- package/lib/managers/docker.js +245 -12
- package/lib/parsers/email.js +277 -0
- package/lib/parsers/largeJson.js +62 -13
- package/lib/parsers/vscodeExtensionId.js +183 -0
- package/lib/server/server.js +198 -2
- package/lib/stages/postgen/annotator.js +17 -1
- package/lib/stages/postgen/introspection/reflect.js +4 -1
- package/lib/stages/postgen/postgen.js +5 -5
- package/lib/stages/postgen/ruleEngine.js +71 -6
- package/lib/stages/postgen/specVersionCompat.js +30 -0
- package/lib/stages/pregen/envAudit.js +1 -0
- package/lib/stages/pregen/pregen.js +231 -62
- package/lib/validator/bomValidator.js +32 -6
- package/lib/validator/generated/runtime.mjs +13 -0
- package/lib/validator/generated/validate-1.6.mjs +27 -8
- package/lib/validator/generated/validate-1.7.mjs +32 -13
- package/lib/validator/generated/validate-2.0.mjs +31 -12
- package/lib/validator/index.js +1 -1
- package/package.json +19 -18
- package/types/bin/cdxgen-secure.d.ts +3 -0
- package/types/bin/cdxgen-secure.d.ts.map +1 -0
- package/types/bin/repl.d.ts.map +1 -1
- package/types/lib/audit/index.d.ts.map +1 -1
- package/types/lib/cli/bomAssembly.d.ts +22 -3
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts +43 -0
- package/types/lib/cli/cliOptions.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts +1 -17
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts +20 -0
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/boundedFile.d.ts +70 -0
- package/types/lib/core/boundedFile.d.ts.map +1 -0
- package/types/lib/core/brotliProbe.d.ts +37 -0
- package/types/lib/core/brotliProbe.d.ts.map +1 -0
- package/types/lib/core/env.d.ts +22 -2
- 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 +24 -0
- package/types/lib/core/httpClient.d.ts.map +1 -1
- package/types/lib/core/lockPaths.d.ts +19 -0
- package/types/lib/core/lockPaths.d.ts.map +1 -0
- package/types/lib/core/processTree.d.ts +2 -0
- package/types/lib/core/processTree.d.ts.map +1 -1
- package/types/lib/core/projectConfig.d.ts.map +1 -1
- package/types/lib/core/projectTooling.d.ts +41 -0
- package/types/lib/core/projectTooling.d.ts.map +1 -0
- package/types/lib/core/propertySanitizer.d.ts +26 -0
- package/types/lib/core/propertySanitizer.d.ts.map +1 -1
- package/types/lib/core/toolRegistry.d.ts +36 -0
- package/types/lib/core/toolRegistry.d.ts.map +1 -0
- package/types/lib/ecosystems/cppEvidence.d.ts +40 -13
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/cppIncludeResolver.d.ts +2 -1
- package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/gemHostProbe.d.ts +28 -0
- package/types/lib/ecosystems/gemHostProbe.d.ts.map +1 -0
- package/types/lib/ecosystems/golem.d.ts +81 -1
- package/types/lib/ecosystems/golem.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +57 -21
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-bazel.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-dotnet.d.ts +40 -4
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-go.d.ts +85 -13
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +17 -20
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts +28 -2
- package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts +37 -58
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts +14 -3
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +19 -1
- package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +144 -12
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts.map +1 -1
- package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -1
- package/types/lib/ecosystems/scalasem.d.ts.map +1 -1
- package/types/lib/ecosystems/vendoredCode.d.ts +26 -0
- package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -1
- package/types/lib/ecosystems/venvProbe.d.ts +40 -0
- package/types/lib/ecosystems/venvProbe.d.ts.map +1 -0
- package/types/lib/evinser/evinser.d.ts +88 -8
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/evinser/swiftsem.d.ts +10 -0
- package/types/lib/evinser/swiftsem.d.ts.map +1 -1
- package/types/lib/inventory/agentSkillSpec.d.ts +13 -0
- package/types/lib/inventory/agentSkillSpec.d.ts.map +1 -0
- package/types/lib/inventory/apkInstalledDb.d.ts +42 -0
- package/types/lib/inventory/apkInstalledDb.d.ts.map +1 -0
- package/types/lib/inventory/atomUtils.d.ts +33 -0
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/brotliDecoder.d.ts +58 -0
- package/types/lib/inventory/brotliDecoder.d.ts.map +1 -0
- package/types/lib/inventory/cacheDir.d.ts +12 -4
- package/types/lib/inventory/cacheDir.d.ts.map +1 -1
- package/types/lib/inventory/cbomutils.d.ts +17 -0
- package/types/lib/inventory/cbomutils.d.ts.map +1 -1
- package/types/lib/inventory/certificates.d.ts +205 -0
- package/types/lib/inventory/certificates.d.ts.map +1 -0
- package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/deps.d.ts +2 -1
- package/types/lib/inventory/deps.d.ts.map +1 -1
- package/types/lib/inventory/display.d.ts +18 -0
- package/types/lib/inventory/display.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +162 -7
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dotnetRuntime.d.ts +44 -0
- package/types/lib/inventory/dotnetRuntime.d.ts.map +1 -0
- package/types/lib/inventory/dynamic.d.ts +6 -1
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/envcontext.d.ts +92 -8
- package/types/lib/inventory/envcontext.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts +29 -10
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
- package/types/lib/inventory/goBuildInfo.d.ts +10 -0
- package/types/lib/inventory/goBuildInfo.d.ts.map +1 -0
- package/types/lib/inventory/gtfobins.d.ts.map +1 -1
- package/types/lib/inventory/hbom.d.ts.map +1 -1
- package/types/lib/inventory/hbomAnalysis.d.ts +10 -0
- package/types/lib/inventory/hbomAnalysis.d.ts.map +1 -1
- package/types/lib/inventory/hostRuntimeProbe.d.ts +44 -0
- package/types/lib/inventory/hostRuntimeProbe.d.ts.map +1 -0
- package/types/lib/inventory/hostTopology.d.ts.map +1 -1
- package/types/lib/inventory/jvmToolEnv.d.ts +12 -0
- package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
- package/types/lib/inventory/licenseEnhancer.d.ts.map +1 -1
- package/types/lib/inventory/mavenCentralMirror.d.ts +170 -0
- package/types/lib/inventory/mavenCentralMirror.d.ts.map +1 -0
- package/types/lib/inventory/nodeBuildInfo.d.ts +95 -0
- package/types/lib/inventory/nodeBuildInfo.d.ts.map +1 -0
- package/types/lib/inventory/nodeHostProbe.d.ts +15 -0
- package/types/lib/inventory/nodeHostProbe.d.ts.map +1 -0
- package/types/lib/inventory/osPackagePurl.d.ts +99 -0
- package/types/lib/inventory/osPackagePurl.d.ts.map +1 -0
- package/types/lib/inventory/osPackageResolver.d.ts +44 -2
- package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
- package/types/lib/inventory/osinfo.d.ts +31 -6
- package/types/lib/inventory/osinfo.d.ts.map +1 -1
- package/types/lib/inventory/osqueryTransform.d.ts +51 -0
- package/types/lib/inventory/osqueryTransform.d.ts.map +1 -1
- package/types/lib/inventory/phpBuildInfo.d.ts +142 -0
- package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -0
- package/types/lib/inventory/phpHostProbe.d.ts +35 -0
- package/types/lib/inventory/phpHostProbe.d.ts.map +1 -0
- package/types/lib/inventory/rootfsCerts.d.ts +27 -0
- package/types/lib/inventory/rootfsCerts.d.ts.map +1 -0
- package/types/lib/inventory/rubyBuildInfo.d.ts +271 -0
- package/types/lib/inventory/rubyBuildInfo.d.ts.map +1 -0
- package/types/lib/inventory/rubyHostProbe.d.ts +47 -0
- package/types/lib/inventory/rubyHostProbe.d.ts.map +1 -0
- package/types/lib/inventory/source.d.ts +38 -0
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/spdx.d.ts +15 -0
- package/types/lib/inventory/spdx.d.ts.map +1 -1
- package/types/lib/inventory/traceRunner.d.ts +4 -1
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- package/types/lib/inventory/tracedCrypto.d.ts +22 -0
- package/types/lib/inventory/tracedCrypto.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts +10 -0
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts +24 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/parsers/email.d.ts +19 -0
- package/types/lib/parsers/email.d.ts.map +1 -0
- package/types/lib/parsers/largeJson.d.ts +17 -3
- package/types/lib/parsers/largeJson.d.ts.map +1 -1
- package/types/lib/server/server.d.ts +8 -0
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
- package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -1
- package/types/lib/stages/postgen/ruleEngine.d.ts +7 -4
- package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
- package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
- package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
- package/types/lib/stages/pregen/pregen.d.ts +26 -9
- package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
|
@@ -0,0 +1,2371 @@
|
|
|
1
|
+
// Certificate identity model shared by every cdxgen producer: the
|
|
2
|
+
// trustinspector rootfs/host reports, the source-directory scan, and (later)
|
|
3
|
+
// observed TLS chains. One certificate is one component wherever it appears,
|
|
4
|
+
// keyed by its whole-DER SHA-256; one public key is one component keyed by
|
|
5
|
+
// its SPKI SHA-256, so cross-signed twins share it. Design contract:
|
|
6
|
+
// ~/work/cbom-cert-distrust/PLAN.md section 2.
|
|
7
|
+
|
|
8
|
+
import { createHash, X509Certificate } from "node:crypto";
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { parseAllDocuments } from "yaml";
|
|
12
|
+
|
|
13
|
+
import { xml2js } from "../parsers/xml.js";
|
|
14
|
+
|
|
15
|
+
// Post-quantum algorithms are named from their OIDs because the toolchains
|
|
16
|
+
// disagree: Go 1.26 prints ML-DSA, SLH-DSA and ML-KEM as "0", Go 1.27 knows
|
|
17
|
+
// only ML-DSA, and a Node linked against a system OpenSSL 3.0 cannot load the
|
|
18
|
+
// keys at all. OIDs and levels: RFC 9881 (ML-DSA, FIPS 204), RFC 9909
|
|
19
|
+
// (SLH-DSA, FIPS 205), RFC 9935 (ML-KEM, FIPS 203), NIST FIPS 204/205
|
|
20
|
+
// security categories. Composite ML-DSA is deliberately absent until its RFC
|
|
21
|
+
// is published; those OIDs stay dotted-OID names.
|
|
22
|
+
const PQ_ALGORITHM_BY_OID = {
|
|
23
|
+
"2.16.840.1.101.3.4.3.17": ["ML-DSA-44", "signature", "44", 2],
|
|
24
|
+
"2.16.840.1.101.3.4.3.18": ["ML-DSA-65", "signature", "65", 3],
|
|
25
|
+
"2.16.840.1.101.3.4.3.19": ["ML-DSA-87", "signature", "87", 5],
|
|
26
|
+
"2.16.840.1.101.3.4.3.20": ["SLH-DSA-SHA2-128s", "signature", "SHA2-128s", 1],
|
|
27
|
+
"2.16.840.1.101.3.4.3.21": ["SLH-DSA-SHA2-128f", "signature", "SHA2-128f", 1],
|
|
28
|
+
"2.16.840.1.101.3.4.3.22": ["SLH-DSA-SHA2-192s", "signature", "SHA2-192s", 3],
|
|
29
|
+
"2.16.840.1.101.3.4.3.23": ["SLH-DSA-SHA2-192f", "signature", "SHA2-192f", 3],
|
|
30
|
+
"2.16.840.1.101.3.4.3.24": ["SLH-DSA-SHA2-256s", "signature", "SHA2-256s", 5],
|
|
31
|
+
"2.16.840.1.101.3.4.3.25": ["SLH-DSA-SHA2-256f", "signature", "SHA2-256f", 5],
|
|
32
|
+
"2.16.840.1.101.3.4.3.26": [
|
|
33
|
+
"SLH-DSA-SHAKE-128s",
|
|
34
|
+
"signature",
|
|
35
|
+
"SHAKE-128s",
|
|
36
|
+
1,
|
|
37
|
+
],
|
|
38
|
+
"2.16.840.1.101.3.4.3.27": [
|
|
39
|
+
"SLH-DSA-SHAKE-128f",
|
|
40
|
+
"signature",
|
|
41
|
+
"SHAKE-128f",
|
|
42
|
+
1,
|
|
43
|
+
],
|
|
44
|
+
"2.16.840.1.101.3.4.3.28": [
|
|
45
|
+
"SLH-DSA-SHAKE-192s",
|
|
46
|
+
"signature",
|
|
47
|
+
"SHAKE-192s",
|
|
48
|
+
3,
|
|
49
|
+
],
|
|
50
|
+
"2.16.840.1.101.3.4.3.29": [
|
|
51
|
+
"SLH-DSA-SHAKE-192f",
|
|
52
|
+
"signature",
|
|
53
|
+
"SHAKE-192f",
|
|
54
|
+
3,
|
|
55
|
+
],
|
|
56
|
+
"2.16.840.1.101.3.4.3.30": [
|
|
57
|
+
"SLH-DSA-SHAKE-256s",
|
|
58
|
+
"signature",
|
|
59
|
+
"SHAKE-256s",
|
|
60
|
+
5,
|
|
61
|
+
],
|
|
62
|
+
"2.16.840.1.101.3.4.3.31": [
|
|
63
|
+
"SLH-DSA-SHAKE-256f",
|
|
64
|
+
"signature",
|
|
65
|
+
"SHAKE-256f",
|
|
66
|
+
5,
|
|
67
|
+
],
|
|
68
|
+
"2.16.840.1.101.3.4.4.1": ["ML-KEM-512", "kem", "512", 1],
|
|
69
|
+
"2.16.840.1.101.3.4.4.2": ["ML-KEM-768", "kem", "768", 3],
|
|
70
|
+
"2.16.840.1.101.3.4.4.3": ["ML-KEM-1024", "kem", "1024", 5],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Classical signature OIDs to the names Go's crypto/x509 prints, so both
|
|
74
|
+
// producers agree (RFC 8017 for RSASSA, RFC 5480 for ECDSA, RFC 8410 for
|
|
75
|
+
// EdDSA).
|
|
76
|
+
const SIGNATURE_NAMES_BY_OID = {
|
|
77
|
+
"1.2.840.113549.1.1.4": "MD5-RSA",
|
|
78
|
+
"1.2.840.113549.1.1.5": "SHA1-RSA",
|
|
79
|
+
"1.2.840.113549.1.1.11": "SHA256-RSA",
|
|
80
|
+
"1.2.840.113549.1.1.12": "SHA384-RSA",
|
|
81
|
+
"1.2.840.113549.1.1.13": "SHA512-RSA",
|
|
82
|
+
"1.2.840.113549.1.1.14": "SHA224-RSA",
|
|
83
|
+
"1.2.840.113549.1.1.10": "RSASSA-PSS",
|
|
84
|
+
"1.2.840.10045.4.1": "ECDSA-SHA1",
|
|
85
|
+
"1.2.840.10045.4.3.1": "ECDSA-SHA224",
|
|
86
|
+
"1.2.840.10045.4.3.2": "ECDSA-SHA256",
|
|
87
|
+
"1.2.840.10045.4.3.3": "ECDSA-SHA384",
|
|
88
|
+
"1.2.840.10045.4.3.4": "ECDSA-SHA512",
|
|
89
|
+
"1.3.101.112": "Ed25519",
|
|
90
|
+
"1.3.101.113": "Ed448",
|
|
91
|
+
"1.2.840.10040.4.3": "DSA-SHA1",
|
|
92
|
+
"2.16.840.1.101.3.4.3.2": "DSA-SHA256",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Public-key OIDs (RFC 8017, RFC 5480, RFC 8410, FIPS 186-4) and the curves
|
|
96
|
+
// that appear in SPKI parameters.
|
|
97
|
+
const KEY_NAMES_BY_OID = {
|
|
98
|
+
"1.2.840.113549.1.1.1": "RSA",
|
|
99
|
+
"1.2.840.113549.1.1.10": "RSA-PSS",
|
|
100
|
+
"1.2.840.10045.2.1": "ECDSA",
|
|
101
|
+
"1.2.840.10040.4.1": "DSA",
|
|
102
|
+
"1.3.101.112": "Ed25519",
|
|
103
|
+
"1.3.101.113": "Ed448",
|
|
104
|
+
};
|
|
105
|
+
// X9.62 prime curves (SEC 2 appendix C) and the NIST/SEC OIDs that carry
|
|
106
|
+
// them; bit sizes per SEC 2 section 2.
|
|
107
|
+
const CURVE_BITS_BY_OID = {
|
|
108
|
+
"1.2.840.10045.3.1.1": 192,
|
|
109
|
+
"1.2.840.10045.3.1.2": 192,
|
|
110
|
+
"1.2.840.10045.3.1.3": 192,
|
|
111
|
+
"1.2.840.10045.3.1.4": 239,
|
|
112
|
+
"1.2.840.10045.3.1.5": 239,
|
|
113
|
+
"1.2.840.10045.3.1.6": 239,
|
|
114
|
+
"1.2.840.10045.3.1.7": 256,
|
|
115
|
+
"1.3.132.0.10": 256,
|
|
116
|
+
"1.3.132.0.33": 224,
|
|
117
|
+
"1.3.132.0.34": 384,
|
|
118
|
+
"1.3.132.0.35": 521,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Bounded parsing: a certificate DER larger than this is rejected, and a PEM
|
|
122
|
+
// bundle yields at most this many certificates (the macOS system keychain
|
|
123
|
+
// reports 158, JDK truststores 144; large public bundles stay under 700).
|
|
124
|
+
const MAX_CERT_DER_BYTES = 1024 * 1024;
|
|
125
|
+
const MAX_PEM_CERTIFICATES = 2000;
|
|
126
|
+
// Pairwise issuer verification is quadratic; past this many certificates in
|
|
127
|
+
// one collection the pass is skipped (trustinspector applies the same cap).
|
|
128
|
+
const ISSUER_LINK_LIMIT = 5000;
|
|
129
|
+
const SAN_DISPLAY_LIMIT = 20;
|
|
130
|
+
|
|
131
|
+
function isHex(value) {
|
|
132
|
+
return typeof value === "string" && /^[0-9a-fA-F]+$/.test(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function toLowerHex(value) {
|
|
136
|
+
return isHex(value) ? value.toLowerCase() : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A SHA-256 digest is 64 hex characters; anything else from a report is not
|
|
140
|
+
// a usable identity.
|
|
141
|
+
function toLowerSha256(value) {
|
|
142
|
+
const hex = toLowerHex(value);
|
|
143
|
+
return hex?.length === 64 ? hex : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Serial numbers are lowercase hex with no colons and no DER zero padding
|
|
147
|
+
// (PLAN section 2.1); BigInt normalisation strips both.
|
|
148
|
+
export function normalizeSerialHex(value) {
|
|
149
|
+
if (typeof value !== "string" || !/^[0-9a-fA-F]+$/.test(value)) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
return BigInt(`0x${value}`).toString(16);
|
|
154
|
+
} catch {
|
|
155
|
+
return value.toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function serialDecimalToHex(value) {
|
|
160
|
+
if (typeof value !== "string" || !/^[0-9]+$/.test(value)) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
return BigInt(value).toString(16);
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// RFC 7468 PEM text encoding: the label on the encapsulation delimiter line
|
|
171
|
+
// names the type of the base64 body that follows.
|
|
172
|
+
const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]+)-----/;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Split PEM text into labelled blocks without decoding them. Only the label
|
|
176
|
+
* line and the matching END line matter; the caller decides which labels to
|
|
177
|
+
* parse. Bounded by block count and total decoded size.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} text file contents
|
|
180
|
+
* @returns {{label: string, body: string}[]} blocks in file order
|
|
181
|
+
*/
|
|
182
|
+
export function splitPemBlocks(text) {
|
|
183
|
+
if (typeof text !== "string" || !text.includes("-----BEGIN ")) {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
const blocks = [];
|
|
187
|
+
const lines = text.split(/\r?\n/);
|
|
188
|
+
let label;
|
|
189
|
+
let body = [];
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
const begin = PEM_BEGIN.exec(line);
|
|
192
|
+
if (begin) {
|
|
193
|
+
label = begin[1];
|
|
194
|
+
body = [];
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (label && line.includes("-----END ")) {
|
|
198
|
+
blocks.push({ label, body: body.join("") });
|
|
199
|
+
label = undefined;
|
|
200
|
+
body = [];
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (label) {
|
|
204
|
+
body.push(line.trim());
|
|
205
|
+
}
|
|
206
|
+
if (blocks.length >= MAX_PEM_CERTIFICATES) {
|
|
207
|
+
return blocks;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return blocks;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Decode the CERTIFICATE blocks of a PEM bundle (RFC 7468) into DER buffers.
|
|
215
|
+
* `TRUSTED CERTIFICATE` blocks (OpenSSL aux data after the DER) are not
|
|
216
|
+
* decoded here; the trustinspector plugin parses their trust/reject lists.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} text PEM file contents
|
|
219
|
+
* @returns {Buffer[]} DER buffers in file order
|
|
220
|
+
*/
|
|
221
|
+
export function pemCertificateDerBlocks(text) {
|
|
222
|
+
const ders = [];
|
|
223
|
+
for (const block of splitPemBlocks(text)) {
|
|
224
|
+
if (block.label !== "CERTIFICATE") {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const der = Buffer.from(block.body, "base64");
|
|
229
|
+
if (der.length > 0 && der.length <= MAX_CERT_DER_BYTES) {
|
|
230
|
+
ders.push(der);
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// base64 garbage between delimiters is skipped
|
|
234
|
+
}
|
|
235
|
+
if (ders.length >= MAX_PEM_CERTIFICATES) {
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return ders;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Private-key PEM labels are matched by header line only; the body is never
|
|
243
|
+
// decoded (PLAN section 1.7). `ENCRYPTED-` prefixes the format when the
|
|
244
|
+
// header says so.
|
|
245
|
+
const PRIVATE_KEY_LABELS = new Set([
|
|
246
|
+
"RSA PRIVATE KEY",
|
|
247
|
+
"EC PRIVATE KEY",
|
|
248
|
+
"DSA PRIVATE KEY",
|
|
249
|
+
"PRIVATE KEY",
|
|
250
|
+
"ENCRYPTED PRIVATE KEY",
|
|
251
|
+
"OPENSSH PRIVATE KEY",
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
export function privateKeyPemFormat(text) {
|
|
255
|
+
for (const block of splitPemBlocks(text)) {
|
|
256
|
+
if (!PRIVATE_KEY_LABELS.has(block.label)) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
switch (block.label) {
|
|
260
|
+
case "RSA PRIVATE KEY":
|
|
261
|
+
return "RSA";
|
|
262
|
+
case "EC PRIVATE KEY":
|
|
263
|
+
return "EC";
|
|
264
|
+
case "DSA PRIVATE KEY":
|
|
265
|
+
return "DSA";
|
|
266
|
+
case "PRIVATE KEY":
|
|
267
|
+
return "PKCS8";
|
|
268
|
+
case "ENCRYPTED PRIVATE KEY":
|
|
269
|
+
return "ENCRYPTED-PKCS8";
|
|
270
|
+
default:
|
|
271
|
+
return "OPENSSH";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A PEM public key (RFC 7468 PUBLIC KEY label) also classifies a matched
|
|
279
|
+
* file as key material rather than a certificate.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} text file contents
|
|
282
|
+
* @returns {boolean} true when a PUBLIC KEY block is present
|
|
283
|
+
*/
|
|
284
|
+
export function hasPemPublicKey(text) {
|
|
285
|
+
return splitPemBlocks(text).some((block) => block.label === "PUBLIC KEY");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Read one DER tag-length-value header. Multi-byte tags are rejected (none of
|
|
290
|
+
* the fields walked use them). X.690 BER/DER; certificate structure per
|
|
291
|
+
* RFC 5280 section 4.1.
|
|
292
|
+
*
|
|
293
|
+
* @param {Buffer} der input buffer
|
|
294
|
+
* @param {number} offset start of the TLV
|
|
295
|
+
* @param {number} end exclusive end
|
|
296
|
+
* @returns {object|undefined} `{tag, start, contentStart, contentEnd, next}` or undefined on malformed input
|
|
297
|
+
*/
|
|
298
|
+
function readTLV(der, offset, end) {
|
|
299
|
+
if (offset < 0 || offset + 2 > end) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
const tag = der[offset];
|
|
303
|
+
if ((tag & 0x1f) === 0x1f) {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
const firstLengthByte = der[offset + 1];
|
|
307
|
+
let headerSize = 2;
|
|
308
|
+
let length;
|
|
309
|
+
if (firstLengthByte < 0x80) {
|
|
310
|
+
length = firstLengthByte;
|
|
311
|
+
} else {
|
|
312
|
+
const lengthBytes = firstLengthByte & 0x7f;
|
|
313
|
+
// Indefinite lengths and lengths over 4 bytes never occur in a bounded
|
|
314
|
+
// certificate walk and would allow absurd skips.
|
|
315
|
+
if (lengthBytes === 0 || lengthBytes > 4) {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
if (offset + 2 + lengthBytes > end) {
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
length = 0;
|
|
322
|
+
for (let i = 0; i < lengthBytes; i++) {
|
|
323
|
+
length = length * 256 + der[offset + 2 + i];
|
|
324
|
+
}
|
|
325
|
+
headerSize = 2 + lengthBytes;
|
|
326
|
+
}
|
|
327
|
+
if (length > end - offset - headerSize) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
tag,
|
|
332
|
+
start: offset,
|
|
333
|
+
contentStart: offset + headerSize,
|
|
334
|
+
contentEnd: offset + headerSize + length,
|
|
335
|
+
next: offset + headerSize + length,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function readOID(der, start, end) {
|
|
340
|
+
if (start >= end) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
const arcs = [];
|
|
344
|
+
// BigInt: arcs such as UUID OIDs (2.25.<128-bit>) exceed 2^53.
|
|
345
|
+
let acc = 0n;
|
|
346
|
+
let closed = true;
|
|
347
|
+
for (let i = start; i < end; i++) {
|
|
348
|
+
const byte = der[i];
|
|
349
|
+
acc = acc * 128n + BigInt(byte & 0x7f);
|
|
350
|
+
if (byte & 0x80) {
|
|
351
|
+
closed = false;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
closed = true;
|
|
355
|
+
arcs.push(acc);
|
|
356
|
+
acc = 0n;
|
|
357
|
+
}
|
|
358
|
+
if (!closed || arcs.length === 0) {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
// X.690 8.19.4: the first subidentifier packs the first two arcs; the
|
|
362
|
+
// first arc is at most 2, and under 2 the second can exceed 39.
|
|
363
|
+
const first = arcs[0] < 40n ? 0n : arcs[0] < 80n ? 1n : 2n;
|
|
364
|
+
return [first, arcs[0] - first * 40n, ...arcs.slice(1)].join(".");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function readAlgorithmOid(der, seq) {
|
|
368
|
+
const first = readTLV(der, seq.contentStart, seq.contentEnd);
|
|
369
|
+
if (!first || first.tag !== 0x06) {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
return readOID(der, first.contentStart, first.contentEnd);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function hexSlice(der, start, end) {
|
|
376
|
+
return Buffer.from(der.buffer, der.byteOffset + start, end - start)
|
|
377
|
+
.toString("hex")
|
|
378
|
+
.toLowerCase();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Walk a certificate DER far enough to learn what the platform parsers may
|
|
383
|
+
* not report: the SubjectPublicKeyInfo (for the SPKI hash and key algorithm
|
|
384
|
+
* OID), the outer signature algorithm OID, the X.509 version, the subject and
|
|
385
|
+
* issuer name DER (byte equality = self-issued), and the SKI/AKI key
|
|
386
|
+
* identifiers (RFC 5280 sections 4.1, 4.2.1.1, 4.2.1.2). Malformed input
|
|
387
|
+
* yields undefined; nothing throws.
|
|
388
|
+
*
|
|
389
|
+
* @param {Buffer} der certificate DER, at most MAX_CERT_DER_BYTES
|
|
390
|
+
* @returns {object|undefined} parsed fields
|
|
391
|
+
*/
|
|
392
|
+
export function parseCertificateDer(der) {
|
|
393
|
+
if (
|
|
394
|
+
!Buffer.isBuffer(der) ||
|
|
395
|
+
der.length === 0 ||
|
|
396
|
+
der.length > MAX_CERT_DER_BYTES
|
|
397
|
+
) {
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
const certificate = readTLV(der, 0, der.length);
|
|
401
|
+
if (!certificate || certificate.tag !== 0x30) {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
const tbs = readTLV(der, certificate.contentStart, certificate.contentEnd);
|
|
405
|
+
if (!tbs || tbs.tag !== 0x30) {
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
let cursor = tbs.contentStart;
|
|
409
|
+
let version = 1;
|
|
410
|
+
const maybeVersion = readTLV(der, cursor, tbs.contentEnd);
|
|
411
|
+
if (maybeVersion && maybeVersion.tag === 0xa0) {
|
|
412
|
+
const inner = readTLV(
|
|
413
|
+
der,
|
|
414
|
+
maybeVersion.contentStart,
|
|
415
|
+
maybeVersion.contentEnd,
|
|
416
|
+
);
|
|
417
|
+
if (
|
|
418
|
+
!inner ||
|
|
419
|
+
inner.tag !== 0x02 ||
|
|
420
|
+
inner.contentEnd !== inner.contentStart + 1
|
|
421
|
+
) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
version = der[inner.contentStart] + 1;
|
|
425
|
+
cursor = maybeVersion.next;
|
|
426
|
+
}
|
|
427
|
+
const serial = readTLV(der, cursor, tbs.contentEnd);
|
|
428
|
+
if (!serial || serial.tag !== 0x02) {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
const tbsSignature = readTLV(der, serial.next, tbs.contentEnd);
|
|
432
|
+
if (!tbsSignature || tbsSignature.tag !== 0x30) {
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
const issuer = readTLV(der, tbsSignature.next, tbs.contentEnd);
|
|
436
|
+
if (!issuer || issuer.tag !== 0x30) {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
const validity = readTLV(der, issuer.next, tbs.contentEnd);
|
|
440
|
+
if (!validity || validity.tag !== 0x30) {
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
const subject = readTLV(der, validity.next, tbs.contentEnd);
|
|
444
|
+
if (!subject || subject.tag !== 0x30) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
const spki = readTLV(der, subject.next, tbs.contentEnd);
|
|
448
|
+
if (!spki || spki.tag !== 0x30) {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
const spkiAlgorithm = readTLV(der, spki.contentStart, spki.contentEnd);
|
|
452
|
+
if (!spkiAlgorithm || spkiAlgorithm.tag !== 0x30) {
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
const keyAlgorithmOid = readAlgorithmOid(der, spkiAlgorithm);
|
|
456
|
+
if (!keyAlgorithmOid) {
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
// Curve parameters sit inside the AlgorithmIdentifier as its second
|
|
460
|
+
// element (RFC 5480 section 2.1.1), not beside it.
|
|
461
|
+
const keyOidTlv = readTLV(
|
|
462
|
+
der,
|
|
463
|
+
spkiAlgorithm.contentStart,
|
|
464
|
+
spkiAlgorithm.contentEnd,
|
|
465
|
+
);
|
|
466
|
+
const keyParameters = readTLV(der, keyOidTlv.next, spkiAlgorithm.contentEnd);
|
|
467
|
+
const curveOid =
|
|
468
|
+
keyParameters && keyParameters.tag === 0x06
|
|
469
|
+
? readOID(der, keyParameters.contentStart, keyParameters.contentEnd)
|
|
470
|
+
: undefined;
|
|
471
|
+
const outerSignature = readTLV(der, tbs.next, certificate.contentEnd);
|
|
472
|
+
if (!outerSignature || outerSignature.tag !== 0x30) {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
const signatureAlgorithmOid = readAlgorithmOid(der, outerSignature);
|
|
476
|
+
const rsaKeyBits = rsaModulusBitLength(der, spkiAlgorithm, spki);
|
|
477
|
+
const ecKeyBits = ecCurveBitsFromSpki(der, spkiAlgorithm, spki);
|
|
478
|
+
// Extensions live under tbsCertificate [3]; issuer/subjectUniqueID ([1],
|
|
479
|
+
// [2]) may precede them and are skipped by tag.
|
|
480
|
+
let extensions;
|
|
481
|
+
let walkCursor = spki.next;
|
|
482
|
+
while (walkCursor < tbs.contentEnd) {
|
|
483
|
+
const entry = readTLV(der, walkCursor, tbs.contentEnd);
|
|
484
|
+
if (!entry) {
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
if (entry.tag === 0xa3) {
|
|
488
|
+
extensions = entry;
|
|
489
|
+
}
|
|
490
|
+
walkCursor = entry.next;
|
|
491
|
+
}
|
|
492
|
+
let subjectKeyId;
|
|
493
|
+
let authorityKeyId;
|
|
494
|
+
if (extensions) {
|
|
495
|
+
const parsed = parseKeyIdentifierExtensions(der, extensions);
|
|
496
|
+
subjectKeyId = parsed.subjectKeyId;
|
|
497
|
+
authorityKeyId = parsed.authorityKeyId;
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
version,
|
|
501
|
+
keyAlgorithmOid,
|
|
502
|
+
curveOid,
|
|
503
|
+
signatureAlgorithmOid,
|
|
504
|
+
rsaKeyBits,
|
|
505
|
+
ecKeyBits,
|
|
506
|
+
spkiDer: der.subarray(spki.start, spki.next),
|
|
507
|
+
subjectDer: der.subarray(subject.start, subject.next),
|
|
508
|
+
issuerDer: der.subarray(issuer.start, issuer.next),
|
|
509
|
+
subjectKeyId,
|
|
510
|
+
authorityKeyId,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }
|
|
515
|
+
// (RFC 8017 appendix A.1.1); the modulus' significant bit length is the key
|
|
516
|
+
// size trustinspector reports.
|
|
517
|
+
function rsaModulusBitLength(der, spkiAlgorithm, spki) {
|
|
518
|
+
const bitString = readTLV(der, spkiAlgorithm.next, spki.contentEnd);
|
|
519
|
+
if (
|
|
520
|
+
!bitString ||
|
|
521
|
+
bitString.tag !== 0x03 ||
|
|
522
|
+
bitString.contentStart >= bitString.contentEnd ||
|
|
523
|
+
der[bitString.contentStart] !== 0
|
|
524
|
+
) {
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
const publicKey = readTLV(
|
|
528
|
+
der,
|
|
529
|
+
bitString.contentStart + 1,
|
|
530
|
+
bitString.contentEnd,
|
|
531
|
+
);
|
|
532
|
+
if (!publicKey || publicKey.tag !== 0x30) {
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
const modulus = readTLV(der, publicKey.contentStart, publicKey.contentEnd);
|
|
536
|
+
if (
|
|
537
|
+
!modulus ||
|
|
538
|
+
modulus.tag !== 0x02 ||
|
|
539
|
+
modulus.contentEnd <= modulus.contentStart
|
|
540
|
+
) {
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
let start = modulus.contentStart;
|
|
544
|
+
while (start < modulus.contentEnd - 1 && der[start] === 0) {
|
|
545
|
+
start++;
|
|
546
|
+
}
|
|
547
|
+
const bits = (modulus.contentEnd - start) * 8;
|
|
548
|
+
const lead = der[start];
|
|
549
|
+
if (lead === 0) {
|
|
550
|
+
return bits;
|
|
551
|
+
}
|
|
552
|
+
let leadZeros = 0;
|
|
553
|
+
for (let mask = 0x80; mask > 0 && !(lead & mask); mask >>= 1) {
|
|
554
|
+
leadZeros++;
|
|
555
|
+
}
|
|
556
|
+
return bits - leadZeros;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// subjectKeyIdentifier (2.5.29.14) is an OCTET STRING wrapping the key
|
|
560
|
+
// identifier OCTET STRING; authorityKeyIdentifier (2.5.29.35) is a SEQUENCE
|
|
561
|
+
// whose [0] IMPLICIT field carries the identifier (RFC 5280 sections
|
|
562
|
+
// 4.2.1.1/4.2.1.2).
|
|
563
|
+
function parseKeyIdentifierExtensions(der, extensions) {
|
|
564
|
+
let subjectKeyId;
|
|
565
|
+
let authorityKeyId;
|
|
566
|
+
const container = readTLV(
|
|
567
|
+
der,
|
|
568
|
+
extensions.contentStart,
|
|
569
|
+
extensions.contentEnd,
|
|
570
|
+
);
|
|
571
|
+
if (!container || container.tag !== 0x30) {
|
|
572
|
+
return {};
|
|
573
|
+
}
|
|
574
|
+
let cursor = container.contentStart;
|
|
575
|
+
while (cursor < container.contentEnd) {
|
|
576
|
+
const extension = readTLV(der, cursor, container.contentEnd);
|
|
577
|
+
if (!extension) {
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
cursor = extension.next;
|
|
581
|
+
if (extension.tag !== 0x30) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const extnID = readTLV(der, extension.contentStart, extension.contentEnd);
|
|
585
|
+
if (!extnID || extnID.tag !== 0x06) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const oid = readOID(der, extnID.contentStart, extnID.contentEnd);
|
|
589
|
+
let valueCursor = extnID.next;
|
|
590
|
+
const maybeCritical = readTLV(der, valueCursor, extension.contentEnd);
|
|
591
|
+
if (maybeCritical && maybeCritical.tag === 0x01) {
|
|
592
|
+
valueCursor = maybeCritical.next;
|
|
593
|
+
}
|
|
594
|
+
const extnValue = readTLV(der, valueCursor, extension.contentEnd);
|
|
595
|
+
if (!extnValue || extnValue.tag !== 0x04) {
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (oid === "2.5.29.14") {
|
|
599
|
+
const inner = readTLV(der, extnValue.contentStart, extnValue.contentEnd);
|
|
600
|
+
if (inner && inner.tag === 0x04) {
|
|
601
|
+
subjectKeyId = hexSlice(der, inner.contentStart, inner.contentEnd);
|
|
602
|
+
}
|
|
603
|
+
} else if (oid === "2.5.29.35") {
|
|
604
|
+
const inner = readTLV(der, extnValue.contentStart, extnValue.contentEnd);
|
|
605
|
+
if (inner && inner.tag === 0x30) {
|
|
606
|
+
const keyId = readTLV(der, inner.contentStart, inner.contentEnd);
|
|
607
|
+
if (keyId && keyId.tag === 0x80) {
|
|
608
|
+
authorityKeyId = hexSlice(der, keyId.contentStart, keyId.contentEnd);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return { subjectKeyId, authorityKeyId };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function sha256Hex(buffer) {
|
|
617
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Name a key algorithm the way the BOM model requires: the post-quantum table
|
|
622
|
+
* first, then classical names derived from the SPKI OID and key size, never
|
|
623
|
+
* the platform's own name for an algorithm it may not know. Names agree with
|
|
624
|
+
* trustinspector's (`RSA-2048`, `ECDSA-P256`, `Ed25519`, `ML-DSA-65`);
|
|
625
|
+
* unknown algorithms keep their dotted OID.
|
|
626
|
+
*
|
|
627
|
+
* @param {object} context `{oid, curveOid, bits}` from the DER walk or report
|
|
628
|
+
* @returns {string|undefined} key name such as `ML-DSA-65`, `RSA-2048`, `ECDSA-P256`
|
|
629
|
+
*/
|
|
630
|
+
/**
|
|
631
|
+
* The family name for a key algorithm component: like
|
|
632
|
+
* {@link keyAlgorithmName} but without the key-size suffix, so every key
|
|
633
|
+
* size sharing an OID (RSA-1024 and RSA-2048, ECDSA P-256 and P-384)
|
|
634
|
+
* references the same single algorithm component. Post-quantum names are
|
|
635
|
+
* already family names (`ML-DSA-65`).
|
|
636
|
+
*
|
|
637
|
+
* @param {string} oid SPKI algorithm OID
|
|
638
|
+
* @returns {string|undefined} family name such as `RSA`, `ECDSA`, `ML-DSA-65`
|
|
639
|
+
*/
|
|
640
|
+
export function keyAlgorithmFamilyName(oid) {
|
|
641
|
+
const name = keyAlgorithmName({ oid });
|
|
642
|
+
if (!name) {
|
|
643
|
+
return undefined;
|
|
644
|
+
}
|
|
645
|
+
if (PQ_ALGORITHM_BY_OID[oid]) {
|
|
646
|
+
return name;
|
|
647
|
+
}
|
|
648
|
+
const KEY_FAMILY_BY_NAME = {
|
|
649
|
+
RSA: "RSA",
|
|
650
|
+
"RSA-PSS": "RSA-PSS",
|
|
651
|
+
ECDSA: "ECDSA",
|
|
652
|
+
DSA: "DSA",
|
|
653
|
+
Ed25519: "Ed25519",
|
|
654
|
+
Ed448: "Ed448",
|
|
655
|
+
};
|
|
656
|
+
return KEY_FAMILY_BY_NAME[name.replace(/-\d+$/, "")] || name;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function keyAlgorithmName(context) {
|
|
660
|
+
const oid = context?.oid;
|
|
661
|
+
if (!oid) {
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
const pq = PQ_ALGORITHM_BY_OID[oid];
|
|
665
|
+
if (pq) {
|
|
666
|
+
return pq[0];
|
|
667
|
+
}
|
|
668
|
+
const bits = Number(context?.bits);
|
|
669
|
+
const hasBits = Number.isInteger(bits) && bits > 0;
|
|
670
|
+
if (oid === "1.2.840.113549.1.1.1" || oid === "1.2.840.113549.1.1.10") {
|
|
671
|
+
return hasBits ? `RSA-${bits}` : "RSA";
|
|
672
|
+
}
|
|
673
|
+
if (oid === "1.2.840.10045.2.1") {
|
|
674
|
+
const curveBits = context?.curveOid
|
|
675
|
+
? CURVE_BITS_BY_OID[context.curveOid]
|
|
676
|
+
: undefined;
|
|
677
|
+
return curveBits
|
|
678
|
+
? `ECDSA-P${curveBits}`
|
|
679
|
+
: hasBits
|
|
680
|
+
? `ECDSA-P${bits}`
|
|
681
|
+
: "ECDSA";
|
|
682
|
+
}
|
|
683
|
+
const base = KEY_NAMES_BY_OID[oid];
|
|
684
|
+
if (!base) {
|
|
685
|
+
return oid;
|
|
686
|
+
}
|
|
687
|
+
return base;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Name a signature algorithm: the post-quantum table, then Go's classical
|
|
692
|
+
* names, then the dotted OID. Never "0" or "unknown".
|
|
693
|
+
*
|
|
694
|
+
* @param {object} context `{oid}` or a trusted producer name
|
|
695
|
+
* @returns {string|undefined} signature name such as `ML-DSA-65`, `SHA256-RSA`
|
|
696
|
+
*/
|
|
697
|
+
export function signatureAlgorithmName(context) {
|
|
698
|
+
const oid = context?.oid;
|
|
699
|
+
if (!oid) {
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
return (
|
|
703
|
+
(PQ_ALGORITHM_BY_OID[oid] && PQ_ALGORITHM_BY_OID[oid][0]) ||
|
|
704
|
+
SIGNATURE_NAMES_BY_OID[oid] ||
|
|
705
|
+
oid
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// The name an algorithm component carries for an OID. Key algorithms are
|
|
710
|
+
// named by family so one OID yields exactly one component whichever key sizes
|
|
711
|
+
// were seen; certificates must reference the same name.
|
|
712
|
+
function algorithmComponentName(oid, name) {
|
|
713
|
+
const isSignatureOnly = !KEY_NAMES_BY_OID[oid] && !PQ_ALGORITHM_BY_OID[oid];
|
|
714
|
+
return isSignatureOnly ? name : keyAlgorithmFamilyName(oid) || name;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Metadata for an algorithm component: primitive, parameter set and NIST
|
|
719
|
+
* quantum security level from the post-quantum table; classical algorithms
|
|
720
|
+
* carry level 0, which is a fact, not a policy.
|
|
721
|
+
*
|
|
722
|
+
* @param {string} oid dotted algorithm OID
|
|
723
|
+
* @param {string} kind `"signature"` or `"kem"`/key use
|
|
724
|
+
* @returns {object} `{primitive, parameterSetIdentifier, nistQuantumSecurityLevel}` when known
|
|
725
|
+
*/
|
|
726
|
+
export function algorithmComponentMetadata(oid) {
|
|
727
|
+
const pq = PQ_ALGORITHM_BY_OID[oid];
|
|
728
|
+
if (pq) {
|
|
729
|
+
return {
|
|
730
|
+
primitive: pq[1],
|
|
731
|
+
parameterSetIdentifier: pq[2],
|
|
732
|
+
nistQuantumSecurityLevel: pq[3],
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (SIGNATURE_NAMES_BY_OID[oid] || KEY_NAMES_BY_OID[oid]) {
|
|
736
|
+
return { nistQuantumSecurityLevel: 0 };
|
|
737
|
+
}
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function certificateBomRef(name, certSha256) {
|
|
742
|
+
return `crypto/certificate/${encodeURIComponent(name)}@sha256:${certSha256}`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function publicKeyBomRef(keyName, spkiSha256) {
|
|
746
|
+
return `crypto/public-key/${encodeURIComponent(keyName)}@spki-sha256:${spkiSha256}`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function algorithmBomRef(name, oid) {
|
|
750
|
+
return `crypto/algorithm/${encodeURIComponent(name)}@${oid}`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function prop(name, value) {
|
|
754
|
+
return { name, value: String(value) };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function sortedUnique(values) {
|
|
758
|
+
return [...new Set(values.filter((value) => value !== undefined))].sort();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Node renders the subject as one RDN per line in certificate order; Go's
|
|
762
|
+
// pkix.Name.String() (the contract's format) emits the reverse order joined
|
|
763
|
+
// by commas. Reversing Node's lines reproduces Go's string byte for byte for
|
|
764
|
+
// the attributes Node exposes.
|
|
765
|
+
function normalizeDn(dn) {
|
|
766
|
+
if (typeof dn !== "string") {
|
|
767
|
+
return undefined;
|
|
768
|
+
}
|
|
769
|
+
const lines = dn
|
|
770
|
+
.split(/\r?\n/)
|
|
771
|
+
.map((line) => line.trim())
|
|
772
|
+
.filter(Boolean);
|
|
773
|
+
if (!lines.length) {
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
return lines.reverse().join(",");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export function commonNameFromDn(dn) {
|
|
780
|
+
if (typeof dn !== "string") {
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
// Both Node's newline-per-RDN form and the joined single-line form; a
|
|
784
|
+
// comma escaped per RFC 4514 ("Foo\, Inc") stays inside its value.
|
|
785
|
+
const parts = dn
|
|
786
|
+
.split(/\n|(?<!\\),/)
|
|
787
|
+
.map((part) => part.trim().replace(/\\(.)/g, "$1"))
|
|
788
|
+
.filter(Boolean);
|
|
789
|
+
const cn = parts.find((part) => part.startsWith("CN="));
|
|
790
|
+
if (cn) {
|
|
791
|
+
return cn.slice(3);
|
|
792
|
+
}
|
|
793
|
+
const first = parts.find(Boolean);
|
|
794
|
+
if (!first) {
|
|
795
|
+
return undefined;
|
|
796
|
+
}
|
|
797
|
+
const eq = first.indexOf("=");
|
|
798
|
+
return eq >= 0 ? first.slice(eq + 1) : first;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function rfc3339(date) {
|
|
802
|
+
if (
|
|
803
|
+
date === undefined ||
|
|
804
|
+
date === null ||
|
|
805
|
+
(typeof date !== "string" && !(date instanceof Date))
|
|
806
|
+
) {
|
|
807
|
+
return undefined;
|
|
808
|
+
}
|
|
809
|
+
const parsed = date instanceof Date ? date : new Date(date.trim());
|
|
810
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
811
|
+
return undefined;
|
|
812
|
+
}
|
|
813
|
+
const iso = parsed.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
814
|
+
// RFC 3339 date-time allows a four-digit year only; toISOString prints
|
|
815
|
+
// expanded years as +YYYYYY/-YYYYYY, which no schema date-time accepts.
|
|
816
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(iso) ? iso : undefined;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// An uncompressed EC point is 0x04 followed by two equal-length coordinates
|
|
820
|
+
// (SEC 1 section 2.3.3); the coordinate length gives the curve size. Used
|
|
821
|
+
// when the curve OID is unknown to the table above.
|
|
822
|
+
function ecCurveBitsFromSpki(der, spkiAlgorithm, spki) {
|
|
823
|
+
const bitString = readTLV(der, spkiAlgorithm.next, spki.contentEnd);
|
|
824
|
+
if (
|
|
825
|
+
!bitString ||
|
|
826
|
+
bitString.tag !== 0x03 ||
|
|
827
|
+
bitString.contentStart >= bitString.contentEnd ||
|
|
828
|
+
der[bitString.contentStart] !== 0
|
|
829
|
+
) {
|
|
830
|
+
return undefined;
|
|
831
|
+
}
|
|
832
|
+
const pointLength = bitString.contentEnd - bitString.contentStart - 1;
|
|
833
|
+
if (pointLength < 1 || (pointLength - 1) % 2 !== 0) {
|
|
834
|
+
return undefined;
|
|
835
|
+
}
|
|
836
|
+
return ((pointLength - 1) / 2) * 8;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ---------------------------------------------------------------------------
|
|
840
|
+
// Certificate records and the collector
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
|
|
843
|
+
function emptyRecord(certSha256) {
|
|
844
|
+
return {
|
|
845
|
+
certSha256,
|
|
846
|
+
spkiSha256: undefined,
|
|
847
|
+
spkiSha256Base64: undefined,
|
|
848
|
+
name: undefined,
|
|
849
|
+
subject: undefined,
|
|
850
|
+
issuer: undefined,
|
|
851
|
+
serialHex: undefined,
|
|
852
|
+
version: undefined,
|
|
853
|
+
isCA: undefined,
|
|
854
|
+
selfSigned: undefined,
|
|
855
|
+
selfSignedUnverified: undefined,
|
|
856
|
+
role: undefined,
|
|
857
|
+
pathLen: undefined,
|
|
858
|
+
subjectKeyId: undefined,
|
|
859
|
+
authorityKeyId: undefined,
|
|
860
|
+
notValidBefore: undefined,
|
|
861
|
+
notValidAfter: undefined,
|
|
862
|
+
fileExtension: undefined,
|
|
863
|
+
keyName: undefined,
|
|
864
|
+
keyBits: undefined,
|
|
865
|
+
keyAlgorithmOid: undefined,
|
|
866
|
+
signatureAlgorithm: undefined,
|
|
867
|
+
signatureAlgorithmOid: undefined,
|
|
868
|
+
keyUsage: undefined,
|
|
869
|
+
extKeyUsage: undefined,
|
|
870
|
+
dnsNames: undefined,
|
|
871
|
+
ocspServers: undefined,
|
|
872
|
+
issuingCertificateUrls: undefined,
|
|
873
|
+
crlDistributionPoints: undefined,
|
|
874
|
+
policyOids: undefined,
|
|
875
|
+
hasNameConstraints: undefined,
|
|
876
|
+
trustAcceptedPurposes: undefined,
|
|
877
|
+
trustRejectedPurposes: undefined,
|
|
878
|
+
hostStores: undefined,
|
|
879
|
+
issuers: undefined,
|
|
880
|
+
issuersUnverified: undefined,
|
|
881
|
+
hashes: undefined,
|
|
882
|
+
algorithm: undefined,
|
|
883
|
+
keyStrength: undefined,
|
|
884
|
+
trustDomain: undefined,
|
|
885
|
+
occurrences: [],
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Normalise a trustinspector material (report-1 or report-2) into a
|
|
891
|
+
* certificate record. Returns undefined for non-certificate kinds.
|
|
892
|
+
*
|
|
893
|
+
* @param {object} material trustinspector material
|
|
894
|
+
* @param {object} [context] `{locationRoot}` to relativise container paths
|
|
895
|
+
* @returns {object|undefined} record
|
|
896
|
+
*/
|
|
897
|
+
export function certRecordFromTrustInspector(material, context = {}) {
|
|
898
|
+
if (!material || material.kind !== "certificate") {
|
|
899
|
+
return undefined;
|
|
900
|
+
}
|
|
901
|
+
const certSha256 =
|
|
902
|
+
toLowerSha256(material.certSha256) ||
|
|
903
|
+
toLowerSha256(material.fingerprint) ||
|
|
904
|
+
toLowerSha256(material.sha256);
|
|
905
|
+
if (!certSha256) {
|
|
906
|
+
return undefined;
|
|
907
|
+
}
|
|
908
|
+
const record = emptyRecord(certSha256);
|
|
909
|
+
record.spkiSha256 = toLowerSha256(material.spkiSha256);
|
|
910
|
+
record.spkiSha256Base64 = material.spkiSha256Base64;
|
|
911
|
+
record.serialHex =
|
|
912
|
+
normalizeSerialHex(material.serialHex) ||
|
|
913
|
+
serialDecimalToHex(material.serial);
|
|
914
|
+
record.subject =
|
|
915
|
+
typeof material.subject === "string" ? material.subject : undefined;
|
|
916
|
+
record.issuer =
|
|
917
|
+
typeof material.issuer === "string" ? material.issuer : undefined;
|
|
918
|
+
// The name is part of the bom-ref, so it comes from the subject by the rule
|
|
919
|
+
// the Node reader applies, and a certificate is named alike with or without
|
|
920
|
+
// the plugin. trustinspector 4.2.0 names a certificate without a CN after
|
|
921
|
+
// the first RDN in DER order, its country ("ES" for
|
|
922
|
+
// "OU=AC RAIZ FNMT-RCM,O=FNMT-RCM,C=ES"), where the most specific RDN names
|
|
923
|
+
// it; its name is only used when the subject gives none.
|
|
924
|
+
record.name =
|
|
925
|
+
commonNameFromDn(record.subject || "") ||
|
|
926
|
+
(typeof material.name === "string" && material.name) ||
|
|
927
|
+
undefined;
|
|
928
|
+
record.notValidBefore = rfc3339(material.createdAt);
|
|
929
|
+
record.notValidAfter = rfc3339(material.expiresAt);
|
|
930
|
+
record.fileExtension = material.fileExtension;
|
|
931
|
+
record.version = material.version;
|
|
932
|
+
record.isCA = typeof material.isCA === "boolean" ? material.isCA : undefined;
|
|
933
|
+
record.selfSigned =
|
|
934
|
+
material.selfSigned === undefined
|
|
935
|
+
? undefined
|
|
936
|
+
: Boolean(material.selfSigned);
|
|
937
|
+
record.selfSignedUnverified = material.selfSignedUnverified
|
|
938
|
+
? true
|
|
939
|
+
: undefined;
|
|
940
|
+
record.role = material.role;
|
|
941
|
+
record.pathLen = material.pathLen;
|
|
942
|
+
record.subjectKeyId = toLowerHex(material.subjectKeyId);
|
|
943
|
+
record.authorityKeyId = toLowerHex(material.authorityKeyId);
|
|
944
|
+
record.keyName = material.keyName;
|
|
945
|
+
record.keyBits = material.keyBits;
|
|
946
|
+
record.keyAlgorithmOid = material.keyAlgorithmOid;
|
|
947
|
+
record.signatureAlgorithm = material.signatureAlgorithm;
|
|
948
|
+
record.signatureAlgorithmOid = material.signatureAlgorithmOid;
|
|
949
|
+
record.keyUsage = Array.isArray(material.keyUsage)
|
|
950
|
+
? [...material.keyUsage]
|
|
951
|
+
: undefined;
|
|
952
|
+
record.extKeyUsage = Array.isArray(material.extKeyUsage)
|
|
953
|
+
? [...material.extKeyUsage]
|
|
954
|
+
: undefined;
|
|
955
|
+
record.dnsNames = Array.isArray(material.dnsNames)
|
|
956
|
+
? [...material.dnsNames]
|
|
957
|
+
: undefined;
|
|
958
|
+
record.ocspServers = Array.isArray(material.ocspServers)
|
|
959
|
+
? [...material.ocspServers]
|
|
960
|
+
: undefined;
|
|
961
|
+
record.issuingCertificateUrls = Array.isArray(material.issuingCertificateUrls)
|
|
962
|
+
? [...material.issuingCertificateUrls]
|
|
963
|
+
: undefined;
|
|
964
|
+
record.crlDistributionPoints = Array.isArray(material.crlDistributionPoints)
|
|
965
|
+
? [...material.crlDistributionPoints]
|
|
966
|
+
: undefined;
|
|
967
|
+
record.policyOids = Array.isArray(material.policyOids)
|
|
968
|
+
? [...material.policyOids]
|
|
969
|
+
: undefined;
|
|
970
|
+
record.hasNameConstraints = material.hasNameConstraints ? true : undefined;
|
|
971
|
+
record.trustAcceptedPurposes = Array.isArray(material.trustAcceptedPurposes)
|
|
972
|
+
? [...material.trustAcceptedPurposes]
|
|
973
|
+
: undefined;
|
|
974
|
+
record.trustRejectedPurposes = Array.isArray(material.trustRejectedPurposes)
|
|
975
|
+
? [...material.trustRejectedPurposes]
|
|
976
|
+
: undefined;
|
|
977
|
+
record.hostStores = Array.isArray(material.hostStores)
|
|
978
|
+
? [...material.hostStores]
|
|
979
|
+
: undefined;
|
|
980
|
+
record.issuers = Array.isArray(material.issuers)
|
|
981
|
+
? [...material.issuers]
|
|
982
|
+
: undefined;
|
|
983
|
+
record.issuersUnverified = Array.isArray(material.issuersUnverified)
|
|
984
|
+
? [...material.issuersUnverified]
|
|
985
|
+
: undefined;
|
|
986
|
+
record.algorithm = material.algorithm;
|
|
987
|
+
record.keyStrength = material.keyStrength;
|
|
988
|
+
record.trustDomain = material.trustDomain;
|
|
989
|
+
// File digests describe a file, not a certificate: they only survive on a
|
|
990
|
+
// single-occurrence certificate, where that one file is the cert.
|
|
991
|
+
const paths =
|
|
992
|
+
Array.isArray(material.paths) && material.paths.length
|
|
993
|
+
? material.paths
|
|
994
|
+
: material.path
|
|
995
|
+
? [material.path]
|
|
996
|
+
: [];
|
|
997
|
+
if (paths.length === 1 && material.sha256) {
|
|
998
|
+
record.hashes = [
|
|
999
|
+
...(material.sha1 && isHex(material.sha1)
|
|
1000
|
+
? [{ alg: "SHA-1", content: material.sha1.toLowerCase() }]
|
|
1001
|
+
: []),
|
|
1002
|
+
{ alg: "SHA-256", content: material.sha256.toLowerCase() },
|
|
1003
|
+
];
|
|
1004
|
+
}
|
|
1005
|
+
for (const location of paths) {
|
|
1006
|
+
record.occurrences.push({
|
|
1007
|
+
location: containerRelativePath(location, context.locationRoot),
|
|
1008
|
+
sourceType: material.sourceType,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
if (Array.isArray(material.sourceTypes) && material.sourceTypes.length) {
|
|
1012
|
+
record.sourceTypes = [...material.sourceTypes];
|
|
1013
|
+
} else if (material.sourceType) {
|
|
1014
|
+
record.sourceTypes = [material.sourceType];
|
|
1015
|
+
}
|
|
1016
|
+
return record;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Build a certificate record from a parsed certificate (Node fallback path).
|
|
1021
|
+
* The DER walk supplies the SPKI, algorithm OIDs, version and key
|
|
1022
|
+
* identifiers; the X509Certificate supplies names, validity and, on runtimes
|
|
1023
|
+
* that expose them, the signature algorithm. Fields the platform cannot
|
|
1024
|
+
* provide (key usage bits, path length) stay unset and are documented as
|
|
1025
|
+
* fallback limits.
|
|
1026
|
+
*
|
|
1027
|
+
* @param {Buffer} der certificate DER
|
|
1028
|
+
* @param {object} context `{location, sourceType, pem}`
|
|
1029
|
+
* @returns {object|undefined} record
|
|
1030
|
+
*/
|
|
1031
|
+
export function certRecordFromDer(der, context = {}) {
|
|
1032
|
+
const walked = parseCertificateDer(der);
|
|
1033
|
+
if (!walked) {
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
const spkiSha256 = sha256Hex(walked.spkiDer);
|
|
1037
|
+
const record = emptyRecord(sha256Hex(der));
|
|
1038
|
+
record.spkiSha256 = spkiSha256;
|
|
1039
|
+
record.spkiSha256Base64 = createHash("sha256")
|
|
1040
|
+
.update(walked.spkiDer)
|
|
1041
|
+
.digest("base64");
|
|
1042
|
+
record.subjectKeyId = walked.subjectKeyId;
|
|
1043
|
+
record.authorityKeyId = walked.authorityKeyId;
|
|
1044
|
+
record.version = walked.version;
|
|
1045
|
+
record.keyAlgorithmOid = walked.keyAlgorithmOid;
|
|
1046
|
+
record.signatureAlgorithmOid = walked.signatureAlgorithmOid;
|
|
1047
|
+
record.der = der;
|
|
1048
|
+
record.walked = walked;
|
|
1049
|
+
let x509;
|
|
1050
|
+
try {
|
|
1051
|
+
x509 = new X509Certificate(der);
|
|
1052
|
+
} catch {
|
|
1053
|
+
x509 = undefined;
|
|
1054
|
+
}
|
|
1055
|
+
if (x509) {
|
|
1056
|
+
record.subject = normalizeDn(x509.subject);
|
|
1057
|
+
record.issuer = normalizeDn(x509.issuer);
|
|
1058
|
+
record.serialHex = normalizeSerialHex(x509.serialNumber);
|
|
1059
|
+
record.notValidBefore = rfc3339(x509.validFrom);
|
|
1060
|
+
record.notValidAfter = rfc3339(x509.validTo);
|
|
1061
|
+
record.isCA = x509.ca === undefined ? undefined : Boolean(x509.ca);
|
|
1062
|
+
if (typeof x509.signatureAlgorithmOid === "string") {
|
|
1063
|
+
record.signatureAlgorithmOid = x509.signatureAlgorithmOid;
|
|
1064
|
+
record.signatureAlgorithm = signatureAlgorithmName({
|
|
1065
|
+
oid: x509.signatureAlgorithmOid,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
try {
|
|
1069
|
+
const bits = x509.publicKey?.asymmetricKeySize;
|
|
1070
|
+
if (Number.isInteger(bits) && bits > 0) {
|
|
1071
|
+
record.keyBits = bits;
|
|
1072
|
+
}
|
|
1073
|
+
} catch {
|
|
1074
|
+
// The runtime cannot load this key (post-quantum on an older OpenSSL);
|
|
1075
|
+
// the OID-based names already carry what is known.
|
|
1076
|
+
}
|
|
1077
|
+
if (x509.subjectAltName) {
|
|
1078
|
+
record.dnsNames = x509.subjectAltName
|
|
1079
|
+
.split(",")
|
|
1080
|
+
.map((entry) => entry.trim())
|
|
1081
|
+
.filter((entry) => entry.startsWith("DNS:"))
|
|
1082
|
+
.map((entry) => entry.slice(4));
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (!record.keyBits) {
|
|
1086
|
+
if (walked.rsaKeyBits) {
|
|
1087
|
+
record.keyBits = walked.rsaKeyBits;
|
|
1088
|
+
} else if (walked.keyAlgorithmOid === "1.2.840.10045.2.1") {
|
|
1089
|
+
// Node reports no size for EC keys; the point size carries it.
|
|
1090
|
+
record.keyBits = walked.ecKeyBits;
|
|
1091
|
+
} else if (walked.keyAlgorithmOid === "1.3.101.112") {
|
|
1092
|
+
// Ed25519 keys are 256 bits (RFC 8410 section 4).
|
|
1093
|
+
record.keyBits = 256;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
record.keyName = keyAlgorithmName({
|
|
1097
|
+
oid: record.keyAlgorithmOid,
|
|
1098
|
+
curveOid: walked.curveOid,
|
|
1099
|
+
bits: record.keyBits,
|
|
1100
|
+
});
|
|
1101
|
+
if (!record.name) {
|
|
1102
|
+
record.name =
|
|
1103
|
+
commonNameFromDn(record.subject || "") ||
|
|
1104
|
+
basename(String(context.location || "")) ||
|
|
1105
|
+
undefined;
|
|
1106
|
+
}
|
|
1107
|
+
if (!record.signatureAlgorithm) {
|
|
1108
|
+
record.signatureAlgorithm = signatureAlgorithmName({
|
|
1109
|
+
oid: record.signatureAlgorithmOid,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
record.x509 = x509;
|
|
1113
|
+
classifySelfIssued(record);
|
|
1114
|
+
if (context.location) {
|
|
1115
|
+
record.occurrences.push({
|
|
1116
|
+
location: context.location,
|
|
1117
|
+
sourceType: context.sourceType,
|
|
1118
|
+
});
|
|
1119
|
+
record.sourceTypes = context.sourceType ? [context.sourceType] : [];
|
|
1120
|
+
}
|
|
1121
|
+
return record;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// JSON legally carries unpaired surrogates, which encodeURIComponent (the
|
|
1125
|
+
// bom-ref builders) rejects with URIError; a presenter's free-text fields are
|
|
1126
|
+
// made well-formed (unpaired surrogates become U+FFFD) instead of failing
|
|
1127
|
+
// the whole BOM.
|
|
1128
|
+
export function toWellFormedText(value) {
|
|
1129
|
+
return typeof value === "string" && value ? value.toWellFormed() : undefined;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Serial numbers are at most 20 octets (RFC 5280 section 4.1.2.2); a longer
|
|
1133
|
+
// hex string is not a serial any conformant certificate carries.
|
|
1134
|
+
const MAX_SERIAL_HEX_CHARS = 64;
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Normalise one certificate of the chain a server presented in a TLS ≤ 1.2
|
|
1138
|
+
* handshake, as safer-exec's crypto result relays it (leaf first). The entry
|
|
1139
|
+
* carries no DER: identity is the two SHA-256 digests, and the server's
|
|
1140
|
+
* ordering is untrusted input, so the record carries no issuer edges and no
|
|
1141
|
+
* SKI/AKI. Fields safer-exec already classified per PLAN section 2.5
|
|
1142
|
+
* (selfSigned, keyName, signatureAlgorithm) are taken as given; `role` is
|
|
1143
|
+
* re-derived from them with the same rule as the DER path.
|
|
1144
|
+
*
|
|
1145
|
+
* @param {object} entry safer-exec CertChainEntry
|
|
1146
|
+
* @param {object} [context] `{host}` the safer-exec `host:port` the chain was
|
|
1147
|
+
* presented to; the occurrence `tls://<host>` is omitted without it
|
|
1148
|
+
* @returns {object|undefined} record, or undefined when the entry has no
|
|
1149
|
+
* usable certificate hash
|
|
1150
|
+
*/
|
|
1151
|
+
export function certRecordFromObservedChainEntry(entry, context = {}) {
|
|
1152
|
+
const certSha256 = toLowerSha256(entry?.certSha256);
|
|
1153
|
+
if (!certSha256) {
|
|
1154
|
+
return undefined;
|
|
1155
|
+
}
|
|
1156
|
+
const record = emptyRecord(certSha256);
|
|
1157
|
+
record.spkiSha256 = toLowerSha256(entry.spkiSha256);
|
|
1158
|
+
record.spkiSha256Base64 =
|
|
1159
|
+
typeof entry.spkiSha256Base64 === "string"
|
|
1160
|
+
? entry.spkiSha256Base64
|
|
1161
|
+
: undefined;
|
|
1162
|
+
record.subject = toWellFormedText(entry.subject);
|
|
1163
|
+
record.issuer = toWellFormedText(entry.issuer);
|
|
1164
|
+
// Named from the subject exactly as the DER path names it, so a CN-less
|
|
1165
|
+
// certificate gets one bom-ref from both producers (safer-exec's own name
|
|
1166
|
+
// takes the first RDN in DER order, cdxgen the first in RFC 4514 order).
|
|
1167
|
+
record.name =
|
|
1168
|
+
(record.subject && commonNameFromDn(record.subject)) ||
|
|
1169
|
+
toWellFormedText(entry.name);
|
|
1170
|
+
record.serialHex =
|
|
1171
|
+
typeof entry.serialNumber === "string" &&
|
|
1172
|
+
entry.serialNumber.length <= MAX_SERIAL_HEX_CHARS
|
|
1173
|
+
? normalizeSerialHex(entry.serialNumber)
|
|
1174
|
+
: undefined;
|
|
1175
|
+
record.version = Number.isInteger(entry.version) ? entry.version : undefined;
|
|
1176
|
+
record.notValidBefore = rfc3339(entry.notValidBefore);
|
|
1177
|
+
record.notValidAfter = rfc3339(entry.notValidAfter);
|
|
1178
|
+
record.signatureAlgorithm = toWellFormedText(entry.signatureAlgorithm);
|
|
1179
|
+
record.signatureAlgorithmOid =
|
|
1180
|
+
typeof entry.signatureAlgorithmOid === "string" &&
|
|
1181
|
+
entry.signatureAlgorithmOid
|
|
1182
|
+
? entry.signatureAlgorithmOid
|
|
1183
|
+
: undefined;
|
|
1184
|
+
record.keyName = toWellFormedText(entry.keyName);
|
|
1185
|
+
record.keyBits =
|
|
1186
|
+
Number.isInteger(entry.keyBits) &&
|
|
1187
|
+
entry.keyBits > 0 &&
|
|
1188
|
+
entry.keyBits <= 16384
|
|
1189
|
+
? entry.keyBits
|
|
1190
|
+
: undefined;
|
|
1191
|
+
record.keyAlgorithmOid =
|
|
1192
|
+
typeof entry.keyAlgorithmOid === "string" && entry.keyAlgorithmOid
|
|
1193
|
+
? entry.keyAlgorithmOid
|
|
1194
|
+
: undefined;
|
|
1195
|
+
record.isCA = typeof entry.isCA === "boolean" ? entry.isCA : undefined;
|
|
1196
|
+
record.selfSigned =
|
|
1197
|
+
typeof entry.selfSigned === "boolean" ? entry.selfSigned : undefined;
|
|
1198
|
+
record.selfSignedUnverified = entry.selfSignedUnverified ? true : undefined;
|
|
1199
|
+
record.role =
|
|
1200
|
+
record.selfSigned && (record.isCA || record.version === 1)
|
|
1201
|
+
? "root"
|
|
1202
|
+
: record.isCA
|
|
1203
|
+
? "intermediate"
|
|
1204
|
+
: "leaf";
|
|
1205
|
+
const host = typeof context.host === "string" ? context.host.trim() : "";
|
|
1206
|
+
if (host) {
|
|
1207
|
+
record.occurrences.push({
|
|
1208
|
+
location: `tls://${host}`,
|
|
1209
|
+
sourceType: "observed-tls",
|
|
1210
|
+
});
|
|
1211
|
+
record.sourceTypes = ["observed-tls"];
|
|
1212
|
+
}
|
|
1213
|
+
return record;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Determine selfSigned/role per PLAN section 2.3/2.5. Signature verification
|
|
1217
|
+
// uses the platform only inside try/catch; when it cannot check (unsupported
|
|
1218
|
+
// algorithm, MD5), a self-issued certificate (subject DER == issuer DER, AKI
|
|
1219
|
+
// absent or equal to SKI) still counts as self-signed and is marked
|
|
1220
|
+
// selfSignedUnverified.
|
|
1221
|
+
function classifySelfIssued(record) {
|
|
1222
|
+
const walked = record.walked;
|
|
1223
|
+
if (!walked) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const selfIssued =
|
|
1227
|
+
walked.subjectDer.length === walked.issuerDer.length &&
|
|
1228
|
+
walked.subjectDer.equals(walked.issuerDer);
|
|
1229
|
+
let verified = false;
|
|
1230
|
+
let couldNotCheck = false;
|
|
1231
|
+
if (selfIssued && record.x509) {
|
|
1232
|
+
try {
|
|
1233
|
+
verified =
|
|
1234
|
+
record.x509.checkIssued(record.x509) &&
|
|
1235
|
+
record.x509.verify(record.x509.publicKey);
|
|
1236
|
+
} catch {
|
|
1237
|
+
// The runtime cannot check this signature (unsupported algorithm); a
|
|
1238
|
+
// signature that was checked and failed is NOT self-signed.
|
|
1239
|
+
couldNotCheck = true;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
let unverified = false;
|
|
1243
|
+
if (selfIssued && !verified && couldNotCheck) {
|
|
1244
|
+
// PLAN 2.5: the AKI is absent or equals the SKI.
|
|
1245
|
+
const keyIdsChain =
|
|
1246
|
+
!walked.authorityKeyId || walked.authorityKeyId === walked.subjectKeyId;
|
|
1247
|
+
if (keyIdsChain) {
|
|
1248
|
+
unverified = true;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
record.selfIssued = selfIssued;
|
|
1252
|
+
if (verified || unverified) {
|
|
1253
|
+
record.selfSigned = true;
|
|
1254
|
+
record.selfSignedUnverified = unverified && !verified ? true : undefined;
|
|
1255
|
+
} else {
|
|
1256
|
+
record.selfSigned = false;
|
|
1257
|
+
}
|
|
1258
|
+
if (record.selfSigned) {
|
|
1259
|
+
record.role = record.isCA || record.version === 1 ? "root" : "leaf";
|
|
1260
|
+
} else if (record.isCA) {
|
|
1261
|
+
record.role = "intermediate";
|
|
1262
|
+
} else {
|
|
1263
|
+
record.role = "leaf";
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Pairwise issuer linking over records parsed in one collection
|
|
1269
|
+
* (`certRecordFromDer` output). A verified edge needs the signature to check;
|
|
1270
|
+
* an uncheckable signature over chained names and key identifiers goes to
|
|
1271
|
+
* issuersUnverified. Capped at ISSUER_LINK_LIMIT certificates.
|
|
1272
|
+
*
|
|
1273
|
+
* @param {object[]} records records from certRecordFromDer
|
|
1274
|
+
*/
|
|
1275
|
+
export function linkFallbackIssuers(records) {
|
|
1276
|
+
const parsed = records.filter((record) => record?.x509 && record?.walked);
|
|
1277
|
+
if (parsed.length > ISSUER_LINK_LIMIT) {
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
for (const child of parsed) {
|
|
1281
|
+
const verified = [];
|
|
1282
|
+
const unverified = [];
|
|
1283
|
+
for (const parent of parsed) {
|
|
1284
|
+
if (parent === child) {
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
const namesChain =
|
|
1288
|
+
child.walked.issuerDer.length === parent.walked.subjectDer.length &&
|
|
1289
|
+
child.walked.issuerDer.equals(parent.walked.subjectDer);
|
|
1290
|
+
if (!namesChain) {
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
const keyIdsChain =
|
|
1294
|
+
!child.walked.authorityKeyId ||
|
|
1295
|
+
!parent.walked.subjectKeyId ||
|
|
1296
|
+
child.walked.authorityKeyId === parent.walked.subjectKeyId;
|
|
1297
|
+
let signatureOk = false;
|
|
1298
|
+
let threw = false;
|
|
1299
|
+
try {
|
|
1300
|
+
signatureOk =
|
|
1301
|
+
child.x509.checkIssued(parent.x509) &&
|
|
1302
|
+
child.x509.verify(parent.x509.publicKey);
|
|
1303
|
+
} catch {
|
|
1304
|
+
threw = true;
|
|
1305
|
+
}
|
|
1306
|
+
if (signatureOk) {
|
|
1307
|
+
verified.push(parent.certSha256);
|
|
1308
|
+
} else if (threw && keyIdsChain && !child.selfIssued) {
|
|
1309
|
+
// Only a signature the runtime cannot check lands here; one that was
|
|
1310
|
+
// checked and failed means "not signed by this key" and is dropped.
|
|
1311
|
+
unverified.push(parent.spkiSha256);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
child.issuers = verified.sort();
|
|
1315
|
+
child.fallbackUnverifiedIssuerSpkis = sortedUnique(unverified);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Relativise a host path against a scan root so producers agree on
|
|
1321
|
+
* locations: `/tmp/x/rootfs/etc/ssl` with root `/tmp/x/rootfs` becomes
|
|
1322
|
+
* `/etc/ssl` (PLAN section 4.4, G18).
|
|
1323
|
+
*
|
|
1324
|
+
* @param {string} location host or already-relative path
|
|
1325
|
+
* @param {string} [root] scan root to strip
|
|
1326
|
+
* @returns {string} normalised container-style path
|
|
1327
|
+
*/
|
|
1328
|
+
export function containerRelativePath(location, root) {
|
|
1329
|
+
if (typeof location !== "string" || !location) {
|
|
1330
|
+
return undefined;
|
|
1331
|
+
}
|
|
1332
|
+
let normalized = location.replace(/\\/g, "/");
|
|
1333
|
+
if (root) {
|
|
1334
|
+
let cleanedRoot = root.replace(/\\/g, "/");
|
|
1335
|
+
let end = cleanedRoot.length;
|
|
1336
|
+
while (end > 1 && cleanedRoot[end - 1] === "/") {
|
|
1337
|
+
end--;
|
|
1338
|
+
}
|
|
1339
|
+
cleanedRoot = cleanedRoot.slice(0, end);
|
|
1340
|
+
const rootPrefix = `${cleanedRoot}/`;
|
|
1341
|
+
if (normalized.startsWith(rootPrefix)) {
|
|
1342
|
+
normalized = normalized.slice(rootPrefix.length - 1);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (!normalized.startsWith("/")) {
|
|
1346
|
+
normalized = `/${normalized}`;
|
|
1347
|
+
}
|
|
1348
|
+
return normalized;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function mergeRecords(existing, incoming) {
|
|
1352
|
+
const fieldNames = [
|
|
1353
|
+
"spkiSha256",
|
|
1354
|
+
"spkiSha256Base64",
|
|
1355
|
+
"subject",
|
|
1356
|
+
"issuer",
|
|
1357
|
+
"serialHex",
|
|
1358
|
+
"version",
|
|
1359
|
+
"isCA",
|
|
1360
|
+
"selfSigned",
|
|
1361
|
+
"role",
|
|
1362
|
+
"pathLen",
|
|
1363
|
+
"subjectKeyId",
|
|
1364
|
+
"authorityKeyId",
|
|
1365
|
+
"notValidBefore",
|
|
1366
|
+
"notValidAfter",
|
|
1367
|
+
"fileExtension",
|
|
1368
|
+
"keyName",
|
|
1369
|
+
"keyBits",
|
|
1370
|
+
"keyAlgorithmOid",
|
|
1371
|
+
"signatureAlgorithm",
|
|
1372
|
+
"signatureAlgorithmOid",
|
|
1373
|
+
"keyUsage",
|
|
1374
|
+
"extKeyUsage",
|
|
1375
|
+
"dnsNames",
|
|
1376
|
+
"ocspServers",
|
|
1377
|
+
"issuingCertificateUrls",
|
|
1378
|
+
"crlDistributionPoints",
|
|
1379
|
+
"policyOids",
|
|
1380
|
+
"hasNameConstraints",
|
|
1381
|
+
"trustAcceptedPurposes",
|
|
1382
|
+
"trustRejectedPurposes",
|
|
1383
|
+
"hostStores",
|
|
1384
|
+
"algorithm",
|
|
1385
|
+
"keyStrength",
|
|
1386
|
+
"trustDomain",
|
|
1387
|
+
"name",
|
|
1388
|
+
];
|
|
1389
|
+
for (const fieldName of fieldNames) {
|
|
1390
|
+
if (
|
|
1391
|
+
existing[fieldName] === undefined &&
|
|
1392
|
+
incoming[fieldName] !== undefined
|
|
1393
|
+
) {
|
|
1394
|
+
existing[fieldName] = incoming[fieldName];
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
if (
|
|
1398
|
+
existing.selfSignedUnverified === undefined &&
|
|
1399
|
+
incoming.selfSignedUnverified
|
|
1400
|
+
) {
|
|
1401
|
+
existing.selfSignedUnverified = incoming.selfSignedUnverified;
|
|
1402
|
+
}
|
|
1403
|
+
if (existing.issuers === undefined && Array.isArray(incoming.issuers)) {
|
|
1404
|
+
existing.issuers = incoming.issuers;
|
|
1405
|
+
} else if (
|
|
1406
|
+
Array.isArray(existing.issuers) &&
|
|
1407
|
+
Array.isArray(incoming.issuers)
|
|
1408
|
+
) {
|
|
1409
|
+
existing.issuers = sortedUnique(existing.issuers.concat(incoming.issuers));
|
|
1410
|
+
}
|
|
1411
|
+
if (Array.isArray(incoming.fallbackUnverifiedIssuerSpkis)) {
|
|
1412
|
+
existing.fallbackUnverifiedIssuerSpkis = sortedUnique(
|
|
1413
|
+
(existing.fallbackUnverifiedIssuerSpkis || []).concat(
|
|
1414
|
+
incoming.fallbackUnverifiedIssuerSpkis,
|
|
1415
|
+
),
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
if (Array.isArray(incoming.issuersUnverified)) {
|
|
1419
|
+
existing.issuersUnverified = sortedUnique(
|
|
1420
|
+
(existing.issuersUnverified || []).concat(incoming.issuersUnverified),
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
if (!existing.k8sSecretName && incoming.k8sSecretName) {
|
|
1424
|
+
existing.k8sSecretName = incoming.k8sSecretName;
|
|
1425
|
+
}
|
|
1426
|
+
// Insertion order is preserved (deduplicated): the report's "first"
|
|
1427
|
+
// sourceType stays the first.
|
|
1428
|
+
existing.sourceTypes = [
|
|
1429
|
+
...new Set((existing.sourceTypes || []).concat(incoming.sourceTypes || [])),
|
|
1430
|
+
];
|
|
1431
|
+
const seenLocations = new Set(
|
|
1432
|
+
existing.occurrences.map((occ) => occ.location),
|
|
1433
|
+
);
|
|
1434
|
+
for (const occurrence of incoming.occurrences) {
|
|
1435
|
+
if (!seenLocations.has(occurrence.location)) {
|
|
1436
|
+
existing.occurrences.push(occurrence);
|
|
1437
|
+
seenLocations.add(occurrence.location);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
if (!existing.hashes?.length && incoming.hashes?.length) {
|
|
1441
|
+
existing.hashes = incoming.hashes;
|
|
1442
|
+
}
|
|
1443
|
+
// File digests describe one file; a certificate merged across producers
|
|
1444
|
+
// with several locations carries none.
|
|
1445
|
+
if (existing.hashes?.length && existing.occurrences.length > 1) {
|
|
1446
|
+
delete existing.hashes;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function buildCertificateExtensions(record) {
|
|
1451
|
+
if (
|
|
1452
|
+
record.role === undefined &&
|
|
1453
|
+
!record.keyUsage?.length &&
|
|
1454
|
+
!record.extKeyUsage?.length
|
|
1455
|
+
) {
|
|
1456
|
+
return undefined;
|
|
1457
|
+
}
|
|
1458
|
+
const extensions = [];
|
|
1459
|
+
// Extensions are only emitted when the certificate can carry them and the
|
|
1460
|
+
// producer observed the constraint positively: a v1 certificate has no
|
|
1461
|
+
// extensions at all (RFC 5280 section 4.1.2.1), and "not a CA" is not the
|
|
1462
|
+
// same statement as "basicConstraints says CA:FALSE".
|
|
1463
|
+
const isCA =
|
|
1464
|
+
record.role === "root" || record.role === "intermediate" || record.isCA;
|
|
1465
|
+
if (record.version !== 1 && isCA) {
|
|
1466
|
+
// RFC 5280 section 4.2.1.9.
|
|
1467
|
+
let value = "CA:TRUE";
|
|
1468
|
+
if (Number.isInteger(record.pathLen)) {
|
|
1469
|
+
value += `, pathlen:${record.pathLen}`;
|
|
1470
|
+
}
|
|
1471
|
+
extensions.push({
|
|
1472
|
+
commonExtensionName: "basicConstraints",
|
|
1473
|
+
commonExtensionValue: value,
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
if (record.keyUsage?.length) {
|
|
1477
|
+
// RFC 5280 section 4.2.1.3 names.
|
|
1478
|
+
extensions.push({
|
|
1479
|
+
commonExtensionName: "keyUsage",
|
|
1480
|
+
commonExtensionValue: record.keyUsage.join(","),
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
if (record.extKeyUsage?.length) {
|
|
1484
|
+
// RFC 5280 section 4.2.1.12; unknown purposes keep their dotted OID.
|
|
1485
|
+
extensions.push({
|
|
1486
|
+
commonExtensionName: "extendedKeyUsage",
|
|
1487
|
+
commonExtensionValue: record.extKeyUsage.join(","),
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
if (record.subjectKeyId) {
|
|
1491
|
+
extensions.push({
|
|
1492
|
+
commonExtensionName: "subjectKeyIdentifier",
|
|
1493
|
+
commonExtensionValue: record.subjectKeyId,
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
if (record.authorityKeyId) {
|
|
1497
|
+
extensions.push({
|
|
1498
|
+
commonExtensionName: "authorityKeyIdentifier",
|
|
1499
|
+
commonExtensionValue: record.authorityKeyId,
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
const aia = [];
|
|
1503
|
+
for (const url of record.issuingCertificateUrls || []) {
|
|
1504
|
+
aia.push(`caIssuers=${url}`);
|
|
1505
|
+
}
|
|
1506
|
+
for (const url of record.ocspServers || []) {
|
|
1507
|
+
aia.push(`ocsp=${url}`);
|
|
1508
|
+
}
|
|
1509
|
+
if (aia.length) {
|
|
1510
|
+
extensions.push({
|
|
1511
|
+
commonExtensionName: "authorityInformationAccess",
|
|
1512
|
+
commonExtensionValue: aia.join("; "),
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
if (record.crlDistributionPoints?.length) {
|
|
1516
|
+
extensions.push({
|
|
1517
|
+
commonExtensionName: "crlDistributionPoints",
|
|
1518
|
+
commonExtensionValue: record.crlDistributionPoints.join(" "),
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
if (record.policyOids?.length) {
|
|
1522
|
+
extensions.push({
|
|
1523
|
+
commonExtensionName: "certificatePolicies",
|
|
1524
|
+
commonExtensionValue: record.policyOids.join(","),
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
if (record.role === "leaf" && record.dnsNames?.length) {
|
|
1528
|
+
const shown = record.dnsNames.slice(0, SAN_DISPLAY_LIMIT);
|
|
1529
|
+
const suffix =
|
|
1530
|
+
record.dnsNames.length > SAN_DISPLAY_LIMIT
|
|
1531
|
+
? ` +${record.dnsNames.length - SAN_DISPLAY_LIMIT} more`
|
|
1532
|
+
: "";
|
|
1533
|
+
extensions.push({
|
|
1534
|
+
commonExtensionName: "subjectAlternativeName",
|
|
1535
|
+
commonExtensionValue: `${shown.join(", ")}${suffix}`,
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
if (record.hasNameConstraints) {
|
|
1539
|
+
extensions.push({
|
|
1540
|
+
customExtensionName: "nameConstraints",
|
|
1541
|
+
customExtensionValue: "present",
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
return extensions.length ? extensions : undefined;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function certificateComponent(
|
|
1548
|
+
record,
|
|
1549
|
+
issuerSpkis,
|
|
1550
|
+
unverifiedIssuerSpkis,
|
|
1551
|
+
publicKeyRef,
|
|
1552
|
+
) {
|
|
1553
|
+
const name = record.name || "certificate";
|
|
1554
|
+
const ref = certificateBomRef(name, record.certSha256);
|
|
1555
|
+
const occurrences = record.occurrences
|
|
1556
|
+
.slice()
|
|
1557
|
+
.sort((left, right) => left.location.localeCompare(right.location));
|
|
1558
|
+
const extensions = buildCertificateExtensions(record);
|
|
1559
|
+
const signatureAlgorithmRef =
|
|
1560
|
+
record.signatureAlgorithmOid &&
|
|
1561
|
+
algorithmComponentMetadata(record.signatureAlgorithmOid)
|
|
1562
|
+
? algorithmBomRef(
|
|
1563
|
+
algorithmComponentName(
|
|
1564
|
+
record.signatureAlgorithmOid,
|
|
1565
|
+
signatureAlgorithmName({ oid: record.signatureAlgorithmOid }),
|
|
1566
|
+
),
|
|
1567
|
+
record.signatureAlgorithmOid,
|
|
1568
|
+
)
|
|
1569
|
+
: undefined;
|
|
1570
|
+
const relatedAssets = [
|
|
1571
|
+
...(publicKeyRef ? [{ type: "publicKey", ref: publicKeyRef }] : []),
|
|
1572
|
+
...(signatureAlgorithmRef && record.signatureAlgorithm
|
|
1573
|
+
? [{ type: "algorithm", ref: signatureAlgorithmRef }]
|
|
1574
|
+
: []),
|
|
1575
|
+
];
|
|
1576
|
+
const properties = [
|
|
1577
|
+
...(record.spkiSha256
|
|
1578
|
+
? [prop("cdx:crypto:spkiSha256", record.spkiSha256)]
|
|
1579
|
+
: []),
|
|
1580
|
+
prop("cdx:crypto:fingerprint", record.certSha256),
|
|
1581
|
+
...(record.role ? [prop("cdx:crypto:certRole", record.role)] : []),
|
|
1582
|
+
...(record.selfSigned === undefined
|
|
1583
|
+
? []
|
|
1584
|
+
: [prop("cdx:crypto:selfSigned", record.selfSigned)]),
|
|
1585
|
+
...(record.selfSignedUnverified
|
|
1586
|
+
? [prop("cdx:crypto:selfSignedUnverified", true)]
|
|
1587
|
+
: []),
|
|
1588
|
+
...(record.isCA === undefined
|
|
1589
|
+
? []
|
|
1590
|
+
: [prop("cdx:crypto:isCA", record.isCA)]),
|
|
1591
|
+
...(Number.isInteger(record.pathLen)
|
|
1592
|
+
? [prop("cdx:crypto:pathLen", record.pathLen)]
|
|
1593
|
+
: []),
|
|
1594
|
+
...(record.subjectKeyId
|
|
1595
|
+
? [prop("cdx:crypto:subjectKeyId", record.subjectKeyId)]
|
|
1596
|
+
: []),
|
|
1597
|
+
...(record.authorityKeyId
|
|
1598
|
+
? [prop("cdx:crypto:authorityKeyId", record.authorityKeyId)]
|
|
1599
|
+
: []),
|
|
1600
|
+
...issuerSpkis.map((spki) => prop("cdx:crypto:issuerSpkiSha256", spki)),
|
|
1601
|
+
...unverifiedIssuerSpkis.map((spki) =>
|
|
1602
|
+
prop("cdx:crypto:unverifiedIssuerSpkiSha256", spki),
|
|
1603
|
+
),
|
|
1604
|
+
...(record.signatureAlgorithm
|
|
1605
|
+
? [prop("cdx:crypto:signatureAlgorithm", record.signatureAlgorithm)]
|
|
1606
|
+
: []),
|
|
1607
|
+
...(record.signatureAlgorithmOid
|
|
1608
|
+
? [prop("cdx:crypto:signatureAlgorithmOid", record.signatureAlgorithmOid)]
|
|
1609
|
+
: []),
|
|
1610
|
+
...(record.keyAlgorithmOid
|
|
1611
|
+
? [prop("cdx:crypto:publicKeyAlgorithmOid", record.keyAlgorithmOid)]
|
|
1612
|
+
: []),
|
|
1613
|
+
...(record.trustAcceptedPurposes?.length
|
|
1614
|
+
? [
|
|
1615
|
+
prop(
|
|
1616
|
+
"cdx:crypto:trustAcceptedPurposes",
|
|
1617
|
+
record.trustAcceptedPurposes.join(","),
|
|
1618
|
+
),
|
|
1619
|
+
]
|
|
1620
|
+
: []),
|
|
1621
|
+
...(record.trustRejectedPurposes?.length
|
|
1622
|
+
? [
|
|
1623
|
+
prop(
|
|
1624
|
+
"cdx:crypto:trustRejectedPurposes",
|
|
1625
|
+
record.trustRejectedPurposes.join(","),
|
|
1626
|
+
),
|
|
1627
|
+
]
|
|
1628
|
+
: []),
|
|
1629
|
+
...(record.hostStores?.length
|
|
1630
|
+
? [prop("cdx:crypto:hostStore", record.hostStores.join(","))]
|
|
1631
|
+
: []),
|
|
1632
|
+
...(record.algorithm
|
|
1633
|
+
? [prop("cdx:crypto:algorithm", record.algorithm)]
|
|
1634
|
+
: []),
|
|
1635
|
+
...(record.keyStrength
|
|
1636
|
+
? [prop("cdx:crypto:keyStrength", record.keyStrength)]
|
|
1637
|
+
: []),
|
|
1638
|
+
...(record.trustDomain
|
|
1639
|
+
? [prop("cdx:crypto:trustDomain", record.trustDomain)]
|
|
1640
|
+
: []),
|
|
1641
|
+
...(record.k8sSecretName
|
|
1642
|
+
? [prop("cdx:crypto:k8sSecretName", record.k8sSecretName)]
|
|
1643
|
+
: []),
|
|
1644
|
+
...sourceTypeProperties(record.sourceTypes),
|
|
1645
|
+
...(occurrences.length
|
|
1646
|
+
? [prop("internal:SrcFile", srcFileOfLocation(occurrences[0].location))]
|
|
1647
|
+
: []),
|
|
1648
|
+
];
|
|
1649
|
+
const component = {
|
|
1650
|
+
"bom-ref": ref,
|
|
1651
|
+
name,
|
|
1652
|
+
type: "cryptographic-asset",
|
|
1653
|
+
version: record.certSha256,
|
|
1654
|
+
...(record.hashes?.length ? { hashes: record.hashes } : {}),
|
|
1655
|
+
description: record.subject || occurrences[0]?.location,
|
|
1656
|
+
cryptoProperties: {
|
|
1657
|
+
assetType: "certificate",
|
|
1658
|
+
algorithmProperties: {
|
|
1659
|
+
executionEnvironment: "unknown",
|
|
1660
|
+
implementationPlatform: "unknown",
|
|
1661
|
+
},
|
|
1662
|
+
certificateProperties: {
|
|
1663
|
+
...(record.serialHex ? { serialNumber: record.serialHex } : {}),
|
|
1664
|
+
...(record.subject ? { subjectName: record.subject } : {}),
|
|
1665
|
+
...(record.issuer ? { issuerName: record.issuer } : {}),
|
|
1666
|
+
...(record.notValidBefore
|
|
1667
|
+
? { notValidBefore: record.notValidBefore }
|
|
1668
|
+
: {}),
|
|
1669
|
+
...(record.notValidAfter
|
|
1670
|
+
? { notValidAfter: record.notValidAfter }
|
|
1671
|
+
: {}),
|
|
1672
|
+
certificateFormat: "X.509",
|
|
1673
|
+
...(record.fileExtension
|
|
1674
|
+
? { certificateFileExtension: record.fileExtension }
|
|
1675
|
+
: {}),
|
|
1676
|
+
fingerprint: { alg: "SHA-256", content: record.certSha256 },
|
|
1677
|
+
...(relatedAssets.length
|
|
1678
|
+
? { relatedCryptographicAssets: relatedAssets }
|
|
1679
|
+
: {}),
|
|
1680
|
+
},
|
|
1681
|
+
},
|
|
1682
|
+
...(occurrences.length
|
|
1683
|
+
? {
|
|
1684
|
+
evidence: {
|
|
1685
|
+
occurrences: occurrences.map(({ location }) => ({ location })),
|
|
1686
|
+
},
|
|
1687
|
+
}
|
|
1688
|
+
: {}),
|
|
1689
|
+
properties,
|
|
1690
|
+
};
|
|
1691
|
+
if (extensions?.length) {
|
|
1692
|
+
component.cryptoProperties.certificateProperties.certificateExtensions =
|
|
1693
|
+
extensions;
|
|
1694
|
+
}
|
|
1695
|
+
return component;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function publicKeyComponent(keyRecord) {
|
|
1699
|
+
const keyName = keyRecord.keyName || "public-key";
|
|
1700
|
+
const ref = publicKeyBomRef(keyName, keyRecord.spkiSha256);
|
|
1701
|
+
const algorithmRef =
|
|
1702
|
+
keyRecord.keyAlgorithmOid &&
|
|
1703
|
+
algorithmComponentMetadata(keyRecord.keyAlgorithmOid)
|
|
1704
|
+
? algorithmBomRef(
|
|
1705
|
+
keyAlgorithmFamilyName(keyRecord.keyAlgorithmOid),
|
|
1706
|
+
keyRecord.keyAlgorithmOid,
|
|
1707
|
+
)
|
|
1708
|
+
: undefined;
|
|
1709
|
+
const occurrences = keyRecord.occurrences
|
|
1710
|
+
.slice()
|
|
1711
|
+
.sort((left, right) => left.location.localeCompare(right.location));
|
|
1712
|
+
const properties = [
|
|
1713
|
+
prop("cdx:crypto:spkiSha256", keyRecord.spkiSha256),
|
|
1714
|
+
...(keyRecord.spkiSha256Base64
|
|
1715
|
+
? [prop("cdx:crypto:spkiSha256Base64", keyRecord.spkiSha256Base64)]
|
|
1716
|
+
: []),
|
|
1717
|
+
...(keyRecord.keyAlgorithmOid
|
|
1718
|
+
? [prop("cdx:crypto:publicKeyAlgorithmOid", keyRecord.keyAlgorithmOid)]
|
|
1719
|
+
: []),
|
|
1720
|
+
...sourceTypeProperties(keyRecord.sourceTypes || [keyRecord.sourceType]),
|
|
1721
|
+
];
|
|
1722
|
+
const isPostQuantum =
|
|
1723
|
+
keyRecord.keyAlgorithmOid && PQ_ALGORITHM_BY_OID[keyRecord.keyAlgorithmOid];
|
|
1724
|
+
return {
|
|
1725
|
+
"bom-ref": ref,
|
|
1726
|
+
name: keyName,
|
|
1727
|
+
type: "cryptographic-asset",
|
|
1728
|
+
version: keyRecord.spkiSha256,
|
|
1729
|
+
cryptoProperties: {
|
|
1730
|
+
assetType: "related-crypto-material",
|
|
1731
|
+
relatedCryptoMaterialProperties: {
|
|
1732
|
+
type: "public-key",
|
|
1733
|
+
...(!isPostQuantum && Number.isInteger(keyRecord.keyBits)
|
|
1734
|
+
? { size: keyRecord.keyBits }
|
|
1735
|
+
: {}),
|
|
1736
|
+
format: "SPKI",
|
|
1737
|
+
fingerprint: { alg: "SHA-256", content: keyRecord.spkiSha256 },
|
|
1738
|
+
...(keyRecord.subjectKeyId && !keyRecord.pin
|
|
1739
|
+
? { id: keyRecord.subjectKeyId }
|
|
1740
|
+
: {}),
|
|
1741
|
+
...(algorithmRef ? { algorithmRef } : {}),
|
|
1742
|
+
},
|
|
1743
|
+
},
|
|
1744
|
+
...(occurrences.length
|
|
1745
|
+
? {
|
|
1746
|
+
evidence: {
|
|
1747
|
+
occurrences: occurrences.map(({ location }) => ({ location })),
|
|
1748
|
+
},
|
|
1749
|
+
}
|
|
1750
|
+
: {}),
|
|
1751
|
+
properties,
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function algorithmComponent(name, oid) {
|
|
1756
|
+
const metadata = algorithmComponentMetadata(oid);
|
|
1757
|
+
return {
|
|
1758
|
+
"bom-ref": algorithmBomRef(name, oid),
|
|
1759
|
+
name,
|
|
1760
|
+
type: "cryptographic-asset",
|
|
1761
|
+
cryptoProperties: {
|
|
1762
|
+
assetType: "algorithm",
|
|
1763
|
+
oid,
|
|
1764
|
+
...(metadata ? { algorithmProperties: { ...metadata } } : {}),
|
|
1765
|
+
},
|
|
1766
|
+
properties: [],
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Collects certificate records from any producer, deduplicates them by
|
|
1772
|
+
* certificate SHA-256 and builds the shared component model: one certificate
|
|
1773
|
+
* component per certificate, one public-key component per SPKI, algorithm
|
|
1774
|
+
* components, and verified issuer `dependsOn` edges.
|
|
1775
|
+
*/
|
|
1776
|
+
export class CertificateCollector {
|
|
1777
|
+
constructor() {
|
|
1778
|
+
this.recordsBySha = new Map();
|
|
1779
|
+
this.pins = [];
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
addRecord(record) {
|
|
1783
|
+
if (!record?.certSha256) {
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
const existing = this.recordsBySha.get(record.certSha256);
|
|
1787
|
+
if (existing) {
|
|
1788
|
+
mergeRecords(existing, record);
|
|
1789
|
+
} else {
|
|
1790
|
+
this.recordsBySha.set(record.certSha256, record);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
addRecords(records) {
|
|
1795
|
+
for (const record of records || []) {
|
|
1796
|
+
this.addRecord(record);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
addTrustInspectorMaterials(materials, context) {
|
|
1801
|
+
for (const material of materials || []) {
|
|
1802
|
+
this.addRecord(certRecordFromTrustInspector(material, context));
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
addPin({ spkiSha256, location }) {
|
|
1807
|
+
const normalized = toLowerHex(spkiSha256);
|
|
1808
|
+
if (normalized && location) {
|
|
1809
|
+
this.pins.push({ spkiSha256: normalized, location });
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Build the shared model. Deterministic: components and dependency lists
|
|
1815
|
+
* are sorted, and no map iteration order reaches the output.
|
|
1816
|
+
*
|
|
1817
|
+
* @returns {{components: object[], dependencies: object[]}}
|
|
1818
|
+
*/
|
|
1819
|
+
build() {
|
|
1820
|
+
const records = [...this.recordsBySha.values()];
|
|
1821
|
+
const bySha = this.recordsBySha;
|
|
1822
|
+
const components = [];
|
|
1823
|
+
const dependencies = [];
|
|
1824
|
+
const publicKeyRecords = new Map();
|
|
1825
|
+
// Seen occurrence locations per public key, so a key shared by many
|
|
1826
|
+
// certificates (a CA key every observed server presented) deduplicates
|
|
1827
|
+
// in linear, not quadratic, time.
|
|
1828
|
+
const keyRecordLocations = new Map();
|
|
1829
|
+
const algorithmOids = new Map();
|
|
1830
|
+
for (const record of records) {
|
|
1831
|
+
if (record.spkiSha256) {
|
|
1832
|
+
let keyRecord = publicKeyRecords.get(record.spkiSha256);
|
|
1833
|
+
if (!keyRecord) {
|
|
1834
|
+
keyRecord = {
|
|
1835
|
+
spkiSha256: record.spkiSha256,
|
|
1836
|
+
spkiSha256Base64: record.spkiSha256Base64,
|
|
1837
|
+
keyName: record.keyName,
|
|
1838
|
+
keyBits: record.keyBits,
|
|
1839
|
+
keyAlgorithmOid: record.keyAlgorithmOid,
|
|
1840
|
+
subjectKeyId: record.subjectKeyId,
|
|
1841
|
+
sourceType: "certificate",
|
|
1842
|
+
sourceTypes: ["certificate"],
|
|
1843
|
+
occurrences: [],
|
|
1844
|
+
};
|
|
1845
|
+
publicKeyRecords.set(record.spkiSha256, keyRecord);
|
|
1846
|
+
keyRecordLocations.set(record.spkiSha256, new Set());
|
|
1847
|
+
}
|
|
1848
|
+
if (!keyRecord.keyName && record.keyName) {
|
|
1849
|
+
keyRecord.keyName = record.keyName;
|
|
1850
|
+
}
|
|
1851
|
+
if (keyRecord.keyBits === undefined && record.keyBits !== undefined) {
|
|
1852
|
+
keyRecord.keyBits = record.keyBits;
|
|
1853
|
+
}
|
|
1854
|
+
if (!keyRecord.keyAlgorithmOid && record.keyAlgorithmOid) {
|
|
1855
|
+
keyRecord.keyAlgorithmOid = record.keyAlgorithmOid;
|
|
1856
|
+
}
|
|
1857
|
+
if (!keyRecord.spkiSha256Base64 && record.spkiSha256Base64) {
|
|
1858
|
+
keyRecord.spkiSha256Base64 = record.spkiSha256Base64;
|
|
1859
|
+
}
|
|
1860
|
+
const seenLocations = keyRecordLocations.get(record.spkiSha256);
|
|
1861
|
+
for (const occurrence of record.occurrences) {
|
|
1862
|
+
if (!seenLocations.has(occurrence.location)) {
|
|
1863
|
+
seenLocations.add(occurrence.location);
|
|
1864
|
+
keyRecord.occurrences.push(occurrence);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
if (record.keyAlgorithmOid) {
|
|
1868
|
+
algorithmOids.set(
|
|
1869
|
+
record.keyAlgorithmOid,
|
|
1870
|
+
keyAlgorithmFamilyName(record.keyAlgorithmOid) ||
|
|
1871
|
+
keyRecord.keyName ||
|
|
1872
|
+
record.keyAlgorithmOid,
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
if (record.signatureAlgorithmOid) {
|
|
1877
|
+
algorithmOids.set(
|
|
1878
|
+
record.signatureAlgorithmOid,
|
|
1879
|
+
signatureAlgorithmName({ oid: record.signatureAlgorithmOid }),
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
for (const pin of this.pins) {
|
|
1884
|
+
let keyRecord = publicKeyRecords.get(pin.spkiSha256);
|
|
1885
|
+
if (!keyRecord) {
|
|
1886
|
+
keyRecord = {
|
|
1887
|
+
spkiSha256: pin.spkiSha256,
|
|
1888
|
+
sourceType: "pin",
|
|
1889
|
+
sourceTypes: [],
|
|
1890
|
+
pin: true,
|
|
1891
|
+
occurrences: [],
|
|
1892
|
+
};
|
|
1893
|
+
publicKeyRecords.set(pin.spkiSha256, keyRecord);
|
|
1894
|
+
}
|
|
1895
|
+
keyRecord.pin = true;
|
|
1896
|
+
keyRecord.sourceType = "pin";
|
|
1897
|
+
if (!keyRecord.sourceTypes.includes("pin")) {
|
|
1898
|
+
keyRecord.sourceTypes.push("pin");
|
|
1899
|
+
}
|
|
1900
|
+
if (
|
|
1901
|
+
!keyRecord.occurrences.some(
|
|
1902
|
+
(occurrence) => occurrence.location === pin.location,
|
|
1903
|
+
)
|
|
1904
|
+
) {
|
|
1905
|
+
keyRecord.occurrences.push({ location: pin.location });
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
const publicKeyRefs = new Map(
|
|
1909
|
+
[...publicKeyRecords.values()].map((keyRecord) => [
|
|
1910
|
+
keyRecord.spkiSha256,
|
|
1911
|
+
publicKeyBomRef(
|
|
1912
|
+
keyRecord.keyName || "public-key",
|
|
1913
|
+
keyRecord.spkiSha256,
|
|
1914
|
+
),
|
|
1915
|
+
]),
|
|
1916
|
+
);
|
|
1917
|
+
for (const record of records) {
|
|
1918
|
+
const issuerSpkis = [];
|
|
1919
|
+
const unverifiedSpkis = [];
|
|
1920
|
+
for (const issuerSha of record.issuers || []) {
|
|
1921
|
+
const issuer = bySha.get(issuerSha);
|
|
1922
|
+
if (issuer?.spkiSha256) {
|
|
1923
|
+
issuerSpkis.push(issuer.spkiSha256);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
for (const issuerSha of record.issuersUnverified || []) {
|
|
1927
|
+
const issuer = bySha.get(issuerSha);
|
|
1928
|
+
if (issuer?.spkiSha256) {
|
|
1929
|
+
unverifiedSpkis.push(issuer.spkiSha256);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
unverifiedSpkis.push(...(record.fallbackUnverifiedIssuerSpkis || []));
|
|
1933
|
+
components.push(
|
|
1934
|
+
certificateComponent(
|
|
1935
|
+
record,
|
|
1936
|
+
sortedUnique(issuerSpkis),
|
|
1937
|
+
sortedUnique(unverifiedSpkis),
|
|
1938
|
+
record.spkiSha256 ? publicKeyRefs.get(record.spkiSha256) : undefined,
|
|
1939
|
+
),
|
|
1940
|
+
);
|
|
1941
|
+
const issuerRefs = sortedUnique(
|
|
1942
|
+
(record.issuers || [])
|
|
1943
|
+
.map((issuerSha) => bySha.get(issuerSha))
|
|
1944
|
+
.filter((issuer) => issuer)
|
|
1945
|
+
.map((issuer) =>
|
|
1946
|
+
certificateBomRef(issuer.name || "certificate", issuer.certSha256),
|
|
1947
|
+
),
|
|
1948
|
+
);
|
|
1949
|
+
if (issuerRefs.length) {
|
|
1950
|
+
dependencies.push({
|
|
1951
|
+
ref: certificateBomRef(
|
|
1952
|
+
record.name || "certificate",
|
|
1953
|
+
record.certSha256,
|
|
1954
|
+
),
|
|
1955
|
+
dependsOn: issuerRefs,
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
for (const keyRecord of publicKeyRecords.values()) {
|
|
1960
|
+
components.push(publicKeyComponent(keyRecord));
|
|
1961
|
+
}
|
|
1962
|
+
for (const [oid, name] of algorithmOids) {
|
|
1963
|
+
// An unknown OID gets no algorithm component; its OID stays a property.
|
|
1964
|
+
if (algorithmComponentMetadata(oid)) {
|
|
1965
|
+
components.push(
|
|
1966
|
+
algorithmComponent(algorithmComponentName(oid, name), oid),
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
components.sort((left, right) =>
|
|
1971
|
+
left["bom-ref"].localeCompare(right["bom-ref"]),
|
|
1972
|
+
);
|
|
1973
|
+
dependencies.sort((left, right) => left.ref.localeCompare(right.ref));
|
|
1974
|
+
return { components, dependencies };
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// Properties the model defines as repeatable (PLAN section 2.3): one entry
|
|
1979
|
+
// per issuer key, so a merge keeps every value.
|
|
1980
|
+
const REPEATABLE_PROPERTIES = new Set([
|
|
1981
|
+
"cdx:crypto:issuerSpkiSha256",
|
|
1982
|
+
"cdx:crypto:unverifiedIssuerSpkiSha256",
|
|
1983
|
+
"cdx:crypto:sourceType",
|
|
1984
|
+
]);
|
|
1985
|
+
|
|
1986
|
+
const SOURCE_TYPE_PROPERTY = "cdx:crypto:sourceType";
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* The file a certificate occurrence lives in. A Kubernetes secret occurrence
|
|
1990
|
+
* is `<file>#data.tls.crt` (or `#stringData.ca.crt`); the file path is what
|
|
1991
|
+
* internal:SrcFile records.
|
|
1992
|
+
*
|
|
1993
|
+
* @param {string} location occurrence location
|
|
1994
|
+
* @returns {string} file path without the secret key fragment
|
|
1995
|
+
*/
|
|
1996
|
+
export function srcFileOfLocation(location) {
|
|
1997
|
+
// Secrets are only read from YAML files, so a certificate file whose own
|
|
1998
|
+
// name contains "#data." keeps its full path.
|
|
1999
|
+
return String(location).replace(
|
|
2000
|
+
/(\.ya?ml)#(?:data|stringData)\.[^#/]+$/i,
|
|
2001
|
+
"$1",
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* One `cdx:crypto:sourceType` property per distinct producer, sorted, so the
|
|
2007
|
+
* value set (not the order the producers ran in) is what a BOM records.
|
|
2008
|
+
*
|
|
2009
|
+
* @param {string[]} [values] sourceType values
|
|
2010
|
+
* @returns {{name: string, value: string}[]} properties
|
|
2011
|
+
*/
|
|
2012
|
+
function sourceTypeProperties(values) {
|
|
2013
|
+
return [...new Set((values || []).filter(Boolean))]
|
|
2014
|
+
.sort()
|
|
2015
|
+
.map((value) => ({ name: SOURCE_TYPE_PROPERTY, value }));
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// Collapse every sourceType property of a component into one sorted,
|
|
2019
|
+
// deduplicated block at the position of the first.
|
|
2020
|
+
function normalizeSourceTypeProperties(component) {
|
|
2021
|
+
const properties = component.properties;
|
|
2022
|
+
if (!Array.isArray(properties)) {
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
const first = properties.findIndex((p) => p?.name === SOURCE_TYPE_PROPERTY);
|
|
2026
|
+
if (first === -1) {
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
const block = sourceTypeProperties(
|
|
2030
|
+
properties
|
|
2031
|
+
.filter((p) => p?.name === SOURCE_TYPE_PROPERTY)
|
|
2032
|
+
.map((p) => p.value),
|
|
2033
|
+
);
|
|
2034
|
+
component.properties = [
|
|
2035
|
+
...properties.slice(0, first),
|
|
2036
|
+
...block,
|
|
2037
|
+
...properties.slice(first).filter((p) => p?.name !== SOURCE_TYPE_PROPERTY),
|
|
2038
|
+
];
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* Merge already-built certificate/public-key components that describe the
|
|
2043
|
+
* same asset (same bom-ref), unioning occurrences and properties. Used at the
|
|
2044
|
+
* final assembly step where producer component lists meet.
|
|
2045
|
+
*
|
|
2046
|
+
* @param {object[]} components component list, possibly with crypto duplicates
|
|
2047
|
+
* @returns {object[]} component list with certificate duplicates merged
|
|
2048
|
+
*/
|
|
2049
|
+
export function mergeDuplicateCryptoComponents(components) {
|
|
2050
|
+
if (!Array.isArray(components)) {
|
|
2051
|
+
return components;
|
|
2052
|
+
}
|
|
2053
|
+
const output = [];
|
|
2054
|
+
const cryptoByRef = new Map();
|
|
2055
|
+
for (const component of components) {
|
|
2056
|
+
if (component?.type !== "cryptographic-asset" || !component["bom-ref"]) {
|
|
2057
|
+
output.push(component);
|
|
2058
|
+
continue;
|
|
2059
|
+
}
|
|
2060
|
+
const existing = cryptoByRef.get(component["bom-ref"]);
|
|
2061
|
+
if (!existing) {
|
|
2062
|
+
cryptoByRef.set(component["bom-ref"], component);
|
|
2063
|
+
output.push(component);
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
const occurrences = [
|
|
2067
|
+
...(existing.evidence?.occurrences || []),
|
|
2068
|
+
...(component.evidence?.occurrences || []),
|
|
2069
|
+
];
|
|
2070
|
+
const seen = new Set();
|
|
2071
|
+
existing.evidence = existing.evidence || {};
|
|
2072
|
+
existing.evidence.occurrences = occurrences
|
|
2073
|
+
.filter((occurrence) => {
|
|
2074
|
+
if (!occurrence?.location || seen.has(occurrence.location)) {
|
|
2075
|
+
return false;
|
|
2076
|
+
}
|
|
2077
|
+
seen.add(occurrence.location);
|
|
2078
|
+
return true;
|
|
2079
|
+
})
|
|
2080
|
+
.sort((left, right) => left.location.localeCompare(right.location));
|
|
2081
|
+
// The component builder keys internal:SrcFile on the first sorted
|
|
2082
|
+
// occurrence; keep a merged component consistent with its own evidence.
|
|
2083
|
+
const srcFileProperty = (existing.properties || []).find(
|
|
2084
|
+
(property) => property.name === "internal:SrcFile",
|
|
2085
|
+
);
|
|
2086
|
+
if (srcFileProperty && existing.evidence.occurrences.length) {
|
|
2087
|
+
srcFileProperty.value = srcFileOfLocation(
|
|
2088
|
+
existing.evidence.occurrences[0].location,
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
// File digests describe one file; a certificate found in several no
|
|
2092
|
+
// longer has one.
|
|
2093
|
+
if (existing.evidence.occurrences.length > 1) {
|
|
2094
|
+
delete existing.hashes;
|
|
2095
|
+
}
|
|
2096
|
+
const propertyNames = new Set(
|
|
2097
|
+
(existing.properties || []).map((property) => property.name),
|
|
2098
|
+
);
|
|
2099
|
+
const seenValues = new Set(
|
|
2100
|
+
(existing.properties || []).map(
|
|
2101
|
+
(property) => `${property.name}=${property.value}`,
|
|
2102
|
+
),
|
|
2103
|
+
);
|
|
2104
|
+
for (const property of component.properties || []) {
|
|
2105
|
+
const key = `${property.name}=${property.value}`;
|
|
2106
|
+
// Repeatable properties (one entry per issuer key) merge by value,
|
|
2107
|
+
// everything else by name.
|
|
2108
|
+
const duplicate = REPEATABLE_PROPERTIES.has(property.name)
|
|
2109
|
+
? seenValues.has(key)
|
|
2110
|
+
: propertyNames.has(property.name);
|
|
2111
|
+
if (!duplicate) {
|
|
2112
|
+
(existing.properties ||= []).push(property);
|
|
2113
|
+
propertyNames.add(property.name);
|
|
2114
|
+
seenValues.add(key);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
normalizeSourceTypeProperties(existing);
|
|
2118
|
+
}
|
|
2119
|
+
return output;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// ---------------------------------------------------------------------------
|
|
2123
|
+
// Kubernetes TLS secrets and Android certificate pins (PLAN section 4.4)
|
|
2124
|
+
// ---------------------------------------------------------------------------
|
|
2125
|
+
|
|
2126
|
+
// A kubernetes.io/tls Secret carries `tls.crt` (a PEM bundle) and `ca.crt`
|
|
2127
|
+
// either base64-encoded in `.data` or as plain PEM text in `.stringData`;
|
|
2128
|
+
// when both define a key, `stringData` wins, as the API server applies it
|
|
2129
|
+
// over `data`. `tls.key` is private and never read.
|
|
2130
|
+
// https://kubernetes.io/docs/concepts/configuration/secret/#tls-secrets
|
|
2131
|
+
function parseYamlDocuments(yamlText) {
|
|
2132
|
+
const documents = [];
|
|
2133
|
+
for (const doc of parseAllDocuments(yamlText)) {
|
|
2134
|
+
// A malformed document does not hide the ones around it.
|
|
2135
|
+
if (doc.errors?.length) {
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
try {
|
|
2139
|
+
documents.push(doc.toJS());
|
|
2140
|
+
} catch {
|
|
2141
|
+
// alias limits and the like: skip this document only
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
return documents;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
const K8S_CERT_KEYS = ["tls.crt", "ca.crt"];
|
|
2148
|
+
|
|
2149
|
+
export function kubernetesTlsSecretRecords(yamlText, { file } = {}) {
|
|
2150
|
+
let documents;
|
|
2151
|
+
try {
|
|
2152
|
+
documents = parseYamlDocuments(yamlText);
|
|
2153
|
+
} catch {
|
|
2154
|
+
return [];
|
|
2155
|
+
}
|
|
2156
|
+
const results = [];
|
|
2157
|
+
for (const doc of documents) {
|
|
2158
|
+
if (
|
|
2159
|
+
!doc ||
|
|
2160
|
+
typeof doc !== "object" ||
|
|
2161
|
+
doc.kind !== "Secret" ||
|
|
2162
|
+
doc.type !== "kubernetes.io/tls"
|
|
2163
|
+
) {
|
|
2164
|
+
continue;
|
|
2165
|
+
}
|
|
2166
|
+
const data = doc.data && typeof doc.data === "object" ? doc.data : {};
|
|
2167
|
+
const stringData =
|
|
2168
|
+
doc.stringData && typeof doc.stringData === "object"
|
|
2169
|
+
? doc.stringData
|
|
2170
|
+
: {};
|
|
2171
|
+
const secretName =
|
|
2172
|
+
typeof doc.metadata?.name === "string" ? doc.metadata.name : "";
|
|
2173
|
+
for (const key of K8S_CERT_KEYS) {
|
|
2174
|
+
let pem;
|
|
2175
|
+
let section;
|
|
2176
|
+
if (typeof stringData[key] === "string" && stringData[key]) {
|
|
2177
|
+
pem = stringData[key];
|
|
2178
|
+
section = "stringData";
|
|
2179
|
+
} else if (typeof data[key] === "string" && data[key]) {
|
|
2180
|
+
try {
|
|
2181
|
+
pem = Buffer.from(data[key], "base64").toString("utf-8");
|
|
2182
|
+
} catch {
|
|
2183
|
+
continue;
|
|
2184
|
+
}
|
|
2185
|
+
section = "data";
|
|
2186
|
+
} else {
|
|
2187
|
+
continue;
|
|
2188
|
+
}
|
|
2189
|
+
for (const der of pemCertificateDerBlocks(pem)) {
|
|
2190
|
+
const record = certRecordFromDer(der, {
|
|
2191
|
+
location: file ? `${file}#${section}.${key}` : `${section}.${key}`,
|
|
2192
|
+
sourceType: "k8s-secret",
|
|
2193
|
+
});
|
|
2194
|
+
if (record) {
|
|
2195
|
+
record.fileExtension = "crt";
|
|
2196
|
+
if (secretName) {
|
|
2197
|
+
record.k8sSecretName = secretName;
|
|
2198
|
+
}
|
|
2199
|
+
results.push(record);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
return results;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// Android network security config pins the base64 SHA-256 of a SPKI:
|
|
2208
|
+
// <pin-set><pin digest="SHA-256">BASE64==</pin></pin-set> under
|
|
2209
|
+
// <domain-config>. https://developer.android.com/privacy-and-security/security-config
|
|
2210
|
+
export function androidNetworkSecurityConfigPins(xmlText, { file } = {}) {
|
|
2211
|
+
let parsed;
|
|
2212
|
+
try {
|
|
2213
|
+
parsed = xml2js(xmlText, {
|
|
2214
|
+
compact: true,
|
|
2215
|
+
textKey: "_",
|
|
2216
|
+
attributesKey: "$",
|
|
2217
|
+
});
|
|
2218
|
+
} catch {
|
|
2219
|
+
return [];
|
|
2220
|
+
}
|
|
2221
|
+
const root = parsed?.["network-security-config"];
|
|
2222
|
+
if (!root) {
|
|
2223
|
+
return [];
|
|
2224
|
+
}
|
|
2225
|
+
const pins = [];
|
|
2226
|
+
const visit = (node) => {
|
|
2227
|
+
if (!node || typeof node !== "object") {
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
if (Array.isArray(node)) {
|
|
2231
|
+
for (const child of node) {
|
|
2232
|
+
visit(child);
|
|
2233
|
+
}
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
for (const [key, value] of Object.entries(node)) {
|
|
2237
|
+
if (key === "pin" && typeof value === "object") {
|
|
2238
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
2239
|
+
for (const entry of entries) {
|
|
2240
|
+
const digest = entry?.$?.digest;
|
|
2241
|
+
const text = typeof entry?._ === "string" ? entry._.trim() : "";
|
|
2242
|
+
if (digest === "SHA-256" && text) {
|
|
2243
|
+
try {
|
|
2244
|
+
const digestBuffer = Buffer.from(text, "base64");
|
|
2245
|
+
// A SHA-256 digest is exactly 32 bytes; anything else is not
|
|
2246
|
+
// a usable pin.
|
|
2247
|
+
if (digestBuffer.length === 32) {
|
|
2248
|
+
pins.push({
|
|
2249
|
+
spkiSha256: digestBuffer.toString("hex"),
|
|
2250
|
+
location: file,
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
} catch {
|
|
2254
|
+
// invalid base64 pin is skipped
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
} else {
|
|
2259
|
+
visit(value);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
};
|
|
2263
|
+
visit(root);
|
|
2264
|
+
return pins;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// ---------------------------------------------------------------------------
|
|
2268
|
+
// Content classification for the source-directory scan (PLAN section 4.4)
|
|
2269
|
+
// ---------------------------------------------------------------------------
|
|
2270
|
+
|
|
2271
|
+
const GPG_LABELS = new Set([
|
|
2272
|
+
"PGP PUBLIC KEY BLOCK",
|
|
2273
|
+
"PGP PRIVATE KEY BLOCK",
|
|
2274
|
+
"PGP MESSAGE",
|
|
2275
|
+
]);
|
|
2276
|
+
|
|
2277
|
+
function sniffDerContainer(der) {
|
|
2278
|
+
// PKCS containers are DER SEQUENCEs. A PKCS #7 file is a ContentInfo
|
|
2279
|
+
// (RFC 5652 section 5.1): SEQUENCE { contentType OID, [0] content }, so
|
|
2280
|
+
// the signedData OID comes first. A PKCS #12 file is a PFX (RFC 7292
|
|
2281
|
+
// section 4): SEQUENCE { version INTEGER, authSafe ContentInfo, ... },
|
|
2282
|
+
// so the data OID sits inside the second element.
|
|
2283
|
+
const outer = readTLV(der, 0, der.length);
|
|
2284
|
+
if (!outer || outer.tag !== 0x30) {
|
|
2285
|
+
return undefined;
|
|
2286
|
+
}
|
|
2287
|
+
let candidate = readTLV(der, outer.contentStart, outer.contentEnd);
|
|
2288
|
+
if (candidate?.tag === 0x02) {
|
|
2289
|
+
candidate = readTLV(der, candidate.next, outer.contentEnd);
|
|
2290
|
+
}
|
|
2291
|
+
if (!candidate || candidate.tag !== 0x30) {
|
|
2292
|
+
return undefined;
|
|
2293
|
+
}
|
|
2294
|
+
const contentType = readTLV(
|
|
2295
|
+
der,
|
|
2296
|
+
candidate.contentStart,
|
|
2297
|
+
candidate.contentEnd,
|
|
2298
|
+
);
|
|
2299
|
+
if (!contentType || contentType.tag !== 0x06) {
|
|
2300
|
+
return undefined;
|
|
2301
|
+
}
|
|
2302
|
+
const oid = readOID(der, contentType.contentStart, contentType.contentEnd);
|
|
2303
|
+
if (oid === "1.2.840.113549.1.7.1") {
|
|
2304
|
+
return "PKCS12";
|
|
2305
|
+
}
|
|
2306
|
+
if (oid === "1.2.840.113549.1.7.2") {
|
|
2307
|
+
return "PKCS7";
|
|
2308
|
+
}
|
|
2309
|
+
return undefined;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/**
|
|
2313
|
+
* Classify a matched file by its content: certificates (PEM bundle or DER),
|
|
2314
|
+
* a PEM private-key header, a GPG key, a PEM public key, or a keystore
|
|
2315
|
+
* container the fallback cannot read. Nothing is decoded beyond what the
|
|
2316
|
+
* header lines and a bounded DER walk reveal.
|
|
2317
|
+
*
|
|
2318
|
+
* @param {Buffer} bytes file contents (already size-capped by the caller)
|
|
2319
|
+
* @returns {object} one of `{kind:"certificates", ders}`,
|
|
2320
|
+
* `{kind:"private-key", format}`, `{kind:"gpg"}`, `{kind:"public-key"}`,
|
|
2321
|
+
* `{kind:"keystore", format}` or `{kind:"unknown"}`
|
|
2322
|
+
*/
|
|
2323
|
+
export function classifyCryptoFile(bytes) {
|
|
2324
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
|
|
2325
|
+
return { kind: "unknown" };
|
|
2326
|
+
}
|
|
2327
|
+
const asText = bytes.toString("utf-8");
|
|
2328
|
+
if (asText.includes("-----BEGIN ")) {
|
|
2329
|
+
const blocks = splitPemBlocks(asText);
|
|
2330
|
+
const ders = pemCertificateDerBlocks(asText);
|
|
2331
|
+
if (ders.length) {
|
|
2332
|
+
return { kind: "certificates", ders };
|
|
2333
|
+
}
|
|
2334
|
+
const privateFormat = privateKeyPemFormat(asText);
|
|
2335
|
+
if (privateFormat) {
|
|
2336
|
+
return { kind: "private-key", format: privateFormat };
|
|
2337
|
+
}
|
|
2338
|
+
if (blocks.some((block) => GPG_LABELS.has(block.label))) {
|
|
2339
|
+
return { kind: "gpg" };
|
|
2340
|
+
}
|
|
2341
|
+
if (hasPemPublicKey(asText)) {
|
|
2342
|
+
return { kind: "public-key" };
|
|
2343
|
+
}
|
|
2344
|
+
// A TRUSTED CERTIFICATE block is certificate material only the plugin
|
|
2345
|
+
// parses (its aux trust data has no Node parser); report the container
|
|
2346
|
+
// as an unreadable bundle rather than calling it a certificate.
|
|
2347
|
+
if (blocks.some((block) => block.label === "TRUSTED CERTIFICATE")) {
|
|
2348
|
+
return { kind: "keystore", format: "PEM-TRUSTED" };
|
|
2349
|
+
}
|
|
2350
|
+
return { kind: "unknown" };
|
|
2351
|
+
}
|
|
2352
|
+
if (bytes.length >= 4 && bytes[0] === 0xfe && bytes[1] === 0xed) {
|
|
2353
|
+
// OpenJDK JavaKeyStore.engineLoad magic 0xfeedfeed.
|
|
2354
|
+
return { kind: "keystore", format: "JKS" };
|
|
2355
|
+
}
|
|
2356
|
+
if (bytes.length >= 4 && bytes[0] === 0xce && bytes[1] === 0xce) {
|
|
2357
|
+
return { kind: "keystore", format: "JCEKS" };
|
|
2358
|
+
}
|
|
2359
|
+
if (bytes[0] === 0x30) {
|
|
2360
|
+
const container = sniffDerContainer(bytes);
|
|
2361
|
+
if (container) {
|
|
2362
|
+
return { kind: "keystore", format: container };
|
|
2363
|
+
}
|
|
2364
|
+
// A structural walk decides: an unknown key algorithm OID (GOST, SM2,
|
|
2365
|
+
// pre-standard) is still a real certificate.
|
|
2366
|
+
if (parseCertificateDer(bytes)) {
|
|
2367
|
+
return { kind: "certificates", ders: [bytes] };
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
return { kind: "unknown" };
|
|
2371
|
+
}
|