@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
|
@@ -1,32 +1,134 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import { DEBUG_MODE } from "../core/activity.js";
|
|
5
|
+
import { getAllFiles, safeExistsSync } from "../core/fs.js";
|
|
6
|
+
import { thoughtLog } from "../core/logger.js";
|
|
7
|
+
import { applyPurl, tryBuildPurl } from "../inventory/purl.js";
|
|
8
|
+
import { isSafeGitRefName } from "../inventory/source.js";
|
|
9
|
+
import { licenseIdFromText } from "../inventory/spdx.js";
|
|
10
|
+
import { submodulePurlCoordinates } from "../parsers/gitmodules.js";
|
|
11
|
+
import {
|
|
12
|
+
mergeTerraformConfigs,
|
|
13
|
+
parseTerraformConfig,
|
|
14
|
+
terraformConfigFileSet,
|
|
15
|
+
} from "../parsers/terraformConfig.js";
|
|
16
|
+
import {
|
|
17
|
+
classifyVcsRef,
|
|
18
|
+
classifyVersionConstraint,
|
|
19
|
+
OPENTOFU_REGISTRY_HOST,
|
|
20
|
+
parseModuleSource,
|
|
21
|
+
parseProviderSource,
|
|
22
|
+
TERRAFORM_REGISTRY_HOST,
|
|
23
|
+
} from "../parsers/terraformSource.js";
|
|
4
24
|
|
|
5
25
|
/**
|
|
6
|
-
* Terraform and OpenTofu
|
|
26
|
+
* Terraform and OpenTofu parsing: the provider lock file, the module
|
|
27
|
+
* manifest `init` writes, and the configuration files that declare modules
|
|
28
|
+
* and required providers.
|
|
7
29
|
*
|
|
8
|
-
* The lock file
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
30
|
+
* The lock file is written in a small, regular subset of HCL: provider
|
|
31
|
+
* blocks, `version` and `constraints` attributes, and a `hashes` list of
|
|
32
|
+
* prefixed digests. A line scanner is sufficient and avoids a general HCL
|
|
33
|
+
* parser (see {@link parseTerraformLockFile}); configuration files go
|
|
34
|
+
* through the dedicated extractor in `lib/parsers/terraformConfig.js`.
|
|
13
35
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
36
|
+
* Modules are not in the lock file — Terraform designed it that way — so the
|
|
37
|
+
* workspace assembly reads `.terraform/modules/modules.json` when `init` has
|
|
38
|
+
* run and falls back to a static walk of `module` blocks. Provider addresses
|
|
39
|
+
* look like `registry.terraform.io/hashicorp/aws`; the registry host and
|
|
40
|
+
* namespace identify the provider upstream, so they are kept in the
|
|
41
|
+
* component group and a generic purl
|
|
17
42
|
* (`pkg:generic/<host>/<namespace>/<type>@<version>`), with the intended type
|
|
18
43
|
* recorded as a `cdx:purl:proposedType` property because no `terraform`
|
|
19
|
-
* purl type is registered.
|
|
44
|
+
* purl type is registered. Local modules are flattened into their calling
|
|
45
|
+
* package: they are part of the project, not dependencies.
|
|
20
46
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Terraform verify an unpacked directory but makes it useless as an artifact
|
|
27
|
-
* checksum; it is kept as a property instead of being mislabelled SHA-256.
|
|
47
|
+
* Everything this module reads comes from untrusted trees, so every read is
|
|
48
|
+
* bounds-checked by {@link readFileWithin} (realpath containment, regular
|
|
49
|
+
* files only, byte caps) and module source strings are sanitized upstream in
|
|
50
|
+
* `terraformSource.js`. No network I/O happens here; remote enrichment lives
|
|
51
|
+
* in `terraformRegistry.js`.
|
|
28
52
|
*/
|
|
29
53
|
|
|
54
|
+
const CONFIG_MAX_BYTES = 5 * 1024 * 1024;
|
|
55
|
+
const MODULES_JSON_MAX_BYTES = 20 * 1024 * 1024;
|
|
56
|
+
const LICENSE_MAX_BYTES = 256 * 1024;
|
|
57
|
+
const GIT_HEAD_MAX_BYTES = 1024;
|
|
58
|
+
const MAX_MODULE_RECORDS = 10000;
|
|
59
|
+
const MAX_LOCAL_DEPTH = 64;
|
|
60
|
+
const MAX_OCCURRENCES = 25;
|
|
61
|
+
const REMOTE_KINDS = new Set(["registry", "git", "hg", "http", "s3", "gcs"]);
|
|
62
|
+
const LICENSE_CANDIDATES = [
|
|
63
|
+
"LICENSE",
|
|
64
|
+
"LICENSE.md",
|
|
65
|
+
"LICENSE.txt",
|
|
66
|
+
"LICENCE",
|
|
67
|
+
"LICENCE.md",
|
|
68
|
+
"LICENCE.txt",
|
|
69
|
+
"COPYING",
|
|
70
|
+
"COPYING.md",
|
|
71
|
+
"COPYING.txt",
|
|
72
|
+
];
|
|
73
|
+
/** Longest archive extensions first so `.tar.gz` survives `.tar` checks. */
|
|
74
|
+
const ARCHIVE_EXTENSIONS = [
|
|
75
|
+
".tar.gz",
|
|
76
|
+
".tar.bz2",
|
|
77
|
+
".tar.xz",
|
|
78
|
+
".tbz2",
|
|
79
|
+
".tgz",
|
|
80
|
+
".txz",
|
|
81
|
+
".zip",
|
|
82
|
+
".tar",
|
|
83
|
+
];
|
|
84
|
+
/**
|
|
85
|
+
* Trees Terraform discovery never descends into. `.terraform` and
|
|
86
|
+
* `.terragrunt-cache` are download caches whose contents the workspace reads
|
|
87
|
+
* through exact paths only. `node_modules` holds other packages' files: a
|
|
88
|
+
* `.tf` shipped inside an npm package is not a configuration of the scanned
|
|
89
|
+
* project, and a local module call that points into `node_modules` is still
|
|
90
|
+
* followed. Listing it explicitly matters because another collector in the
|
|
91
|
+
* same multi-type scan may have set `includeNodeModulesDir` on the shared
|
|
92
|
+
* options, which otherwise makes every glob walk it.
|
|
93
|
+
*/
|
|
94
|
+
const TERRAFORM_EXCLUDES = [
|
|
95
|
+
"**/.terraform/**",
|
|
96
|
+
"**/.terragrunt-cache/**",
|
|
97
|
+
"**/node_modules/**",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The configuration and lock files Terraform discovery considers under a
|
|
102
|
+
* path, with the discovery exclusions applied.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} scanPath Directory to scan
|
|
105
|
+
* @param {Object} [options] CLI options
|
|
106
|
+
* @returns {string[]} Matching files
|
|
107
|
+
*/
|
|
108
|
+
function terraformDiscoveryFiles(scanPath, options = {}) {
|
|
109
|
+
const opts = {
|
|
110
|
+
...options,
|
|
111
|
+
exclude: [...(options.exclude || []), ...TERRAFORM_EXCLUDES],
|
|
112
|
+
};
|
|
113
|
+
const prefix = options.multiProject ? "**/" : "";
|
|
114
|
+
return [
|
|
115
|
+
...getAllFiles(scanPath, `${prefix}*.{tf,tf.json,tofu,tofu.json}`, opts),
|
|
116
|
+
...getAllFiles(scanPath, `${prefix}.terraform.lock.hcl`, opts),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether a path holds any Terraform or OpenTofu configuration or lock file
|
|
122
|
+
* the workspace assembly would consider; used by project-type autodetection.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} scanPath Directory to scan
|
|
125
|
+
* @param {Object} [options] CLI options
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
export function hasTerraformConfiguration(scanPath, options = {}) {
|
|
129
|
+
return terraformDiscoveryFiles(scanPath, options).length > 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
30
132
|
/**
|
|
31
133
|
* Parse a `.terraform.lock.hcl` file.
|
|
32
134
|
*
|
|
@@ -135,6 +237,7 @@ function buildProviderComponent(block, srcFile) {
|
|
|
135
237
|
const properties = [
|
|
136
238
|
{ name: "internal:SrcFile", value: srcFile },
|
|
137
239
|
{ name: "cdx:purl:proposedType", value: "terraform-provider" },
|
|
240
|
+
{ name: "cdx:tf:kind", value: "provider" },
|
|
138
241
|
{ name: "cdx:tf:address", value: block.address },
|
|
139
242
|
];
|
|
140
243
|
if (block.constraints) {
|
|
@@ -192,3 +295,1454 @@ function zipDigestsHex(candidates) {
|
|
|
192
295
|
}
|
|
193
296
|
return [...digests];
|
|
194
297
|
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Read a file only when it stays inside `allowedRoot` (by realpath), is a
|
|
301
|
+
* regular file, and is under `maxBytes`.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} file Absolute path to read
|
|
304
|
+
* @param {string} allowedRoot Absolute directory the file must live under
|
|
305
|
+
* @param {number} maxBytes Byte cap
|
|
306
|
+
* @returns {string|undefined} File contents, or undefined on any failure
|
|
307
|
+
*/
|
|
308
|
+
function readFileWithin(file, allowedRoot, maxBytes) {
|
|
309
|
+
try {
|
|
310
|
+
const realFile = realpathSync(file);
|
|
311
|
+
const realRoot = realpathSync(allowedRoot);
|
|
312
|
+
if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
const stats = statSync(realFile);
|
|
316
|
+
if (!stats.isFile() || stats.size > maxBytes) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
return readFileSync(realFile, "utf-8");
|
|
320
|
+
} catch {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* List a directory's entry names, or undefined when unreadable.
|
|
327
|
+
*
|
|
328
|
+
* @param {string} dir Directory
|
|
329
|
+
* @returns {string[]|undefined} Entry names
|
|
330
|
+
*/
|
|
331
|
+
function listDirNames(dir) {
|
|
332
|
+
try {
|
|
333
|
+
return readdirSync(dir);
|
|
334
|
+
} catch {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* POSIX-style path of `target` relative to `from`.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} from Base directory
|
|
343
|
+
* @param {string} target Absolute path
|
|
344
|
+
* @returns {string} Relative path with `/` separators
|
|
345
|
+
*/
|
|
346
|
+
function posixRelative(from, target) {
|
|
347
|
+
return relative(from, target).split(sep).join("/");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Whether `child` is `parent` or lives underneath it (lexical check; real
|
|
352
|
+
* paths are enforced separately by {@link readFileWithin}).
|
|
353
|
+
*
|
|
354
|
+
* @param {string} parent Absolute directory
|
|
355
|
+
* @param {string} child Absolute path
|
|
356
|
+
* @returns {boolean}
|
|
357
|
+
*/
|
|
358
|
+
function isInsideDir(parent, child) {
|
|
359
|
+
const rel = relative(parent, child);
|
|
360
|
+
if (!rel) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
if (isAbsolute(rel)) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
const first = rel.split(sep)[0];
|
|
367
|
+
return first !== ".." && first !== ".";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Parent key of a dotted manifest key. */
|
|
371
|
+
function parentKeyOf(key) {
|
|
372
|
+
const dot = key.lastIndexOf(".");
|
|
373
|
+
return dot === -1 ? "" : key.slice(0, dot);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Safe realpath wrapper. */
|
|
377
|
+
function realpathOf(target) {
|
|
378
|
+
try {
|
|
379
|
+
return realpathSync(target);
|
|
380
|
+
} catch {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Locate the Terraform roots under `scanPath`: directories that own a
|
|
387
|
+
* configuration — those with a lock file or module manifest, plus config
|
|
388
|
+
* directories no other config directory references through a local module
|
|
389
|
+
* call.
|
|
390
|
+
*
|
|
391
|
+
* @param {string} scanPath Directory to scan
|
|
392
|
+
* @param {Object} options CLI options
|
|
393
|
+
* @returns {{ dir: string, lockFile?: string, manifestFile?: string }[]} Roots sorted by relative POSIX path
|
|
394
|
+
*/
|
|
395
|
+
export function findTerraformRoots(scanPath, options = {}) {
|
|
396
|
+
const configFiles = terraformDiscoveryFiles(scanPath, options);
|
|
397
|
+
const scanReal = realpathOf(scanPath);
|
|
398
|
+
if (!scanReal) {
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
const candidates = new Set();
|
|
402
|
+
for (const file of configFiles) {
|
|
403
|
+
const dir = join(file, "..");
|
|
404
|
+
const dirReal = realpathOf(dir);
|
|
405
|
+
if (!dirReal || !isInsideDir(scanReal, dirReal)) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const rel = relative(scanPath, dir).split(sep);
|
|
409
|
+
if (
|
|
410
|
+
rel.includes(".terraform") ||
|
|
411
|
+
rel.includes(".terragrunt-cache") ||
|
|
412
|
+
rel.includes("node_modules")
|
|
413
|
+
) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
candidates.add(dir);
|
|
417
|
+
}
|
|
418
|
+
const candidateList = [...candidates].sort();
|
|
419
|
+
if (!candidateList.length) {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
const configs = new Map();
|
|
423
|
+
for (const dir of candidateList) {
|
|
424
|
+
configs.set(dir, loadMergedConfig(dir, scanReal));
|
|
425
|
+
}
|
|
426
|
+
const referenced = new Set();
|
|
427
|
+
for (const dir of candidateList) {
|
|
428
|
+
for (const call of configs.get(dir).moduleCalls) {
|
|
429
|
+
if (!call.source || call.sourceLiteral !== true) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const parsed = parseModuleSource(call.source);
|
|
433
|
+
if (parsed?.kind !== "local") {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const target = resolve(dir, parsed.path);
|
|
437
|
+
if (target !== dir && candidates.has(target)) {
|
|
438
|
+
referenced.add(target);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
let roots = candidateList.filter(
|
|
443
|
+
(dir) =>
|
|
444
|
+
safeExistsSync(join(dir, ".terraform.lock.hcl")) ||
|
|
445
|
+
safeExistsSync(join(dir, ".terraform", "modules", "modules.json")) ||
|
|
446
|
+
!referenced.has(dir),
|
|
447
|
+
);
|
|
448
|
+
if (!roots.length) {
|
|
449
|
+
roots = candidateList;
|
|
450
|
+
}
|
|
451
|
+
return roots
|
|
452
|
+
.map((dir) => rootDescriptor(dir))
|
|
453
|
+
.sort((a, b) =>
|
|
454
|
+
posixRelative(scanPath, a.dir) < posixRelative(scanPath, b.dir) ? -1 : 1,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Build the root descriptor with its lock and manifest paths.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} dir Root directory
|
|
462
|
+
* @param {string} scanPath Scan root
|
|
463
|
+
* @returns {{ dir: string, lockFile?: string, manifestFile?: string }}
|
|
464
|
+
*/
|
|
465
|
+
function rootDescriptor(dir) {
|
|
466
|
+
const lockFile = join(dir, ".terraform.lock.hcl");
|
|
467
|
+
const manifestFile = join(dir, ".terraform", "modules", "modules.json");
|
|
468
|
+
return {
|
|
469
|
+
dir,
|
|
470
|
+
...(safeExistsSync(lockFile) ? { lockFile } : {}),
|
|
471
|
+
...(safeExistsSync(manifestFile) ? { manifestFile } : {}),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Load and merge one directory's configuration files. Only the exact files
|
|
477
|
+
* Terraform itself would load are read (OpenTofu `.tofu` precedence and
|
|
478
|
+
* override ordering included), each through the bounded reader.
|
|
479
|
+
*
|
|
480
|
+
* @param {string} dir Directory to read
|
|
481
|
+
* @param {string} scanReal Realpath of the scan root (read containment)
|
|
482
|
+
* @returns {object} Merged configuration
|
|
483
|
+
*/
|
|
484
|
+
function loadMergedConfig(dir, scanReal) {
|
|
485
|
+
const names = listDirNames(dir) || [];
|
|
486
|
+
const fileSet = terraformConfigFileSet(names);
|
|
487
|
+
const parsedFiles = [];
|
|
488
|
+
for (const base of [...fileSet.primary, ...fileSet.overrides]) {
|
|
489
|
+
const abs = join(dir, base);
|
|
490
|
+
const text = readFileWithin(abs, scanReal, CONFIG_MAX_BYTES);
|
|
491
|
+
if (text === undefined) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
parsedFiles.push({
|
|
495
|
+
file: base,
|
|
496
|
+
override: fileSet.overrides.includes(base),
|
|
497
|
+
result: parseTerraformConfig(text, { json: base.endsWith(".json") }),
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return mergeTerraformConfigs(parsedFiles);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Read `.terraform/modules/modules.json` for one root.
|
|
505
|
+
*
|
|
506
|
+
* @param {string} manifestFile Absolute manifest path
|
|
507
|
+
* @param {string} rootDir Root directory
|
|
508
|
+
* @returns {undefined|{ records: object[] }} Manifest records, or undefined when absent/invalid
|
|
509
|
+
*/
|
|
510
|
+
function readModulesManifest(manifestFile, rootDir) {
|
|
511
|
+
const text = readFileWithin(
|
|
512
|
+
manifestFile,
|
|
513
|
+
join(rootDir, ".terraform"),
|
|
514
|
+
MODULES_JSON_MAX_BYTES,
|
|
515
|
+
);
|
|
516
|
+
if (text === undefined) {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
let doc;
|
|
520
|
+
try {
|
|
521
|
+
doc = JSON.parse(text);
|
|
522
|
+
} catch {
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
const modules = doc?.Modules;
|
|
526
|
+
if (!Array.isArray(modules) || modules.length > MAX_MODULE_RECORDS) {
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
const records = [];
|
|
530
|
+
for (const record of modules) {
|
|
531
|
+
if (!record || typeof record !== "object") {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (
|
|
535
|
+
typeof record.Key !== "string" ||
|
|
536
|
+
typeof record.Source !== "string" ||
|
|
537
|
+
typeof record.Dir !== "string"
|
|
538
|
+
) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
records.push({
|
|
542
|
+
key: record.Key,
|
|
543
|
+
source: record.Source,
|
|
544
|
+
...(typeof record.Version === "string"
|
|
545
|
+
? { version: record.Version }
|
|
546
|
+
: {}),
|
|
547
|
+
dirRaw: record.Dir,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return { records };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Inventory a Terraform workspace: providers from the lock file and
|
|
555
|
+
* `required_providers`, modules from the manifest or the configuration, the
|
|
556
|
+
* dependency graph between them, and offline licenses from the installed
|
|
557
|
+
* packages.
|
|
558
|
+
*
|
|
559
|
+
* @param {string} scanPath Directory to scan
|
|
560
|
+
* @param {Object} options CLI options
|
|
561
|
+
* @param {string} parentRef `bom-ref` of the parent component the CLI built
|
|
562
|
+
* @returns {{ pkgList: object[], dependencies: object[], parentProperties: object[], srcFiles: string[] }}
|
|
563
|
+
*/
|
|
564
|
+
export function parseTerraformWorkspace(scanPath, options, parentRef) {
|
|
565
|
+
const roots = parentRef ? findTerraformRoots(scanPath, options) : [];
|
|
566
|
+
const state = {
|
|
567
|
+
byRef: new Map(),
|
|
568
|
+
edges: new Map(),
|
|
569
|
+
parentProperties: [],
|
|
570
|
+
srcFiles: [],
|
|
571
|
+
parentRef,
|
|
572
|
+
scanPath,
|
|
573
|
+
scanReal: realpathOf(scanPath),
|
|
574
|
+
// Lexical root for path *resolution* (module sources resolve against the
|
|
575
|
+
// path the caller gave, which may itself sit behind a symlink; the real
|
|
576
|
+
// path is enforced where files are actually read).
|
|
577
|
+
scanLexRoot: resolve(scanPath),
|
|
578
|
+
};
|
|
579
|
+
if (!roots.length) {
|
|
580
|
+
return {
|
|
581
|
+
pkgList: [],
|
|
582
|
+
dependencies: [],
|
|
583
|
+
parentProperties: [],
|
|
584
|
+
srcFiles: [],
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (DEBUG_MODE) {
|
|
588
|
+
console.log(
|
|
589
|
+
`Found ${roots.length} terraform root(s) at ${scanPath}: ${roots
|
|
590
|
+
.map((root) => posixRelative(scanPath, root.dir))
|
|
591
|
+
.join(", ")}`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
state.singleRoot = roots.length === 1;
|
|
595
|
+
for (const root of roots) {
|
|
596
|
+
processRoot(state, root);
|
|
597
|
+
}
|
|
598
|
+
const pkgList = [...state.byRef.values()].sort((a, b) =>
|
|
599
|
+
a["bom-ref"] < b["bom-ref"] ? -1 : 1,
|
|
600
|
+
);
|
|
601
|
+
const allRefs = new Set([...state.byRef.keys(), parentRef]);
|
|
602
|
+
const dependencies = [...allRefs].sort().map((ref) => ({
|
|
603
|
+
ref,
|
|
604
|
+
dependsOn: [...(state.edges.get(ref) || new Set())]
|
|
605
|
+
.filter((dep) => dep !== ref && allRefs.has(dep))
|
|
606
|
+
.sort(),
|
|
607
|
+
}));
|
|
608
|
+
return {
|
|
609
|
+
pkgList,
|
|
610
|
+
dependencies,
|
|
611
|
+
parentProperties: state.parentProperties,
|
|
612
|
+
srcFiles: state.srcFiles,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Process one root directory into components and edges.
|
|
618
|
+
*
|
|
619
|
+
* @param {object} state Accumulator shared across roots
|
|
620
|
+
* @param {{ dir: string, lockFile?: string, manifestFile?: string }} root Root descriptor
|
|
621
|
+
*/
|
|
622
|
+
function processRoot(state, root) {
|
|
623
|
+
const { scanPath, scanReal, parentRef } = state;
|
|
624
|
+
const rootDir = root.dir;
|
|
625
|
+
const rootRel = posixRelative(scanPath, rootDir) || ".";
|
|
626
|
+
const configCache = new Map();
|
|
627
|
+
const configOf = (dir) => {
|
|
628
|
+
if (!configCache.has(dir)) {
|
|
629
|
+
configCache.set(dir, loadMergedConfig(dir, scanReal));
|
|
630
|
+
}
|
|
631
|
+
return configCache.get(dir);
|
|
632
|
+
};
|
|
633
|
+
const parsedDirs = new Map();
|
|
634
|
+
const parseConfigInto = (dir) => {
|
|
635
|
+
if (!parsedDirs.has(dir)) {
|
|
636
|
+
parsedDirs.set(dir, configOf(dir));
|
|
637
|
+
}
|
|
638
|
+
return parsedDirs.get(dir);
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const rootConfig = parseConfigInto(rootDir);
|
|
642
|
+
|
|
643
|
+
// Providers from the lock file, indexed by full address and by
|
|
644
|
+
// namespace/type (the host is not always spelled out in the config).
|
|
645
|
+
const lockProviders = [];
|
|
646
|
+
if (root.lockFile) {
|
|
647
|
+
for (const pkg of parseTerraformLockFile(root.lockFile).pkgList) {
|
|
648
|
+
pkg.evidence = {
|
|
649
|
+
identity: {
|
|
650
|
+
field: "purl",
|
|
651
|
+
confidence: 1,
|
|
652
|
+
methods: [
|
|
653
|
+
{
|
|
654
|
+
technique: "manifest-analysis",
|
|
655
|
+
confidence: 1,
|
|
656
|
+
value: posixRelative(scanPath, root.lockFile),
|
|
657
|
+
},
|
|
658
|
+
],
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
lockProviders.push(pkg);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const lockByAddress = new Map(
|
|
665
|
+
lockProviders
|
|
666
|
+
.map((pkg) => [findProperty(pkg, "cdx:tf:address"), pkg])
|
|
667
|
+
.filter(([address]) => address),
|
|
668
|
+
);
|
|
669
|
+
const lockByNsType = new Map();
|
|
670
|
+
for (const [address, pkg] of lockByAddress) {
|
|
671
|
+
const parts = address.split("/");
|
|
672
|
+
if (parts.length === 3) {
|
|
673
|
+
lockByNsType.set(`${parts[1]}/${parts[2]}`, pkg);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const ctx = {
|
|
677
|
+
state,
|
|
678
|
+
rootDir,
|
|
679
|
+
rootRel,
|
|
680
|
+
scanLexRoot: state.scanLexRoot,
|
|
681
|
+
terraformDir: join(rootDir, ".terraform"),
|
|
682
|
+
defaultHost: defaultRegistryHostFor(rootDir, lockByAddress),
|
|
683
|
+
lockByAddress,
|
|
684
|
+
lockByNsType,
|
|
685
|
+
unresolved: 0,
|
|
686
|
+
configOf,
|
|
687
|
+
};
|
|
688
|
+
const rootRef = state.singleRoot ? parentRef : `terraform-root:${rootRel}`;
|
|
689
|
+
ctx.rootRef = rootRef;
|
|
690
|
+
ctx.singleRoot = state.singleRoot;
|
|
691
|
+
|
|
692
|
+
// Every locked provider is a component up front, with its offline license.
|
|
693
|
+
for (const pkg of lockProviders) {
|
|
694
|
+
applyProviderLicense(ctx, pkg);
|
|
695
|
+
mergeIntoState(state, pkg);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const manifest = root.manifestFile
|
|
699
|
+
? readModulesManifest(root.manifestFile, rootDir)
|
|
700
|
+
: undefined;
|
|
701
|
+
const records = [];
|
|
702
|
+
if (manifest) {
|
|
703
|
+
collectManifestRecords(ctx, manifest, records, parseConfigInto);
|
|
704
|
+
} else {
|
|
705
|
+
collectStaticRecords(ctx, records, parseConfigInto, new Set([rootDir]));
|
|
706
|
+
}
|
|
707
|
+
records.sort((a, b) => (a.key < b.key ? -1 : 1));
|
|
708
|
+
const recordByKey = new Map(records.map((record) => [record.key, record]));
|
|
709
|
+
const dirOwner = new Map();
|
|
710
|
+
for (const record of records) {
|
|
711
|
+
if (record.dirValid && record.dirAbs && !dirOwner.has(record.dirAbs)) {
|
|
712
|
+
dirOwner.set(record.dirAbs, record.key);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (!dirOwner.has(rootDir)) {
|
|
716
|
+
dirOwner.set(rootDir, "");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Pass 1: components for remote records, so owners resolve regardless of
|
|
720
|
+
// the order the manifest listed parents and children in.
|
|
721
|
+
for (const record of records) {
|
|
722
|
+
if (record.remote) {
|
|
723
|
+
record.ref = emitModuleComponent(ctx, record)?.["bom-ref"];
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// Pass 2: module edges. A local record's owner is its parent's owner; a
|
|
727
|
+
// remote record owns itself.
|
|
728
|
+
const moduleRefs = new Map(
|
|
729
|
+
records.filter((r) => r.ref).map((r) => [r.key, r.ref]),
|
|
730
|
+
);
|
|
731
|
+
const ownerRefForKey = (key) => {
|
|
732
|
+
if (!key) {
|
|
733
|
+
return rootRef;
|
|
734
|
+
}
|
|
735
|
+
const record = recordByKey.get(key);
|
|
736
|
+
if (!record || record.remote) {
|
|
737
|
+
return moduleRefs.get(key);
|
|
738
|
+
}
|
|
739
|
+
return ownerRefForKey(record.parentKey);
|
|
740
|
+
};
|
|
741
|
+
for (const record of records) {
|
|
742
|
+
if (!record.remote || !record.ref) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const owner = ownerRefForKey(record.parentKey);
|
|
746
|
+
addEdge(state, owner, record.ref);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Required providers of every parsed directory, including the root's.
|
|
750
|
+
for (const [dir, config] of parsedDirs) {
|
|
751
|
+
const ownerKey = dirOwner.get(dir) ?? "";
|
|
752
|
+
emitProviderEdges(ctx, config, dir, ownerRefForKey(ownerKey));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Lock providers nothing in the configuration claims still belong to the
|
|
756
|
+
// root, so the graph stays connected.
|
|
757
|
+
connectOrphanLockProviders(ctx);
|
|
758
|
+
|
|
759
|
+
// Root properties land on the parent for a single root, otherwise on the
|
|
760
|
+
// root application component.
|
|
761
|
+
const rootProperties = [
|
|
762
|
+
{ name: "cdx:tf:root", value: rootRel },
|
|
763
|
+
...[...new Set(rootConfig.requiredVersions)].map((value) => ({
|
|
764
|
+
name: "cdx:tf:requiredVersion",
|
|
765
|
+
value,
|
|
766
|
+
})),
|
|
767
|
+
];
|
|
768
|
+
if (ctx.unresolved > 0) {
|
|
769
|
+
rootProperties.push({
|
|
770
|
+
name: "cdx:tf:unresolvedModuleCalls",
|
|
771
|
+
value: `${ctx.unresolved}`,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
if (ctx.singleRoot) {
|
|
775
|
+
state.parentProperties.push(...rootProperties);
|
|
776
|
+
} else {
|
|
777
|
+
state.byRef.set(rootRef, {
|
|
778
|
+
type: "application",
|
|
779
|
+
name: rootRel === "." ? basename(resolve(scanPath)) : rootRel,
|
|
780
|
+
"bom-ref": rootRef,
|
|
781
|
+
properties: rootProperties,
|
|
782
|
+
});
|
|
783
|
+
addEdge(state, parentRef, rootRef);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const srcFiles = [];
|
|
787
|
+
if (root.lockFile) {
|
|
788
|
+
srcFiles.push(posixRelative(scanPath, root.lockFile));
|
|
789
|
+
}
|
|
790
|
+
if (root.manifestFile) {
|
|
791
|
+
srcFiles.push(posixRelative(scanPath, root.manifestFile));
|
|
792
|
+
}
|
|
793
|
+
state.srcFiles.push(...(srcFiles.length ? srcFiles : [rootRel]));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The registry host shorthand resolves against: OpenTofu's registry when the
|
|
798
|
+
* lock file uses it or the directory has `.tofu` files.
|
|
799
|
+
*
|
|
800
|
+
* @param {string} rootDir Root directory
|
|
801
|
+
* @param {Map} lockByAddress Lock providers by full address
|
|
802
|
+
* @returns {string}
|
|
803
|
+
*/
|
|
804
|
+
function defaultRegistryHostFor(rootDir, lockByAddress) {
|
|
805
|
+
for (const address of lockByAddress.keys()) {
|
|
806
|
+
if (address.startsWith(`${OPENTOFU_REGISTRY_HOST}/`)) {
|
|
807
|
+
return OPENTOFU_REGISTRY_HOST;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const names = listDirNames(rootDir) || [];
|
|
811
|
+
if (
|
|
812
|
+
names.some((name) => name.endsWith(".tofu") || name.endsWith(".tofu.json"))
|
|
813
|
+
) {
|
|
814
|
+
return OPENTOFU_REGISTRY_HOST;
|
|
815
|
+
}
|
|
816
|
+
return TERRAFORM_REGISTRY_HOST;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Collect module records from `modules.json`, plus declared-only calls the
|
|
821
|
+
* manifest does not list.
|
|
822
|
+
*
|
|
823
|
+
* @param {object} ctx Per-root context
|
|
824
|
+
* @param {{ records: object[] }} manifest Parsed manifest
|
|
825
|
+
* @param {object[]} records Output record list
|
|
826
|
+
* @param {Function} parseConfigInto Bounded config loader
|
|
827
|
+
*/
|
|
828
|
+
function collectManifestRecords(ctx, manifest, records, parseConfigInto) {
|
|
829
|
+
const { rootDir, scanLexRoot, defaultHost } = ctx;
|
|
830
|
+
const modulesDir = join(rootDir, ".terraform", "modules");
|
|
831
|
+
const byKey = new Map();
|
|
832
|
+
for (const entry of manifest.records) {
|
|
833
|
+
byKey.set(entry.key, { entry });
|
|
834
|
+
}
|
|
835
|
+
if (!byKey.has("")) {
|
|
836
|
+
byKey.set("", { entry: { key: "", source: "", dirRaw: "." } });
|
|
837
|
+
}
|
|
838
|
+
// Resolve directories first so declarations can be found afterwards.
|
|
839
|
+
for (const holder of byKey.values()) {
|
|
840
|
+
const entry = holder.entry;
|
|
841
|
+
const isRootRecord = entry.key === "";
|
|
842
|
+
const parsed = entry.source
|
|
843
|
+
? parseModuleSource(entry.source, { defaultRegistryHost: defaultHost })
|
|
844
|
+
: undefined;
|
|
845
|
+
const local = isRootRecord || parsed?.kind === "local";
|
|
846
|
+
const remote = !local && REMOTE_KINDS.has(parsed?.kind);
|
|
847
|
+
let dirAbs;
|
|
848
|
+
let dirValid = false;
|
|
849
|
+
let packageRoot;
|
|
850
|
+
if (isRootRecord) {
|
|
851
|
+
dirAbs = rootDir;
|
|
852
|
+
dirValid = true;
|
|
853
|
+
} else if (
|
|
854
|
+
entry.dirRaw &&
|
|
855
|
+
!entry.dirRaw.includes("\0") &&
|
|
856
|
+
!isAbsolute(entry.dirRaw)
|
|
857
|
+
) {
|
|
858
|
+
const target = join(rootDir, ...entry.dirRaw.split("/"));
|
|
859
|
+
if (isInsideDir(local ? scanLexRoot : modulesDir, target)) {
|
|
860
|
+
dirAbs = target;
|
|
861
|
+
dirValid = true;
|
|
862
|
+
}
|
|
863
|
+
if (entry.dirRaw.startsWith(".terraform/modules/")) {
|
|
864
|
+
const first = entry.dirRaw
|
|
865
|
+
.slice(".terraform/modules/".length)
|
|
866
|
+
.split("/")[0];
|
|
867
|
+
if (first) {
|
|
868
|
+
packageRoot = join(modulesDir, first);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
holder.record = {
|
|
873
|
+
key: entry.key,
|
|
874
|
+
parentKey: parentKeyOf(entry.key),
|
|
875
|
+
name: entry.key.split(".").pop(),
|
|
876
|
+
remote,
|
|
877
|
+
parsed: remote ? parsed : undefined,
|
|
878
|
+
version: entry.version,
|
|
879
|
+
installed: true,
|
|
880
|
+
dirAbs,
|
|
881
|
+
dirValid,
|
|
882
|
+
packageRoot,
|
|
883
|
+
};
|
|
884
|
+
records.push(holder.record);
|
|
885
|
+
if (dirValid) {
|
|
886
|
+
parseConfigInto(dirAbs);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// Declarations live in the merged config of each record's parent.
|
|
890
|
+
for (const record of records) {
|
|
891
|
+
if (!record.remote) {
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
const parentDir = byKey.get(record.parentKey)?.record?.dirValid
|
|
895
|
+
? byKey.get(record.parentKey).record.dirAbs
|
|
896
|
+
: undefined;
|
|
897
|
+
if (!parentDir) {
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
const call = parseConfigInto(parentDir).moduleCalls.find(
|
|
901
|
+
(candidate) => candidate.name === record.name,
|
|
902
|
+
);
|
|
903
|
+
if (call) {
|
|
904
|
+
record.declaration = call;
|
|
905
|
+
record.declarationDir = parentDir;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Remote calls the manifest does not list are declared-only components.
|
|
909
|
+
for (const record of [...records]) {
|
|
910
|
+
if (!record.dirValid || !record.dirAbs) {
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
const ownerKey = manifestOwnerKey(records, record.dirAbs);
|
|
914
|
+
for (const call of parseConfigInto(record.dirAbs).moduleCalls) {
|
|
915
|
+
const expectedKey = ownerKey ? `${ownerKey}.${call.name}` : call.name;
|
|
916
|
+
if (byKey.has(expectedKey)) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
if (!call.source || call.sourceLiteral !== true) {
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
const parsed = parseModuleSource(call.source, {
|
|
923
|
+
defaultRegistryHost: defaultHost,
|
|
924
|
+
});
|
|
925
|
+
if (!REMOTE_KINDS.has(parsed?.kind)) {
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
const declared = {
|
|
929
|
+
key: expectedKey,
|
|
930
|
+
parentKey: ownerKey,
|
|
931
|
+
name: call.name,
|
|
932
|
+
remote: true,
|
|
933
|
+
parsed,
|
|
934
|
+
installed: false,
|
|
935
|
+
dirAbs: undefined,
|
|
936
|
+
dirValid: false,
|
|
937
|
+
declaration: call,
|
|
938
|
+
declarationDir: record.dirAbs,
|
|
939
|
+
};
|
|
940
|
+
records.push(declared);
|
|
941
|
+
byKey.set(expectedKey, { entry: declared, record: declared });
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Key of the record that owns a directory, preferring the shortest key so
|
|
948
|
+
* the root record wins ties.
|
|
949
|
+
*
|
|
950
|
+
* @param {object[]} records Records with resolved directories
|
|
951
|
+
* @param {string} dir Directory
|
|
952
|
+
* @returns {string} Owning key ("" for the root)
|
|
953
|
+
*/
|
|
954
|
+
function manifestOwnerKey(records, dir) {
|
|
955
|
+
let best;
|
|
956
|
+
for (const record of records) {
|
|
957
|
+
if (record.dirValid && record.dirAbs === dir) {
|
|
958
|
+
if (best === undefined || record.key.length < best.length) {
|
|
959
|
+
best = record.key;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return best || "";
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Walk the configuration statically when no manifest exists. Local module
|
|
968
|
+
* calls are flattened into the calling root, so every edge produced here
|
|
969
|
+
* hangs off the root ref.
|
|
970
|
+
*
|
|
971
|
+
* @param {object} ctx Per-root context
|
|
972
|
+
* @param {object[]} records Output record list
|
|
973
|
+
* @param {Function} parseConfigInto Bounded config loader
|
|
974
|
+
* @param {Set<string>} visited Absolute directories already walked
|
|
975
|
+
*/
|
|
976
|
+
function collectStaticRecords(ctx, records, parseConfigInto, visited) {
|
|
977
|
+
const { rootDir, scanLexRoot, defaultHost } = ctx;
|
|
978
|
+
const walk = (dir, keyPrefix, depth) => {
|
|
979
|
+
for (const call of parseConfigInto(dir).moduleCalls) {
|
|
980
|
+
if (!call.source || call.sourceLiteral !== true) {
|
|
981
|
+
ctx.unresolved += 1;
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
const parsed = parseModuleSource(call.source, {
|
|
985
|
+
defaultRegistryHost: defaultHost,
|
|
986
|
+
});
|
|
987
|
+
if (parsed.kind === "local") {
|
|
988
|
+
const target = resolve(dir, parsed.path);
|
|
989
|
+
if (!isInsideDir(scanLexRoot, target)) {
|
|
990
|
+
ctx.unresolved += 1;
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (visited.has(target) || depth >= MAX_LOCAL_DEPTH) {
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
visited.add(target);
|
|
997
|
+
walk(target, `${keyPrefix}${call.name}.`, depth + 1);
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (!REMOTE_KINDS.has(parsed.kind)) {
|
|
1001
|
+
ctx.unresolved += 1;
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
records.push({
|
|
1005
|
+
key: keyPrefix ? `${keyPrefix}${call.name}` : call.name,
|
|
1006
|
+
// Local children are flattened into the calling root, so every
|
|
1007
|
+
// static-flow module hangs off the root ref.
|
|
1008
|
+
parentKey: "",
|
|
1009
|
+
name: call.name,
|
|
1010
|
+
remote: true,
|
|
1011
|
+
parsed,
|
|
1012
|
+
installed: false,
|
|
1013
|
+
dirAbs: undefined,
|
|
1014
|
+
dirValid: false,
|
|
1015
|
+
declaration: call,
|
|
1016
|
+
declarationDir: dir,
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
walk(rootDir, "", 0);
|
|
1021
|
+
thoughtLog(
|
|
1022
|
+
`Terraform static walk from ${rootDir} produced ${records.length} remote module records.`,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Turn one remote module record into a component, or count it unresolved.
|
|
1028
|
+
*
|
|
1029
|
+
* @param {object} ctx Per-root context
|
|
1030
|
+
* @param {object} record Module record
|
|
1031
|
+
* @returns {object|undefined} The merged component
|
|
1032
|
+
*/
|
|
1033
|
+
function emitModuleComponent(ctx, record) {
|
|
1034
|
+
const { state } = ctx;
|
|
1035
|
+
const { scanPath } = state;
|
|
1036
|
+
const parsed = record.parsed;
|
|
1037
|
+
const pkg = {
|
|
1038
|
+
type: "library",
|
|
1039
|
+
scope: "required",
|
|
1040
|
+
properties: [
|
|
1041
|
+
{ name: "cdx:tf:kind", value: "module" },
|
|
1042
|
+
{ name: "cdx:tf:module:sourceType", value: parsed.kind },
|
|
1043
|
+
{
|
|
1044
|
+
name: "cdx:tf:module:installed",
|
|
1045
|
+
value: record.installed ? "true" : "false",
|
|
1046
|
+
},
|
|
1047
|
+
],
|
|
1048
|
+
};
|
|
1049
|
+
if (parsed.displaySource) {
|
|
1050
|
+
pkg.properties.push({
|
|
1051
|
+
name: "cdx:tf:module:source",
|
|
1052
|
+
value: parsed.displaySource,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
const manifestRel = posixRelative(
|
|
1056
|
+
scanPath,
|
|
1057
|
+
join(ctx.rootDir, ".terraform", "modules", "modules.json"),
|
|
1058
|
+
);
|
|
1059
|
+
let identityFile = manifestRel;
|
|
1060
|
+
let confidence = 0.6;
|
|
1061
|
+
if (parsed.kind === "registry") {
|
|
1062
|
+
let version;
|
|
1063
|
+
if (record.installed) {
|
|
1064
|
+
version = record.version;
|
|
1065
|
+
if (version) {
|
|
1066
|
+
confidence = 1;
|
|
1067
|
+
}
|
|
1068
|
+
} else {
|
|
1069
|
+
const exact = classifyVersionConstraint(record.declaration?.version);
|
|
1070
|
+
version = exact.pinning === "exact" ? exact.version : undefined;
|
|
1071
|
+
identityFile = declarationRelPath(scanPath, record) || manifestRel;
|
|
1072
|
+
}
|
|
1073
|
+
const purl = tryBuildPurl({
|
|
1074
|
+
type: "generic",
|
|
1075
|
+
namespace: `${parsed.host}/${parsed.namespace}/${parsed.name}`,
|
|
1076
|
+
name: parsed.system,
|
|
1077
|
+
version: version || null,
|
|
1078
|
+
subpath: parsed.subdir || null,
|
|
1079
|
+
});
|
|
1080
|
+
if (!purl) {
|
|
1081
|
+
ctx.unresolved += 1;
|
|
1082
|
+
return undefined;
|
|
1083
|
+
}
|
|
1084
|
+
pkg.group = `${parsed.host}/${parsed.namespace}`;
|
|
1085
|
+
pkg.name = `${parsed.name}/${parsed.system}`;
|
|
1086
|
+
if (version) {
|
|
1087
|
+
pkg.version = version;
|
|
1088
|
+
}
|
|
1089
|
+
pkg.properties.push(
|
|
1090
|
+
{ name: "cdx:purl:proposedType", value: "terraform-module" },
|
|
1091
|
+
{ name: "cdx:tf:address", value: parsed.address },
|
|
1092
|
+
);
|
|
1093
|
+
applyPurl(pkg, purl, `library:${parsed.address}:${version || ""}`);
|
|
1094
|
+
} else if (parsed.kind === "git" || parsed.kind === "hg") {
|
|
1095
|
+
const ref = parsed.ref;
|
|
1096
|
+
const safeRef = isSafeGitRefName(ref) ? ref : undefined;
|
|
1097
|
+
const commit =
|
|
1098
|
+
record.installed && record.dirValid
|
|
1099
|
+
? installedCommitFor(ctx, record)
|
|
1100
|
+
: undefined;
|
|
1101
|
+
const version = safeRef || commit || undefined;
|
|
1102
|
+
const coordinates = parsed.url
|
|
1103
|
+
? submodulePurlCoordinates(parsed.url, version)
|
|
1104
|
+
: null;
|
|
1105
|
+
const purl = coordinates
|
|
1106
|
+
? tryBuildPurl({ ...coordinates, subpath: parsed.subdir || null })
|
|
1107
|
+
: null;
|
|
1108
|
+
if (!purl) {
|
|
1109
|
+
ctx.unresolved += 1;
|
|
1110
|
+
return undefined;
|
|
1111
|
+
}
|
|
1112
|
+
if (coordinates.namespace) {
|
|
1113
|
+
pkg.group = coordinates.namespace;
|
|
1114
|
+
}
|
|
1115
|
+
pkg.name = coordinates.name;
|
|
1116
|
+
if (version) {
|
|
1117
|
+
pkg.version = version;
|
|
1118
|
+
}
|
|
1119
|
+
if (safeRef) {
|
|
1120
|
+
pkg.properties.push({ name: "cdx:tf:module:ref", value: safeRef });
|
|
1121
|
+
}
|
|
1122
|
+
if (commit) {
|
|
1123
|
+
pkg.properties.push({ name: "cdx:tf:module:commit", value: commit });
|
|
1124
|
+
}
|
|
1125
|
+
pkg.properties.push({
|
|
1126
|
+
name: "cdx:tf:module:pinning",
|
|
1127
|
+
value: classifyVcsRef(ref),
|
|
1128
|
+
});
|
|
1129
|
+
applyPurl(pkg, purl, `library:${parsed.displaySource}:${version || ""}`);
|
|
1130
|
+
if (!record.installed) {
|
|
1131
|
+
identityFile = declarationRelPath(scanPath, record) || manifestRel;
|
|
1132
|
+
} else if (version) {
|
|
1133
|
+
confidence = 1;
|
|
1134
|
+
}
|
|
1135
|
+
} else {
|
|
1136
|
+
// Archive module over http/s3/gcs.
|
|
1137
|
+
const base = archiveBaseName(parsed.url);
|
|
1138
|
+
const host = urlHost(parsed.url);
|
|
1139
|
+
const purl =
|
|
1140
|
+
base && host
|
|
1141
|
+
? tryBuildPurl({
|
|
1142
|
+
type: "generic",
|
|
1143
|
+
namespace: host,
|
|
1144
|
+
name: base,
|
|
1145
|
+
qualifiers: { download_url: parsed.url },
|
|
1146
|
+
subpath: parsed.subdir || null,
|
|
1147
|
+
})
|
|
1148
|
+
: null;
|
|
1149
|
+
if (!purl) {
|
|
1150
|
+
ctx.unresolved += 1;
|
|
1151
|
+
return undefined;
|
|
1152
|
+
}
|
|
1153
|
+
pkg.group = host;
|
|
1154
|
+
pkg.name = base;
|
|
1155
|
+
pkg.distribution = { url: parsed.url };
|
|
1156
|
+
if (parsed.checksum) {
|
|
1157
|
+
pkg.hashes = [
|
|
1158
|
+
{ alg: parsed.checksum.alg, content: parsed.checksum.content },
|
|
1159
|
+
];
|
|
1160
|
+
}
|
|
1161
|
+
pkg.properties.push({
|
|
1162
|
+
name: "cdx:tf:module:pinning",
|
|
1163
|
+
value: parsed.checksum ? "checksum" : "none",
|
|
1164
|
+
});
|
|
1165
|
+
applyPurl(pkg, purl, `library:${parsed.displaySource}`);
|
|
1166
|
+
if (!record.installed) {
|
|
1167
|
+
identityFile = declarationRelPath(scanPath, record) || manifestRel;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (record.declaration?.version) {
|
|
1171
|
+
pkg.properties.push({
|
|
1172
|
+
name: "cdx:tf:constraints",
|
|
1173
|
+
value: record.declaration.version,
|
|
1174
|
+
});
|
|
1175
|
+
if (parsed.kind === "registry") {
|
|
1176
|
+
pkg.properties.push({
|
|
1177
|
+
name: "cdx:tf:module:pinning",
|
|
1178
|
+
value: classifyVersionConstraint(record.declaration.version).pinning,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
} else if (parsed.kind === "registry" && record.declaration) {
|
|
1182
|
+
// The call was found but sets no version constraint.
|
|
1183
|
+
pkg.properties.push({ name: "cdx:tf:module:pinning", value: "none" });
|
|
1184
|
+
}
|
|
1185
|
+
if (parsed.credentialInSource) {
|
|
1186
|
+
pkg.properties.push({
|
|
1187
|
+
name: "cdx:tf:module:credentialInSource",
|
|
1188
|
+
value: "true",
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
const srcFile =
|
|
1192
|
+
record.declaration && record.declarationDir
|
|
1193
|
+
? join(record.declarationDir, record.declaration.file)
|
|
1194
|
+
: join(ctx.rootDir, ".terraform", "modules", "modules.json");
|
|
1195
|
+
pkg.properties.push({ name: "internal:SrcFile", value: srcFile });
|
|
1196
|
+
const occurrences = [];
|
|
1197
|
+
if (record.declaration && record.declarationDir) {
|
|
1198
|
+
const occurrence = {
|
|
1199
|
+
location: posixRelative(scanPath, srcFile),
|
|
1200
|
+
symbol: `module.${record.name}`,
|
|
1201
|
+
};
|
|
1202
|
+
if (!record.declaration.file.endsWith(".json") && record.declaration.line) {
|
|
1203
|
+
occurrence.line = record.declaration.line;
|
|
1204
|
+
}
|
|
1205
|
+
occurrences.push(occurrence);
|
|
1206
|
+
}
|
|
1207
|
+
pkg.evidence = {
|
|
1208
|
+
identity: {
|
|
1209
|
+
field: "purl",
|
|
1210
|
+
confidence,
|
|
1211
|
+
methods: [
|
|
1212
|
+
{
|
|
1213
|
+
technique: "manifest-analysis",
|
|
1214
|
+
confidence,
|
|
1215
|
+
value: identityFile,
|
|
1216
|
+
},
|
|
1217
|
+
],
|
|
1218
|
+
},
|
|
1219
|
+
...(occurrences.length ? { occurrences } : {}),
|
|
1220
|
+
};
|
|
1221
|
+
// Offline license from the installed package.
|
|
1222
|
+
if (record.installed && record.dirValid && record.dirAbs) {
|
|
1223
|
+
applyModuleLicense(ctx, pkg, record);
|
|
1224
|
+
}
|
|
1225
|
+
return mergeIntoState(state, pkg);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Relative POSIX path of a record's declaration file.
|
|
1230
|
+
*
|
|
1231
|
+
* @param {string} scanPath Scan root
|
|
1232
|
+
* @param {object} record Module record
|
|
1233
|
+
* @returns {string|undefined}
|
|
1234
|
+
*/
|
|
1235
|
+
function declarationRelPath(scanPath, record) {
|
|
1236
|
+
if (record.declaration && record.declarationDir) {
|
|
1237
|
+
return posixRelative(
|
|
1238
|
+
scanPath,
|
|
1239
|
+
join(record.declarationDir, record.declaration.file),
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
return undefined;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Read the installed commit of a VCS module package from `.git/HEAD`.
|
|
1247
|
+
*
|
|
1248
|
+
* @param {object} ctx Per-root context
|
|
1249
|
+
* @param {object} record Module record
|
|
1250
|
+
* @returns {string|undefined} 40/64-hex commit
|
|
1251
|
+
*/
|
|
1252
|
+
function installedCommitFor(ctx, record) {
|
|
1253
|
+
if (!record.packageRoot) {
|
|
1254
|
+
return undefined;
|
|
1255
|
+
}
|
|
1256
|
+
const head = readFileWithin(
|
|
1257
|
+
join(record.packageRoot, ".git", "HEAD"),
|
|
1258
|
+
join(ctx.rootDir, ".terraform"),
|
|
1259
|
+
GIT_HEAD_MAX_BYTES,
|
|
1260
|
+
);
|
|
1261
|
+
if (head === undefined) {
|
|
1262
|
+
return undefined;
|
|
1263
|
+
}
|
|
1264
|
+
const trimmed = head.trim();
|
|
1265
|
+
// Never follow `ref:` lines; only a detached sha is usable.
|
|
1266
|
+
if (/^[0-9a-f]{40}$/u.test(trimmed) || /^[0-9a-f]{64}$/u.test(trimmed)) {
|
|
1267
|
+
return trimmed;
|
|
1268
|
+
}
|
|
1269
|
+
return undefined;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Strip the archive extension from the last path segment of a URL.
|
|
1274
|
+
*
|
|
1275
|
+
* @param {string} url Sanitized archive URL
|
|
1276
|
+
* @returns {string|undefined} Basename without extension
|
|
1277
|
+
*/
|
|
1278
|
+
function archiveBaseName(url) {
|
|
1279
|
+
try {
|
|
1280
|
+
const segments = new URL(url).pathname.split("/").filter(Boolean);
|
|
1281
|
+
const last = segments[segments.length - 1];
|
|
1282
|
+
if (!last) {
|
|
1283
|
+
return undefined;
|
|
1284
|
+
}
|
|
1285
|
+
const lower = last.toLowerCase();
|
|
1286
|
+
for (const ext of ARCHIVE_EXTENSIONS) {
|
|
1287
|
+
if (lower.endsWith(ext)) {
|
|
1288
|
+
return last.slice(0, -ext.length) || undefined;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return last;
|
|
1292
|
+
} catch {
|
|
1293
|
+
return undefined;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Hostname of a sanitized URL.
|
|
1299
|
+
*
|
|
1300
|
+
* @param {string} url URL
|
|
1301
|
+
* @returns {string|undefined}
|
|
1302
|
+
*/
|
|
1303
|
+
function urlHost(url) {
|
|
1304
|
+
try {
|
|
1305
|
+
return new URL(url).hostname;
|
|
1306
|
+
} catch {
|
|
1307
|
+
return undefined;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Map one directory's required providers onto provider components and edges.
|
|
1313
|
+
*
|
|
1314
|
+
* @param {object} ctx Per-root context
|
|
1315
|
+
* @param {object} config Merged configuration of the directory
|
|
1316
|
+
* @param {string} dir Directory absolute path
|
|
1317
|
+
* @param {string} ownerRef Owner bom-ref
|
|
1318
|
+
*/
|
|
1319
|
+
function emitProviderEdges(ctx, config, dir, ownerRef) {
|
|
1320
|
+
for (const entry of config.requiredProviders || []) {
|
|
1321
|
+
let host;
|
|
1322
|
+
let namespace;
|
|
1323
|
+
let type;
|
|
1324
|
+
// A source that spells out its host (`registry.opentofu.org/hashicorp/aws`)
|
|
1325
|
+
// names exactly one provider; only shorthand may match a lock entry on
|
|
1326
|
+
// another host by namespace/type.
|
|
1327
|
+
let explicitHost = false;
|
|
1328
|
+
if (entry.source) {
|
|
1329
|
+
const parsed = parseProviderSource(entry.source, {
|
|
1330
|
+
defaultRegistryHost: ctx.defaultHost,
|
|
1331
|
+
});
|
|
1332
|
+
if (parsed?.builtin) {
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
if (parsed) {
|
|
1336
|
+
host = parsed.host;
|
|
1337
|
+
namespace = parsed.namespace;
|
|
1338
|
+
type = parsed.type;
|
|
1339
|
+
explicitHost = entry.source.trim().split("/").length === 3;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
if (!type) {
|
|
1343
|
+
// Terraform's implied fallback for the legacy and source-less forms.
|
|
1344
|
+
host = ctx.defaultHost;
|
|
1345
|
+
namespace = "hashicorp";
|
|
1346
|
+
type = entry.localName;
|
|
1347
|
+
}
|
|
1348
|
+
const address = `${host}/${namespace}/${type}`;
|
|
1349
|
+
const pkg =
|
|
1350
|
+
ctx.lockByAddress.get(address) ||
|
|
1351
|
+
(explicitHost
|
|
1352
|
+
? undefined
|
|
1353
|
+
: ctx.lockByNsType.get(`${namespace}/${type}`)) ||
|
|
1354
|
+
buildConfigOnlyProvider(ctx, host, namespace, type, entry, dir);
|
|
1355
|
+
if (!pkg) {
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
const merged = mergeIntoState(ctx.state, pkg);
|
|
1359
|
+
const occurrence = {
|
|
1360
|
+
location: posixRelative(ctx.state.scanPath, join(dir, entry.file)),
|
|
1361
|
+
symbol: `required_providers.${entry.localName}`,
|
|
1362
|
+
};
|
|
1363
|
+
if (!entry.file.endsWith(".json") && entry.line) {
|
|
1364
|
+
occurrence.line = entry.line;
|
|
1365
|
+
}
|
|
1366
|
+
addOccurrence(merged, occurrence);
|
|
1367
|
+
addEdge(ctx.state, ownerRef, merged["bom-ref"]);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Build a provider component that only exists in `required_providers`.
|
|
1373
|
+
*
|
|
1374
|
+
* @param {object} ctx Per-root context
|
|
1375
|
+
* @param {string} host Registry host
|
|
1376
|
+
* @param {string} namespace Registry namespace
|
|
1377
|
+
* @param {string} type Provider type
|
|
1378
|
+
* @param {object} entry Merged required_providers entry
|
|
1379
|
+
* @param {string} dir Directory the requirement lives in
|
|
1380
|
+
* @returns {object|undefined} Component
|
|
1381
|
+
*/
|
|
1382
|
+
function buildConfigOnlyProvider(ctx, host, namespace, type, entry, dir) {
|
|
1383
|
+
const exact = classifyVersionConstraint(entry.version);
|
|
1384
|
+
const version = exact.pinning === "exact" ? exact.version : undefined;
|
|
1385
|
+
const address = `${host}/${namespace}/${type}`;
|
|
1386
|
+
const purl = tryBuildPurl({
|
|
1387
|
+
type: "generic",
|
|
1388
|
+
namespace: `${host}/${namespace}`,
|
|
1389
|
+
name: type,
|
|
1390
|
+
version: version || null,
|
|
1391
|
+
});
|
|
1392
|
+
if (!purl) {
|
|
1393
|
+
return undefined;
|
|
1394
|
+
}
|
|
1395
|
+
const srcFile = join(dir, entry.file);
|
|
1396
|
+
const occurrence = {
|
|
1397
|
+
location: posixRelative(ctx.state.scanPath, srcFile),
|
|
1398
|
+
symbol: `required_providers.${entry.localName}`,
|
|
1399
|
+
};
|
|
1400
|
+
if (!entry.file.endsWith(".json") && entry.line) {
|
|
1401
|
+
occurrence.line = entry.line;
|
|
1402
|
+
}
|
|
1403
|
+
const pkg = {
|
|
1404
|
+
group: `${host}/${namespace}`,
|
|
1405
|
+
name: type,
|
|
1406
|
+
...(version ? { version } : {}),
|
|
1407
|
+
type: "library",
|
|
1408
|
+
scope: "required",
|
|
1409
|
+
properties: [
|
|
1410
|
+
{ name: "internal:SrcFile", value: srcFile },
|
|
1411
|
+
{ name: "cdx:purl:proposedType", value: "terraform-provider" },
|
|
1412
|
+
{ name: "cdx:tf:kind", value: "provider" },
|
|
1413
|
+
{ name: "cdx:tf:address", value: address },
|
|
1414
|
+
...(entry.version
|
|
1415
|
+
? [{ name: "cdx:tf:constraints", value: entry.version }]
|
|
1416
|
+
: []),
|
|
1417
|
+
],
|
|
1418
|
+
evidence: {
|
|
1419
|
+
identity: {
|
|
1420
|
+
field: "purl",
|
|
1421
|
+
confidence: 0.6,
|
|
1422
|
+
methods: [
|
|
1423
|
+
{
|
|
1424
|
+
technique: "manifest-analysis",
|
|
1425
|
+
confidence: 0.6,
|
|
1426
|
+
value: posixRelative(ctx.state.scanPath, srcFile),
|
|
1427
|
+
},
|
|
1428
|
+
],
|
|
1429
|
+
},
|
|
1430
|
+
occurrences: [occurrence],
|
|
1431
|
+
},
|
|
1432
|
+
};
|
|
1433
|
+
applyPurl(pkg, purl, `library:${address}:${version || ""}`);
|
|
1434
|
+
return pkg;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Give lock providers nothing in the configuration claims an edge to the
|
|
1439
|
+
* root, so the graph stays connected.
|
|
1440
|
+
*
|
|
1441
|
+
* @param {object} ctx Per-root context
|
|
1442
|
+
*/
|
|
1443
|
+
function connectOrphanLockProviders(ctx) {
|
|
1444
|
+
const { state, rootRef, lockByAddress } = ctx;
|
|
1445
|
+
const seen = new Set([rootRef]);
|
|
1446
|
+
const queue = [rootRef];
|
|
1447
|
+
while (queue.length) {
|
|
1448
|
+
const ref = queue.shift();
|
|
1449
|
+
for (const dep of state.edges.get(ref) || []) {
|
|
1450
|
+
if (!seen.has(dep)) {
|
|
1451
|
+
seen.add(dep);
|
|
1452
|
+
queue.push(dep);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
// Every locked provider, keyed by full address: two providers that share a
|
|
1457
|
+
// namespace/type on different hosts are both components and both need an
|
|
1458
|
+
// owner.
|
|
1459
|
+
for (const pkg of lockByAddress.values()) {
|
|
1460
|
+
if (!seen.has(pkg["bom-ref"])) {
|
|
1461
|
+
addEdge(state, rootRef, pkg["bom-ref"]);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* Find and apply the offline license of an installed module package.
|
|
1468
|
+
*
|
|
1469
|
+
* @param {object} ctx Per-root context
|
|
1470
|
+
* @param {object} pkg Component under construction
|
|
1471
|
+
* @param {object} record Module record
|
|
1472
|
+
*/
|
|
1473
|
+
function applyModuleLicense(ctx, pkg, record) {
|
|
1474
|
+
const allowedRoot = join(ctx.rootDir, ".terraform");
|
|
1475
|
+
const searchDirs = [];
|
|
1476
|
+
if (record.dirAbs) {
|
|
1477
|
+
searchDirs.push(record.dirAbs);
|
|
1478
|
+
}
|
|
1479
|
+
if (record.packageRoot && record.packageRoot !== record.dirAbs) {
|
|
1480
|
+
searchDirs.push(record.packageRoot);
|
|
1481
|
+
}
|
|
1482
|
+
for (const dir of searchDirs) {
|
|
1483
|
+
const found = findLicenseInDir(dir, allowedRoot);
|
|
1484
|
+
if (found) {
|
|
1485
|
+
applyLicense(ctx, pkg, found.file, found.text);
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Find and apply the offline license of an installed provider.
|
|
1493
|
+
*
|
|
1494
|
+
* @param {object} ctx Per-root context
|
|
1495
|
+
* @param {object} pkg Provider component
|
|
1496
|
+
*/
|
|
1497
|
+
function applyProviderLicense(ctx, pkg) {
|
|
1498
|
+
if (!pkg.version) {
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
const groupParts = (pkg.group || "").split("/");
|
|
1502
|
+
if (groupParts.length !== 2) {
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
const segments = [...groupParts, pkg.name, pkg.version];
|
|
1506
|
+
for (const segment of segments) {
|
|
1507
|
+
if (
|
|
1508
|
+
!segment ||
|
|
1509
|
+
segment.length > 255 ||
|
|
1510
|
+
segment.includes("/") ||
|
|
1511
|
+
segment.includes("\\") ||
|
|
1512
|
+
segment.includes("..") ||
|
|
1513
|
+
segment.includes("\0")
|
|
1514
|
+
) {
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const allowedRoot = join(ctx.rootDir, ".terraform");
|
|
1519
|
+
const providerDir = join(ctx.rootDir, ".terraform", "providers", ...segments);
|
|
1520
|
+
let entries;
|
|
1521
|
+
try {
|
|
1522
|
+
entries = readdirSync(providerDir, { withFileTypes: true });
|
|
1523
|
+
} catch {
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
const platforms = entries
|
|
1527
|
+
.filter(
|
|
1528
|
+
(entry) =>
|
|
1529
|
+
entry.isDirectory() &&
|
|
1530
|
+
!entry.name.endsWith(".lock") &&
|
|
1531
|
+
!entry.isSymbolicLink(),
|
|
1532
|
+
)
|
|
1533
|
+
.map((entry) => entry.name)
|
|
1534
|
+
.sort();
|
|
1535
|
+
for (const platform of platforms) {
|
|
1536
|
+
const found = findLicenseInDir(join(providerDir, platform), allowedRoot);
|
|
1537
|
+
if (found) {
|
|
1538
|
+
applyLicense(ctx, pkg, found.file, found.text);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Locate a license file in one directory, in the documented name order.
|
|
1546
|
+
*
|
|
1547
|
+
* @param {string} dir Directory to inspect
|
|
1548
|
+
* @param {string} allowedRoot Read containment root
|
|
1549
|
+
* @returns {undefined|{ file: string, text: string }} License descriptor
|
|
1550
|
+
*/
|
|
1551
|
+
function findLicenseInDir(dir, allowedRoot) {
|
|
1552
|
+
const names = listDirNames(dir);
|
|
1553
|
+
if (!names) {
|
|
1554
|
+
return undefined;
|
|
1555
|
+
}
|
|
1556
|
+
const lower = new Map(names.map((name) => [name.toLowerCase(), name]));
|
|
1557
|
+
for (const candidate of LICENSE_CANDIDATES) {
|
|
1558
|
+
const actual = lower.get(candidate.toLowerCase());
|
|
1559
|
+
if (!actual) {
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
const file = join(dir, actual);
|
|
1563
|
+
const text = readFileWithin(file, allowedRoot, LICENSE_MAX_BYTES);
|
|
1564
|
+
if (text !== undefined) {
|
|
1565
|
+
return { file, text };
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return undefined;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Record an identified license on a component.
|
|
1573
|
+
*
|
|
1574
|
+
* @param {object} ctx Per-root context
|
|
1575
|
+
* @param {object} pkg Component
|
|
1576
|
+
* @param {string} file Absolute license path
|
|
1577
|
+
* @param {string} text License text
|
|
1578
|
+
*/
|
|
1579
|
+
function applyLicense(ctx, pkg, file, text) {
|
|
1580
|
+
const rel = posixRelative(ctx.state.scanPath, file);
|
|
1581
|
+
const id = licenseIdFromText(text);
|
|
1582
|
+
if (id && !pkg.license) {
|
|
1583
|
+
pkg.license = id;
|
|
1584
|
+
pkg.properties.push({ name: "cdx:tf:licenseSource", value: "file" });
|
|
1585
|
+
}
|
|
1586
|
+
pkg.properties.push({ name: "cdx:tf:licenseFile", value: rel });
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Merge a component into the workspace state, deduplicating by bom-ref.
|
|
1591
|
+
*
|
|
1592
|
+
* @param {object} state Workspace accumulator
|
|
1593
|
+
* @param {object} pkg Component
|
|
1594
|
+
* @returns {object} The merged component
|
|
1595
|
+
*/
|
|
1596
|
+
function mergeIntoState(state, pkg) {
|
|
1597
|
+
const ref = pkg["bom-ref"];
|
|
1598
|
+
const existing = state.byRef.get(ref);
|
|
1599
|
+
if (!existing) {
|
|
1600
|
+
state.byRef.set(ref, pkg);
|
|
1601
|
+
if (!state.edges.has(ref)) {
|
|
1602
|
+
state.edges.set(ref, new Set());
|
|
1603
|
+
}
|
|
1604
|
+
return pkg;
|
|
1605
|
+
}
|
|
1606
|
+
mergeProperties(existing, pkg.properties || []);
|
|
1607
|
+
for (const occurrence of pkg.evidence?.occurrences || []) {
|
|
1608
|
+
addOccurrence(existing, occurrence);
|
|
1609
|
+
}
|
|
1610
|
+
// A component seen again (another root, or the installed copy of a module
|
|
1611
|
+
// first seen as declared-only) may carry what the first emission could not
|
|
1612
|
+
// read: a license file, more lock digests, a stronger identity. Its
|
|
1613
|
+
// `cdx:tf:licenseSource` property is merged above, so the license it names
|
|
1614
|
+
// must come along, or the BOM claims a file license it does not carry.
|
|
1615
|
+
if (!existing.license && pkg.license) {
|
|
1616
|
+
existing.license = pkg.license;
|
|
1617
|
+
}
|
|
1618
|
+
if (pkg.hashes?.length) {
|
|
1619
|
+
const known = new Set(
|
|
1620
|
+
(existing.hashes || []).map((hash) => `${hash.alg}:${hash.content}`),
|
|
1621
|
+
);
|
|
1622
|
+
for (const hash of pkg.hashes) {
|
|
1623
|
+
if (!known.has(`${hash.alg}:${hash.content}`)) {
|
|
1624
|
+
known.add(`${hash.alg}:${hash.content}`);
|
|
1625
|
+
(existing.hashes = existing.hashes || []).push(hash);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
if (!existing.distribution && pkg.distribution) {
|
|
1630
|
+
existing.distribution = pkg.distribution;
|
|
1631
|
+
}
|
|
1632
|
+
const incomingIdentity = pkg.evidence?.identity;
|
|
1633
|
+
if (
|
|
1634
|
+
incomingIdentity &&
|
|
1635
|
+
(incomingIdentity.confidence || 0) >
|
|
1636
|
+
(existing.evidence?.identity?.confidence || 0)
|
|
1637
|
+
) {
|
|
1638
|
+
existing.evidence = { ...existing.evidence, identity: incomingIdentity };
|
|
1639
|
+
}
|
|
1640
|
+
return existing;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Union two property lists by name+value, with `installed=true` replacing
|
|
1645
|
+
* `installed=false`.
|
|
1646
|
+
*
|
|
1647
|
+
* @param {object} existing Component accumulated so far
|
|
1648
|
+
* @param {object[]} incoming Properties of the duplicate
|
|
1649
|
+
*/
|
|
1650
|
+
function mergeProperties(existing, incoming) {
|
|
1651
|
+
const merged = [...(existing.properties || [])];
|
|
1652
|
+
for (const prop of incoming) {
|
|
1653
|
+
if (prop.name === "cdx:tf:module:installed") {
|
|
1654
|
+
const index = merged.findIndex(
|
|
1655
|
+
(p) => p.name === "cdx:tf:module:installed",
|
|
1656
|
+
);
|
|
1657
|
+
if (index === -1) {
|
|
1658
|
+
merged.push(prop);
|
|
1659
|
+
} else if (merged[index].value !== "true" && prop.value === "true") {
|
|
1660
|
+
merged.splice(index, 1, prop);
|
|
1661
|
+
}
|
|
1662
|
+
continue;
|
|
1663
|
+
}
|
|
1664
|
+
if (!merged.some((p) => p.name === prop.name && p.value === prop.value)) {
|
|
1665
|
+
merged.push(prop);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
existing.properties = merged;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* Add one occurrence, deduplicated and capped.
|
|
1673
|
+
*
|
|
1674
|
+
* @param {object} pkg Component
|
|
1675
|
+
* @param {{ location: string, line?: number, symbol: string }} occurrence
|
|
1676
|
+
*/
|
|
1677
|
+
function addOccurrence(pkg, occurrence) {
|
|
1678
|
+
pkg.evidence = pkg.evidence || {
|
|
1679
|
+
identity: {
|
|
1680
|
+
field: "purl",
|
|
1681
|
+
confidence: 0.6,
|
|
1682
|
+
methods: [{ technique: "manifest-analysis", confidence: 0.6, value: "" }],
|
|
1683
|
+
},
|
|
1684
|
+
};
|
|
1685
|
+
const occurrences = (pkg.evidence.occurrences =
|
|
1686
|
+
pkg.evidence.occurrences || []);
|
|
1687
|
+
const key = `${occurrence.location}|${occurrence.line || ""}|${occurrence.symbol}`;
|
|
1688
|
+
if (
|
|
1689
|
+
occurrences.some((o) => `${o.location}|${o.line || ""}|${o.symbol}` === key)
|
|
1690
|
+
) {
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
occurrences.push(occurrence);
|
|
1694
|
+
occurrences.sort(compareOccurrences);
|
|
1695
|
+
if (occurrences.length > MAX_OCCURRENCES) {
|
|
1696
|
+
occurrences.length = MAX_OCCURRENCES;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Order occurrences by location, then line, then symbol, so the list and the
|
|
1702
|
+
* cap applied to it are the same on every run.
|
|
1703
|
+
*
|
|
1704
|
+
* @param {{ location: string, line?: number, symbol: string }} a
|
|
1705
|
+
* @param {{ location: string, line?: number, symbol: string }} b
|
|
1706
|
+
* @returns {number}
|
|
1707
|
+
*/
|
|
1708
|
+
function compareOccurrences(a, b) {
|
|
1709
|
+
if (a.location !== b.location) {
|
|
1710
|
+
return a.location < b.location ? -1 : 1;
|
|
1711
|
+
}
|
|
1712
|
+
const lineDiff = (a.line || 0) - (b.line || 0);
|
|
1713
|
+
if (lineDiff) {
|
|
1714
|
+
return lineDiff;
|
|
1715
|
+
}
|
|
1716
|
+
if (a.symbol === b.symbol) {
|
|
1717
|
+
return 0;
|
|
1718
|
+
}
|
|
1719
|
+
return a.symbol < b.symbol ? -1 : 1;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* Add a dependency edge.
|
|
1724
|
+
*
|
|
1725
|
+
* @param {object} state Workspace accumulator
|
|
1726
|
+
* @param {string} from Ref that depends
|
|
1727
|
+
* @param {string} to Ref depended on
|
|
1728
|
+
*/
|
|
1729
|
+
function addEdge(state, from, to) {
|
|
1730
|
+
if (!from || !to || from === to) {
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (!state.edges.has(from)) {
|
|
1734
|
+
state.edges.set(from, new Set());
|
|
1735
|
+
}
|
|
1736
|
+
state.edges.get(from).add(to);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Read a component property.
|
|
1741
|
+
*
|
|
1742
|
+
* @param {object} pkg Component
|
|
1743
|
+
* @param {string} name Property name
|
|
1744
|
+
* @returns {string|undefined}
|
|
1745
|
+
*/
|
|
1746
|
+
function findProperty(pkg, name) {
|
|
1747
|
+
return (pkg.properties || []).find((prop) => prop.name === name)?.value;
|
|
1748
|
+
}
|