@cdxgen/cdxgen 13.2.0 → 13.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -14
- package/bin/cdxgen.js +310 -138
- package/bin/evinse.js +22 -0
- package/bin/repl.js +86 -0
- package/bin/sign.js +119 -15
- package/bin/tracebom.js +69 -5
- package/bin/validate.js +107 -19
- package/bin/verify.js +172 -63
- package/data/helpers/init.gradle +46 -21
- package/data/remediations.json +85 -0
- package/data/rules/dependency-sources.yaml +67 -0
- package/data/rules/scala-evidence.yaml +115 -0
- package/lib/audit/index.js +38 -6
- package/lib/cli/bomAssembly.js +105 -15
- package/lib/cli/cliOptions.js +93 -1
- package/lib/cli/index.js +156 -58
- package/lib/cli/jsBom.js +99 -53
- package/lib/cli/jvmBom.js +708 -147
- package/lib/cli/managedBom.js +415 -224
- package/lib/cli/nativeBom.js +264 -44
- package/lib/core/activity.js +12 -0
- package/lib/core/env.js +22 -15
- package/lib/core/fs.js +176 -7
- package/lib/core/httpClient.js +159 -24
- package/lib/core/metadataProperties.js +97 -0
- package/lib/core/pluginRun.js +184 -0
- package/lib/core/processTree.js +100 -0
- package/lib/core/projectConfig.js +31 -4
- package/lib/core/runState.js +39 -0
- package/lib/core/toml.js +72 -0
- package/lib/ecosystems/asarutils.js +4 -2
- package/lib/ecosystems/bunutils.js +274 -108
- package/lib/ecosystems/cargoCache.js +231 -8
- package/lib/ecosystems/caxa.js +434 -2
- package/lib/ecosystems/cmakeResolver.js +76 -21
- package/lib/ecosystems/cppBuildContext.js +631 -0
- package/lib/ecosystems/cppEvidence.js +399 -51
- package/lib/ecosystems/cppIncludeResolver.js +294 -0
- package/lib/ecosystems/denoutils.js +93 -66
- package/lib/ecosystems/ecosystems.js +1983 -793
- package/lib/ecosystems/golem.js +28 -12
- package/lib/ecosystems/gradleutils.js +356 -37
- package/lib/ecosystems/jsEvidence.js +104 -44
- package/lib/ecosystems/kosi.js +31 -7
- package/lib/ecosystems/npmutils.js +174 -9
- package/lib/ecosystems/parsers-dotnet.js +616 -159
- package/lib/ecosystems/parsers-elm.js +46 -28
- package/lib/ecosystems/parsers-gleam.js +3 -4
- package/lib/ecosystems/parsers-go.js +371 -70
- package/lib/ecosystems/parsers-js.js +263 -76
- package/lib/ecosystems/parsers-julia.js +2 -3
- package/lib/ecosystems/parsers-jvm.js +204 -12
- package/lib/ecosystems/parsers-misc.js +929 -216
- package/lib/ecosystems/parsers-python.js +167 -24
- package/lib/ecosystems/parsers-rust.js +28 -22
- package/lib/ecosystems/parsers-terraform.js +1573 -19
- package/lib/ecosystems/pythonutils.js +102 -6
- package/lib/ecosystems/remote/huggingface.js +3 -0
- package/lib/ecosystems/rubyutils.js +1492 -182
- package/lib/ecosystems/rusi.js +54 -10
- package/lib/ecosystems/sbtutils.js +243 -187
- package/lib/ecosystems/scalacliutils.js +342 -0
- package/lib/ecosystems/scalasem.js +2618 -0
- package/lib/ecosystems/terraformRegistry.js +288 -0
- package/lib/ecosystems/utils.js +2 -0
- package/lib/ecosystems/vendoredCode.js +156 -0
- package/lib/evinser/evinser.js +1008 -191
- package/lib/evinser/swiftsem.js +1914 -302
- package/lib/helpers/agenticToolutils.js +2 -2
- package/lib/helpers/bomSigner.js +1101 -222
- package/lib/inventory/aiCollector.js +4 -1
- package/lib/inventory/analyzer.js +110 -127
- package/lib/inventory/atomUtils.js +703 -43
- package/lib/inventory/auditCategories.js +7 -0
- package/lib/inventory/buildToolRateLimit.js +144 -0
- package/lib/inventory/cacheCommands.js +2 -1
- package/lib/inventory/cdxrs.js +103 -12
- package/lib/inventory/cmakeBuildDirs.js +254 -0
- package/lib/inventory/deps.js +501 -124
- package/lib/inventory/depsUtils.js +183 -11
- package/lib/inventory/display.js +4 -5
- package/lib/inventory/dosai.js +798 -57
- package/lib/inventory/dosaiParsers.js +310 -15
- package/lib/inventory/dynamic.js +90 -6
- package/lib/inventory/evidenceUtils.js +166 -113
- package/lib/inventory/fetchBatch.js +690 -55
- package/lib/inventory/fetchDiskCache.js +597 -0
- package/lib/inventory/forgeEnricher.js +14 -5
- package/lib/inventory/jvmLocalRepos.js +917 -0
- package/lib/inventory/mcpConfigParser.js +2 -3
- package/lib/inventory/parentSelection.js +146 -0
- package/lib/inventory/protobom.js +8 -1
- package/lib/inventory/purl.js +152 -3
- package/lib/inventory/rustFormulationParser.js +3 -4
- package/lib/inventory/sbomDocument.js +2 -70
- package/lib/inventory/scalaCoords.js +153 -0
- package/lib/inventory/source.js +45 -27
- package/lib/inventory/spdx.js +101 -0
- package/lib/inventory/traceRunner.js +295 -25
- package/lib/inventory/tracedCrypto.js +294 -0
- package/lib/managers/binary.js +115 -24
- package/lib/managers/docker.js +467 -46
- package/lib/managers/oci.js +63 -3
- package/lib/parsers/cmakeCache.js +69 -0
- package/lib/parsers/cmakeLists.js +466 -0
- package/lib/parsers/cmakePresets.js +400 -0
- package/lib/parsers/compileCommands.js +557 -0
- package/lib/parsers/largeJson.js +454 -0
- package/lib/parsers/npmrc.js +19 -1
- package/lib/parsers/strictJson.js +322 -0
- package/lib/parsers/terraformConfig.js +1073 -0
- package/lib/parsers/terraformSource.js +652 -0
- package/lib/server/server.js +23 -0
- package/lib/stages/postgen/introspection/markers.js +1 -1
- package/lib/stages/postgen/postgen.js +83 -16
- package/lib/stages/postgen/specVersionCompat.js +32 -0
- package/lib/validator/bomValidator.js +6 -16
- package/lib/validator/index.js +64 -11
- package/lib/validator/reporters/console.js +3 -0
- package/lib/validator/reporters/json.js +2 -0
- package/lib/validator/reporters/sarif.js +7 -0
- package/package.json +23 -23
- package/types/lib/audit/index.d.ts.map +1 -1
- package/types/lib/cli/bomAssembly.d.ts +2 -1
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts +33 -1
- 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.map +1 -1
- package/types/lib/cli/jvmBom.d.ts +24 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts +7 -4
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts +3 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/activity.d.ts.map +1 -1
- package/types/lib/core/env.d.ts +9 -0
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts +55 -0
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/httpClient.d.ts +13 -8
- package/types/lib/core/httpClient.d.ts.map +1 -1
- package/types/lib/core/metadataProperties.d.ts +39 -0
- package/types/lib/core/metadataProperties.d.ts.map +1 -0
- package/types/lib/core/pluginRun.d.ts +97 -0
- package/types/lib/core/pluginRun.d.ts.map +1 -0
- package/types/lib/core/processTree.d.ts +36 -0
- package/types/lib/core/processTree.d.ts.map +1 -0
- package/types/lib/core/projectConfig.d.ts +6 -2
- package/types/lib/core/projectConfig.d.ts.map +1 -1
- package/types/lib/core/runState.d.ts +29 -0
- package/types/lib/core/runState.d.ts.map +1 -0
- package/types/lib/core/toml.d.ts +20 -0
- package/types/lib/core/toml.d.ts.map +1 -0
- package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
- package/types/lib/ecosystems/bunutils.d.ts +12 -5
- package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
- package/types/lib/ecosystems/cargoCache.d.ts +12 -0
- package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
- package/types/lib/ecosystems/caxa.d.ts +14 -0
- package/types/lib/ecosystems/caxa.d.ts.map +1 -1
- package/types/lib/ecosystems/cmakeResolver.d.ts +28 -3
- package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
- package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
- package/types/lib/ecosystems/cppEvidence.d.ts +17 -1
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
- package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
- package/types/lib/ecosystems/denoutils.d.ts +9 -3
- package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts +62 -13
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/golem.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +70 -4
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
- package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/kosi.d.ts +13 -0
- package/types/lib/ecosystems/kosi.d.ts.map +1 -1
- package/types/lib/ecosystems/npmutils.d.ts +57 -0
- package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-go.d.ts +54 -2
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +16 -5
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts +70 -4
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts +53 -3
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
- package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +27 -2
- package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
- package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +164 -3
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts +13 -0
- package/types/lib/ecosystems/rusi.d.ts.map +1 -1
- package/types/lib/ecosystems/sbtutils.d.ts +88 -2
- package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
- package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
- package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
- package/types/lib/ecosystems/scalasem.d.ts +311 -0
- package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
- package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
- package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
- package/types/lib/ecosystems/utils.d.ts +2 -2
- package/types/lib/ecosystems/utils.d.ts.map +1 -1
- package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
- package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
- package/types/lib/evinser/evinser.d.ts +132 -15
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/evinser/swiftsem.d.ts +280 -15
- package/types/lib/evinser/swiftsem.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +182 -13
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/inventory/aiCollector.d.ts.map +1 -1
- package/types/lib/inventory/analyzer.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +219 -5
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/auditCategories.d.ts +3 -0
- package/types/lib/inventory/auditCategories.d.ts.map +1 -1
- package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
- package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
- package/types/lib/inventory/cacheCommands.d.ts +2 -1
- package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
- package/types/lib/inventory/cdxrs.d.ts +8 -3
- package/types/lib/inventory/cdxrs.d.ts.map +1 -1
- package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
- package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
- package/types/lib/inventory/deps.d.ts +59 -1
- package/types/lib/inventory/deps.d.ts.map +1 -1
- package/types/lib/inventory/depsUtils.d.ts +15 -0
- package/types/lib/inventory/depsUtils.d.ts.map +1 -1
- package/types/lib/inventory/display.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +121 -9
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dosaiParsers.d.ts +53 -7
- package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
- package/types/lib/inventory/dynamic.d.ts +10 -0
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts +19 -4
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts +83 -14
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
- package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
- package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
- package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
- package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
- package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/parentSelection.d.ts +34 -0
- package/types/lib/inventory/parentSelection.d.ts.map +1 -0
- package/types/lib/inventory/plugins.d.ts.map +1 -1
- package/types/lib/inventory/protobom.d.ts.map +1 -1
- package/types/lib/inventory/purl.d.ts +71 -2
- package/types/lib/inventory/purl.d.ts.map +1 -1
- package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
- package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
- package/types/lib/inventory/scalaCoords.d.ts +80 -0
- package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/spdx.d.ts +9 -0
- package/types/lib/inventory/spdx.d.ts.map +1 -1
- package/types/lib/inventory/traceRunner.d.ts +59 -1
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- package/types/lib/inventory/tracedCrypto.d.ts +42 -0
- package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
- package/types/lib/managers/binary.d.ts +30 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts +29 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +17 -1
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/parsers/cmakeCache.d.ts +16 -0
- package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
- package/types/lib/parsers/cmakeLists.d.ts +77 -0
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
- package/types/lib/parsers/cmakePresets.d.ts +78 -0
- package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
- package/types/lib/parsers/compileCommands.d.ts +115 -0
- package/types/lib/parsers/compileCommands.d.ts.map +1 -0
- package/types/lib/parsers/largeJson.d.ts +52 -0
- package/types/lib/parsers/largeJson.d.ts.map +1 -0
- package/types/lib/parsers/npmrc.d.ts +5 -0
- package/types/lib/parsers/npmrc.d.ts.map +1 -1
- package/types/lib/parsers/strictJson.d.ts +46 -0
- package/types/lib/parsers/strictJson.d.ts.map +1 -0
- package/types/lib/parsers/terraformConfig.d.ts +70 -0
- package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
- package/types/lib/parsers/terraformSource.d.ts +66 -0
- package/types/lib/parsers/terraformSource.d.ts.map +1 -0
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +18 -1
- package/types/lib/validator/index.d.ts.map +1 -1
- package/types/lib/validator/reporters/console.d.ts.map +1 -1
- package/types/lib/validator/reporters/json.d.ts.map +1 -1
- package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
- package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
|
@@ -0,0 +1,2618 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
import { DEBUG_MODE, readEnvironmentVariable } from "../core/activity.js";
|
|
6
|
+
import { deferFailOnError } from "../core/deferredExit.js";
|
|
7
|
+
import { PROJECT_TYPE_ALIASES } from "../core/env.js";
|
|
8
|
+
import {
|
|
9
|
+
getAllFiles,
|
|
10
|
+
getTmpDir,
|
|
11
|
+
safeCopyFileSync,
|
|
12
|
+
safeExistsSync,
|
|
13
|
+
safeMkdirSync,
|
|
14
|
+
safeMkdtempSync,
|
|
15
|
+
safeRmSync,
|
|
16
|
+
safeSpawnSync,
|
|
17
|
+
} from "../core/fs.js";
|
|
18
|
+
import { dirNameStr } from "../core/paths.js";
|
|
19
|
+
import { resolvePluginSourceDir } from "../core/pluginRun.js";
|
|
20
|
+
import { formatDuration } from "../core/processTree.js";
|
|
21
|
+
import { parsetoolsFile } from "../inventory/atomUtils.js";
|
|
22
|
+
import { applyAlgorithmProperties } from "../inventory/cryptoAlgorithmFamily.js";
|
|
23
|
+
import { getJarClasses } from "../inventory/deps.js";
|
|
24
|
+
import {
|
|
25
|
+
parseScalaArtifact,
|
|
26
|
+
scalaCoordinateKey,
|
|
27
|
+
scalaCoordinateOfPurl,
|
|
28
|
+
} from "../inventory/scalaCoords.js";
|
|
29
|
+
import { parsePkgLock } from "./parsers-js.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* scalasem is the Scala semantic analyzer shipped inside atom-parsetools. This
|
|
33
|
+
* module is the only place that spawns it, and it owns the projection of the
|
|
34
|
+
* scalasem report (schema scalasem/2) onto CycloneDX evidence. It follows the
|
|
35
|
+
* golem and kosi discipline: a failed run may degrade the Scala evidence but
|
|
36
|
+
* never aborts SBOM generation, and it always says why.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Longest wait for a scalasem run. Large repositories degrade, not hang. */
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
|
|
41
|
+
|
|
42
|
+
/** Occurrences kept per component unless CDXGEN_SCALASEM_MAX_OCCURRENCES says otherwise. */
|
|
43
|
+
const DEFAULT_MAX_OCCURRENCES = 200;
|
|
44
|
+
|
|
45
|
+
// The JDK ships these packages and no component stands for it. `javax.` is
|
|
46
|
+
// split between the JDK and libraries (`javax.servlet`, `javax.inject`), so a
|
|
47
|
+
// `javax.` symbol joins a library only through a class the library ships.
|
|
48
|
+
export const JDK_OWNER_PREFIXES = ["java.", "jdk.", "sun."];
|
|
49
|
+
const JDK_EXTENSION_PREFIX = "javax.";
|
|
50
|
+
|
|
51
|
+
// The language runtimes every module carries, on each platform. Their
|
|
52
|
+
// occurrences are capped at one per file: they say a file is Scala, not that
|
|
53
|
+
// it uses a library.
|
|
54
|
+
export const SCALA_STDLIB_ARTIFACTS = new Set([
|
|
55
|
+
"auxlib",
|
|
56
|
+
"javalib",
|
|
57
|
+
"nativelib",
|
|
58
|
+
"scala-library",
|
|
59
|
+
"scala3-library",
|
|
60
|
+
"scalajs-library",
|
|
61
|
+
"scalalib",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export function appendUniqueProperty(properties, name, value) {
|
|
65
|
+
if (value === undefined || value === null || value === "") {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const propertyValue = String(value);
|
|
69
|
+
if (
|
|
70
|
+
!properties.some(
|
|
71
|
+
(property) => property.name === name && property.value === propertyValue,
|
|
72
|
+
)
|
|
73
|
+
) {
|
|
74
|
+
properties.push({ name, value: propertyValue });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function addPropertyValue(map, key, name, value) {
|
|
79
|
+
if (!key || value === undefined || value === null || value === "") {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
map[key] ??= [];
|
|
83
|
+
appendUniqueProperty(map[key], name, value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function addSetValue(map, key, value) {
|
|
87
|
+
if (!key || !value) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
map[key] ??= new Set();
|
|
91
|
+
map[key].add(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True for every project type the Scala path serves, matching the alias list
|
|
96
|
+
* the BOM generators dispatch on.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} language Project language or type.
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function isScalasemLanguage(language) {
|
|
102
|
+
return PROJECT_TYPE_ALIASES.scala.includes(
|
|
103
|
+
String(language || "").toLowerCase(),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* True when scalasem is turned off, through CDXGEN_SCALASEM_DISABLE or the
|
|
109
|
+
* --no-scalasem flag, which yargs carries as `scalasem: false`. Library code never reads
|
|
110
|
+
* process.argv for this.
|
|
111
|
+
*
|
|
112
|
+
* @param {Object} options CLI options.
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
export function scalasemDisabled(options = {}) {
|
|
116
|
+
const raw = (
|
|
117
|
+
readEnvironmentVariable("CDXGEN_SCALASEM_DISABLE") || ""
|
|
118
|
+
).toLowerCase();
|
|
119
|
+
return (
|
|
120
|
+
raw === "1" || raw === "true" || raw === "all" || options.scalasem === false
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The scalasem entry point: SCALASEM_CMD wins, then the scalasem.js inside
|
|
126
|
+
* the installed atom-parsetools package.
|
|
127
|
+
*
|
|
128
|
+
* @returns {string|undefined}
|
|
129
|
+
*/
|
|
130
|
+
export function resolveScalasemCommand() {
|
|
131
|
+
return (
|
|
132
|
+
readEnvironmentVariable("SCALASEM_CMD") || parsetoolsFile("scalasem.js")
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function scalasemTimeoutMs(options = {}) {
|
|
137
|
+
const raw =
|
|
138
|
+
options.scalasemTimeoutMs ??
|
|
139
|
+
readEnvironmentVariable("CDXGEN_SCALASEM_TIMEOUT");
|
|
140
|
+
const parsed = Number.parseInt(String(raw ?? ""), 10);
|
|
141
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Time scalasem gets to stop its builds and itself after its own limit,
|
|
145
|
+
// before cdxgen stops it.
|
|
146
|
+
const TIMEOUT_GRACE_MS = 30_000;
|
|
147
|
+
|
|
148
|
+
// The build files that identify a Scala project, for a source argument that
|
|
149
|
+
// names the working directory relative to itself.
|
|
150
|
+
const SCALA_BUILD_MARKERS = [
|
|
151
|
+
"build.sbt",
|
|
152
|
+
"build.sc",
|
|
153
|
+
"build.mill",
|
|
154
|
+
"pom.xml",
|
|
155
|
+
"project.scala",
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Where the scalasem report is persisted. An absolute --semantics-slices-file
|
|
160
|
+
* keeps its directory; anything else lands in the evinse output directory, the
|
|
161
|
+
* same rule the atom slice writer follows.
|
|
162
|
+
*
|
|
163
|
+
* @param {Object} options CLI options carrying `output` and `semanticsSlicesFile`.
|
|
164
|
+
* @returns {string} Absolute report path.
|
|
165
|
+
*/
|
|
166
|
+
export function scalasemOutputFile(options = {}) {
|
|
167
|
+
const requested = options.semanticsSlicesFile || "semantics.slices.json";
|
|
168
|
+
// Comparing with resolve() missed Windows paths written with forward slashes
|
|
169
|
+
// or without a drive.
|
|
170
|
+
if (isAbsolute(requested)) {
|
|
171
|
+
return requested;
|
|
172
|
+
}
|
|
173
|
+
let outputDir;
|
|
174
|
+
if (options.output) {
|
|
175
|
+
const resolvedOutput = resolve(options.output);
|
|
176
|
+
let isDir = false;
|
|
177
|
+
try {
|
|
178
|
+
isDir =
|
|
179
|
+
safeExistsSync(resolvedOutput) &&
|
|
180
|
+
lstatSync(resolvedOutput).isDirectory();
|
|
181
|
+
} catch (_err) {
|
|
182
|
+
isDir = false;
|
|
183
|
+
}
|
|
184
|
+
outputDir = isDir ? resolvedOutput : dirname(resolvedOutput);
|
|
185
|
+
} else {
|
|
186
|
+
outputDir = safeMkdtempSync(join(getTmpDir(), "scalasem-"));
|
|
187
|
+
}
|
|
188
|
+
return join(outputDir, basename(requested));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readScalasemReport(reportFile) {
|
|
192
|
+
if (!reportFile || !safeExistsSync(reportFile)) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(readFileSync(reportFile, "utf-8"));
|
|
197
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
198
|
+
} catch (_err) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isNewerThan(file, otherFile) {
|
|
204
|
+
try {
|
|
205
|
+
return statSync(file).mtimeMs >= statSync(otherFile).mtimeMs;
|
|
206
|
+
} catch (_err) {
|
|
207
|
+
// Without both timestamps the project checks alone decide.
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A version 1 semantics slice (the shape atom's scalasem wrote before schema
|
|
214
|
+
* version 2): keyed by .scala files with `usedTypes`, no `_meta.schemaVersion`.
|
|
215
|
+
*
|
|
216
|
+
* @param {Object} parsed Candidate slice.
|
|
217
|
+
* @returns {boolean}
|
|
218
|
+
*/
|
|
219
|
+
export function isV1SemanticsSlice(parsed) {
|
|
220
|
+
if (!parsed || parsed._meta?.schemaVersion) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return Object.keys(parsed).some((key) => key.endsWith(".scala"));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Whether a version 1 slice describes this project: it names no project, so
|
|
228
|
+
* every source file it lists must exist under the project directory.
|
|
229
|
+
*/
|
|
230
|
+
function isV1SliceOfProject(slice, projectDir) {
|
|
231
|
+
const files = Object.keys(slice).filter((key) => key.endsWith(".scala"));
|
|
232
|
+
return (
|
|
233
|
+
files.length > 0 &&
|
|
234
|
+
files.every((file) => {
|
|
235
|
+
const path = resolve(projectDir, file);
|
|
236
|
+
return path.startsWith(`${projectDir}${sep}`) && safeExistsSync(path);
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run scalasem over a Scala project and return its report, reusing a
|
|
243
|
+
* persisted version 2 report that belongs to this project and is newer than
|
|
244
|
+
* the input BOM.
|
|
245
|
+
*
|
|
246
|
+
* Failures are visible rather than silent: the reason is printed once, the
|
|
247
|
+
* run's diagnostics reach the caller as `cdx:scalasem:diagnostic` properties,
|
|
248
|
+
* and --fail-on-error claims the exit status. A version 1 slice of this
|
|
249
|
+
* project, at the path the user named or at the report path, is returned
|
|
250
|
+
* separately and never overwritten; the fresh report then goes to a
|
|
251
|
+
* temporary file.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} src Project directory.
|
|
254
|
+
* @param {Object} options CLI options.
|
|
255
|
+
* @returns {Object|undefined} `{ report, reportFile, v1Slice, metadataProperties }`,
|
|
256
|
+
* or undefined when the analyzer is disabled.
|
|
257
|
+
*/
|
|
258
|
+
export function analyzeScalaProject(src, options = {}) {
|
|
259
|
+
if (scalasemDisabled(options)) {
|
|
260
|
+
console.log(
|
|
261
|
+
"scalasem: disabled; skipping the Scala semantic evidence. Unset CDXGEN_SCALASEM_DISABLE or drop --no-scalasem to re-enable it.",
|
|
262
|
+
);
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
const analysisDir = resolvePluginSourceDir(src, SCALA_BUILD_MARKERS);
|
|
266
|
+
const reportFile = scalasemOutputFile(options);
|
|
267
|
+
const candidates = [
|
|
268
|
+
...new Set(
|
|
269
|
+
[
|
|
270
|
+
options.semanticsSlicesFile
|
|
271
|
+
? resolve(options.semanticsSlicesFile)
|
|
272
|
+
: undefined,
|
|
273
|
+
reportFile,
|
|
274
|
+
].filter(Boolean),
|
|
275
|
+
),
|
|
276
|
+
];
|
|
277
|
+
let v1Slice;
|
|
278
|
+
let v1File;
|
|
279
|
+
for (const candidate of candidates) {
|
|
280
|
+
const existing = readScalasemReport(candidate);
|
|
281
|
+
if (existing?._meta?.schemaVersion === "scalasem/2") {
|
|
282
|
+
if (
|
|
283
|
+
resolve(existing._meta.projectPath || "") === analysisDir &&
|
|
284
|
+
isNewerThan(candidate, options.input)
|
|
285
|
+
) {
|
|
286
|
+
if (DEBUG_MODE) {
|
|
287
|
+
console.log(`Reusing the scalasem report "${candidate}".`);
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
report: existing,
|
|
291
|
+
reportFile: candidate,
|
|
292
|
+
v1Slice: undefined,
|
|
293
|
+
metadataProperties: scalasemMetadataProperties(existing),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
} else if (
|
|
297
|
+
!v1Slice &&
|
|
298
|
+
isV1SemanticsSlice(existing) &&
|
|
299
|
+
isV1SliceOfProject(existing, analysisDir)
|
|
300
|
+
) {
|
|
301
|
+
v1Slice = existing;
|
|
302
|
+
v1File = candidate;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return runScalasem(analysisDir, options, reportFile, v1Slice, v1File);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function scalasemRunFailure(code, message, options, args, status) {
|
|
309
|
+
console.warn(`scalasem: ${message}`);
|
|
310
|
+
deferFailOnError(options, {
|
|
311
|
+
ecosystem: "scala",
|
|
312
|
+
tool: "scalasem",
|
|
313
|
+
detail: message,
|
|
314
|
+
exitCode: typeof status === "number" ? status : undefined,
|
|
315
|
+
command: `${process.execPath} ${args.join(" ")}`,
|
|
316
|
+
});
|
|
317
|
+
return [{ code }];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function runScalasem(analysisDir, options, requestedFile, v1Slice, v1File) {
|
|
321
|
+
const scalasem = options.scalasemCommand || resolveScalasemCommand();
|
|
322
|
+
if (!scalasem) {
|
|
323
|
+
const diagnostics = scalasemRunFailure(
|
|
324
|
+
"scalasem-missing",
|
|
325
|
+
"analyzer not found, so the BOM carries no Scala semantic evidence. Install @appthreat/atom-parsetools or set SCALASEM_CMD.",
|
|
326
|
+
options,
|
|
327
|
+
[],
|
|
328
|
+
);
|
|
329
|
+
return {
|
|
330
|
+
report: undefined,
|
|
331
|
+
reportFile: undefined,
|
|
332
|
+
v1Slice,
|
|
333
|
+
metadataProperties: scalasemMetadataProperties(undefined, diagnostics),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// scalasem writes into a directory of this run's own, so a report found
|
|
337
|
+
// there afterwards is this run's. Writing to the requested path directly
|
|
338
|
+
// left only the file's modification time to tell a fresh report from one an
|
|
339
|
+
// earlier run (or another project's run) had left there, and a report
|
|
340
|
+
// written within the millisecond the run started passed for fresh.
|
|
341
|
+
const runDir = safeMkdtempSync(join(getTmpDir(), "scalasem-"));
|
|
342
|
+
const runReportFile = join(runDir, "scalasem.slices.json");
|
|
343
|
+
const args = [scalasem, analysisDir, runReportFile];
|
|
344
|
+
if (options.installDeps === false) {
|
|
345
|
+
args.push("--no-build");
|
|
346
|
+
}
|
|
347
|
+
if (options.scalasemIncludeTests && !options.requiredOnly) {
|
|
348
|
+
args.push("--include-tests");
|
|
349
|
+
}
|
|
350
|
+
if (DEBUG_MODE) {
|
|
351
|
+
console.log("Executing", process.execPath, args.join(" "));
|
|
352
|
+
}
|
|
353
|
+
const timeoutMs = scalasemTimeoutMs(options);
|
|
354
|
+
const startedAt = Date.now();
|
|
355
|
+
// scalasem enforces the limit itself, so the builds it started stop with
|
|
356
|
+
// it; cdxgen's own timeout is the backstop. Naming cdxgen as the supervisor
|
|
357
|
+
// stops scalasem when cdxgen is killed.
|
|
358
|
+
const result = safeSpawnSync(process.execPath, args, {
|
|
359
|
+
cwd: analysisDir,
|
|
360
|
+
shell: false,
|
|
361
|
+
killSignal: "SIGKILL",
|
|
362
|
+
timeout: timeoutMs + Math.min(TIMEOUT_GRACE_MS, timeoutMs),
|
|
363
|
+
env: {
|
|
364
|
+
...process.env,
|
|
365
|
+
ATOM_PARENT_PID: String(process.pid),
|
|
366
|
+
SCALASEM_TIMEOUT: String(timeoutMs),
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
const elapsedMs = Date.now() - startedAt;
|
|
370
|
+
const wroteReport = safeExistsSync(runReportFile);
|
|
371
|
+
const written = wroteReport ? readScalasemReport(runReportFile) : undefined;
|
|
372
|
+
const usable = written?._meta?.schemaVersion === "scalasem/2";
|
|
373
|
+
const report = usable ? written : undefined;
|
|
374
|
+
// scalasem stops itself at the limit and exits with an error and no
|
|
375
|
+
// report; cdxgen's own timeout is only the backstop. A run that ends without
|
|
376
|
+
// a usable report once the limit has passed overran it, however it exited.
|
|
377
|
+
const timedOut =
|
|
378
|
+
result?.error?.code === "ETIMEDOUT" || (!usable && elapsedMs >= timeoutMs);
|
|
379
|
+
let diagnostics;
|
|
380
|
+
if (timedOut) {
|
|
381
|
+
diagnostics = scalasemRunFailure(
|
|
382
|
+
"scalasem-timeout",
|
|
383
|
+
`did not finish within ${formatDuration(timeoutMs)} and was stopped, so the BOM carries no Scala semantic evidence. Allow more time with CDXGEN_SCALASEM_TIMEOUT (milliseconds).`,
|
|
384
|
+
options,
|
|
385
|
+
args,
|
|
386
|
+
);
|
|
387
|
+
} else if (result?.error) {
|
|
388
|
+
diagnostics = scalasemRunFailure(
|
|
389
|
+
"scalasem-not-runnable",
|
|
390
|
+
`could not be started (${result.error.message}). Check SCALASEM_CMD or the atom-parsetools install.`,
|
|
391
|
+
options,
|
|
392
|
+
args,
|
|
393
|
+
);
|
|
394
|
+
} else if (!wroteReport) {
|
|
395
|
+
diagnostics = scalasemRunFailure(
|
|
396
|
+
"scalasem-no-report",
|
|
397
|
+
`wrote no report for ${analysisDir} (exit status ${result?.status ?? "unknown"}); run with CDXGEN_DEBUG_MODE=debug to see its output.`,
|
|
398
|
+
options,
|
|
399
|
+
args,
|
|
400
|
+
result?.status,
|
|
401
|
+
);
|
|
402
|
+
} else if (!written) {
|
|
403
|
+
diagnostics = scalasemRunFailure(
|
|
404
|
+
"scalasem-invalid-report",
|
|
405
|
+
`wrote a report for ${analysisDir} that is not valid JSON; run with CDXGEN_DEBUG_MODE=debug to see its output.`,
|
|
406
|
+
options,
|
|
407
|
+
args,
|
|
408
|
+
result?.status,
|
|
409
|
+
);
|
|
410
|
+
} else if (!usable) {
|
|
411
|
+
diagnostics = scalasemRunFailure(
|
|
412
|
+
"scalasem-old-report",
|
|
413
|
+
"wrote a version 1 report. Scala evidence needs atom-parsetools 1.10.0 or later.",
|
|
414
|
+
options,
|
|
415
|
+
args,
|
|
416
|
+
result?.status,
|
|
417
|
+
);
|
|
418
|
+
} else {
|
|
419
|
+
diagnostics = report._meta.diagnostics || [];
|
|
420
|
+
if (result?.status !== 0) {
|
|
421
|
+
diagnostics = [
|
|
422
|
+
...diagnostics,
|
|
423
|
+
...scalasemRunFailure(
|
|
424
|
+
"scalasem-exit-status",
|
|
425
|
+
`exited with status ${result?.status}; its report is used, and may be incomplete.`,
|
|
426
|
+
options,
|
|
427
|
+
args,
|
|
428
|
+
result?.status,
|
|
429
|
+
),
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
const summary = summarizeDiagnostics(report._meta.diagnostics);
|
|
433
|
+
if (summary) {
|
|
434
|
+
console.warn(
|
|
435
|
+
`scalasem: the report is degraded: ${summary}. The cdx:scalasem:diagnostic:* metadata properties of the BOM say where.`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (DEBUG_MODE && !usable && (result?.stdout || result?.stderr)) {
|
|
440
|
+
console.log(result.stdout, result.stderr);
|
|
441
|
+
}
|
|
442
|
+
// Only a usable report replaces the one at the requested path, and a
|
|
443
|
+
// version 1 slice there stays as the user left it.
|
|
444
|
+
let reportFile;
|
|
445
|
+
if (usable && v1File !== requestedFile) {
|
|
446
|
+
try {
|
|
447
|
+
safeMkdirSync(dirname(requestedFile), { recursive: true });
|
|
448
|
+
safeCopyFileSync(runReportFile, requestedFile);
|
|
449
|
+
reportFile = requestedFile;
|
|
450
|
+
} catch (err) {
|
|
451
|
+
console.warn(
|
|
452
|
+
`scalasem: the report could not be kept at ${requestedFile} (${err.message}); this run still uses it.`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
safeRmSync(runDir, { recursive: true, force: true });
|
|
457
|
+
return {
|
|
458
|
+
report,
|
|
459
|
+
reportFile,
|
|
460
|
+
v1Slice,
|
|
461
|
+
metadataProperties: scalasemMetadataProperties(report, diagnostics),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function summarizeDiagnostics(diagnostics = []) {
|
|
466
|
+
const byCode = new Map();
|
|
467
|
+
for (const diagnostic of diagnostics || []) {
|
|
468
|
+
if (diagnostic?.code) {
|
|
469
|
+
byCode.set(
|
|
470
|
+
diagnostic.code,
|
|
471
|
+
(byCode.get(diagnostic.code) || 0) + (diagnostic.count ?? 1),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return [...byCode.entries()]
|
|
476
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
477
|
+
.map(([code, count]) => `${code} (${count})`)
|
|
478
|
+
.join(", ");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The `cdx:scalasem:*` metadata properties of one run: what produced the
|
|
483
|
+
* report, and what limited it.
|
|
484
|
+
*
|
|
485
|
+
* @param {Object|undefined} report Parsed scalasem report, when one exists.
|
|
486
|
+
* @param {Object[]} diagnostics Run diagnostics, from the report or the spawn.
|
|
487
|
+
* @returns {Object[]} Metadata properties.
|
|
488
|
+
*/
|
|
489
|
+
export function scalasemMetadataProperties(
|
|
490
|
+
report,
|
|
491
|
+
diagnostics = report?._meta?.diagnostics || [],
|
|
492
|
+
) {
|
|
493
|
+
const properties = [];
|
|
494
|
+
const meta = report?._meta || {};
|
|
495
|
+
appendUniqueProperty(
|
|
496
|
+
properties,
|
|
497
|
+
"cdx:scalasem:schemaVersion",
|
|
498
|
+
meta.schemaVersion,
|
|
499
|
+
);
|
|
500
|
+
appendUniqueProperty(
|
|
501
|
+
properties,
|
|
502
|
+
"cdx:scalasem:factsSource",
|
|
503
|
+
sortedCsv(meta.generatedFrom),
|
|
504
|
+
);
|
|
505
|
+
appendUniqueProperty(
|
|
506
|
+
properties,
|
|
507
|
+
"cdx:scalasem:compilerSource",
|
|
508
|
+
sortedCsv((meta.compilers || []).map((compiler) => compiler.source)),
|
|
509
|
+
);
|
|
510
|
+
appendUniqueProperty(
|
|
511
|
+
properties,
|
|
512
|
+
"cdx:scalasem:scalaVersions",
|
|
513
|
+
sortedCsv((meta.compilers || []).map((compiler) => compiler.version)),
|
|
514
|
+
);
|
|
515
|
+
appendUniqueProperty(
|
|
516
|
+
properties,
|
|
517
|
+
"cdx:scalasem:platforms",
|
|
518
|
+
sortedCsv(meta.platforms),
|
|
519
|
+
);
|
|
520
|
+
appendUniqueProperty(
|
|
521
|
+
properties,
|
|
522
|
+
"cdx:scalasem:filesAnalyzed",
|
|
523
|
+
meta.counts?.files,
|
|
524
|
+
);
|
|
525
|
+
if (meta.truncated || diagnostics.length) {
|
|
526
|
+
appendUniqueProperty(properties, "cdx:scalasem:degraded", "true");
|
|
527
|
+
}
|
|
528
|
+
const byCode = {};
|
|
529
|
+
for (const diagnostic of diagnostics) {
|
|
530
|
+
if (!diagnostic?.code) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
byCode[diagnostic.code] =
|
|
534
|
+
(byCode[diagnostic.code] || 0) + (diagnostic.count ?? 1);
|
|
535
|
+
}
|
|
536
|
+
for (const code of Object.keys(byCode).sort()) {
|
|
537
|
+
appendUniqueProperty(
|
|
538
|
+
properties,
|
|
539
|
+
`cdx:scalasem:diagnostic:${code}`,
|
|
540
|
+
byCode[code],
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
return properties;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function sortedCsv(values) {
|
|
547
|
+
const items = values instanceof Set ? [...values] : values || [];
|
|
548
|
+
const filtered = [...new Set(items.filter(Boolean))].sort();
|
|
549
|
+
return filtered.length ? filtered.join(",") : undefined;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The platform a purl's artifact name targets: `_sjs1` is Scala.js,
|
|
554
|
+
* `_native0.x` is Scala Native, and neither is the JVM.
|
|
555
|
+
*
|
|
556
|
+
* @param {string} purl Package URL.
|
|
557
|
+
* @returns {"jvm"|"js"|"native"} Platform key.
|
|
558
|
+
*/
|
|
559
|
+
export function scalaPurlPlatform(purl) {
|
|
560
|
+
const name = purlPurlName(purl);
|
|
561
|
+
if (!name) {
|
|
562
|
+
return "jvm";
|
|
563
|
+
}
|
|
564
|
+
if (/_(sjs\d+(?:\.\d+)?)$/.test(name)) {
|
|
565
|
+
return "js";
|
|
566
|
+
}
|
|
567
|
+
if (/_(native\d+(?:\.\d+)*)$/.test(name)) {
|
|
568
|
+
return "native";
|
|
569
|
+
}
|
|
570
|
+
return "jvm";
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function purlPurlName(purl) {
|
|
574
|
+
const match = /pkg:[^/]+\/(?:[^/@?]+\/)?([^/@?]+)/.exec(purl || "");
|
|
575
|
+
if (!match) {
|
|
576
|
+
return undefined;
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
return decodeURIComponent(match[1]);
|
|
580
|
+
} catch (_err) {
|
|
581
|
+
return match[1];
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The class and package index that joins scalasem symbols to component purls.
|
|
587
|
+
*
|
|
588
|
+
* Symbols are indexed from three sources in priority order: the classpath the
|
|
589
|
+
* report recorded (1), the `internal:Namespaces` properties of the components
|
|
590
|
+
* (2), and the jar namespace map cdxgen writes beside the BOM (3). A symbol an
|
|
591
|
+
* earlier source already attributed is never re-attributed by a later one.
|
|
592
|
+
*
|
|
593
|
+
* A jar answers for the classes it ships and for the packages it ships classes
|
|
594
|
+
* in directly, never for the parents of those packages: a library with one
|
|
595
|
+
* class under `scala.collection.compat` owns neither `scala.collection` nor
|
|
596
|
+
* `scala`. The JDK has no component, and neither do the project's own classes.
|
|
597
|
+
*/
|
|
598
|
+
export class ScalaJoinIndex {
|
|
599
|
+
constructor() {
|
|
600
|
+
this.classPurls = new Map();
|
|
601
|
+
this.packagePurls = new Map();
|
|
602
|
+
this.sourceBySymbol = new Map();
|
|
603
|
+
this.purlsBySource = new Map();
|
|
604
|
+
this.namespacesByPurl = new Map();
|
|
605
|
+
this.projectClasses = new Set();
|
|
606
|
+
this.projectPackages = new Set();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
hasNamespaces(purl) {
|
|
610
|
+
return (this.purlsBySource.get(purl) || 0) > 0;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Index the classes of one library: each class under its dotted name (a
|
|
615
|
+
* nested `Outer$Inner` also as `Outer.Inner`, an object `Foo$` as `Foo`),
|
|
616
|
+
* and the package each one sits in.
|
|
617
|
+
*
|
|
618
|
+
* @param {string} purl The owning component purl.
|
|
619
|
+
* @param {string[]} names Class names from the jar or the namespace property.
|
|
620
|
+
* @param {number} source The source priority, 1 to 3.
|
|
621
|
+
*/
|
|
622
|
+
addNamespaces(purl, names, source) {
|
|
623
|
+
if (!purl || this.hasNamespaces(purl)) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
this.purlsBySource.set(purl, source);
|
|
627
|
+
const indexed = [];
|
|
628
|
+
this.namespacesByPurl.set(purl, indexed);
|
|
629
|
+
for (const name of names || []) {
|
|
630
|
+
const symbol = String(name || "").trim();
|
|
631
|
+
if (!symbol || symbol.includes(" ") || symbol.includes("/")) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
indexed.push(symbol);
|
|
635
|
+
const dollar = symbol.indexOf("$");
|
|
636
|
+
const topLevel = dollar > 0 ? symbol.slice(0, dollar) : symbol;
|
|
637
|
+
this.addSymbol(this.classPurls, topLevel, purl, source);
|
|
638
|
+
if (dollar > 0) {
|
|
639
|
+
const nested = symbol.replaceAll("$", ".").replace(/\.+$/, "");
|
|
640
|
+
if (nested !== topLevel) {
|
|
641
|
+
this.addSymbol(this.classPurls, nested, purl, source);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const lastDot = topLevel.lastIndexOf(".");
|
|
645
|
+
if (lastDot > 0) {
|
|
646
|
+
this.addSymbol(
|
|
647
|
+
this.packagePurls,
|
|
648
|
+
topLevel.slice(0, lastDot),
|
|
649
|
+
purl,
|
|
650
|
+
source,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
addSymbol(map, symbol, purl, source) {
|
|
657
|
+
const key = `${map === this.classPurls ? "c" : "p"}:${symbol}`;
|
|
658
|
+
const existing = this.sourceBySymbol.get(key);
|
|
659
|
+
if (existing !== undefined && existing < source) {
|
|
660
|
+
// A higher-priority source (lower number) already attributed it.
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (existing !== undefined && existing > source) {
|
|
664
|
+
map.set(symbol, new Set());
|
|
665
|
+
}
|
|
666
|
+
this.sourceBySymbol.set(key, source);
|
|
667
|
+
if (!map.has(symbol)) {
|
|
668
|
+
map.set(symbol, new Set());
|
|
669
|
+
}
|
|
670
|
+
map.get(symbol).add(purl);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Record the classes the project defines, so that references to them, and
|
|
675
|
+
* to packages only the project fills, never join a library.
|
|
676
|
+
*
|
|
677
|
+
* @param {Object} report Parsed scalasem report.
|
|
678
|
+
*/
|
|
679
|
+
addProjectDefinitions(report) {
|
|
680
|
+
for (const [fileKey, fileEntry] of Object.entries(report || {})) {
|
|
681
|
+
if (!isReportFileKey(fileKey) || !Array.isArray(fileEntry?.definitions)) {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
for (const definition of fileEntry.definitions) {
|
|
685
|
+
if (
|
|
686
|
+
!["class", "object", "trait", "enum"].includes(definition?.kind) ||
|
|
687
|
+
!definition.owner ||
|
|
688
|
+
!definition.name
|
|
689
|
+
) {
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
this.projectClasses.add(`${definition.owner}.${definition.name}`);
|
|
693
|
+
this.projectPackages.add(definition.owner);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Whether a symbol belongs to code the BOM has no component for: the JDK,
|
|
700
|
+
* or the project itself.
|
|
701
|
+
*/
|
|
702
|
+
isUnowned(symbol) {
|
|
703
|
+
if (JDK_OWNER_PREFIXES.some((prefix) => symbol.startsWith(prefix))) {
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
const parts = symbol.replace(/\$+$/, "").split(".");
|
|
707
|
+
for (let i = parts.length; i > 1; i--) {
|
|
708
|
+
if (this.projectClasses.has(parts.slice(0, i).join("."))) {
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The libraries that ship the class a symbol names: the longest dotted
|
|
717
|
+
* prefix of the symbol that is a class name, for a member or a nested type.
|
|
718
|
+
*/
|
|
719
|
+
classOwners(symbol) {
|
|
720
|
+
const parts = symbol.replace(/\$+$/, "").split(".");
|
|
721
|
+
for (let i = parts.length; i > 0; i--) {
|
|
722
|
+
const owners = this.classPurls.get(parts.slice(0, i).join("."));
|
|
723
|
+
if (owners?.size) {
|
|
724
|
+
return owners;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return undefined;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* The libraries that ship classes directly in the package a symbol names or
|
|
732
|
+
* sits in. A package the project also fills is ambiguous and joins nothing,
|
|
733
|
+
* and `javax.` names join only through an exact class, since the JDK ships
|
|
734
|
+
* most of them.
|
|
735
|
+
*/
|
|
736
|
+
packageOwners(symbol) {
|
|
737
|
+
if (symbol.startsWith(JDK_EXTENSION_PREFIX)) {
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
const parts = symbol.replace(/\$+$/, "").split(".");
|
|
741
|
+
for (let i = parts.length; i > 0; i--) {
|
|
742
|
+
const name = parts.slice(0, i).join(".");
|
|
743
|
+
const owners = this.packagePurls.get(name);
|
|
744
|
+
if (owners?.size) {
|
|
745
|
+
return this.projectPackages.has(name) ? undefined : owners;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* The purl owning a symbol: the class first, then the package it sits in,
|
|
753
|
+
* each narrowed to the module's own classpath and the file's platform, and
|
|
754
|
+
* kept only when a single owner remains.
|
|
755
|
+
*
|
|
756
|
+
* @param {string} symbol Fully qualified symbol from the report.
|
|
757
|
+
* @param {string|undefined} platform Platform of the referencing file.
|
|
758
|
+
* @param {Set<string>|undefined} allowedPurls Purls on the module's classpath.
|
|
759
|
+
* @returns {string|undefined} Component purl.
|
|
760
|
+
*/
|
|
761
|
+
lookup(symbol, platform, allowedPurls) {
|
|
762
|
+
if (!symbol || this.isUnowned(symbol)) {
|
|
763
|
+
return undefined;
|
|
764
|
+
}
|
|
765
|
+
const byClass = this.narrow(
|
|
766
|
+
this.classOwners(symbol),
|
|
767
|
+
platform,
|
|
768
|
+
allowedPurls,
|
|
769
|
+
);
|
|
770
|
+
if (byClass.length) {
|
|
771
|
+
return byClass.length === 1 ? byClass[0] : undefined;
|
|
772
|
+
}
|
|
773
|
+
const byPackage = this.narrow(
|
|
774
|
+
this.packageOwners(symbol),
|
|
775
|
+
platform,
|
|
776
|
+
allowedPurls,
|
|
777
|
+
);
|
|
778
|
+
return byPackage.length === 1 ? byPackage[0] : undefined;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* The owners a stack is evidence for: every jar that ships the class, since
|
|
783
|
+
* which one served the call cannot be told from the source, or the single
|
|
784
|
+
* package owner when no jar lists the class.
|
|
785
|
+
*/
|
|
786
|
+
lookupAll(symbol, platform, allowedPurls) {
|
|
787
|
+
if (!symbol || this.isUnowned(symbol)) {
|
|
788
|
+
return [];
|
|
789
|
+
}
|
|
790
|
+
const byClass = this.narrow(
|
|
791
|
+
this.classOwners(symbol),
|
|
792
|
+
platform,
|
|
793
|
+
allowedPurls,
|
|
794
|
+
);
|
|
795
|
+
if (byClass.length) {
|
|
796
|
+
return byClass;
|
|
797
|
+
}
|
|
798
|
+
const single = this.lookup(symbol, platform, allowedPurls);
|
|
799
|
+
return single ? [single] : [];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Narrow candidate owners to what the referencing file can use. A module
|
|
804
|
+
* compiles against its own classpath: a library on it wins, and a library
|
|
805
|
+
* only other modules compile against does not qualify. A library on no
|
|
806
|
+
* module's classpath stays a candidate, since the report cannot list every
|
|
807
|
+
* jar (a locally published snapshot, say). The file's platform then picks
|
|
808
|
+
* among the JVM, Scala.js and Scala Native variants of one library.
|
|
809
|
+
*/
|
|
810
|
+
narrow(candidates, platform, allowedPurls) {
|
|
811
|
+
let pool = [...(candidates || [])];
|
|
812
|
+
if (!pool.length) {
|
|
813
|
+
return pool;
|
|
814
|
+
}
|
|
815
|
+
let onClasspath = false;
|
|
816
|
+
if (allowedPurls?.size) {
|
|
817
|
+
const inModule = pool.filter((purl) => allowedPurls.has(purl));
|
|
818
|
+
onClasspath = inModule.length > 0;
|
|
819
|
+
pool = onClasspath
|
|
820
|
+
? inModule
|
|
821
|
+
: pool.filter((purl) => !this.classpathPurls?.has(purl));
|
|
822
|
+
}
|
|
823
|
+
if (platform && pool.length) {
|
|
824
|
+
const samePlatform = pool.filter(
|
|
825
|
+
(purl) => scalaPurlPlatform(purl) === platform,
|
|
826
|
+
);
|
|
827
|
+
if (samePlatform.length || !onClasspath) {
|
|
828
|
+
pool = samePlatform;
|
|
829
|
+
}
|
|
830
|
+
} else if (pool.length > 1) {
|
|
831
|
+
// Without a platform the JVM speaks for a library built for several.
|
|
832
|
+
const jvm = pool.filter((purl) => scalaPurlPlatform(purl) === "jvm");
|
|
833
|
+
if (jvm.length) {
|
|
834
|
+
pool = jvm;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return pool;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Whether a report key names a source file entry. The schema keys file
|
|
843
|
+
* entries by their relative path, for Scala sources, scripts and the Java
|
|
844
|
+
* sources of a mixed module alike.
|
|
845
|
+
*/
|
|
846
|
+
export function isReportFileKey(key) {
|
|
847
|
+
return /\.(scala|sc|java)$/.test(key || "");
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function decodeSafe(value) {
|
|
851
|
+
if (!value) {
|
|
852
|
+
return "";
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
return decodeURIComponent(value);
|
|
856
|
+
} catch (_err) {
|
|
857
|
+
return value;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// The Scala binary versions a component was built for: the suffix its purl
|
|
862
|
+
// keeps, or every cdx:scala:compilerVersion it carries, since one component can
|
|
863
|
+
// stand for the same library in builds of several Scala versions.
|
|
864
|
+
function componentBinaryVersions(component, coordinate) {
|
|
865
|
+
if (coordinate.binaryVersion) {
|
|
866
|
+
return new Set([coordinate.binaryVersion]);
|
|
867
|
+
}
|
|
868
|
+
return new Set(
|
|
869
|
+
(component.properties || [])
|
|
870
|
+
.filter((property) => property.name === "cdx:scala:compilerVersion")
|
|
871
|
+
.map((property) => property.value)
|
|
872
|
+
.filter(Boolean),
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Index the BOM components by their normalized Scala coordinates, both with
|
|
878
|
+
* and without the version, so a classpath jar, a namespace property and a map
|
|
879
|
+
* entry of the same library meet on one component. Each entry keeps the Scala
|
|
880
|
+
* binary version the component was built for, so the `_2.13` and `_3`
|
|
881
|
+
* artifacts of one library in a Maven build stay apart.
|
|
882
|
+
*/
|
|
883
|
+
export function componentCoordinateIndex(components = []) {
|
|
884
|
+
const byVersion = new Map();
|
|
885
|
+
const withoutVersion = new Map();
|
|
886
|
+
for (const component of components) {
|
|
887
|
+
const coordinate = scalaCoordinateOfPurl(component?.purl);
|
|
888
|
+
if (!coordinate) {
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const [type, group, artifactBase, platformSuffix] =
|
|
892
|
+
coordinate.key.split(":");
|
|
893
|
+
const entry = {
|
|
894
|
+
component,
|
|
895
|
+
binaryVersions: componentBinaryVersions(component, coordinate),
|
|
896
|
+
};
|
|
897
|
+
if (!byVersion.has(coordinate.key)) {
|
|
898
|
+
byVersion.set(coordinate.key, []);
|
|
899
|
+
}
|
|
900
|
+
byVersion.get(coordinate.key).push(entry);
|
|
901
|
+
const key = scalaCoordinateKey({
|
|
902
|
+
type,
|
|
903
|
+
group,
|
|
904
|
+
artifactBase,
|
|
905
|
+
platformSuffix,
|
|
906
|
+
});
|
|
907
|
+
if (!withoutVersion.has(key)) {
|
|
908
|
+
withoutVersion.set(key, []);
|
|
909
|
+
}
|
|
910
|
+
withoutVersion.get(key).push(entry);
|
|
911
|
+
}
|
|
912
|
+
return { byVersion, withoutVersion };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function pickByBinaryVersion(entries, binaryVersion) {
|
|
916
|
+
if (!entries?.length) {
|
|
917
|
+
return [];
|
|
918
|
+
}
|
|
919
|
+
if (binaryVersion) {
|
|
920
|
+
const same = entries.filter((entry) =>
|
|
921
|
+
entry.binaryVersions.has(binaryVersion),
|
|
922
|
+
);
|
|
923
|
+
if (same.length) {
|
|
924
|
+
return same;
|
|
925
|
+
}
|
|
926
|
+
const unknown = entries.filter((entry) => !entry.binaryVersions.size);
|
|
927
|
+
if (unknown.length) {
|
|
928
|
+
return unknown;
|
|
929
|
+
}
|
|
930
|
+
return [];
|
|
931
|
+
}
|
|
932
|
+
return entries;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* The component a report classpath entry belongs to. Coordinates are
|
|
937
|
+
* normalized (`upickle_sjs1_3` and the purl name `upickle_sjs1` share a key),
|
|
938
|
+
* the entry's version and Scala binary version win, and a versionless match
|
|
939
|
+
* must have a single owner. A jar the BOM does not hold matches nothing: no
|
|
940
|
+
* component is invented.
|
|
941
|
+
*
|
|
942
|
+
* @returns {{component: Object, exact: boolean}|undefined} The component, and
|
|
943
|
+
* whether its version is the one on the classpath.
|
|
944
|
+
*/
|
|
945
|
+
export function matchClasspathEntry(
|
|
946
|
+
entry,
|
|
947
|
+
{ byVersion, withoutVersion },
|
|
948
|
+
platform,
|
|
949
|
+
) {
|
|
950
|
+
const parsed = parseScalaArtifact(entry?.artifact);
|
|
951
|
+
const parts = {
|
|
952
|
+
group: entry?.group,
|
|
953
|
+
artifactBase: parsed.artifactBase,
|
|
954
|
+
platformSuffix: parsed.platformSuffix,
|
|
955
|
+
};
|
|
956
|
+
const exact = pickByBinaryVersion(
|
|
957
|
+
byVersion.get(scalaCoordinateKey({ ...parts, version: entry?.version })),
|
|
958
|
+
parsed.binaryVersion,
|
|
959
|
+
);
|
|
960
|
+
if (exact.length === 1) {
|
|
961
|
+
return { component: exact[0].component, exact: true };
|
|
962
|
+
}
|
|
963
|
+
if (exact.length > 1) {
|
|
964
|
+
return undefined;
|
|
965
|
+
}
|
|
966
|
+
const candidates = pickByBinaryVersion(
|
|
967
|
+
withoutVersion.get(scalaCoordinateKey(parts)),
|
|
968
|
+
parsed.binaryVersion,
|
|
969
|
+
);
|
|
970
|
+
const matching = candidates.filter(
|
|
971
|
+
(candidate) => scalaPurlPlatform(candidate.component.purl) === platform,
|
|
972
|
+
);
|
|
973
|
+
const pool = matching.length ? matching : candidates;
|
|
974
|
+
return pool.length === 1
|
|
975
|
+
? { component: pool[0].component, exact: false }
|
|
976
|
+
: undefined;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Build the symbol index from the three sources, reading each classpath jar
|
|
981
|
+
* once, and only for components that carry no namespaces yet.
|
|
982
|
+
*
|
|
983
|
+
* @param {Object} report Parsed scalasem report.
|
|
984
|
+
* @param {Object[]} components BOM components.
|
|
985
|
+
* @param {Object} jarNSMapping The jar namespace map cdxgen writes beside the BOM.
|
|
986
|
+
* @returns {Promise<ScalaJoinIndex>}
|
|
987
|
+
*/
|
|
988
|
+
export async function buildScalaJoinIndex(report, components, jarNSMapping) {
|
|
989
|
+
const index = new ScalaJoinIndex();
|
|
990
|
+
index.addProjectDefinitions(report);
|
|
991
|
+
index.exactPurls = new Set();
|
|
992
|
+
const coordinateIndex = componentCoordinateIndex(components);
|
|
993
|
+
const purlsWithNamespaces = new Set(
|
|
994
|
+
components
|
|
995
|
+
.filter((component) =>
|
|
996
|
+
(component.properties || []).some(
|
|
997
|
+
(property) => property.name === "internal:Namespaces",
|
|
998
|
+
),
|
|
999
|
+
)
|
|
1000
|
+
.map((component) => component.purl),
|
|
1001
|
+
);
|
|
1002
|
+
const readJars = new Set();
|
|
1003
|
+
for (const module of report.modules || []) {
|
|
1004
|
+
for (const entry of module.classpath || []) {
|
|
1005
|
+
const matched = matchClasspathEntry(
|
|
1006
|
+
entry,
|
|
1007
|
+
coordinateIndex,
|
|
1008
|
+
module.platform,
|
|
1009
|
+
);
|
|
1010
|
+
const purl = matched?.component?.purl;
|
|
1011
|
+
if (!purl || purlsWithNamespaces.has(purl) || index.hasNamespaces(purl)) {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (!entry.path || readJars.has(entry.path)) {
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
readJars.add(entry.path);
|
|
1018
|
+
if (!safeExistsSync(entry.path)) {
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
let classes = [];
|
|
1022
|
+
try {
|
|
1023
|
+
classes = await getJarClasses(entry.path);
|
|
1024
|
+
} catch (_err) {
|
|
1025
|
+
classes = [];
|
|
1026
|
+
}
|
|
1027
|
+
if (classes.length) {
|
|
1028
|
+
index.addNamespaces(purl, classes, 1);
|
|
1029
|
+
if (matched.exact) {
|
|
1030
|
+
index.exactPurls.add(purl);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
for (const component of components) {
|
|
1036
|
+
if (purlsWithNamespaces.has(component.purl)) {
|
|
1037
|
+
const namespaces = (component.properties || [])
|
|
1038
|
+
.filter((property) => property.name === "internal:Namespaces")
|
|
1039
|
+
.flatMap((property) => (property.value || "").split("\n"));
|
|
1040
|
+
index.addNamespaces(component.purl, namespaces, 2);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
for (const mapPurl of Object.keys(jarNSMapping || {})) {
|
|
1044
|
+
const entry = jarNSMapping[mapPurl] || {};
|
|
1045
|
+
if (!Array.isArray(entry.namespaces) || !entry.namespaces.length) {
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
const segments = /^pkg:[^/]+\/(?:([^/@?]+)\/)?([^@?]+)@([^?#]*)/.exec(
|
|
1049
|
+
mapPurl,
|
|
1050
|
+
);
|
|
1051
|
+
if (!segments) {
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
const matched = matchClasspathEntry(
|
|
1055
|
+
{
|
|
1056
|
+
group: decodeSafe(segments[1]),
|
|
1057
|
+
artifact: decodeSafe(segments[2]),
|
|
1058
|
+
version: decodeSafe(segments[3]),
|
|
1059
|
+
},
|
|
1060
|
+
coordinateIndex,
|
|
1061
|
+
scalaPurlPlatform(mapPurl),
|
|
1062
|
+
);
|
|
1063
|
+
if (matched) {
|
|
1064
|
+
index.addNamespaces(matched.component.purl, entry.namespaces, 3);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return index;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// The registry of known algorithm OIDs, shared with the golem converter.
|
|
1071
|
+
const SCALASEM_CRYPTO_OIDS = JSON.parse(
|
|
1072
|
+
readFileSync(join(dirNameStr, "data", "crypto-oid.json"), "utf-8"),
|
|
1073
|
+
);
|
|
1074
|
+
const SCALASEM_OID_KEYS = new Map(
|
|
1075
|
+
Object.keys(SCALASEM_CRYPTO_OIDS).map((key) => [key.toLowerCase(), key]),
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
// Canonical scalasem algorithm spellings that have no key of their own in the
|
|
1079
|
+
// OID registry, mapped to the key that names the same algorithm. Everything
|
|
1080
|
+
// the rules can emit is covered by this table, the key-size dependent AES
|
|
1081
|
+
// family below, or one of the explicit no-OID sets.
|
|
1082
|
+
const SCALASEM_OID_ALIASES = {
|
|
1083
|
+
HS256: "hmacWithSHA256",
|
|
1084
|
+
HS384: "hmacWithSHA384",
|
|
1085
|
+
HS512: "hmacWithSHA512",
|
|
1086
|
+
HmacSHA1: "hmacWithSHA1",
|
|
1087
|
+
HmacSHA224: "hmacWithSHA224",
|
|
1088
|
+
HmacSHA256: "hmacWithSHA256",
|
|
1089
|
+
HmacSHA384: "hmacWithSHA384",
|
|
1090
|
+
HmacSHA512: "hmacWithSHA512",
|
|
1091
|
+
ES256: "ecdsaWithSHA256",
|
|
1092
|
+
ES384: "ecdsaWithSHA384",
|
|
1093
|
+
ES512: "ecdsaWithSHA512",
|
|
1094
|
+
RS256: "sha256WithRSAEncryption",
|
|
1095
|
+
RS384: "sha384WithRSAEncryption",
|
|
1096
|
+
RS512: "sha512WithRSAEncryption",
|
|
1097
|
+
PS256: "rsassa-pss",
|
|
1098
|
+
PS384: "rsassa-pss",
|
|
1099
|
+
PS512: "rsassa-pss",
|
|
1100
|
+
SHA1withDSA: "dsaWithSha1",
|
|
1101
|
+
SHA1withRSA: "sha1-with-rsa-signature",
|
|
1102
|
+
SHA224withDSA: "dsaWithSha224",
|
|
1103
|
+
SHA256withDSA: "dsaWithSha256",
|
|
1104
|
+
SHA1withECDSA: "ecdsaWithSHA1",
|
|
1105
|
+
SHA224withECDSA: "ecdsaWithSHA224",
|
|
1106
|
+
SHA256withECDSA: "ecdsaWithSHA256",
|
|
1107
|
+
SHA384withECDSA: "ecdsaWithSHA384",
|
|
1108
|
+
SHA512withECDSA: "ecdsaWithSHA512",
|
|
1109
|
+
SHA224withRSA: "sha224WithRSAEncryption",
|
|
1110
|
+
SHA256withRSA: "sha256WithRSAEncryption",
|
|
1111
|
+
SHA384withRSA: "sha384WithRSAEncryption",
|
|
1112
|
+
SHA512withRSA: "sha512WithRSAEncryption",
|
|
1113
|
+
MD2withRSA: "md2WithRSAEncryption",
|
|
1114
|
+
MD5withRSA: "md5WithRSAEncryption",
|
|
1115
|
+
"RSA-OAEP": "id-RSAES-OAEP",
|
|
1116
|
+
"RSASSA-PKCS1-v1_5": "rsaSignature",
|
|
1117
|
+
"SHA256withRSA/PSS": "rsassa-pss",
|
|
1118
|
+
"ChaCha20-Poly1305": "chaCha20Poly1305",
|
|
1119
|
+
X448: "curveX448",
|
|
1120
|
+
PBKDF2WithHmacSHA1: "PBKDF2",
|
|
1121
|
+
PBKDF2WithHmacSHA256: "PBKDF2",
|
|
1122
|
+
PBKDF2WithHmacSHA384: "PBKDF2",
|
|
1123
|
+
PBKDF2WithHmacSHA512: "PBKDF2",
|
|
1124
|
+
PBEWithMD5AndDES: "pbeWithMD5AndDES-CBC",
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
// AES spellings resolve through the key size and mode when both are known,
|
|
1128
|
+
// and to the generic `aes` key otherwise.
|
|
1129
|
+
const SCALASEM_AES_MODE_KEYS = {
|
|
1130
|
+
ECB: "ECB",
|
|
1131
|
+
CBC: "CBC",
|
|
1132
|
+
CFB: "CFB",
|
|
1133
|
+
OFB: "OFB",
|
|
1134
|
+
CTR: "CTR",
|
|
1135
|
+
GCM: "GCM",
|
|
1136
|
+
CCM: "CCM",
|
|
1137
|
+
GMAC: "GMAC",
|
|
1138
|
+
};
|
|
1139
|
+
const SCALASEM_AES_NAMES = new Set([
|
|
1140
|
+
"AES",
|
|
1141
|
+
"AES-CBC",
|
|
1142
|
+
"AES-CTR",
|
|
1143
|
+
"AES-GCM",
|
|
1144
|
+
"AES-KW",
|
|
1145
|
+
]);
|
|
1146
|
+
|
|
1147
|
+
// Names that never resolve to an OID. Protocols, keystores and random
|
|
1148
|
+
// generators are emitted as their own asset kinds without an OID; the
|
|
1149
|
+
// algorithms listed here have no registry key and become
|
|
1150
|
+
// `cdx:scalasem:crypto:*` properties instead of assets. Ciphers whose key
|
|
1151
|
+
// depends on the mode (Blowfish, DESede, RC2) and the curve algorithms
|
|
1152
|
+
// resolve only when the finding names the mode or the curve, and are
|
|
1153
|
+
// properties otherwise.
|
|
1154
|
+
export const SCALASEM_PROTOCOL_NAMES = new Set([
|
|
1155
|
+
"SSL",
|
|
1156
|
+
"SSLv3",
|
|
1157
|
+
"TLS",
|
|
1158
|
+
"TLSv1",
|
|
1159
|
+
"TLSv1.1",
|
|
1160
|
+
"TLSv1.2",
|
|
1161
|
+
"TLSv1.3",
|
|
1162
|
+
"DTLSv1.2",
|
|
1163
|
+
]);
|
|
1164
|
+
export const SCALASEM_KEYSTORE_NAMES = new Set([
|
|
1165
|
+
"JKS",
|
|
1166
|
+
"JCEKS",
|
|
1167
|
+
"PKCS12",
|
|
1168
|
+
"BCFKS",
|
|
1169
|
+
]);
|
|
1170
|
+
// Algorithms that resolve to an OID only together with what the finding
|
|
1171
|
+
// says about them: the mode of a mode-keyed cipher, or the curve.
|
|
1172
|
+
export const SCALASEM_QUALIFIED_ALGORITHMS = new Set([
|
|
1173
|
+
"Blowfish",
|
|
1174
|
+
"DESede",
|
|
1175
|
+
"EC",
|
|
1176
|
+
"ECDH",
|
|
1177
|
+
"ECDSA",
|
|
1178
|
+
"ECIES",
|
|
1179
|
+
"EdDSA",
|
|
1180
|
+
"RC2",
|
|
1181
|
+
"XDH",
|
|
1182
|
+
]);
|
|
1183
|
+
export const SCALASEM_NO_OID_ALGORITHMS = new Set([
|
|
1184
|
+
"Argon2",
|
|
1185
|
+
"Argon2d",
|
|
1186
|
+
"Argon2i",
|
|
1187
|
+
"Argon2id",
|
|
1188
|
+
"BLAKE2b-512",
|
|
1189
|
+
"BLAKE2s-256",
|
|
1190
|
+
"CMAC",
|
|
1191
|
+
"Camellia",
|
|
1192
|
+
"ChaCha20",
|
|
1193
|
+
"DH",
|
|
1194
|
+
"DRBG",
|
|
1195
|
+
"HKDF",
|
|
1196
|
+
"HMAC",
|
|
1197
|
+
"HmacSHA3-256",
|
|
1198
|
+
"HmacSHA3-512",
|
|
1199
|
+
"MD2withDSA",
|
|
1200
|
+
"MD2withECDSA",
|
|
1201
|
+
"MD5withDSA",
|
|
1202
|
+
"MD5withECDSA",
|
|
1203
|
+
"NativePRNG",
|
|
1204
|
+
"NONEwithECDSA",
|
|
1205
|
+
"NONEwithRSA",
|
|
1206
|
+
"PBEWithHmacSHA256AndAES_128",
|
|
1207
|
+
"PBEWithHmacSHA256AndAES_256",
|
|
1208
|
+
"PBEWithMD5AndTripleDES",
|
|
1209
|
+
"PBEWithSHA1AndDESede",
|
|
1210
|
+
"PBEWithSHA1AndRC4_128",
|
|
1211
|
+
"Poly1305",
|
|
1212
|
+
"SHA-512/224",
|
|
1213
|
+
"SHA-512/256",
|
|
1214
|
+
"SHA1PRNG",
|
|
1215
|
+
"SHA3-256withECDSA",
|
|
1216
|
+
"SHA384withDSA",
|
|
1217
|
+
"SHA512withDSA",
|
|
1218
|
+
"SM3",
|
|
1219
|
+
"SM4",
|
|
1220
|
+
"Whirlpool",
|
|
1221
|
+
"Windows-PRNG",
|
|
1222
|
+
"bcrypt",
|
|
1223
|
+
"none",
|
|
1224
|
+
"scrypt",
|
|
1225
|
+
]);
|
|
1226
|
+
|
|
1227
|
+
// Ciphers whose registry key names the mode: `blowfishCBC`, `rc2ECB`.
|
|
1228
|
+
const SCALASEM_MODE_KEYED_CIPHERS = {
|
|
1229
|
+
Blowfish: (mode) => `blowfish${mode}`,
|
|
1230
|
+
DESede: (mode) => (mode === "CBC" ? "des-EDE3-CBC" : undefined),
|
|
1231
|
+
RC2: (mode) => `rc2${mode}`,
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
// NIST names of the prime curves, as the registry spells them.
|
|
1235
|
+
const SCALASEM_CURVE_ALIASES = {
|
|
1236
|
+
"p-256": "secp256r1",
|
|
1237
|
+
"p-384": "secp384r1",
|
|
1238
|
+
"p-521": "secp521r1",
|
|
1239
|
+
};
|
|
1240
|
+
const SCALASEM_WEIERSTRASS_ALGORITHMS = new Set([
|
|
1241
|
+
"EC",
|
|
1242
|
+
"ECDH",
|
|
1243
|
+
"ECDSA",
|
|
1244
|
+
"ECIES",
|
|
1245
|
+
]);
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* The registry key of a curve algorithm on a named curve. A key pair or an
|
|
1249
|
+
* ECDSA or ECDH use on a prime curve is identified by that curve. The
|
|
1250
|
+
* Montgomery curves serve key agreement only and the Edwards curves
|
|
1251
|
+
* signatures only, so a curve the algorithm cannot use resolves to nothing.
|
|
1252
|
+
*/
|
|
1253
|
+
function curveOidKey(name, curve) {
|
|
1254
|
+
const normalized = String(curve || "").toLowerCase();
|
|
1255
|
+
if (!normalized) {
|
|
1256
|
+
return undefined;
|
|
1257
|
+
}
|
|
1258
|
+
const montgomery = /^(x|curve)(25519|448)$/.exec(normalized);
|
|
1259
|
+
const edwards = /^ed(25519|448)$/.exec(normalized);
|
|
1260
|
+
if (montgomery) {
|
|
1261
|
+
return ["XDH", "ECDH"].includes(name)
|
|
1262
|
+
? SCALASEM_OID_KEYS.get(
|
|
1263
|
+
montgomery[2] === "25519" ? "x25519" : "curvex448",
|
|
1264
|
+
)
|
|
1265
|
+
: undefined;
|
|
1266
|
+
}
|
|
1267
|
+
if (edwards) {
|
|
1268
|
+
return name === "EdDSA"
|
|
1269
|
+
? SCALASEM_OID_KEYS.get(`ed${edwards[1]}`)
|
|
1270
|
+
: undefined;
|
|
1271
|
+
}
|
|
1272
|
+
if (!SCALASEM_WEIERSTRASS_ALGORITHMS.has(name)) {
|
|
1273
|
+
return undefined;
|
|
1274
|
+
}
|
|
1275
|
+
return SCALASEM_OID_KEYS.get(
|
|
1276
|
+
SCALASEM_CURVE_ALIASES[normalized] || normalized,
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* The CycloneDX padding enum value of a JCA padding name.
|
|
1282
|
+
*/
|
|
1283
|
+
function paddingOf(padding) {
|
|
1284
|
+
const normalized = String(padding || "").toLowerCase();
|
|
1285
|
+
if (!normalized) {
|
|
1286
|
+
return undefined;
|
|
1287
|
+
}
|
|
1288
|
+
if (normalized.startsWith("oaep")) {
|
|
1289
|
+
return "oaep";
|
|
1290
|
+
}
|
|
1291
|
+
return SCALASEM_PADDING[normalized] || "other";
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* The OID registry key for a canonical scalasem algorithm name, with the key
|
|
1296
|
+
* size, mode, curve or padding when the name needs one. Names that have no
|
|
1297
|
+
* key return undefined.
|
|
1298
|
+
*
|
|
1299
|
+
* @param {string} name Canonical algorithm name.
|
|
1300
|
+
* @param {number} [keySize] Key size in bits, when the report carries one.
|
|
1301
|
+
* @param {string} [mode] Block cipher mode, such as `GCM`.
|
|
1302
|
+
* @param {string} [curve] Elliptic curve name.
|
|
1303
|
+
* @param {string} [padding] JCA padding name.
|
|
1304
|
+
* @returns {string|undefined} Registry key such as `aes256-GCM`.
|
|
1305
|
+
*/
|
|
1306
|
+
export function scalasemCryptoOidKey(name, keySize, mode, curve, padding) {
|
|
1307
|
+
if (!name) {
|
|
1308
|
+
return undefined;
|
|
1309
|
+
}
|
|
1310
|
+
if (SCALASEM_AES_NAMES.has(name)) {
|
|
1311
|
+
const modeKey =
|
|
1312
|
+
SCALASEM_AES_MODE_KEYS[mode] ||
|
|
1313
|
+
SCALASEM_AES_MODE_KEYS[name.split("-")[1]];
|
|
1314
|
+
const wrap = name === "AES-KW" || mode === "KW";
|
|
1315
|
+
if (keySize && (modeKey !== undefined || wrap)) {
|
|
1316
|
+
const sizedKey = wrap ? `aes${keySize}-wrap` : `aes${keySize}-${modeKey}`;
|
|
1317
|
+
const hit = SCALASEM_OID_KEYS.get(sizedKey.toLowerCase());
|
|
1318
|
+
if (hit) {
|
|
1319
|
+
return hit;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return SCALASEM_OID_KEYS.get("aes");
|
|
1323
|
+
}
|
|
1324
|
+
if (SCALASEM_MODE_KEYED_CIPHERS[name]) {
|
|
1325
|
+
const key = mode && SCALASEM_MODE_KEYED_CIPHERS[name](mode.toUpperCase());
|
|
1326
|
+
return key ? SCALASEM_OID_KEYS.get(key.toLowerCase()) : undefined;
|
|
1327
|
+
}
|
|
1328
|
+
if (name === "RSA") {
|
|
1329
|
+
const rsaPadding = paddingOf(padding);
|
|
1330
|
+
if (rsaPadding === "oaep") {
|
|
1331
|
+
return SCALASEM_OID_KEYS.get("id-rsaes-oaep");
|
|
1332
|
+
}
|
|
1333
|
+
if (rsaPadding === "pkcs1v15") {
|
|
1334
|
+
return SCALASEM_OID_KEYS.get("rsaencryption");
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
const aliased = SCALASEM_OID_ALIASES[name];
|
|
1338
|
+
if (aliased && SCALASEM_OID_KEYS.has(aliased.toLowerCase())) {
|
|
1339
|
+
return SCALASEM_OID_KEYS.get(aliased.toLowerCase());
|
|
1340
|
+
}
|
|
1341
|
+
if (
|
|
1342
|
+
SCALASEM_WEIERSTRASS_ALGORITHMS.has(name) ||
|
|
1343
|
+
name === "XDH" ||
|
|
1344
|
+
name === "EdDSA"
|
|
1345
|
+
) {
|
|
1346
|
+
return curveOidKey(name, curve);
|
|
1347
|
+
}
|
|
1348
|
+
return SCALASEM_OID_KEYS.get(name.toLowerCase());
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function maxOccurrences() {
|
|
1352
|
+
const parsed = Number.parseInt(
|
|
1353
|
+
String(readEnvironmentVariable("CDXGEN_SCALASEM_MAX_OCCURRENCES") ?? ""),
|
|
1354
|
+
10,
|
|
1355
|
+
);
|
|
1356
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
1357
|
+
? parsed
|
|
1358
|
+
: DEFAULT_MAX_OCCURRENCES;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function isStdlibPurl(purl) {
|
|
1362
|
+
return SCALA_STDLIB_ARTIFACTS.has(
|
|
1363
|
+
parseScalaArtifact(purlPurlName(purl) || "").artifactBase,
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* The component purls each module's own classpath names, so a file compiled
|
|
1369
|
+
* against one version of a library attributes its uses to that version.
|
|
1370
|
+
*/
|
|
1371
|
+
function classpathPurlsByModule(report, coordinateIndex) {
|
|
1372
|
+
const byModule = new Map();
|
|
1373
|
+
for (const module of report.modules || []) {
|
|
1374
|
+
const purls = new Set();
|
|
1375
|
+
for (const entry of module.classpath || []) {
|
|
1376
|
+
const matched = matchClasspathEntry(
|
|
1377
|
+
entry,
|
|
1378
|
+
coordinateIndex,
|
|
1379
|
+
module.platform,
|
|
1380
|
+
);
|
|
1381
|
+
if (matched) {
|
|
1382
|
+
purls.add(matched.component.purl);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
byModule.set(module.id, purls);
|
|
1386
|
+
}
|
|
1387
|
+
return byModule;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function allowedPurlsFor(fileEntry, allowedPurlsByModule) {
|
|
1391
|
+
if (!fileEntry?.module) {
|
|
1392
|
+
return undefined;
|
|
1393
|
+
}
|
|
1394
|
+
return allowedPurlsByModule.get(fileEntry.module);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Project a scalasem report onto CycloneDX evidence: occurrences for the
|
|
1399
|
+
* joined components, call stacks for the library sinks, namespaces for the
|
|
1400
|
+
* components the classpath join read, and the run's metadata properties.
|
|
1401
|
+
*
|
|
1402
|
+
* @param {Object} report Parsed scalasem report.
|
|
1403
|
+
* @param {Object[]} components BOM components.
|
|
1404
|
+
* @param {Object} options CLI options; `scalaNamespaceMap` carries the jar
|
|
1405
|
+
* namespace map cdxgen wrote beside the BOM.
|
|
1406
|
+
* @returns {Promise<Object>} The evidence maps `createEvinseFile` consumes.
|
|
1407
|
+
*/
|
|
1408
|
+
export async function collectScalasemEvidence(
|
|
1409
|
+
report = {},
|
|
1410
|
+
components = [],
|
|
1411
|
+
options = {},
|
|
1412
|
+
) {
|
|
1413
|
+
const joinIndex = await buildScalaJoinIndex(
|
|
1414
|
+
report,
|
|
1415
|
+
components,
|
|
1416
|
+
options.scalaNamespaceMap || {},
|
|
1417
|
+
);
|
|
1418
|
+
const purlLocationMap = {};
|
|
1419
|
+
const componentPropertiesMap = {};
|
|
1420
|
+
const dataFlowFrames = {};
|
|
1421
|
+
const metadataProperties = scalasemMetadataProperties(report);
|
|
1422
|
+
const cryptoComponentsByRef = new Map();
|
|
1423
|
+
const cryptoGeneratePurls = {};
|
|
1424
|
+
if (options.deep || options.scalasemNamespaces) {
|
|
1425
|
+
attachJoinedNamespaces(joinIndex, components, componentPropertiesMap);
|
|
1426
|
+
}
|
|
1427
|
+
const allowedPurlsByModule = classpathPurlsByModule(
|
|
1428
|
+
report,
|
|
1429
|
+
componentCoordinateIndex(components),
|
|
1430
|
+
);
|
|
1431
|
+
joinIndex.classpathPurls = new Set(
|
|
1432
|
+
[...allowedPurlsByModule.values()].flatMap((purls) => [...purls]),
|
|
1433
|
+
);
|
|
1434
|
+
addUsageEvidence(
|
|
1435
|
+
report,
|
|
1436
|
+
joinIndex,
|
|
1437
|
+
purlLocationMap,
|
|
1438
|
+
componentPropertiesMap,
|
|
1439
|
+
options,
|
|
1440
|
+
allowedPurlsByModule,
|
|
1441
|
+
);
|
|
1442
|
+
addCallStackEvidence(
|
|
1443
|
+
report,
|
|
1444
|
+
joinIndex,
|
|
1445
|
+
dataFlowFrames,
|
|
1446
|
+
allowedPurlsByModule,
|
|
1447
|
+
options,
|
|
1448
|
+
);
|
|
1449
|
+
addCryptoEvidence(
|
|
1450
|
+
report,
|
|
1451
|
+
components,
|
|
1452
|
+
cryptoComponentsByRef,
|
|
1453
|
+
cryptoGeneratePurls,
|
|
1454
|
+
componentPropertiesMap,
|
|
1455
|
+
metadataProperties,
|
|
1456
|
+
options,
|
|
1457
|
+
);
|
|
1458
|
+
addJsModuleEvidence(
|
|
1459
|
+
report,
|
|
1460
|
+
components,
|
|
1461
|
+
purlLocationMap,
|
|
1462
|
+
componentPropertiesMap,
|
|
1463
|
+
metadataProperties,
|
|
1464
|
+
);
|
|
1465
|
+
return {
|
|
1466
|
+
componentPropertiesMap,
|
|
1467
|
+
cryptoComponents: Array.from(cryptoComponentsByRef.values()).sort(
|
|
1468
|
+
(left, right) =>
|
|
1469
|
+
`${left.name}:${left["bom-ref"]}`.localeCompare(
|
|
1470
|
+
`${right.name}:${right["bom-ref"]}`,
|
|
1471
|
+
),
|
|
1472
|
+
),
|
|
1473
|
+
cryptoGeneratePurls,
|
|
1474
|
+
dataFlowFrames,
|
|
1475
|
+
joinIndex,
|
|
1476
|
+
metadataProperties,
|
|
1477
|
+
purlLocationMap,
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* With --deep, publish the namespaces the classpath join read as
|
|
1483
|
+
* `internal:Namespaces` properties, the property the deep Maven flow
|
|
1484
|
+
* attaches, so components from builds that report no namespaces gain them.
|
|
1485
|
+
* Only a jar of the component's own version speaks for its classes.
|
|
1486
|
+
*/
|
|
1487
|
+
function attachJoinedNamespaces(joinIndex, components, componentPropertiesMap) {
|
|
1488
|
+
const known = new Set(
|
|
1489
|
+
components
|
|
1490
|
+
.filter((component) =>
|
|
1491
|
+
(component.properties || []).some(
|
|
1492
|
+
(property) => property.name === "internal:Namespaces",
|
|
1493
|
+
),
|
|
1494
|
+
)
|
|
1495
|
+
.map((component) => component.purl),
|
|
1496
|
+
);
|
|
1497
|
+
for (const [purl, source] of joinIndex.purlsBySource.entries()) {
|
|
1498
|
+
if (source !== 1 || known.has(purl) || !joinIndex.exactPurls?.has(purl)) {
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
const namespaces = joinIndex.namespacesByPurl.get(purl) || [];
|
|
1502
|
+
if (namespaces.length) {
|
|
1503
|
+
componentPropertiesMap[purl] ??= [];
|
|
1504
|
+
appendUniqueProperty(
|
|
1505
|
+
componentPropertiesMap[purl],
|
|
1506
|
+
"internal:Namespaces",
|
|
1507
|
+
namespaces.join("\n"),
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Occurrences from the report's references and calls: every symbol and call
|
|
1515
|
+
* owner joined to a component records its source location. Test sources count
|
|
1516
|
+
* only when they were asked for, the Scala runtime is capped at one
|
|
1517
|
+
* occurrence per file, and each component keeps at most a bounded number of
|
|
1518
|
+
* locations, every file it is used in first and further lines after. Call
|
|
1519
|
+
* sites are counted in full, whatever the cap kept.
|
|
1520
|
+
*/
|
|
1521
|
+
function addUsageEvidence(
|
|
1522
|
+
report,
|
|
1523
|
+
joinIndex,
|
|
1524
|
+
purlLocationMap,
|
|
1525
|
+
componentPropertiesMap,
|
|
1526
|
+
options,
|
|
1527
|
+
allowedPurlsByModule,
|
|
1528
|
+
) {
|
|
1529
|
+
const includeTests = options.scalasemIncludeTests === true;
|
|
1530
|
+
const cap = maxOccurrences();
|
|
1531
|
+
const scopesByPurl = {};
|
|
1532
|
+
const platformsByPurl = {};
|
|
1533
|
+
const callSitesByPurl = {};
|
|
1534
|
+
const stdlibFilesByPurl = {};
|
|
1535
|
+
const linesByPurl = new Map();
|
|
1536
|
+
const addOccurrence = (purl, file, line, kind, scope, platform) => {
|
|
1537
|
+
if (!purl || !line) {
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
addSetValue(scopesByPurl, purl, scope);
|
|
1541
|
+
addSetValue(platformsByPurl, purl, platform);
|
|
1542
|
+
if (kind === "call") {
|
|
1543
|
+
callSitesByPurl[purl] = (callSitesByPurl[purl] || 0) + 1;
|
|
1544
|
+
}
|
|
1545
|
+
if (isStdlibPurl(purl)) {
|
|
1546
|
+
stdlibFilesByPurl[purl] ??= new Set();
|
|
1547
|
+
if (stdlibFilesByPurl[purl].has(file)) {
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
stdlibFilesByPurl[purl].add(file);
|
|
1551
|
+
}
|
|
1552
|
+
if (!linesByPurl.has(purl)) {
|
|
1553
|
+
linesByPurl.set(purl, new Map());
|
|
1554
|
+
}
|
|
1555
|
+
const byFile = linesByPurl.get(purl);
|
|
1556
|
+
if (!byFile.has(file)) {
|
|
1557
|
+
byFile.set(file, new Set());
|
|
1558
|
+
}
|
|
1559
|
+
byFile.get(file).add(line);
|
|
1560
|
+
};
|
|
1561
|
+
for (const [fileKey, fileEntry] of Object.entries(report)) {
|
|
1562
|
+
if (
|
|
1563
|
+
!isReportFileKey(fileKey) ||
|
|
1564
|
+
!fileEntry ||
|
|
1565
|
+
typeof fileEntry !== "object"
|
|
1566
|
+
) {
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
if (fileEntry.scope === "test" && !includeTests) {
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
const platform = fileEntry.platform || (fileEntry.platforms || [])[0];
|
|
1573
|
+
const allowed = allowedPurlsFor(fileEntry, allowedPurlsByModule);
|
|
1574
|
+
const scope = fileEntry.scope || "main";
|
|
1575
|
+
for (const reference of fileEntry.references || []) {
|
|
1576
|
+
addOccurrence(
|
|
1577
|
+
joinIndex.lookup(reference.symbol, platform, allowed),
|
|
1578
|
+
fileKey,
|
|
1579
|
+
reference.line,
|
|
1580
|
+
"reference",
|
|
1581
|
+
scope,
|
|
1582
|
+
platform,
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
for (const call of fileEntry.calls || []) {
|
|
1586
|
+
addOccurrence(
|
|
1587
|
+
joinIndex.lookup(call.owner, platform, allowed),
|
|
1588
|
+
fileKey,
|
|
1589
|
+
call.line,
|
|
1590
|
+
"call",
|
|
1591
|
+
scope,
|
|
1592
|
+
platform,
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
for (const [purl, byFile] of linesByPurl) {
|
|
1597
|
+
const locations = (purlLocationMap[purl] ??= new Set());
|
|
1598
|
+
const files = [...byFile.entries()].map(([file, lines]) => [
|
|
1599
|
+
file,
|
|
1600
|
+
[...lines].sort((left, right) => left - right),
|
|
1601
|
+
]);
|
|
1602
|
+
// Every file first, then the further lines of each file in turn.
|
|
1603
|
+
for (let depth = 0; locations.size < cap; depth++) {
|
|
1604
|
+
let added = false;
|
|
1605
|
+
for (const [file, lines] of files) {
|
|
1606
|
+
if (depth < lines.length && locations.size < cap) {
|
|
1607
|
+
locations.add(`${file}#${lines[depth]}`);
|
|
1608
|
+
added = true;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (!added) {
|
|
1612
|
+
break;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
for (const purl of Object.keys(scopesByPurl)) {
|
|
1617
|
+
addPropertyValue(
|
|
1618
|
+
componentPropertiesMap,
|
|
1619
|
+
purl,
|
|
1620
|
+
"cdx:scalasem:usageScopes",
|
|
1621
|
+
sortedCsv(scopesByPurl[purl]),
|
|
1622
|
+
);
|
|
1623
|
+
addPropertyValue(
|
|
1624
|
+
componentPropertiesMap,
|
|
1625
|
+
purl,
|
|
1626
|
+
"cdx:scalasem:platforms",
|
|
1627
|
+
sortedCsv(platformsByPurl[purl]),
|
|
1628
|
+
);
|
|
1629
|
+
if (callSitesByPurl[purl]) {
|
|
1630
|
+
addPropertyValue(
|
|
1631
|
+
componentPropertiesMap,
|
|
1632
|
+
purl,
|
|
1633
|
+
"cdx:scalasem:callSites",
|
|
1634
|
+
callSitesByPurl[purl],
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Call stacks: each stack the report found becomes one frame list on the
|
|
1642
|
+
* components of the library it ends in, with the entry point first and the
|
|
1643
|
+
* library call last. CycloneDX holds a single call stack per component, and
|
|
1644
|
+
* `framePicker` chooses which of the stacks a component collected to publish.
|
|
1645
|
+
* A library called on the sink's line is reached by the stack too, but a
|
|
1646
|
+
* stack that ends in a call to the component itself is the better evidence:
|
|
1647
|
+
* such a component is offered only its own stacks. Stacks that differ only in
|
|
1648
|
+
* which call of one method into the library they end at are one path; the
|
|
1649
|
+
* method's last call stands for it.
|
|
1650
|
+
*/
|
|
1651
|
+
function addCallStackEvidence(
|
|
1652
|
+
report,
|
|
1653
|
+
joinIndex,
|
|
1654
|
+
dataFlowFrames,
|
|
1655
|
+
allowedPurlsByModule,
|
|
1656
|
+
options = {},
|
|
1657
|
+
) {
|
|
1658
|
+
const includeTests = options.scalasemIncludeTests === true;
|
|
1659
|
+
const ownStacks = {};
|
|
1660
|
+
const reachedStacks = {};
|
|
1661
|
+
for (const stack of report.callStacks || []) {
|
|
1662
|
+
const sink = stack.sink;
|
|
1663
|
+
if (!sink?.owner) {
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
const fileEntry = report[sink.file];
|
|
1667
|
+
if (fileEntry?.scope === "test" && !includeTests) {
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
const platform = fileEntry?.platform || (fileEntry?.platforms || [])[0];
|
|
1671
|
+
const allowed = allowedPurlsFor(fileEntry, allowedPurlsByModule);
|
|
1672
|
+
const sinkPurls = new Set(
|
|
1673
|
+
joinIndex.lookupAll(sink.owner, platform, allowed),
|
|
1674
|
+
);
|
|
1675
|
+
const purls = stackTargetPurls(
|
|
1676
|
+
sink,
|
|
1677
|
+
fileEntry,
|
|
1678
|
+
joinIndex,
|
|
1679
|
+
platform,
|
|
1680
|
+
allowed,
|
|
1681
|
+
);
|
|
1682
|
+
if (!purls.length) {
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
const frames = (stack.frames || [])
|
|
1686
|
+
.filter((frame) => frame?.file)
|
|
1687
|
+
.map((frame) => stackFrame(report, frame));
|
|
1688
|
+
const sinkCovered = frames.some(
|
|
1689
|
+
(frame) => frame.fullFilename === sink.file && frame.line === sink.line,
|
|
1690
|
+
);
|
|
1691
|
+
if (!sinkCovered) {
|
|
1692
|
+
frames.push({
|
|
1693
|
+
package: packageOf(sink.owner),
|
|
1694
|
+
module: sink.owner,
|
|
1695
|
+
function: sink.name || "",
|
|
1696
|
+
line: sink.line,
|
|
1697
|
+
column: undefined,
|
|
1698
|
+
fullFilename: sink.file,
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
for (const purl of purls) {
|
|
1702
|
+
const target = sinkPurls.has(purl) ? ownStacks : reachedStacks;
|
|
1703
|
+
target[purl] ??= [];
|
|
1704
|
+
target[purl].push(frames);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
for (const purl of new Set([
|
|
1708
|
+
...Object.keys(ownStacks),
|
|
1709
|
+
...Object.keys(reachedStacks),
|
|
1710
|
+
])) {
|
|
1711
|
+
dataFlowFrames[purl] = onePerPath(ownStacks[purl] || reachedStacks[purl]);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function onePerPath(stacks) {
|
|
1716
|
+
const byPath = new Map();
|
|
1717
|
+
for (const frames of stacks) {
|
|
1718
|
+
const last = frames[frames.length - 1];
|
|
1719
|
+
const key = [
|
|
1720
|
+
...frames
|
|
1721
|
+
.slice(0, -1)
|
|
1722
|
+
.map((frame) => `${frame.fullFilename}#${frame.line}`),
|
|
1723
|
+
`${last.fullFilename}#${last.module}.${last.function}`,
|
|
1724
|
+
].join(">");
|
|
1725
|
+
const known = byPath.get(key);
|
|
1726
|
+
if (!known || (known[known.length - 1].line || 0) < (last.line || 0)) {
|
|
1727
|
+
byPath.set(key, frames);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
return [...byPath.values()];
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* One call stack frame in the shape `framePicker` reads. The report names the
|
|
1735
|
+
* method; the definition that encloses the frame's line names its class.
|
|
1736
|
+
*/
|
|
1737
|
+
function stackFrame(report, frame) {
|
|
1738
|
+
const owner = enclosingOwner(report[frame.file], frame);
|
|
1739
|
+
return {
|
|
1740
|
+
package: owner ? packageOf(owner) : "",
|
|
1741
|
+
module: owner || "",
|
|
1742
|
+
function: frame.function || "",
|
|
1743
|
+
line: frame.line || undefined,
|
|
1744
|
+
column: frame.column || undefined,
|
|
1745
|
+
fullFilename: frame.file,
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function enclosingOwner(fileEntry, frame) {
|
|
1750
|
+
let best;
|
|
1751
|
+
for (const definition of fileEntry?.definitions || []) {
|
|
1752
|
+
if (
|
|
1753
|
+
definition?.kind !== "def" ||
|
|
1754
|
+
definition.name !== frame.function ||
|
|
1755
|
+
!definition.owner ||
|
|
1756
|
+
definition.line > frame.line ||
|
|
1757
|
+
(definition.endLine || definition.line) < frame.line
|
|
1758
|
+
) {
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
if (!best || definition.line > best.line) {
|
|
1762
|
+
best = definition;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return best?.owner;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* The components a stack is evidence for: the jars that ship the sink class,
|
|
1770
|
+
* plus the libraries called on the sink's own line, which is the terminal hop
|
|
1771
|
+
* the sink call came through. The Scala runtime is ambient and never a
|
|
1772
|
+
* target.
|
|
1773
|
+
*/
|
|
1774
|
+
function stackTargetPurls(sink, fileEntry, joinIndex, platform, allowed) {
|
|
1775
|
+
const purls = new Set(joinIndex.lookupAll(sink.owner, platform, allowed));
|
|
1776
|
+
for (const call of fileEntry?.calls || []) {
|
|
1777
|
+
if (call.line !== sink.line || !call.owner) {
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
for (const purl of joinIndex.lookupAll(call.owner, platform, allowed)) {
|
|
1781
|
+
if (!isStdlibPurl(purl)) {
|
|
1782
|
+
purls.add(purl);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
return [...purls];
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function packageOf(className) {
|
|
1790
|
+
const text = String(className || "").replace(/\$+$/, "");
|
|
1791
|
+
const dot = text.lastIndexOf(".");
|
|
1792
|
+
return dot > 0 ? text.slice(0, dot) : "";
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
const SCALASEM_CRYPTO_PRIMITIVES = new Set([
|
|
1796
|
+
"drbg",
|
|
1797
|
+
"mac",
|
|
1798
|
+
"block-cipher",
|
|
1799
|
+
"stream-cipher",
|
|
1800
|
+
"signature",
|
|
1801
|
+
"hash",
|
|
1802
|
+
"pke",
|
|
1803
|
+
"xof",
|
|
1804
|
+
"kdf",
|
|
1805
|
+
"key-agree",
|
|
1806
|
+
"kem",
|
|
1807
|
+
"ae",
|
|
1808
|
+
"combiner",
|
|
1809
|
+
"key-wrap",
|
|
1810
|
+
"other",
|
|
1811
|
+
"unknown",
|
|
1812
|
+
]);
|
|
1813
|
+
|
|
1814
|
+
const SCALASEM_MODES = new Set([
|
|
1815
|
+
"cbc",
|
|
1816
|
+
"ecb",
|
|
1817
|
+
"ccm",
|
|
1818
|
+
"gcm",
|
|
1819
|
+
"cfb",
|
|
1820
|
+
"ofb",
|
|
1821
|
+
"ctr",
|
|
1822
|
+
]);
|
|
1823
|
+
|
|
1824
|
+
const SCALASEM_PADDING = {
|
|
1825
|
+
nopadding: "raw",
|
|
1826
|
+
pkcs5padding: "pkcs5",
|
|
1827
|
+
pkcs7padding: "pkcs7",
|
|
1828
|
+
pkcs1padding: "pkcs1v15",
|
|
1829
|
+
oaepwithsha1andmgf1: "oaep",
|
|
1830
|
+
ssl3padding: "other",
|
|
1831
|
+
iso10126padding: "other",
|
|
1832
|
+
};
|
|
1833
|
+
|
|
1834
|
+
// The spellings one purl answers to: sbt components carry the encoded purl and
|
|
1835
|
+
// the decoded bom-ref, and dependency refs use either.
|
|
1836
|
+
function providerAliases(purl, components = []) {
|
|
1837
|
+
if (!purl) {
|
|
1838
|
+
return [];
|
|
1839
|
+
}
|
|
1840
|
+
const component = components.find((candidate) => candidate.purl === purl);
|
|
1841
|
+
return component?.["bom-ref"] && component["bom-ref"] !== purl
|
|
1842
|
+
? [component["bom-ref"]]
|
|
1843
|
+
: [];
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* The component purl of the library a crypto finding names as its provider.
|
|
1848
|
+
* Maven coordinates match on group and base artifact; the short aliases cover
|
|
1849
|
+
* the providers the rules emit for libraries without a coordinate.
|
|
1850
|
+
*/
|
|
1851
|
+
export function scalasemProviderPurl(provider, components = []) {
|
|
1852
|
+
if (!provider) {
|
|
1853
|
+
return undefined;
|
|
1854
|
+
}
|
|
1855
|
+
let group;
|
|
1856
|
+
let artifactPrefix;
|
|
1857
|
+
if (provider === "bcprov") {
|
|
1858
|
+
group = "org.bouncycastle";
|
|
1859
|
+
artifactPrefix = "bcprov";
|
|
1860
|
+
} else if (provider.includes(":")) {
|
|
1861
|
+
[group, artifactPrefix] = provider.split(":");
|
|
1862
|
+
} else {
|
|
1863
|
+
return undefined;
|
|
1864
|
+
}
|
|
1865
|
+
const base = parseScalaArtifact(artifactPrefix).artifactBase;
|
|
1866
|
+
for (const component of components) {
|
|
1867
|
+
if (!component?.purl) {
|
|
1868
|
+
continue;
|
|
1869
|
+
}
|
|
1870
|
+
const segments = /^pkg:[^/]+\/(?:([^/@?]+)\/)?([^@?]+)/.exec(
|
|
1871
|
+
component.purl,
|
|
1872
|
+
);
|
|
1873
|
+
if (!segments || decodeSafe(segments[1]) !== group) {
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
const name = parseScalaArtifact(decodeSafe(segments[2])).artifactBase;
|
|
1877
|
+
if (name === base || name.startsWith(`${base}-`)) {
|
|
1878
|
+
return component.purl;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
return undefined;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// Primitives a mode of operation applies to. RSA spells `RSA/ECB/...` in JCA,
|
|
1885
|
+
// but a public key cipher has no mode, and calling it ECB would read as the
|
|
1886
|
+
// weak block cipher mode.
|
|
1887
|
+
const SCALASEM_MODE_PRIMITIVES = new Set(["block-cipher", "ae"]);
|
|
1888
|
+
|
|
1889
|
+
function cryptoModeOf(finding) {
|
|
1890
|
+
const mode = String(finding.mode || "").toLowerCase();
|
|
1891
|
+
return mode &&
|
|
1892
|
+
SCALASEM_MODES.has(mode) &&
|
|
1893
|
+
SCALASEM_MODE_PRIMITIVES.has(finding.primitive)
|
|
1894
|
+
? mode
|
|
1895
|
+
: undefined;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* The asset name: the algorithm with its key size, mode and padding, so one
|
|
1900
|
+
* cipher used two ways is two assets, and an AES name that carries its mode
|
|
1901
|
+
* (`AES-GCM`) reads the same as AES with that mode.
|
|
1902
|
+
*/
|
|
1903
|
+
function cryptoAssetDisplayName(finding) {
|
|
1904
|
+
let base = finding.algorithm;
|
|
1905
|
+
let mode = cryptoModeOf(finding)?.toUpperCase();
|
|
1906
|
+
if (SCALASEM_AES_NAMES.has(base) && base.includes("-")) {
|
|
1907
|
+
[base] = base.split("-");
|
|
1908
|
+
mode ||= finding.algorithm.split("-")[1];
|
|
1909
|
+
}
|
|
1910
|
+
const parts = [base];
|
|
1911
|
+
if (finding.keySize) {
|
|
1912
|
+
parts.push(String(finding.keySize));
|
|
1913
|
+
}
|
|
1914
|
+
if (mode) {
|
|
1915
|
+
parts.push(mode);
|
|
1916
|
+
}
|
|
1917
|
+
const padding = paddingOf(finding.padding);
|
|
1918
|
+
if (finding.primitive === "pke" && padding && padding !== "raw") {
|
|
1919
|
+
parts.push(padding.toUpperCase());
|
|
1920
|
+
}
|
|
1921
|
+
return parts.join("-");
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function cryptoAlgorithmProperties(finding) {
|
|
1925
|
+
const properties = {};
|
|
1926
|
+
if (finding.primitive) {
|
|
1927
|
+
const primitive = finding.primitive === "rng" ? "drbg" : finding.primitive;
|
|
1928
|
+
properties.primitive = SCALASEM_CRYPTO_PRIMITIVES.has(primitive)
|
|
1929
|
+
? primitive
|
|
1930
|
+
: "other";
|
|
1931
|
+
}
|
|
1932
|
+
const mode = cryptoModeOf(finding);
|
|
1933
|
+
if (mode) {
|
|
1934
|
+
properties.mode = mode;
|
|
1935
|
+
}
|
|
1936
|
+
const padding = paddingOf(finding.padding);
|
|
1937
|
+
if (padding) {
|
|
1938
|
+
properties.padding = padding;
|
|
1939
|
+
}
|
|
1940
|
+
if (finding.keySize) {
|
|
1941
|
+
properties.parameterSetIdentifier = String(finding.keySize);
|
|
1942
|
+
}
|
|
1943
|
+
return properties;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function cryptoBomRef(kind, name, identifier) {
|
|
1947
|
+
return `crypto/${kind}/${encodeURIComponent(name)}@${encodeURIComponent(identifier || name)}`;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Crypto findings become CycloneDX crypto assets. Algorithms resolve through
|
|
1952
|
+
* the OID alias table and are skipped without one, protocols and keystores
|
|
1953
|
+
* are their own asset kinds, and everything the registry cannot name becomes
|
|
1954
|
+
* a `cdx:scalasem:crypto:*` property on the providing component instead of an
|
|
1955
|
+
* asset with an invented identifier. A finding the report could not resolve
|
|
1956
|
+
* never becomes an asset.
|
|
1957
|
+
*/
|
|
1958
|
+
function addCryptoEvidence(
|
|
1959
|
+
report,
|
|
1960
|
+
components,
|
|
1961
|
+
cryptoComponentsByRef,
|
|
1962
|
+
cryptoGeneratePurls,
|
|
1963
|
+
componentPropertiesMap,
|
|
1964
|
+
metadataProperties,
|
|
1965
|
+
options = {},
|
|
1966
|
+
) {
|
|
1967
|
+
const includeTests = options.scalasemIncludeTests === true;
|
|
1968
|
+
const nativeLibraryBySymbol = new Map();
|
|
1969
|
+
for (const binding of report.nativeBindings || []) {
|
|
1970
|
+
if (binding.library && binding.symbol) {
|
|
1971
|
+
nativeLibraryBySymbol.set(binding.symbol, binding.library);
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
const assetOccurrencesByRef = new Map();
|
|
1975
|
+
for (const finding of report.crypto || []) {
|
|
1976
|
+
const scope = report[finding.file]?.scope || "main";
|
|
1977
|
+
if (scope === "test" && !includeTests) {
|
|
1978
|
+
continue;
|
|
1979
|
+
}
|
|
1980
|
+
const location = `${finding.file || ""}#${finding.line || 0}`;
|
|
1981
|
+
const nativeLibrary = finding.kind?.startsWith("native")
|
|
1982
|
+
? nativeLibraryBySymbol.get(finding.name)
|
|
1983
|
+
: undefined;
|
|
1984
|
+
const providerPurl = scalasemProviderPurl(finding.provider, components);
|
|
1985
|
+
const asset =
|
|
1986
|
+
finding.resolution === "unresolved"
|
|
1987
|
+
? undefined
|
|
1988
|
+
: cryptoAssetFor(finding, options);
|
|
1989
|
+
if (asset) {
|
|
1990
|
+
const existing = cryptoComponentsByRef.get(asset["bom-ref"]);
|
|
1991
|
+
const target = existing || asset;
|
|
1992
|
+
if (!existing) {
|
|
1993
|
+
cryptoComponentsByRef.set(asset["bom-ref"], asset);
|
|
1994
|
+
}
|
|
1995
|
+
for (const property of cryptoAssetProperties(finding, nativeLibrary)) {
|
|
1996
|
+
appendUniqueProperty(target.properties, property.name, property.value);
|
|
1997
|
+
}
|
|
1998
|
+
appendUniqueProperty(
|
|
1999
|
+
target.properties,
|
|
2000
|
+
"cdx:scalasem:usageScopes",
|
|
2001
|
+
scope,
|
|
2002
|
+
);
|
|
2003
|
+
const occurrences = assetOccurrencesByRef.get(asset["bom-ref"]) || [];
|
|
2004
|
+
if (!occurrences.includes(location)) {
|
|
2005
|
+
occurrences.push(location);
|
|
2006
|
+
}
|
|
2007
|
+
assetOccurrencesByRef.set(asset["bom-ref"], occurrences);
|
|
2008
|
+
if (providerPurl) {
|
|
2009
|
+
cryptoGeneratePurls[providerPurl] ??= new Set();
|
|
2010
|
+
cryptoGeneratePurls[providerPurl].add(asset["bom-ref"]);
|
|
2011
|
+
addPropertyValue(
|
|
2012
|
+
componentPropertiesMap,
|
|
2013
|
+
providerPurl,
|
|
2014
|
+
"cdx:scalasem:cryptoAlgorithms",
|
|
2015
|
+
finding.algorithm,
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
providerAliases(providerPurl, components).forEach((alias) => {
|
|
2019
|
+
cryptoGeneratePurls[alias] ??= new Set();
|
|
2020
|
+
cryptoGeneratePurls[alias].add(asset["bom-ref"]);
|
|
2021
|
+
});
|
|
2022
|
+
} else if (finding.algorithm || finding.name) {
|
|
2023
|
+
// No registry OID: the finding stays visible as a property, and a weak
|
|
2024
|
+
// one says so, since no asset carries the verdict.
|
|
2025
|
+
const name = finding.algorithm || finding.name;
|
|
2026
|
+
const value = `${name}@${location}`;
|
|
2027
|
+
const record = (propertyName) => {
|
|
2028
|
+
if (providerPurl) {
|
|
2029
|
+
addPropertyValue(
|
|
2030
|
+
componentPropertiesMap,
|
|
2031
|
+
providerPurl,
|
|
2032
|
+
propertyName,
|
|
2033
|
+
value,
|
|
2034
|
+
);
|
|
2035
|
+
} else {
|
|
2036
|
+
appendUniqueProperty(metadataProperties, propertyName, value);
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
record(`cdx:scalasem:crypto:${finding.kind}`);
|
|
2040
|
+
if (finding.weak) {
|
|
2041
|
+
record("cdx:scalasem:crypto:weakFinding");
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
for (const [ref, asset] of cryptoComponentsByRef.entries()) {
|
|
2046
|
+
asset.evidence = {
|
|
2047
|
+
occurrences: assetOccurrencesByRef
|
|
2048
|
+
.get(ref)
|
|
2049
|
+
.sort()
|
|
2050
|
+
.map((location) => parseLocationString(location)),
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function parseLocationString(location) {
|
|
2056
|
+
const separator = location.lastIndexOf("#");
|
|
2057
|
+
const line = Number.parseInt(location.slice(separator + 1), 10);
|
|
2058
|
+
return {
|
|
2059
|
+
location: location.slice(0, separator),
|
|
2060
|
+
...(Number.isFinite(line) && line > 0 ? { line } : {}),
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// Protocol names as CycloneDX types and versions. SSL predates TLS and has
|
|
2065
|
+
// no type of its own, and the bare names carry no version.
|
|
2066
|
+
const SCALASEM_PROTOCOLS = {
|
|
2067
|
+
SSL: { type: "other" },
|
|
2068
|
+
SSLv3: { type: "other", version: "3.0" },
|
|
2069
|
+
TLS: { type: "tls" },
|
|
2070
|
+
TLSv1: { type: "tls", version: "1.0" },
|
|
2071
|
+
"TLSv1.1": { type: "tls", version: "1.1" },
|
|
2072
|
+
"TLSv1.2": { type: "tls", version: "1.2" },
|
|
2073
|
+
"TLSv1.3": { type: "tls", version: "1.3" },
|
|
2074
|
+
"DTLSv1.2": { type: "dtls", version: "1.2" },
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2077
|
+
function cryptoAssetFor(finding) {
|
|
2078
|
+
const name = finding.algorithm || finding.name;
|
|
2079
|
+
if (!name) {
|
|
2080
|
+
return undefined;
|
|
2081
|
+
}
|
|
2082
|
+
if (SCALASEM_PROTOCOL_NAMES.has(name) || finding.kind === "protocol") {
|
|
2083
|
+
const protocol = SCALASEM_PROTOCOLS[name] || { type: "other" };
|
|
2084
|
+
return {
|
|
2085
|
+
type: "cryptographic-asset",
|
|
2086
|
+
name,
|
|
2087
|
+
"bom-ref": cryptoBomRef("protocol", name),
|
|
2088
|
+
description:
|
|
2089
|
+
"Cryptographic protocol detected by scalasem source analysis",
|
|
2090
|
+
cryptoProperties: {
|
|
2091
|
+
assetType: "protocol",
|
|
2092
|
+
protocolProperties: { ...protocol },
|
|
2093
|
+
},
|
|
2094
|
+
properties: [],
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
if (SCALASEM_KEYSTORE_NAMES.has(name) || finding.kind === "keystore") {
|
|
2098
|
+
return {
|
|
2099
|
+
type: "cryptographic-asset",
|
|
2100
|
+
name,
|
|
2101
|
+
"bom-ref": cryptoBomRef("keystore", name),
|
|
2102
|
+
description: "Key store detected by scalasem source analysis",
|
|
2103
|
+
cryptoProperties: {
|
|
2104
|
+
assetType: "related-crypto-material",
|
|
2105
|
+
relatedCryptoMaterialProperties: { type: "other" },
|
|
2106
|
+
},
|
|
2107
|
+
properties: [],
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
const oidKey = scalasemCryptoOidKey(
|
|
2111
|
+
name,
|
|
2112
|
+
finding.keySize,
|
|
2113
|
+
finding.mode,
|
|
2114
|
+
finding.curve,
|
|
2115
|
+
finding.padding,
|
|
2116
|
+
);
|
|
2117
|
+
const oid = oidKey ? SCALASEM_CRYPTO_OIDS[oidKey]?.oid : undefined;
|
|
2118
|
+
if (!oid) {
|
|
2119
|
+
return undefined;
|
|
2120
|
+
}
|
|
2121
|
+
const algorithmProperties = cryptoAlgorithmProperties(finding);
|
|
2122
|
+
const displayName = cryptoAssetDisplayName(finding);
|
|
2123
|
+
// A native finding's bom-ref carries the C function it resolves to, so the
|
|
2124
|
+
// asset stays distinct per binding and findable by the function name.
|
|
2125
|
+
const nativeSuffix =
|
|
2126
|
+
finding.kind?.startsWith("native") && finding.name
|
|
2127
|
+
? `#${finding.name}`
|
|
2128
|
+
: "";
|
|
2129
|
+
const component = {
|
|
2130
|
+
type: "cryptographic-asset",
|
|
2131
|
+
name: displayName,
|
|
2132
|
+
"bom-ref": `${cryptoBomRef("algorithm", displayName, oid)}${nativeSuffix}`,
|
|
2133
|
+
description: "Cryptographic algorithm detected by scalasem source analysis",
|
|
2134
|
+
cryptoProperties: {
|
|
2135
|
+
assetType: "algorithm",
|
|
2136
|
+
oid,
|
|
2137
|
+
algorithmProperties,
|
|
2138
|
+
},
|
|
2139
|
+
properties: [],
|
|
2140
|
+
};
|
|
2141
|
+
// The family and the curve in the schema's own spellings (`secg/secp256r1`);
|
|
2142
|
+
// spec compatibility keeps them for 1.7 and later only.
|
|
2143
|
+
applyAlgorithmProperties(component, {
|
|
2144
|
+
name: SCALASEM_AES_NAMES.has(name) ? "AES" : name,
|
|
2145
|
+
curve: finding.curve,
|
|
2146
|
+
});
|
|
2147
|
+
return component;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
function cryptoAssetProperties(finding, nativeLibrary) {
|
|
2151
|
+
const properties = [];
|
|
2152
|
+
appendUniqueProperty(
|
|
2153
|
+
properties,
|
|
2154
|
+
"cdx:scalasem:crypto:resolution",
|
|
2155
|
+
finding.resolution,
|
|
2156
|
+
);
|
|
2157
|
+
appendUniqueProperty(properties, "cdx:scalasem:crypto:api", finding.api);
|
|
2158
|
+
appendUniqueProperty(
|
|
2159
|
+
properties,
|
|
2160
|
+
"cdx:scalasem:crypto:provider",
|
|
2161
|
+
finding.provider,
|
|
2162
|
+
);
|
|
2163
|
+
if (finding.weak) {
|
|
2164
|
+
appendUniqueProperty(properties, "cdx:scalasem:crypto:weak", "true");
|
|
2165
|
+
}
|
|
2166
|
+
if (finding.name && finding.kind?.startsWith("native")) {
|
|
2167
|
+
appendUniqueProperty(
|
|
2168
|
+
properties,
|
|
2169
|
+
"cdx:scalasem:crypto:nativeFunction",
|
|
2170
|
+
finding.name,
|
|
2171
|
+
);
|
|
2172
|
+
appendUniqueProperty(
|
|
2173
|
+
properties,
|
|
2174
|
+
"cdx:scalasem:crypto:nativeLibrary",
|
|
2175
|
+
nativeLibrary,
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
if (finding.bits) {
|
|
2179
|
+
appendUniqueProperty(
|
|
2180
|
+
properties,
|
|
2181
|
+
"cdx:scalasem:crypto:gcmTagBits",
|
|
2182
|
+
finding.bits,
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
return properties;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* A JDBC address without what may carry credentials: userinfo, the Oracle
|
|
2190
|
+
* `user/password@` prefix, URL parameters and the `;key=value` properties
|
|
2191
|
+
* some drivers take. The report is sanitized already; this keeps the BOM
|
|
2192
|
+
* safe from a report written by anything else.
|
|
2193
|
+
*
|
|
2194
|
+
* @param {string} value JDBC URL.
|
|
2195
|
+
* @returns {string} The address part.
|
|
2196
|
+
*/
|
|
2197
|
+
export function sanitizeJdbcUrl(value) {
|
|
2198
|
+
let text = String(value || "").trim();
|
|
2199
|
+
if (!/^jdbc:/i.test(text)) {
|
|
2200
|
+
return text;
|
|
2201
|
+
}
|
|
2202
|
+
text = `jdbc:${text.slice(5)}`;
|
|
2203
|
+
for (const separator of ["?", "#", ";"]) {
|
|
2204
|
+
const index = text.indexOf(separator);
|
|
2205
|
+
if (index >= 0) {
|
|
2206
|
+
text = text.slice(0, index);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
const slashes = text.indexOf("//");
|
|
2210
|
+
if (slashes >= 0) {
|
|
2211
|
+
const authorityEnd = text.indexOf("/", slashes + 2);
|
|
2212
|
+
const authority = text.slice(
|
|
2213
|
+
slashes + 2,
|
|
2214
|
+
authorityEnd >= 0 ? authorityEnd : undefined,
|
|
2215
|
+
);
|
|
2216
|
+
const at = authority.lastIndexOf("@");
|
|
2217
|
+
if (at >= 0) {
|
|
2218
|
+
text =
|
|
2219
|
+
text.slice(0, slashes + 2) +
|
|
2220
|
+
authority.slice(at + 1) +
|
|
2221
|
+
(authorityEnd >= 0 ? text.slice(authorityEnd) : "");
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
// Oracle thin: jdbc:oracle:thin:user/password@host:port:sid
|
|
2225
|
+
const thin = /^(jdbc:oracle:thin:)[^@]*@/i.exec(text);
|
|
2226
|
+
if (thin) {
|
|
2227
|
+
text = `${thin[1]}@${text.slice(thin[0].length)}`;
|
|
2228
|
+
}
|
|
2229
|
+
return text;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function jdbcServiceName(value) {
|
|
2233
|
+
const address = sanitizeJdbcUrl(value);
|
|
2234
|
+
const scheme = address.slice(5).split(":", 1)[0].toLowerCase() || "jdbc";
|
|
2235
|
+
let host = /\/\/([^/:]+)/.exec(address)?.[1];
|
|
2236
|
+
if (!host) {
|
|
2237
|
+
// Oracle thin and similar: the host follows the @.
|
|
2238
|
+
host = /@(?:\/\/)?([^/:]+)/.exec(address)?.[1];
|
|
2239
|
+
}
|
|
2240
|
+
return [scheme, host].filter(Boolean).join("-");
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/**
|
|
2244
|
+
* The host a service URL names, or the topic and JDBC scheme equivalents, so
|
|
2245
|
+
* every outbound service is named after where it talks to rather than the
|
|
2246
|
+
* client library it uses.
|
|
2247
|
+
*/
|
|
2248
|
+
export function scalasemServiceName(service) {
|
|
2249
|
+
if (service?.topic) {
|
|
2250
|
+
return scalasemServiceEndpoint(service);
|
|
2251
|
+
}
|
|
2252
|
+
const value = service?.url || service?.host;
|
|
2253
|
+
if (!value) {
|
|
2254
|
+
return undefined;
|
|
2255
|
+
}
|
|
2256
|
+
if (/^jdbc:/i.test(value)) {
|
|
2257
|
+
return jdbcServiceName(value);
|
|
2258
|
+
}
|
|
2259
|
+
if (service?.host) {
|
|
2260
|
+
return service.host.split(":")[0];
|
|
2261
|
+
}
|
|
2262
|
+
try {
|
|
2263
|
+
return new URL(value).hostname || undefined;
|
|
2264
|
+
} catch (_err) {
|
|
2265
|
+
return undefined;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* The endpoint a service row carries, normalized: a URL as its scheme and
|
|
2271
|
+
* host, a host as it is, a topic as `client:topic`, a JDBC URL with its
|
|
2272
|
+
* credentials and parameters dropped.
|
|
2273
|
+
*/
|
|
2274
|
+
export function scalasemServiceEndpoint(service) {
|
|
2275
|
+
if (service?.topic) {
|
|
2276
|
+
const scheme = service.client || "messaging";
|
|
2277
|
+
return `${scheme}:${service.topic}`;
|
|
2278
|
+
}
|
|
2279
|
+
const value = service?.url || service?.host;
|
|
2280
|
+
if (!value) {
|
|
2281
|
+
return undefined;
|
|
2282
|
+
}
|
|
2283
|
+
if (/^jdbc:/i.test(value)) {
|
|
2284
|
+
// The database name in the path is part of the address.
|
|
2285
|
+
return sanitizeJdbcUrl(value);
|
|
2286
|
+
}
|
|
2287
|
+
if (service?.host) {
|
|
2288
|
+
return service.host;
|
|
2289
|
+
}
|
|
2290
|
+
try {
|
|
2291
|
+
const parsed = new URL(value);
|
|
2292
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
2293
|
+
} catch (_err) {
|
|
2294
|
+
const hostMatch = /:\/\/([^/?#@]+@)?([^/?#]+)/.exec(value);
|
|
2295
|
+
return hostMatch ? `${value.split("://")[0]}://${hostMatch[2]}` : undefined;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
function serviceLocation(service) {
|
|
2300
|
+
if (!service?.file) {
|
|
2301
|
+
return undefined;
|
|
2302
|
+
}
|
|
2303
|
+
return service.line ? `${service.file}#${service.line}` : service.file;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* Outbound services: HTTP and websocket clients, data stores, messaging
|
|
2308
|
+
* topics and cloud clients. Locations ride in
|
|
2309
|
+
* `cdx:scalasem:service:location` properties because CycloneDX services
|
|
2310
|
+
* carry no evidence field before 2.0; spec compatibility keeps the
|
|
2311
|
+
* occurrences below that only at 2.0.
|
|
2312
|
+
*
|
|
2313
|
+
* @param {Object} report Parsed scalasem report.
|
|
2314
|
+
* @param {Object} servicesMap Map populated with service definitions.
|
|
2315
|
+
* @returns {Object} The mutated services map.
|
|
2316
|
+
*/
|
|
2317
|
+
export function collectScalasemServices(report = {}, servicesMap = {}) {
|
|
2318
|
+
for (const service of report.services || []) {
|
|
2319
|
+
const name = scalasemServiceName(service);
|
|
2320
|
+
if (!name) {
|
|
2321
|
+
continue;
|
|
2322
|
+
}
|
|
2323
|
+
const definition = (servicesMap[name] ??= {
|
|
2324
|
+
name,
|
|
2325
|
+
endpoints: new Set(),
|
|
2326
|
+
authenticated: undefined,
|
|
2327
|
+
xTrustBoundary: true,
|
|
2328
|
+
properties: [],
|
|
2329
|
+
});
|
|
2330
|
+
definition.endpoints ??= new Set();
|
|
2331
|
+
definition.properties ??= [];
|
|
2332
|
+
const value = scalasemServiceEndpoint(service);
|
|
2333
|
+
if (value) {
|
|
2334
|
+
definition.endpoints.add(value);
|
|
2335
|
+
}
|
|
2336
|
+
const location = serviceLocation(service);
|
|
2337
|
+
if (location) {
|
|
2338
|
+
appendUniqueProperty(
|
|
2339
|
+
definition.properties,
|
|
2340
|
+
"cdx:scalasem:service:location",
|
|
2341
|
+
location,
|
|
2342
|
+
);
|
|
2343
|
+
definition.evidence ??= { occurrences: [] };
|
|
2344
|
+
const known = definition.evidence.occurrences;
|
|
2345
|
+
if (!known.some((occurrence) => occurrence.location === location)) {
|
|
2346
|
+
known.push(parseLocationString(location));
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
appendUniqueProperty(
|
|
2350
|
+
definition.properties,
|
|
2351
|
+
"cdx:scalasem:service:kind",
|
|
2352
|
+
service.kind,
|
|
2353
|
+
);
|
|
2354
|
+
appendUniqueProperty(
|
|
2355
|
+
definition.properties,
|
|
2356
|
+
"cdx:scalasem:service:client",
|
|
2357
|
+
service.client,
|
|
2358
|
+
);
|
|
2359
|
+
appendUniqueProperty(
|
|
2360
|
+
definition.properties,
|
|
2361
|
+
"cdx:scalasem:service:resolution",
|
|
2362
|
+
service.resolution,
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
return servicesMap;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/**
|
|
2369
|
+
* Inbound endpoints, one service per route, named the way the OpenAPI reader
|
|
2370
|
+
* names its own so the two converge on one entry for the same route.
|
|
2371
|
+
*
|
|
2372
|
+
* @param {Object} report Parsed scalasem report.
|
|
2373
|
+
* @param {Object} servicesMap Map populated with service definitions.
|
|
2374
|
+
* @returns {Object} The mutated services map.
|
|
2375
|
+
*/
|
|
2376
|
+
export function collectScalasemApiEndpoints(report = {}, servicesMap = {}) {
|
|
2377
|
+
for (const endpoint of report.endpoints || []) {
|
|
2378
|
+
const path = endpoint?.path;
|
|
2379
|
+
const method = String(endpoint.method || "").toUpperCase();
|
|
2380
|
+
if (!path || !method) {
|
|
2381
|
+
continue;
|
|
2382
|
+
}
|
|
2383
|
+
const serviceName = `service-${path.replaceAll("/", "")}-${method.toLowerCase()}`;
|
|
2384
|
+
const definition = (servicesMap[serviceName] ??= {
|
|
2385
|
+
name: serviceName,
|
|
2386
|
+
endpoints: new Set(),
|
|
2387
|
+
authenticated: undefined,
|
|
2388
|
+
xTrustBoundary: undefined,
|
|
2389
|
+
properties: [],
|
|
2390
|
+
});
|
|
2391
|
+
definition.endpoints ??= new Set();
|
|
2392
|
+
definition.properties ??= [];
|
|
2393
|
+
definition.endpoints.add(path);
|
|
2394
|
+
if (endpoint.authenticated === true) {
|
|
2395
|
+
definition.authenticated = true;
|
|
2396
|
+
definition.xTrustBoundary = true;
|
|
2397
|
+
}
|
|
2398
|
+
if (endpoint.handler) {
|
|
2399
|
+
appendUniqueProperty(
|
|
2400
|
+
definition.properties,
|
|
2401
|
+
"internal:operationId",
|
|
2402
|
+
endpoint.handler,
|
|
2403
|
+
);
|
|
2404
|
+
appendUniqueProperty(
|
|
2405
|
+
definition.properties,
|
|
2406
|
+
"cdx:scalasem:endpoint:handler",
|
|
2407
|
+
endpoint.handler,
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
appendUniqueProperty(
|
|
2411
|
+
definition.properties,
|
|
2412
|
+
"cdx:service:httpMethod",
|
|
2413
|
+
method,
|
|
2414
|
+
);
|
|
2415
|
+
appendUniqueProperty(
|
|
2416
|
+
definition.properties,
|
|
2417
|
+
"cdx:scalasem:endpoint:framework",
|
|
2418
|
+
endpoint.framework,
|
|
2419
|
+
);
|
|
2420
|
+
const location = serviceLocation(endpoint);
|
|
2421
|
+
if (location) {
|
|
2422
|
+
appendUniqueProperty(
|
|
2423
|
+
definition.properties,
|
|
2424
|
+
"cdx:scalasem:service:location",
|
|
2425
|
+
location,
|
|
2426
|
+
);
|
|
2427
|
+
definition.evidence ??= { occurrences: [] };
|
|
2428
|
+
const known = definition.evidence.occurrences;
|
|
2429
|
+
if (!known.some((occurrence) => occurrence.location === location)) {
|
|
2430
|
+
known.push(parseLocationString(location));
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
return servicesMap;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
/**
|
|
2438
|
+
* The npm packages a Scala.js build bundles. scalajs-bundler installs them
|
|
2439
|
+
* with a lock file under `target`, and a bundler workspace such as a Vite
|
|
2440
|
+
* client keeps its own manifest and lock file beside the build. Their npm
|
|
2441
|
+
* components join the BOM so the report's JavaScript module imports can be
|
|
2442
|
+
* attributed to them. Only lock files name the installed versions, and a
|
|
2443
|
+
* workspace counts only when its manifest uses Scala.js, so a documentation
|
|
2444
|
+
* site or a tool's own `package.json` adds nothing.
|
|
2445
|
+
*
|
|
2446
|
+
* @param {string} src Project directory.
|
|
2447
|
+
* @param {Object[]} pkgList Components the build tools reported.
|
|
2448
|
+
* @param {Object} [options] CLI options; `exclude` is honoured.
|
|
2449
|
+
* @returns {Promise<{components: Object[], roots: string[], dependencies: Object[]}>}
|
|
2450
|
+
* Npm components, the bom-refs the workspaces depend on directly, and the
|
|
2451
|
+
* lock files' dependency edges between the npm components.
|
|
2452
|
+
*/
|
|
2453
|
+
export async function collectScalaJsNpmComponents(
|
|
2454
|
+
src,
|
|
2455
|
+
pkgList = [],
|
|
2456
|
+
options = {},
|
|
2457
|
+
) {
|
|
2458
|
+
const isScalaJs = pkgList.some((pkg) => {
|
|
2459
|
+
const name = pkg?.name || purlPurlName(pkg?.purl) || "";
|
|
2460
|
+
return /_(sjs\d+(?:\.\d+)?)$/.test(name) || name === "scalajs-library";
|
|
2461
|
+
});
|
|
2462
|
+
const empty = { components: [], roots: [], dependencies: [] };
|
|
2463
|
+
if (!isScalaJs) {
|
|
2464
|
+
return empty;
|
|
2465
|
+
}
|
|
2466
|
+
const projectDir = resolve(src);
|
|
2467
|
+
const searchOptions = {
|
|
2468
|
+
includeNodeModulesDir: false,
|
|
2469
|
+
exclude: options.exclude,
|
|
2470
|
+
};
|
|
2471
|
+
const manifestDirs = new Set();
|
|
2472
|
+
for (const file of getAllFiles(
|
|
2473
|
+
projectDir,
|
|
2474
|
+
"**/scalajs-bundler/**/package.json",
|
|
2475
|
+
searchOptions,
|
|
2476
|
+
)) {
|
|
2477
|
+
manifestDirs.add(dirname(file));
|
|
2478
|
+
}
|
|
2479
|
+
// A workspace beside the build: the project's own manifests, not the ones
|
|
2480
|
+
// node_modules holds. Two levels cover the usual client/server split.
|
|
2481
|
+
for (const pattern of ["package.json", "*/package.json"]) {
|
|
2482
|
+
for (const file of getAllFiles(projectDir, pattern, searchOptions)) {
|
|
2483
|
+
if (manifestUsesScalaJs(file)) {
|
|
2484
|
+
manifestDirs.add(dirname(file));
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
const components = [];
|
|
2489
|
+
const roots = [];
|
|
2490
|
+
const dependencies = [];
|
|
2491
|
+
for (const manifestDir of manifestDirs) {
|
|
2492
|
+
const lockFile = ["package-lock.json", "npm-shrinkwrap.json"]
|
|
2493
|
+
.map((name) => join(manifestDir, name))
|
|
2494
|
+
.find((file) => safeExistsSync(file));
|
|
2495
|
+
if (!lockFile) {
|
|
2496
|
+
continue;
|
|
2497
|
+
}
|
|
2498
|
+
const lockData = await parsePkgLock(lockFile, options);
|
|
2499
|
+
// The lock file's own package is the workspace, which is part of the
|
|
2500
|
+
// project rather than a dependency of it.
|
|
2501
|
+
const workspaceRefs = new Set(
|
|
2502
|
+
(lockData?.pkgList || [])
|
|
2503
|
+
.filter((pkg) => pkg.type === "application")
|
|
2504
|
+
.map((pkg) => pkg["bom-ref"]),
|
|
2505
|
+
);
|
|
2506
|
+
components.push(
|
|
2507
|
+
...(lockData?.pkgList || []).filter((pkg) => pkg.type !== "application"),
|
|
2508
|
+
);
|
|
2509
|
+
for (const dependency of lockData?.dependenciesList || []) {
|
|
2510
|
+
if (workspaceRefs.has(dependency.ref)) {
|
|
2511
|
+
roots.push(...(dependency.dependsOn || []));
|
|
2512
|
+
} else {
|
|
2513
|
+
dependencies.push(dependency);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
return { components, roots: [...new Set(roots)], dependencies };
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function manifestUsesScalaJs(manifestFile) {
|
|
2521
|
+
try {
|
|
2522
|
+
const manifest = JSON.parse(readFileSync(manifestFile, "utf-8"));
|
|
2523
|
+
const names = Object.keys({
|
|
2524
|
+
...(manifest?.dependencies || {}),
|
|
2525
|
+
...(manifest?.devDependencies || {}),
|
|
2526
|
+
});
|
|
2527
|
+
return names.some((name) => /scala-?js/i.test(name));
|
|
2528
|
+
} catch (_err) {
|
|
2529
|
+
return false;
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// The package name an npm purl carries, with the scope decoded back to
|
|
2534
|
+
// `@scope/name`, which is how JavaScript names its modules.
|
|
2535
|
+
function npmNameFromPurl(purl) {
|
|
2536
|
+
const rest = /^pkg:npm\/(.+)$/.exec(purl || "")?.[1];
|
|
2537
|
+
if (!rest) {
|
|
2538
|
+
return undefined;
|
|
2539
|
+
}
|
|
2540
|
+
const at = rest.lastIndexOf("@");
|
|
2541
|
+
const name = at > 0 ? rest.slice(0, at) : rest;
|
|
2542
|
+
try {
|
|
2543
|
+
return decodeURIComponent(name);
|
|
2544
|
+
} catch (_err) {
|
|
2545
|
+
return name;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
/**
|
|
2550
|
+
* Attribute the report's JavaScript module imports to the npm components the
|
|
2551
|
+
* build bundles. A module no component owns is reported on the project
|
|
2552
|
+
* component instead, so the import stays visible either way.
|
|
2553
|
+
*/
|
|
2554
|
+
function addJsModuleEvidence(
|
|
2555
|
+
report,
|
|
2556
|
+
components,
|
|
2557
|
+
purlLocationMap,
|
|
2558
|
+
componentPropertiesMap,
|
|
2559
|
+
metadataProperties,
|
|
2560
|
+
) {
|
|
2561
|
+
const jsModules = report.jsModules || [];
|
|
2562
|
+
if (!jsModules.length) {
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
const npmByName = new Map();
|
|
2566
|
+
for (const component of components) {
|
|
2567
|
+
const name = npmNameFromPurl(component?.purl);
|
|
2568
|
+
if (name) {
|
|
2569
|
+
npmByName.set(name, component);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
const moduleMatchesComponent = (moduleName) => {
|
|
2573
|
+
if (npmByName.has(moduleName)) {
|
|
2574
|
+
return npmByName.get(moduleName);
|
|
2575
|
+
}
|
|
2576
|
+
// `pkg/subpath` and `@scope/pkg/subpath` belong to the package named by
|
|
2577
|
+
// their leading segments.
|
|
2578
|
+
const segments = moduleName.split("/");
|
|
2579
|
+
const scoped =
|
|
2580
|
+
moduleName.startsWith("@") && segments.length > 2
|
|
2581
|
+
? `${segments[0]}/${segments[1]}`
|
|
2582
|
+
: segments[0];
|
|
2583
|
+
return npmByName.get(scoped);
|
|
2584
|
+
};
|
|
2585
|
+
const unmatched = new Set();
|
|
2586
|
+
const seenLocations = new Set();
|
|
2587
|
+
for (const jsModule of jsModules) {
|
|
2588
|
+
const moduleName = jsModule.module;
|
|
2589
|
+
if (!moduleName || moduleName.startsWith(".") || moduleName.includes("*")) {
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
const component = moduleMatchesComponent(moduleName);
|
|
2593
|
+
const location = jsModule.line
|
|
2594
|
+
? `${jsModule.file}#${jsModule.line}`
|
|
2595
|
+
: jsModule.file;
|
|
2596
|
+
if (!component) {
|
|
2597
|
+
unmatched.add(moduleName);
|
|
2598
|
+
continue;
|
|
2599
|
+
}
|
|
2600
|
+
if (!seenLocations.has(`${component.purl}#${location}`)) {
|
|
2601
|
+
seenLocations.add(`${component.purl}#${location}`);
|
|
2602
|
+
addSetValue(purlLocationMap, component.purl, location);
|
|
2603
|
+
}
|
|
2604
|
+
addPropertyValue(
|
|
2605
|
+
componentPropertiesMap,
|
|
2606
|
+
component.purl,
|
|
2607
|
+
"cdx:scalasem:jsModule",
|
|
2608
|
+
moduleName,
|
|
2609
|
+
);
|
|
2610
|
+
}
|
|
2611
|
+
if (unmatched.size) {
|
|
2612
|
+
appendUniqueProperty(
|
|
2613
|
+
metadataProperties,
|
|
2614
|
+
"cdx:scalasem:jsModules",
|
|
2615
|
+
sortedCsv(unmatched),
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
}
|