@cdxgen/cdxgen 13.1.0 → 13.3.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 +32 -15
- package/bin/cdxgen.js +243 -122
- package/bin/evinse.js +7 -0
- package/bin/sign.js +88 -14
- package/bin/tracebom.js +64 -4
- package/bin/validate.js +29 -7
- package/bin/verify.js +79 -39
- package/data/crypto-oid.json +88 -12
- package/data/remediations.json +47 -0
- package/data/rules/ai-bom.yaml +78 -0
- package/data/rules/cargo-rust.yaml +132 -0
- package/data/rules/mcp-servers.yaml +122 -0
- package/lib/audit/targets.js +6 -1
- package/lib/cli/bomAssembly.js +117 -15
- package/lib/cli/cliOptions.js +93 -1
- package/lib/cli/index.js +235 -5
- package/lib/cli/jsBom.js +108 -26
- package/lib/cli/jvmBom.js +68 -12
- package/lib/cli/managedBom.js +264 -41
- package/lib/cli/nativeBom.js +923 -15
- package/lib/core/activity.js +120 -2
- package/lib/core/env.js +44 -7
- package/lib/core/fs.js +174 -6
- package/lib/core/metadataProperties.js +97 -0
- package/lib/core/projectConfig.js +31 -4
- package/lib/core/userAgent.js +151 -0
- package/lib/ecosystems/bunutils.js +269 -101
- package/lib/ecosystems/cargoCache.js +364 -0
- package/lib/ecosystems/caxa.js +447 -7
- package/lib/ecosystems/cmakeResolver.js +66 -0
- package/lib/ecosystems/cppEvidence.js +104 -33
- package/lib/ecosystems/ecosystems.js +260 -99
- package/lib/ecosystems/golem.js +11 -1
- package/lib/ecosystems/gradleutils.js +185 -9
- package/lib/ecosystems/jsEvidence.js +19 -20
- package/lib/ecosystems/kosi.js +1263 -0
- package/lib/ecosystems/npmutils.js +110 -9
- package/lib/ecosystems/parsers-crystal.js +179 -0
- package/lib/ecosystems/parsers-dotnet.js +120 -49
- package/lib/ecosystems/parsers-elm.js +816 -0
- package/lib/ecosystems/parsers-gleam.js +1 -1
- package/lib/ecosystems/parsers-go.js +47 -9
- package/lib/ecosystems/parsers-js.js +171 -50
- package/lib/ecosystems/parsers-julia.js +268 -0
- package/lib/ecosystems/parsers-lua.js +218 -0
- package/lib/ecosystems/parsers-misc.js +919 -161
- package/lib/ecosystems/parsers-nim.js +221 -0
- package/lib/ecosystems/parsers-opam.js +180 -0
- package/lib/ecosystems/parsers-perl.js +277 -0
- package/lib/ecosystems/parsers-python.js +27 -15
- package/lib/ecosystems/parsers-r.js +148 -0
- package/lib/ecosystems/parsers-rebar.js +411 -0
- package/lib/ecosystems/parsers-rust.js +954 -33
- package/lib/ecosystems/parsers-spack.js +204 -0
- package/lib/ecosystems/parsers-stack.js +264 -0
- package/lib/ecosystems/parsers-terraform.js +194 -0
- package/lib/ecosystems/rubyutils.js +1426 -166
- package/lib/ecosystems/rusi.js +158 -7
- package/lib/evinser/evinser.js +547 -129
- package/lib/evinser/swiftsem.js +1914 -302
- package/lib/helpers/agenticToolutils.js +925 -0
- package/lib/helpers/bomSigner.js +652 -221
- package/lib/inventory/agentFormulationParser.js +57 -4
- package/lib/inventory/aiCollector.js +504 -10
- package/lib/inventory/aiInventory.js +12 -0
- package/lib/inventory/analyzer.js +488 -30
- package/lib/inventory/astWorker.js +6 -0
- package/lib/inventory/atomUtils.js +568 -31
- package/lib/inventory/auditCategories.js +8 -0
- package/lib/inventory/cbomutils.js +30 -0
- package/lib/inventory/cdxrs.js +110 -14
- package/lib/inventory/communityAiConfigParser.js +215 -2
- package/lib/inventory/depsUtils.js +183 -11
- package/lib/inventory/display.js +4 -5
- package/lib/inventory/dosai.js +627 -13
- package/lib/inventory/dynamic.js +39 -1
- package/lib/inventory/envcontext.js +9 -6
- package/lib/inventory/evidenceUtils.js +26 -5
- package/lib/inventory/fetchBatch.js +190 -19
- package/lib/inventory/fetchRate.js +74 -11
- package/lib/inventory/forgeEnricher.js +3 -2
- package/lib/inventory/jvmToolEnv.js +6 -5
- package/lib/inventory/mcpConfigParser.js +166 -2
- package/lib/inventory/mcpDiscovery.js +11 -0
- package/lib/inventory/mcpServerManifest.js +389 -0
- package/lib/inventory/mcpbBundle.js +419 -0
- package/lib/inventory/plugins.js +3 -0
- package/lib/inventory/protobom.js +62 -89
- package/lib/inventory/protobomLoader.js +4 -0
- package/lib/inventory/purl.js +152 -3
- package/lib/inventory/rustFormulationParser.js +1 -1
- package/lib/inventory/sbomDocument.js +2 -70
- package/lib/inventory/source.js +47 -23
- package/lib/inventory/stylePackageRefs.js +41 -0
- package/lib/inventory/svelteUtils.js +524 -0
- package/lib/inventory/traceRunner.js +210 -21
- package/lib/managers/binary.js +115 -24
- package/lib/managers/docker.js +440 -20
- package/lib/managers/oci.js +39 -1
- package/lib/parsers/cmakeLists.js +452 -0
- package/lib/parsers/huggingfaceUtils.js +15 -7
- package/lib/parsers/largeJson.js +454 -0
- package/lib/parsers/npmrc.js +19 -1
- package/lib/parsers/onnx.js +294 -0
- package/lib/parsers/safetensors.js +114 -0
- package/lib/stages/postgen/introspection/markers.js +29 -6
- package/lib/stages/postgen/postgen.js +237 -16
- package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +462 -0
- package/lib/validator/bomValidator.js +6 -16
- package/lib/validator/index.js +48 -10
- package/lib/validator/reporters/console.js +3 -0
- package/lib/validator/reporters/json.js +1 -0
- package/lib/validator/reporters/sarif.js +7 -0
- package/package.json +27 -27
- package/types/lib/audit/targets.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 +2 -2
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts +23 -0
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts +33 -0
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts +88 -8
- 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 +21 -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/metadataProperties.d.ts +39 -0
- package/types/lib/core/metadataProperties.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/userAgent.d.ts +56 -0
- package/types/lib/core/userAgent.d.ts.map +1 -0
- 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 +75 -0
- package/types/lib/ecosystems/cargoCache.d.ts.map +1 -0
- 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 +23 -0
- package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/cppEvidence.d.ts +2 -2
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts +15 -1
- 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 +40 -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 +86 -0
- package/types/lib/ecosystems/kosi.d.ts.map +1 -0
- package/types/lib/ecosystems/npmutils.d.ts +42 -0
- package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-crystal.d.ts +27 -0
- package/types/lib/ecosystems/parsers-crystal.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-dotnet.d.ts +19 -0
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-elm.d.ts +79 -0
- package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-go.d.ts +10 -0
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +10 -4
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-julia.d.ts +57 -0
- package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-lua.d.ts +50 -0
- package/types/lib/ecosystems/parsers-lua.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-misc.d.ts +90 -3
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-nim.d.ts +43 -0
- package/types/lib/ecosystems/parsers-nim.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-opam.d.ts +31 -0
- package/types/lib/ecosystems/parsers-opam.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-perl.d.ts +36 -0
- package/types/lib/ecosystems/parsers-perl.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-r.d.ts +35 -0
- package/types/lib/ecosystems/parsers-r.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-rebar.d.ts +42 -0
- package/types/lib/ecosystems/parsers-rebar.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-rust.d.ts +199 -0
- package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-spack.d.ts +33 -0
- package/types/lib/ecosystems/parsers-spack.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-stack.d.ts +35 -0
- package/types/lib/ecosystems/parsers-stack.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-terraform.d.ts +34 -0
- package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -0
- package/types/lib/ecosystems/pythonutils.d.ts +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +143 -2
- 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/evinser/evinser.d.ts +81 -2
- 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/agenticToolutils.d.ts +87 -0
- package/types/lib/helpers/agenticToolutils.d.ts.map +1 -0
- package/types/lib/helpers/bomSigner.d.ts +106 -13
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/inventory/agentFormulationParser.d.ts.map +1 -1
- package/types/lib/inventory/aiCollector.d.ts.map +1 -1
- package/types/lib/inventory/aiInventory.d.ts.map +1 -1
- package/types/lib/inventory/analyzer.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +145 -3
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/auditCategories.d.ts +4 -0
- package/types/lib/inventory/auditCategories.d.ts.map +1 -1
- package/types/lib/inventory/cbomutils.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/communityAiConfigParser.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 +87 -1
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/envcontext.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts +34 -0
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/fetchRate.d.ts +9 -6
- package/types/lib/inventory/fetchRate.d.ts.map +1 -1
- package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
- package/types/lib/inventory/jvmToolEnv.d.ts +5 -4
- package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
- package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/mcpDiscovery.d.ts.map +1 -1
- package/types/lib/inventory/mcpServerManifest.d.ts +19 -0
- package/types/lib/inventory/mcpServerManifest.d.ts.map +1 -0
- package/types/lib/inventory/mcpbBundle.d.ts +19 -0
- package/types/lib/inventory/mcpbBundle.d.ts.map +1 -0
- package/types/lib/inventory/plugins.d.ts.map +1 -1
- package/types/lib/inventory/protobom.d.ts +18 -13
- package/types/lib/inventory/protobom.d.ts.map +1 -1
- package/types/lib/inventory/protobomLoader.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/sbomDocument.d.ts.map +1 -1
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/stylePackageRefs.d.ts +17 -0
- package/types/lib/inventory/stylePackageRefs.d.ts.map +1 -0
- package/types/lib/inventory/svelteUtils.d.ts +124 -0
- package/types/lib/inventory/svelteUtils.d.ts.map +1 -0
- package/types/lib/inventory/traceRunner.d.ts +47 -1
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- 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 +14 -0
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/parsers/cmakeLists.d.ts +76 -0
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
- package/types/lib/parsers/huggingfaceUtils.d.ts +8 -1
- package/types/lib/parsers/huggingfaceUtils.d.ts.map +1 -1
- 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/onnx.d.ts +28 -0
- package/types/lib/parsers/onnx.d.ts.map +1 -0
- package/types/lib/parsers/safetensors.d.ts +22 -0
- package/types/lib/parsers/safetensors.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/markers.d.ts +3 -2
- package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts +33 -0
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +10 -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
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
import { DEBUG_MODE, readEnvironmentVariable } from "../core/activity.js";
|
|
6
|
+
import {
|
|
7
|
+
getTmpDir,
|
|
8
|
+
safeExistsSync,
|
|
9
|
+
safeMkdtempSync,
|
|
10
|
+
safeRmSync,
|
|
11
|
+
safeSpawnSync,
|
|
12
|
+
} from "../core/fs.js";
|
|
13
|
+
import { resolvePluginBinary } from "../inventory/plugins.js";
|
|
14
|
+
import {
|
|
15
|
+
parsePurlLoose,
|
|
16
|
+
tryBuildPurl,
|
|
17
|
+
tryParsePurl,
|
|
18
|
+
} from "../inventory/purl.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* kosi — JS bridge to the Kotlin-native evidence binary (thirdparty/kosi in
|
|
22
|
+
* cdxgen-plugins-bin). This module is the ONLY place that spawns kosi. It
|
|
23
|
+
* follows the cdxrs discipline: no failure mode may abort SBOM generation.
|
|
24
|
+
* A missing binary, a non-zero exit, a timeout or CDXGEN_KOSI_DISABLE logs
|
|
25
|
+
* once and returns undefined so callers keep the plain JS evidence.
|
|
26
|
+
*
|
|
27
|
+
* The consumed report is kosi's `analyze` JSON (schema kosi/1): usages[],
|
|
28
|
+
* callGraph, dataFlow.slices[], crypto and services[]; the attribute
|
|
29
|
+
* contract is thirdparty/kosi/JSON_ATTRIBUTE_REFERENCE.md.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const KOTLIN_LANGUAGES = new Set(["kotlin", "kt", "kotlin-lang"]);
|
|
33
|
+
|
|
34
|
+
/** Longest wait for a kosi analyze run. Large repos degrade, not hang. */
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds the workspace component candidate for one kosi module.
|
|
39
|
+
*
|
|
40
|
+
* kosi derives module names from Gradle settings files, so a quoted literal
|
|
41
|
+
* (rootProject.name = 'KotlinGoat') rides into the reported purl. A purl
|
|
42
|
+
* cdx-purl rejects anchors nothing and must not reach the BOM: keep the
|
|
43
|
+
* reported purl when it parses, rebuild one from the cleaned coordinates
|
|
44
|
+
* when it does not, and return undefined when neither works.
|
|
45
|
+
*
|
|
46
|
+
* @param {Object|undefined} workspaceModule One entry of kosi's modules[].
|
|
47
|
+
* @returns {Object|undefined} Component candidate with a valid purl, or undefined.
|
|
48
|
+
*/
|
|
49
|
+
export function kosiWorkspaceComponent(workspaceModule) {
|
|
50
|
+
if (!workspaceModule?.purl) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
// The version is read through the purl parser: cutting the text after `@`
|
|
54
|
+
// kept its escaping (`1.0.0%2Bbuild`) and any `?type=jar` qualifiers.
|
|
55
|
+
const version =
|
|
56
|
+
parsePurlLoose(workspaceModule.purl)?.version || "unspecified";
|
|
57
|
+
const cleanName = (workspaceModule.name || "").replace(/^['"]|['"]+$/g, "");
|
|
58
|
+
const purl =
|
|
59
|
+
tryParsePurl(workspaceModule.purl) ||
|
|
60
|
+
tryBuildPurl({ type: "generic", name: cleanName, version });
|
|
61
|
+
if (!purl) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
type: "application",
|
|
66
|
+
name: cleanName || workspaceModule.name,
|
|
67
|
+
version,
|
|
68
|
+
purl,
|
|
69
|
+
"bom-ref": decodeURIComponent(purl),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isKosiKotlinLanguage(language) {
|
|
74
|
+
return KOTLIN_LANGUAGES.has(String(language || "").toLowerCase());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* True when kosi is disabled by CDXGEN_KOSI_DISABLE (1/all/true) or --no-kosi.
|
|
79
|
+
* The check happens before any binary resolution so a disabled run never
|
|
80
|
+
* even looks for the plugin.
|
|
81
|
+
*/
|
|
82
|
+
export function kosiDisabled() {
|
|
83
|
+
const raw = (
|
|
84
|
+
readEnvironmentVariable("CDXGEN_KOSI_DISABLE") || ""
|
|
85
|
+
).toLowerCase();
|
|
86
|
+
if (raw === "1" || raw === "true" || raw === "all") {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return process.argv.includes("--no-kosi");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the kosi command: KOSI_CMD wins (may be a native binary or the
|
|
94
|
+
* kosi-all.jar), then the bundled plugin binary.
|
|
95
|
+
*/
|
|
96
|
+
function kosiCommand() {
|
|
97
|
+
const cmd = resolvePluginBinary("kosi");
|
|
98
|
+
if (!cmd) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (cmd.endsWith(".jar")) {
|
|
102
|
+
return { cmd: "java", args: ["-Djava.awt.headless=true", "-jar", cmd] };
|
|
103
|
+
}
|
|
104
|
+
return { cmd, args: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Builds the kosi argv for one analyze mode.
|
|
109
|
+
*/
|
|
110
|
+
function kosiArgs(kosi, src, outputFile, dataflowMode) {
|
|
111
|
+
const args = [
|
|
112
|
+
...kosi.args,
|
|
113
|
+
"analyze",
|
|
114
|
+
"--backend",
|
|
115
|
+
"resolved",
|
|
116
|
+
"--dataflow",
|
|
117
|
+
dataflowMode,
|
|
118
|
+
"--deps",
|
|
119
|
+
"--endpoint-sources",
|
|
120
|
+
"--dir",
|
|
121
|
+
resolve(src),
|
|
122
|
+
"--out",
|
|
123
|
+
outputFile,
|
|
124
|
+
];
|
|
125
|
+
const classpathFile = join(resolve(src), "classpath.txt");
|
|
126
|
+
if (safeExistsSync(classpathFile)) {
|
|
127
|
+
args.push("--classpath-file", classpathFile);
|
|
128
|
+
}
|
|
129
|
+
return args;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function runKosi(kosi, src, args, outputFile, options) {
|
|
133
|
+
const result = safeSpawnSync(kosi.cmd, args, {
|
|
134
|
+
cwd: resolve(src),
|
|
135
|
+
shell: false,
|
|
136
|
+
timeout: options.kosiTimeoutMs || DEFAULT_TIMEOUT_MS,
|
|
137
|
+
});
|
|
138
|
+
if (result?.status !== 0 || !safeExistsSync(outputFile)) {
|
|
139
|
+
if (DEBUG_MODE) {
|
|
140
|
+
console.error(result?.stdout, result?.stderr);
|
|
141
|
+
} else {
|
|
142
|
+
console.log(
|
|
143
|
+
"kosi: analyze did not produce a report; skipping the Kotlin native evidence.",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
return JSON.parse(readFileSync(outputFile, "utf-8"));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Orchestrates the kosi runs over src and returns the parsed reports: one
|
|
153
|
+
* `all` pass (every slice kind, dependency tier) and one `reachable` pass
|
|
154
|
+
* (call-graph reachability from the roots). The analysis is read-only and
|
|
155
|
+
* offline: kosi never executes the project's build. A classpath.txt beside
|
|
156
|
+
* the sources (the documented build-tool report) is passed through when
|
|
157
|
+
* present.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} src Directory to analyze.
|
|
160
|
+
* @param {Object} options Configuration options.
|
|
161
|
+
* @returns {Object|undefined} { report, reachableReport } or undefined.
|
|
162
|
+
*/
|
|
163
|
+
export function analyzeKosiProject(src, options = {}) {
|
|
164
|
+
if (kosiDisabled()) {
|
|
165
|
+
console.log(
|
|
166
|
+
"kosi: disabled via CDXGEN_KOSI_DISABLE; skipping the Kotlin native evidence.",
|
|
167
|
+
);
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const kosi = kosiCommand();
|
|
171
|
+
if (!kosi) {
|
|
172
|
+
console.log(
|
|
173
|
+
"kosi: plugin binary not found; skipping the Kotlin native evidence.",
|
|
174
|
+
);
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
const tempDir = safeMkdtempSync(join(getTmpDir(), "kosi-"));
|
|
178
|
+
try {
|
|
179
|
+
const outputFile = join(tempDir, "kosi-all.json");
|
|
180
|
+
const allArgs = kosiArgs(kosi, src, outputFile, "all");
|
|
181
|
+
if (DEBUG_MODE) {
|
|
182
|
+
console.log("Executing", kosi.cmd, allArgs.join(" "));
|
|
183
|
+
}
|
|
184
|
+
const report = runKosi(kosi, src, allArgs, outputFile, options);
|
|
185
|
+
if (!report) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
// The reachable pass answers a different question (call-graph
|
|
189
|
+
// reachability from the roots): its slice set is the intersection
|
|
190
|
+
// with that reachability, so its evidence is a strict subset of the
|
|
191
|
+
// all-pass's. Losing it would silently drop the filtered evidence,
|
|
192
|
+
// so a failure here names itself but keeps the all-pass evidence.
|
|
193
|
+
const reachableFile = join(tempDir, "kosi-reachable.json");
|
|
194
|
+
const reachableArgs = kosiArgs(kosi, src, reachableFile, "reachable");
|
|
195
|
+
const reachableReport = runKosi(
|
|
196
|
+
kosi,
|
|
197
|
+
src,
|
|
198
|
+
reachableArgs,
|
|
199
|
+
reachableFile,
|
|
200
|
+
options,
|
|
201
|
+
);
|
|
202
|
+
return { report, reachableReport };
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (DEBUG_MODE) {
|
|
205
|
+
console.error("kosi: analyze failed", error);
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
} finally {
|
|
209
|
+
safeRmSync(tempDir, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function mergeSetMap(into, from) {
|
|
214
|
+
for (const [key, values] of Object.entries(from || {})) {
|
|
215
|
+
for (const value of values) {
|
|
216
|
+
addSetValue(into, key, value);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function mergeFramesMap(into, from) {
|
|
222
|
+
for (const [key, frames] of Object.entries(from || {})) {
|
|
223
|
+
into[key] ??= [];
|
|
224
|
+
into[key].push(...frames);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function mergePropertiesMap(into, from) {
|
|
229
|
+
for (const [key, properties] of Object.entries(from || {})) {
|
|
230
|
+
into[key] ??= [];
|
|
231
|
+
for (const property of properties) {
|
|
232
|
+
appendUniqueProperty(into[key], property.name, property.value);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Merges the reachable pass's evidence into the all pass's maps, mutating
|
|
239
|
+
* the first argument.
|
|
240
|
+
*
|
|
241
|
+
* The reachable pass's slices are the all pass's INTERSECTED with call-graph
|
|
242
|
+
* reachability from the roots, so every key it produces is a key the all pass
|
|
243
|
+
* already has, and the unions below cannot add one. That made the second full
|
|
244
|
+
* `--backend resolved --deps` analysis — the most expensive thing this arm
|
|
245
|
+
* runs, and it runs it twice — contribute exactly nothing: the one fact it
|
|
246
|
+
* computes, reachability, was discarded at the merge (the P23 review's R144).
|
|
247
|
+
* It is recorded now, on the components whose evidence survived the
|
|
248
|
+
* intersection, which is the signal an SBOM consumer prioritises with. kosi's
|
|
249
|
+
* own schema deliberately stopped carrying `reachableFromRoots` per slice in
|
|
250
|
+
* P22 because there it was a mode-level fact wearing a per-slice field's
|
|
251
|
+
* clothes; here it is neither, it is the measured difference between two runs.
|
|
252
|
+
*/
|
|
253
|
+
export function mergeKosiEvidence(all, reachable) {
|
|
254
|
+
if (!reachable) {
|
|
255
|
+
return all;
|
|
256
|
+
}
|
|
257
|
+
for (const purl of Object.keys(reachable.purlLocationMap || {})) {
|
|
258
|
+
addPropertyValue(
|
|
259
|
+
all.componentPropertiesMap,
|
|
260
|
+
purl,
|
|
261
|
+
"cdx:kosi:reachableFromRoots",
|
|
262
|
+
"true",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
mergeSetMap(all.purlLocationMap, reachable.purlLocationMap);
|
|
266
|
+
mergeFramesMap(all.dataFlowFrames, reachable.dataFlowFrames);
|
|
267
|
+
mergePropertiesMap(
|
|
268
|
+
all.componentPropertiesMap,
|
|
269
|
+
reachable.componentPropertiesMap,
|
|
270
|
+
);
|
|
271
|
+
for (const component of reachable.cryptoComponents || []) {
|
|
272
|
+
if (
|
|
273
|
+
!all.cryptoComponents.some(
|
|
274
|
+
(existing) => existing["bom-ref"] === component["bom-ref"],
|
|
275
|
+
)
|
|
276
|
+
) {
|
|
277
|
+
all.cryptoComponents.push(component);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return all;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function appendUniqueProperty(properties, name, value) {
|
|
284
|
+
if (value === undefined || value === null || value === "") {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const propertyValue = String(value);
|
|
288
|
+
if (
|
|
289
|
+
!properties.some(
|
|
290
|
+
(property) => property.name === name && property.value === propertyValue,
|
|
291
|
+
)
|
|
292
|
+
) {
|
|
293
|
+
properties.push({ name, value: propertyValue });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function addSetValue(map, key, value) {
|
|
298
|
+
if (!key || !value) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
map[key] ??= new Set();
|
|
302
|
+
map[key].add(value);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function addPropertyValue(map, key, name, value) {
|
|
306
|
+
if (!key || value === undefined || value === null || value === "") {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
map[key] ??= [];
|
|
310
|
+
appendUniqueProperty(map[key], name, value);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function positionLocation(position) {
|
|
314
|
+
if (!position?.filename) {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
if (position.line && position.line > 0) {
|
|
318
|
+
return `${position.filename}#${position.line}`;
|
|
319
|
+
}
|
|
320
|
+
return position.filename;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function purlWithoutVersion(purl) {
|
|
324
|
+
return purl?.split("?")[0].split("#")[0].split("@")[0];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function createPurlAliasMap(components = []) {
|
|
328
|
+
const purlAliasMap = new Map();
|
|
329
|
+
const nameIndex = new Map();
|
|
330
|
+
for (const component of components) {
|
|
331
|
+
if (!component?.purl) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
purlAliasMap.set(component.purl, component.purl);
|
|
335
|
+
const noVersionPurl = purlWithoutVersion(component.purl);
|
|
336
|
+
if (noVersionPurl && !purlAliasMap.has(noVersionPurl)) {
|
|
337
|
+
purlAliasMap.set(noVersionPurl, component.purl);
|
|
338
|
+
}
|
|
339
|
+
// kosi names jars from an offline classpath as pkg:generic/<file>@<v>
|
|
340
|
+
// (thirdparty/kosi/JSON_ATTRIBUTE_REFERENCE.md). Join those to the
|
|
341
|
+
// resolved component by NAME: "timber-5.0.1" -> the timber component.
|
|
342
|
+
if (component.name) {
|
|
343
|
+
nameIndex.set(String(component.name).toLowerCase(), component.purl);
|
|
344
|
+
const artifact = String(component.purl).match(
|
|
345
|
+
/pkg:[^/]+\/[^/]+\/([^/@?]+)@/,
|
|
346
|
+
);
|
|
347
|
+
if (artifact && !nameIndex.has(artifact[1].toLowerCase())) {
|
|
348
|
+
nameIndex.set(artifact[1].toLowerCase(), component.purl);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { purlAliasMap, nameIndex };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function resolveComponentPurl(purl, purlAliasMap) {
|
|
356
|
+
if (!purl) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
return purlAliasMap.get(purl) || purlAliasMap.get(purlWithoutVersion(purl));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The offline-analysis join: a pkg:generic purl resolves through the name
|
|
364
|
+
* index (exact, then with a trailing version stripped). Unresolved purls
|
|
365
|
+
* stay unresolved — no purl is invented.
|
|
366
|
+
*/
|
|
367
|
+
function resolveLoosePurl(purl, nameIndex) {
|
|
368
|
+
if (!purl?.startsWith("pkg:generic/")) {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
let candidate = purl.slice("pkg:generic/".length).split("@")[0].split("?")[0];
|
|
372
|
+
candidate = decodeURIComponent(candidate).toLowerCase();
|
|
373
|
+
const hit = nameIndex.get(candidate);
|
|
374
|
+
if (hit) {
|
|
375
|
+
return hit;
|
|
376
|
+
}
|
|
377
|
+
const stripped = candidate.replace(/-v?\d+(?:\.\d+)*$/u, "");
|
|
378
|
+
return stripped === candidate ? undefined : nameIndex.get(stripped);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function resolveKosiPurl(purl, purlAliasMap, nameIndex) {
|
|
382
|
+
return (
|
|
383
|
+
resolveComponentPurl(purl, purlAliasMap) ||
|
|
384
|
+
resolveLoosePurl(purl, nameIndex)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function frameFromNode(node = {}) {
|
|
389
|
+
if (!node.filePath) {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
package: node.modulePath || node.purl || "",
|
|
394
|
+
module: node.kind || "",
|
|
395
|
+
function: node.name || "",
|
|
396
|
+
line: node.position?.line || undefined,
|
|
397
|
+
column: node.position?.column || undefined,
|
|
398
|
+
fullFilename: node.filePath,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* P24: kosi's per-slice `frames[]` are the named hops —
|
|
404
|
+
* (function, file, line, role), source first, sink last, with the
|
|
405
|
+
* callee-internal hops spliced in at every summary boundary. They are a
|
|
406
|
+
* BETTER callstack than the node-derived one (every hop names its
|
|
407
|
+
* function, not just its register), so they are preferred whenever the
|
|
408
|
+
* report carries them; the node walk stays as the pre-P24 fallback.
|
|
409
|
+
*/
|
|
410
|
+
function framesFromSlice(slice) {
|
|
411
|
+
if (!Array.isArray(slice.frames) || !slice.frames.length) {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
const frames = [];
|
|
415
|
+
for (const frame of slice.frames) {
|
|
416
|
+
if (!frame?.file) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
frames.push({
|
|
420
|
+
package: "",
|
|
421
|
+
module: frame.role || "",
|
|
422
|
+
function: frame.function || "",
|
|
423
|
+
line: frame.line || undefined,
|
|
424
|
+
column: undefined,
|
|
425
|
+
fullFilename: frame.file,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return frames.length ? frames : undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function frameLocationKey(frame) {
|
|
432
|
+
if (!frame?.fullFilename) {
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
return `${frame.fullFilename}#${frame.line || ""}#${frame.column || ""}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function dedupeFrames(frames = []) {
|
|
439
|
+
const seen = new Set();
|
|
440
|
+
const out = [];
|
|
441
|
+
for (const frame of frames) {
|
|
442
|
+
const key = frameLocationKey(frame);
|
|
443
|
+
if (!key || seen.has(key)) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
seen.add(key);
|
|
447
|
+
out.push(frame);
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function addFrame(dataFlowFrames, purl, frame) {
|
|
453
|
+
if (!purl || !frame) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
dataFlowFrames[purl] ??= [];
|
|
457
|
+
dataFlowFrames[purl].push([frame]);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function cryptoBomRef(kind, name, detail) {
|
|
461
|
+
return `crypto/kosi/${kind}/${encodeURIComponent(name)}@${encodeURIComponent(detail || name)}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* kosi's exit contract (kosi-cli Main.kt): `analyze` exits 0 whenever a
|
|
466
|
+
* report was written — a degraded run (budget trip, callgraph-failed, even
|
|
467
|
+
* ERROR-severity diagnostics) is DATA in the report, printed to stderr but
|
|
468
|
+
* never a crash. A non-zero exit means usage error or an uncaught failure,
|
|
469
|
+
* and in both cases no report file exists. So runKosi's discard on
|
|
470
|
+
* status !== 0 never throws away a partial report — there is never one to
|
|
471
|
+
* throw away — and a report that made it this far is always the complete
|
|
472
|
+
* record of what kosi did, degradations included. Those degradations are
|
|
473
|
+
* joined onto the BOM below; before that they stopped at the report and a
|
|
474
|
+
* half-read repository produced a BOM indistinguishable from a fully-read
|
|
475
|
+
* one (the same defect kosi itself was fixed for, one layer up).
|
|
476
|
+
*/
|
|
477
|
+
function aggregateDiagnostics(kosiReport = {}) {
|
|
478
|
+
const byCode = new Map();
|
|
479
|
+
for (const diagnostic of kosiReport.diagnostics || []) {
|
|
480
|
+
if (!diagnostic?.code) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
// An entry without a count is one event (no-sources); a counted entry
|
|
484
|
+
// carries its own multiplicity (resolution-errors per file, count =
|
|
485
|
+
// errors in it), so the sum is the population the code affected.
|
|
486
|
+
const entry = byCode.get(diagnostic.code) ?? { count: 0, error: false };
|
|
487
|
+
entry.count += diagnostic.count ?? 1;
|
|
488
|
+
// Severity is the half a bare count cannot carry. `callgraph-failed`
|
|
489
|
+
// is an ERROR — the call graph is ABSENT from the report — while
|
|
490
|
+
// `resolution-errors` is a warning about imperfect typing on a run
|
|
491
|
+
// that otherwise completed. Published as one count each, they read
|
|
492
|
+
// alike, and a consumer sorting by count reads the warning as the
|
|
493
|
+
// bigger problem. Error wins for a code once any entry carries it.
|
|
494
|
+
entry.error = entry.error || diagnostic.severity === "error";
|
|
495
|
+
byCode.set(diagnostic.code, entry);
|
|
496
|
+
}
|
|
497
|
+
return byCode;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function addMetadataProperties(properties, kosiReport = {}) {
|
|
501
|
+
appendUniqueProperty(
|
|
502
|
+
properties,
|
|
503
|
+
"cdx:kosi:schemaVersion",
|
|
504
|
+
kosiReport.schemaVersion,
|
|
505
|
+
);
|
|
506
|
+
appendUniqueProperty(
|
|
507
|
+
properties,
|
|
508
|
+
"cdx:kosi:toolVersion",
|
|
509
|
+
kosiReport.tool?.version,
|
|
510
|
+
);
|
|
511
|
+
appendUniqueProperty(
|
|
512
|
+
properties,
|
|
513
|
+
"cdx:kosi:kotlinVersion",
|
|
514
|
+
kosiReport.runtime?.kotlinVersion,
|
|
515
|
+
);
|
|
516
|
+
// Signals are counted at the BOM level as well as attached to components,
|
|
517
|
+
// because one of them cannot be attached: a cinterop `.def` file carries no
|
|
518
|
+
// purl — it is not a module source — and attaching it to nothing would make
|
|
519
|
+
// a real finding disappear between the report and the BOM.
|
|
520
|
+
const signals = kosiReport.securitySignals || [];
|
|
521
|
+
if (signals.length) {
|
|
522
|
+
appendUniqueProperty(
|
|
523
|
+
properties,
|
|
524
|
+
"cdx:kosi:securitySignalCount",
|
|
525
|
+
String(signals.length),
|
|
526
|
+
);
|
|
527
|
+
for (const code of Array.from(
|
|
528
|
+
new Set(signals.map((signal) => signal?.code).filter(Boolean)),
|
|
529
|
+
).sort()) {
|
|
530
|
+
appendUniqueProperty(properties, "cdx:kosi:securitySignal", code);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const stats = kosiReport.stats || {};
|
|
534
|
+
// A run kosi itself marked incomplete. `no-jdk` means the resolved tier
|
|
535
|
+
// attached no JDK, so every `java.*` symbol resolved to nothing;
|
|
536
|
+
// `kotlin-version` means a version mismatch coincided with heavy
|
|
537
|
+
// resolution fallout. Either way the counts below are not facts about the
|
|
538
|
+
// code, and a BOM that carries the counts without the caveat is the
|
|
539
|
+
// failure this property exists to prevent.
|
|
540
|
+
if (stats.degraded) {
|
|
541
|
+
appendUniqueProperty(properties, "cdx:kosi:degraded", stats.degraded);
|
|
542
|
+
}
|
|
543
|
+
if ((stats.sliceCount ?? 0) > 0) {
|
|
544
|
+
appendUniqueProperty(properties, "cdx:kosi:sliceCount", stats.sliceCount);
|
|
545
|
+
}
|
|
546
|
+
if ((stats.crossDependencySliceCount ?? 0) > 0) {
|
|
547
|
+
appendUniqueProperty(
|
|
548
|
+
properties,
|
|
549
|
+
"cdx:kosi:crossDependencySliceCount",
|
|
550
|
+
stats.crossDependencySliceCount,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const dataFlowStats = kosiReport.dataFlow?.stats || {};
|
|
554
|
+
if ((dataFlowStats.bytecodeSummaries ?? 0) > 0) {
|
|
555
|
+
appendUniqueProperty(
|
|
556
|
+
properties,
|
|
557
|
+
"cdx:kosi:bytecodeSummariesApplied",
|
|
558
|
+
dataFlowStats.bytecodeSummaries,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
// Source coverage: discovery against what is on disk, published WITH its
|
|
562
|
+
// denominators (kosi's own rule — a rate without its denominator is not a
|
|
563
|
+
// result). The `source-coverage-gap` diagnostic fires on nonTestRatio;
|
|
564
|
+
// the numbers let a consumer judge runs the threshold stayed silent on.
|
|
565
|
+
const coverage = stats.sourceCoverage;
|
|
566
|
+
if (coverage) {
|
|
567
|
+
appendUniqueProperty(
|
|
568
|
+
properties,
|
|
569
|
+
"cdx:kosi:sourceCoverageDiscovered",
|
|
570
|
+
coverage.discovered,
|
|
571
|
+
);
|
|
572
|
+
appendUniqueProperty(
|
|
573
|
+
properties,
|
|
574
|
+
"cdx:kosi:sourceCoveragePresent",
|
|
575
|
+
coverage.present,
|
|
576
|
+
);
|
|
577
|
+
appendUniqueProperty(
|
|
578
|
+
properties,
|
|
579
|
+
"cdx:kosi:sourceCoverageTestPresent",
|
|
580
|
+
coverage.testPresent,
|
|
581
|
+
);
|
|
582
|
+
appendUniqueProperty(
|
|
583
|
+
properties,
|
|
584
|
+
"cdx:kosi:sourceCoverageRatio",
|
|
585
|
+
coverage.ratio,
|
|
586
|
+
);
|
|
587
|
+
appendUniqueProperty(
|
|
588
|
+
properties,
|
|
589
|
+
"cdx:kosi:sourceCoverageNonTestRatio",
|
|
590
|
+
coverage.nonTestRatio,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
// Every degradation kosi suffered, named by its diagnostic code and
|
|
594
|
+
// counted. Silence here was the join losing the caveat: an endpoint kosi
|
|
595
|
+
// explicitly marked unread (endpoint-unsubstantiated), a budget trip
|
|
596
|
+
// (analysis-time-budget, rss-budget), a coverage gap — none of it reached
|
|
597
|
+
// the BOM, so the SaaSBOM's services and the metadata read exactly like a
|
|
598
|
+
// fully-analysed run's.
|
|
599
|
+
const diagnostics = aggregateDiagnostics(kosiReport);
|
|
600
|
+
for (const [code, entry] of diagnostics) {
|
|
601
|
+
appendUniqueProperty(
|
|
602
|
+
properties,
|
|
603
|
+
`cdx:kosi:diagnostic:${code}`,
|
|
604
|
+
entry.count,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
// Which of those codes were ERRORS, in one property a consumer can read
|
|
608
|
+
// without knowing kosi's diagnostic vocabulary. An error means a piece of
|
|
609
|
+
// the analysis is ABSENT from this report (the call graph crashed, a file
|
|
610
|
+
// could not be read), not merely imperfect — and that is the difference a
|
|
611
|
+
// per-code count on its own cannot express.
|
|
612
|
+
const errorCodes = [...diagnostics]
|
|
613
|
+
.filter(([, entry]) => entry.error)
|
|
614
|
+
.map(([code]) => code)
|
|
615
|
+
.sort();
|
|
616
|
+
if (errorCodes.length) {
|
|
617
|
+
appendUniqueProperty(
|
|
618
|
+
properties,
|
|
619
|
+
"cdx:kosi:diagnosticErrors",
|
|
620
|
+
errorCodes.join(","),
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Occurrence evidence: every workspace/library usage with a resolved purl
|
|
627
|
+
* and a source position.
|
|
628
|
+
*/
|
|
629
|
+
function addUsageEvidence(
|
|
630
|
+
kosiReport,
|
|
631
|
+
purlAliasMap,
|
|
632
|
+
nameIndex,
|
|
633
|
+
purlLocationMap,
|
|
634
|
+
dataFlowFrames,
|
|
635
|
+
) {
|
|
636
|
+
for (const usage of kosiReport.usages || []) {
|
|
637
|
+
const purl = resolveKosiPurl(usage.purl, purlAliasMap, nameIndex);
|
|
638
|
+
if (!purl) {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
addSetValue(purlLocationMap, purl, positionLocation(usage.position));
|
|
642
|
+
addFrame(dataFlowFrames, purl, {
|
|
643
|
+
package: usage.purl || "",
|
|
644
|
+
module: usage.kind || "",
|
|
645
|
+
function: usage.name || "",
|
|
646
|
+
line: usage.position?.line || undefined,
|
|
647
|
+
column: usage.position?.column || undefined,
|
|
648
|
+
fullFilename: usage.position?.filename,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Callstack, reachability and data-flow evidence from the slices: each
|
|
655
|
+
* slice's trace walks source -> sink; the frames are the callstack, kosi's
|
|
656
|
+
* per-slice `pathKind` says what that trace IS (complete | partial |
|
|
657
|
+
* symbol-only), and the rule identities are the data-flow classification.
|
|
658
|
+
* Reachability is NOT here: it is the difference between the two passes and
|
|
659
|
+
* is stamped in `mergeKosiEvidence`. This sentence said "the
|
|
660
|
+
* reachableFromRoots flag is the reachability evidence" for a field kosi
|
|
661
|
+
* deleted in P22, seventy lines above the code that had already stopped
|
|
662
|
+
* reading it.
|
|
663
|
+
*/
|
|
664
|
+
function addDataFlowEvidence(
|
|
665
|
+
kosiReport,
|
|
666
|
+
purlAliasMap,
|
|
667
|
+
nameIndex,
|
|
668
|
+
purlLocationMap,
|
|
669
|
+
dataFlowFrames,
|
|
670
|
+
componentPropertiesMap,
|
|
671
|
+
) {
|
|
672
|
+
const dataFlow = kosiReport.dataFlow || {};
|
|
673
|
+
const nodeMap = new Map();
|
|
674
|
+
for (const node of dataFlow.nodes || []) {
|
|
675
|
+
nodeMap.set(node.id, node);
|
|
676
|
+
}
|
|
677
|
+
const dataFlowCounts = {};
|
|
678
|
+
for (const slice of dataFlow.slices || []) {
|
|
679
|
+
const purls = new Set();
|
|
680
|
+
for (const value of slice.purls || []) {
|
|
681
|
+
const resolvedPurl = resolveKosiPurl(value, purlAliasMap, nameIndex);
|
|
682
|
+
if (resolvedPurl) {
|
|
683
|
+
purls.add(resolvedPurl);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
for (const nodeId of slice.nodeIds || []) {
|
|
687
|
+
const node = nodeMap.get(nodeId);
|
|
688
|
+
const resolvedPurl = resolveKosiPurl(node?.purl, purlAliasMap, nameIndex);
|
|
689
|
+
if (resolvedPurl) {
|
|
690
|
+
purls.add(resolvedPurl);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (!purls.size) {
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
// P24: the named hops first; the node walk only when the report
|
|
697
|
+
// predates frames[].
|
|
698
|
+
const frames =
|
|
699
|
+
framesFromSlice(slice) ||
|
|
700
|
+
(slice.nodeIds || [])
|
|
701
|
+
.map((nodeId) => frameFromNode(nodeMap.get(nodeId)))
|
|
702
|
+
.filter(Boolean);
|
|
703
|
+
const deduped = dedupeFrames(frames);
|
|
704
|
+
const category = [slice.sourceCategory, slice.sinkCategory]
|
|
705
|
+
.filter(Boolean)
|
|
706
|
+
.join("->");
|
|
707
|
+
for (const purl of purls) {
|
|
708
|
+
dataFlowCounts[purl] = (dataFlowCounts[purl] || 0) + 1;
|
|
709
|
+
const sourceNode = nodeMap.get(slice.sourceId);
|
|
710
|
+
const sinkNode = nodeMap.get(slice.sinkId);
|
|
711
|
+
addSetValue(
|
|
712
|
+
purlLocationMap,
|
|
713
|
+
purl,
|
|
714
|
+
positionLocation(sourceNode?.position),
|
|
715
|
+
);
|
|
716
|
+
addSetValue(purlLocationMap, purl, positionLocation(sinkNode?.position));
|
|
717
|
+
if (deduped.length) {
|
|
718
|
+
dataFlowFrames[purl] ??= [];
|
|
719
|
+
dataFlowFrames[purl].push(deduped);
|
|
720
|
+
}
|
|
721
|
+
addPropertyValue(
|
|
722
|
+
componentPropertiesMap,
|
|
723
|
+
purl,
|
|
724
|
+
"cdx:kosi:dataFlowCategories",
|
|
725
|
+
category,
|
|
726
|
+
);
|
|
727
|
+
addPropertyValue(
|
|
728
|
+
componentPropertiesMap,
|
|
729
|
+
purl,
|
|
730
|
+
"cdx:kosi:dataFlowRuleName",
|
|
731
|
+
slice.ruleName,
|
|
732
|
+
);
|
|
733
|
+
// P22: kosi replaced the constant-false reachableFromRoots flag with
|
|
734
|
+
// pathKind (complete | partial | symbol-only) — what the slice's
|
|
735
|
+
// trace IS, published per slice instead of a mode-level fact.
|
|
736
|
+
if (slice.pathKind) {
|
|
737
|
+
addPropertyValue(
|
|
738
|
+
componentPropertiesMap,
|
|
739
|
+
purl,
|
|
740
|
+
"cdx:kosi:pathKind",
|
|
741
|
+
slice.pathKind,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (slice.crossesDependency) {
|
|
745
|
+
addPropertyValue(
|
|
746
|
+
componentPropertiesMap,
|
|
747
|
+
purl,
|
|
748
|
+
"cdx:kosi:crossesDependency",
|
|
749
|
+
"true",
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
for (const [purl, count] of Object.entries(dataFlowCounts)) {
|
|
755
|
+
addPropertyValue(
|
|
756
|
+
componentPropertiesMap,
|
|
757
|
+
purl,
|
|
758
|
+
"cdx:kosi:dataFlowSliceCount",
|
|
759
|
+
count,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Crypto-flow evidence: kosi's crypto collector yields the CBOM assets and
|
|
766
|
+
* materials; crypto-FLOW slices connect material stores to sinks.
|
|
767
|
+
*/
|
|
768
|
+
/**
|
|
769
|
+
* kosi's `securitySignals[]` codes, with the severity each one carries. The
|
|
770
|
+
* vocabulary is CLOSED (kosi's 02-ARCHITECTURE.md §8), so this is a table and
|
|
771
|
+
* not a guess; a code kosi adds later arrives here as an unknown and is
|
|
772
|
+
* published with its code and no severity rather than silently dropped.
|
|
773
|
+
*
|
|
774
|
+
* kosi's signal shape is not golem's — `code`/`message`/`symbol` against
|
|
775
|
+
* golem's `category`/`severity` — which is why the golem reader could not
|
|
776
|
+
* simply be pointed at it, and why these signals reached no BOM at all while
|
|
777
|
+
* golem's did. kosi's report contract described the field as reserved and
|
|
778
|
+
* empty long after it began emitting, which is the likeliest reason nobody
|
|
779
|
+
* wired it up.
|
|
780
|
+
*/
|
|
781
|
+
const KOSI_SIGNAL_SEVERITY = {
|
|
782
|
+
"native-interop": "info",
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Non-flow findings: the JNI seams, `loadLibrary` binding sites and cinterop
|
|
787
|
+
* `.def` files kosi reports. Each signal already names the purl and module it
|
|
788
|
+
* attaches to, so no symbol-to-module resolution is needed — unlike golem,
|
|
789
|
+
* where the package path has to be matched back to a module.
|
|
790
|
+
*/
|
|
791
|
+
function addSecuritySignalEvidence(
|
|
792
|
+
kosiReport,
|
|
793
|
+
purlAliasMap,
|
|
794
|
+
nameIndex,
|
|
795
|
+
purlLocationMap,
|
|
796
|
+
componentPropertiesMap,
|
|
797
|
+
) {
|
|
798
|
+
for (const signal of kosiReport.securitySignals || []) {
|
|
799
|
+
const code = signal?.code;
|
|
800
|
+
if (!code) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const purl = resolveKosiPurl(signal.purl, purlAliasMap, nameIndex);
|
|
804
|
+
if (!purl) {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
addPropertyValue(
|
|
808
|
+
componentPropertiesMap,
|
|
809
|
+
purl,
|
|
810
|
+
"cdx:kosi:securitySignalCode",
|
|
811
|
+
code,
|
|
812
|
+
);
|
|
813
|
+
addPropertyValue(
|
|
814
|
+
componentPropertiesMap,
|
|
815
|
+
purl,
|
|
816
|
+
"cdx:kosi:securitySignalSeverity",
|
|
817
|
+
KOSI_SIGNAL_SEVERITY[code],
|
|
818
|
+
);
|
|
819
|
+
addPropertyValue(
|
|
820
|
+
componentPropertiesMap,
|
|
821
|
+
purl,
|
|
822
|
+
"cdx:kosi:securitySignalSymbol",
|
|
823
|
+
signal.symbol,
|
|
824
|
+
);
|
|
825
|
+
// A `.def` file carries a repo-relative path and no module of its own,
|
|
826
|
+
// so the filePath is the only location it has.
|
|
827
|
+
const location =
|
|
828
|
+
positionLocation(signal.position) ||
|
|
829
|
+
(signal.filePath ? signal.filePath : undefined);
|
|
830
|
+
if (location) {
|
|
831
|
+
addSetValue(purlLocationMap, purl, location);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function addCryptoEvidence(
|
|
837
|
+
kosiReport,
|
|
838
|
+
purlAliasMap,
|
|
839
|
+
nameIndex,
|
|
840
|
+
cryptoComponentsByRef,
|
|
841
|
+
componentPropertiesMap,
|
|
842
|
+
) {
|
|
843
|
+
const crypto = kosiReport.crypto || {};
|
|
844
|
+
for (const asset of crypto.assets || []) {
|
|
845
|
+
const component = {
|
|
846
|
+
type: "cryptographic-asset",
|
|
847
|
+
name: asset.algorithm || asset.symbol || "unknown",
|
|
848
|
+
"bom-ref": cryptoBomRef(
|
|
849
|
+
"algorithm",
|
|
850
|
+
asset.algorithm || asset.symbol || "unknown",
|
|
851
|
+
asset.form || "literal",
|
|
852
|
+
),
|
|
853
|
+
description: "Cryptographic algorithm detected by kosi source analysis",
|
|
854
|
+
cryptoProperties: {
|
|
855
|
+
assetType: "algorithm",
|
|
856
|
+
algorithmProperties: { primitive: asset.kind || "unknown" },
|
|
857
|
+
},
|
|
858
|
+
properties: [],
|
|
859
|
+
};
|
|
860
|
+
appendUniqueProperty(
|
|
861
|
+
component.properties,
|
|
862
|
+
"cdx:kosi:crypto:form",
|
|
863
|
+
asset.form,
|
|
864
|
+
);
|
|
865
|
+
appendUniqueProperty(
|
|
866
|
+
component.properties,
|
|
867
|
+
"cdx:kosi:crypto:resolution",
|
|
868
|
+
asset.resolution,
|
|
869
|
+
);
|
|
870
|
+
cryptoComponentsByRef.set(component["bom-ref"], component);
|
|
871
|
+
}
|
|
872
|
+
for (const material of crypto.materials || []) {
|
|
873
|
+
const component = {
|
|
874
|
+
type: "cryptographic-asset",
|
|
875
|
+
name: material.name || "material",
|
|
876
|
+
"bom-ref": cryptoBomRef(
|
|
877
|
+
"material",
|
|
878
|
+
material.name || "material",
|
|
879
|
+
material.kind || "secret",
|
|
880
|
+
),
|
|
881
|
+
description:
|
|
882
|
+
"Cryptographic material indicator detected by kosi source analysis",
|
|
883
|
+
cryptoProperties: {
|
|
884
|
+
assetType: "related-crypto-material",
|
|
885
|
+
relatedCryptoMaterialProperties: { type: material.kind || "secret" },
|
|
886
|
+
},
|
|
887
|
+
properties: [],
|
|
888
|
+
};
|
|
889
|
+
appendUniqueProperty(
|
|
890
|
+
component.properties,
|
|
891
|
+
"cdx:kosi:crypto:function",
|
|
892
|
+
material.function,
|
|
893
|
+
);
|
|
894
|
+
cryptoComponentsByRef.set(component["bom-ref"], component);
|
|
895
|
+
}
|
|
896
|
+
for (const slice of kosiReport.dataFlow?.slices || []) {
|
|
897
|
+
const isCryptoFlow =
|
|
898
|
+
(slice.sourceCategory || "").startsWith("hardcoded") ||
|
|
899
|
+
slice.sinkCategory === "crypto-asset" ||
|
|
900
|
+
slice.sinkCategory === "insecure-tls";
|
|
901
|
+
if (!isCryptoFlow) {
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
for (const purl of slice.purls || []) {
|
|
905
|
+
// The SAME join every other evidence kind uses. This site alone used
|
|
906
|
+
// the exact-match resolver, so a crypto flow whose purl is an offline
|
|
907
|
+
// `pkg:generic/...` one — which is every workspace-local flow, and the
|
|
908
|
+
// hardcoded-secret -> crypto-asset flow on the sample is exactly that
|
|
909
|
+
// — silently attached to nothing while the other four kinds landed.
|
|
910
|
+
const resolvedPurl = resolveKosiPurl(purl, purlAliasMap, nameIndex);
|
|
911
|
+
if (resolvedPurl) {
|
|
912
|
+
addPropertyValue(
|
|
913
|
+
componentPropertiesMap,
|
|
914
|
+
resolvedPurl,
|
|
915
|
+
"cdx:kosi:cryptoFlow",
|
|
916
|
+
`${slice.sourceCategory}->${slice.sinkCategory}`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Occurrence evidence for one service call site. Mirrors the dosai shape:
|
|
925
|
+
* `evidence.occurrences[]` is a CycloneDX 2.0 field that spec-version
|
|
926
|
+
* compatibility strips below 2.0, so the same location also lands in a
|
|
927
|
+
* property where it survives at 1.6 and 1.7.
|
|
928
|
+
*/
|
|
929
|
+
function addServiceOccurrence(definition, filePath, position, enclosingSymbol) {
|
|
930
|
+
const path = filePath || position?.filename;
|
|
931
|
+
if (!path) {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const location = {
|
|
935
|
+
path,
|
|
936
|
+
line: position?.line || undefined,
|
|
937
|
+
column: position?.column || undefined,
|
|
938
|
+
};
|
|
939
|
+
definition.evidence ??= { occurrences: [] };
|
|
940
|
+
definition.evidence.occurrences ??= [];
|
|
941
|
+
const key = `${location.path}#${location.line}#${location.column}`;
|
|
942
|
+
const known = definition.evidence.occurrences;
|
|
943
|
+
if (
|
|
944
|
+
!known.some(
|
|
945
|
+
(seen) =>
|
|
946
|
+
`${seen?.location?.path}#${seen?.location?.line}#${seen?.location?.column}` ===
|
|
947
|
+
key,
|
|
948
|
+
)
|
|
949
|
+
) {
|
|
950
|
+
known.push({ location });
|
|
951
|
+
}
|
|
952
|
+
definition.properties ??= [];
|
|
953
|
+
appendUniqueProperty(
|
|
954
|
+
definition.properties,
|
|
955
|
+
"cdx:kosi:service:location",
|
|
956
|
+
`${location.path}:${location.line || 0}:${location.column || 0}`,
|
|
957
|
+
);
|
|
958
|
+
appendUniqueProperty(
|
|
959
|
+
definition.properties,
|
|
960
|
+
"cdx:kosi:service:enclosingSymbol",
|
|
961
|
+
enclosingSymbol,
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* kosi's `urls[]` rows, indexed by the value they carry. Every outbound call
|
|
967
|
+
* site appears here with its file, position, enclosing symbol and resolution,
|
|
968
|
+
* while `services[]` carries ONE position for the service as a whole — so a
|
|
969
|
+
* service reached from three places gets three occurrences only through this
|
|
970
|
+
* index. Both spellings are indexed (`url` is the normalised value, `raw` the
|
|
971
|
+
* source text) because a service's `endpoints[]` may match either.
|
|
972
|
+
*/
|
|
973
|
+
function urlEvidenceIndex(kosiReport = {}) {
|
|
974
|
+
const index = new Map();
|
|
975
|
+
for (const row of kosiReport.urls || []) {
|
|
976
|
+
for (const value of [row?.url, row?.raw]) {
|
|
977
|
+
if (!value) {
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
if (!index.has(value)) {
|
|
981
|
+
index.set(value, []);
|
|
982
|
+
}
|
|
983
|
+
const rows = index.get(value);
|
|
984
|
+
if (!rows.includes(row)) {
|
|
985
|
+
rows.push(row);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return index;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Services evidence: kosi's outbound services[] rows are already
|
|
994
|
+
* CycloneDX-shaped.
|
|
995
|
+
*/
|
|
996
|
+
export function collectKosiServices(kosiReport = {}, servicesMap = {}) {
|
|
997
|
+
const urlIndex = urlEvidenceIndex(kosiReport);
|
|
998
|
+
for (const service of kosiReport.services || []) {
|
|
999
|
+
if (!service?.id || !service?.name) {
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
const definition = (servicesMap[service.id] ??= {
|
|
1003
|
+
name: service.name,
|
|
1004
|
+
bomRef: service.id,
|
|
1005
|
+
endpoints: new Set(),
|
|
1006
|
+
properties: [],
|
|
1007
|
+
});
|
|
1008
|
+
for (const endpoint of service.endpoints || []) {
|
|
1009
|
+
definition.endpoints.add(endpoint);
|
|
1010
|
+
}
|
|
1011
|
+
if (typeof definition.authenticated === "undefined") {
|
|
1012
|
+
definition.authenticated = service.authenticated ?? undefined;
|
|
1013
|
+
}
|
|
1014
|
+
// kosi publishes the HYPHENATED CycloneDX key, not the camelCase one this
|
|
1015
|
+
// read used, so every kosi service lost its trust boundary — the property
|
|
1016
|
+
// was never present under the name being asked for. It also belongs in
|
|
1017
|
+
// `x-trust-boundary`, which the normaliser maps to the CycloneDX field;
|
|
1018
|
+
// `trustZone` is a different concept and put the flag somewhere no
|
|
1019
|
+
// consumer reads it as one.
|
|
1020
|
+
const trustBoundary = service["x-trust-boundary"] ?? service.xTrustBoundary;
|
|
1021
|
+
if (
|
|
1022
|
+
typeof trustBoundary === "boolean" &&
|
|
1023
|
+
typeof definition["x-trust-boundary"] === "undefined"
|
|
1024
|
+
) {
|
|
1025
|
+
definition["x-trust-boundary"] = trustBoundary;
|
|
1026
|
+
}
|
|
1027
|
+
// Where the call was made. The service's own position is one call site;
|
|
1028
|
+
// the url rows carry the rest, with the function each one sits in.
|
|
1029
|
+
addServiceOccurrence(
|
|
1030
|
+
definition,
|
|
1031
|
+
service.position?.filename,
|
|
1032
|
+
service.position,
|
|
1033
|
+
);
|
|
1034
|
+
for (const endpoint of service.endpoints || []) {
|
|
1035
|
+
for (const row of urlIndex.get(endpoint) || []) {
|
|
1036
|
+
addServiceOccurrence(
|
|
1037
|
+
definition,
|
|
1038
|
+
row.filePath,
|
|
1039
|
+
row.position,
|
|
1040
|
+
row.enclosingSymbol,
|
|
1041
|
+
);
|
|
1042
|
+
appendUniqueProperty(
|
|
1043
|
+
definition.properties,
|
|
1044
|
+
"cdx:kosi:service:urlResolution",
|
|
1045
|
+
row.resolution,
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
appendUniqueProperty(
|
|
1050
|
+
definition.properties,
|
|
1051
|
+
"cdx:kosi:service:protocol",
|
|
1052
|
+
service.protocol,
|
|
1053
|
+
);
|
|
1054
|
+
appendUniqueProperty(
|
|
1055
|
+
definition.properties,
|
|
1056
|
+
"cdx:kosi:service:resolution",
|
|
1057
|
+
service.resolution,
|
|
1058
|
+
);
|
|
1059
|
+
appendUniqueProperty(
|
|
1060
|
+
definition.properties,
|
|
1061
|
+
"cdx:kosi:service:clientLibrary",
|
|
1062
|
+
service.clientLibrary,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
return servicesMap;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* INBOUND endpoints: kosi's apiEndpoints[] rows are the routes the analysed
|
|
1070
|
+
* application itself serves, which `services[]` (kosi's OUTBOUND calls)
|
|
1071
|
+
* does not carry. Without this the whole inbound surface — the path
|
|
1072
|
+
* template, the framework that declared it, and above all whether anything
|
|
1073
|
+
* authenticates it — stopped at the kosi report and never reached the
|
|
1074
|
+
* SaaSBOM.
|
|
1075
|
+
*
|
|
1076
|
+
* One service per endpoint, named the way detectServicesFromOpenAPI names
|
|
1077
|
+
* its own (`service-<path>-<method>`), so an OpenAPI spec and a kosi run
|
|
1078
|
+
* over the same route converge on one entry instead of two.
|
|
1079
|
+
*
|
|
1080
|
+
* `authenticated` is left UNDEFINED when the endpoint declares no
|
|
1081
|
+
* authentication: kosi's empty list means "nothing was declared here",
|
|
1082
|
+
* which is not the same claim as "this route is unauthenticated", and
|
|
1083
|
+
* cdxgen's own detectors use undefined for exactly that unknown.
|
|
1084
|
+
*
|
|
1085
|
+
* @param {Object} kosiReport The parsed JSON generated by kosi.
|
|
1086
|
+
* @param {Object} servicesMap Map populated with detected service definitions
|
|
1087
|
+
* @returns {Object} The mutated services map
|
|
1088
|
+
*/
|
|
1089
|
+
export function collectKosiApiEndpoints(kosiReport = {}, servicesMap = {}) {
|
|
1090
|
+
for (const endpoint of kosiReport.apiEndpoints || []) {
|
|
1091
|
+
const pathTemplate = endpoint?.pathTemplate;
|
|
1092
|
+
if (!pathTemplate) {
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
// kosi's schema field is httpMethod (SINGULAR) — reading a plural
|
|
1096
|
+
// `httpMethods` silently fell to ["ALL"] for every endpoint, losing the
|
|
1097
|
+
// verb and splitting one route into kosi's service-x-all beside an
|
|
1098
|
+
// OpenAPI spec's service-x-get: the duplicate the shared naming exists
|
|
1099
|
+
// to prevent. Found by running the join on a real kosi report (P16 §4).
|
|
1100
|
+
const methods = endpoint.httpMethod?.length ? endpoint.httpMethod : ["ALL"];
|
|
1101
|
+
for (const httpMethod of methods) {
|
|
1102
|
+
const serviceName = `service-${pathTemplate.replaceAll("/", "")}-${httpMethod.toLowerCase()}`;
|
|
1103
|
+
const definition = (servicesMap[serviceName] ??= {
|
|
1104
|
+
name: serviceName,
|
|
1105
|
+
endpoints: new Set(),
|
|
1106
|
+
authenticated: undefined,
|
|
1107
|
+
xTrustBoundary: undefined,
|
|
1108
|
+
properties: [],
|
|
1109
|
+
});
|
|
1110
|
+
definition.properties ??= [];
|
|
1111
|
+
definition.endpoints ??= new Set();
|
|
1112
|
+
definition.endpoints.add(pathTemplate);
|
|
1113
|
+
if (endpoint.authentication?.length) {
|
|
1114
|
+
definition.authenticated = true;
|
|
1115
|
+
definition.xTrustBoundary = true;
|
|
1116
|
+
for (const scheme of endpoint.authentication) {
|
|
1117
|
+
appendUniqueProperty(
|
|
1118
|
+
definition.properties,
|
|
1119
|
+
"cdx:kosi:endpoint:authentication",
|
|
1120
|
+
scheme,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
appendUniqueProperty(
|
|
1125
|
+
definition.properties,
|
|
1126
|
+
"cdx:service:httpMethod",
|
|
1127
|
+
httpMethod,
|
|
1128
|
+
);
|
|
1129
|
+
appendUniqueProperty(
|
|
1130
|
+
definition.properties,
|
|
1131
|
+
"cdx:kosi:endpoint:framework",
|
|
1132
|
+
endpoint.framework,
|
|
1133
|
+
);
|
|
1134
|
+
appendUniqueProperty(
|
|
1135
|
+
definition.properties,
|
|
1136
|
+
"cdx:kosi:endpoint:handler",
|
|
1137
|
+
endpoint.handlerCanonicalName || endpoint.handlerSymbol,
|
|
1138
|
+
);
|
|
1139
|
+
// The caveat kosi spent rounds learning to say: `substantiated:
|
|
1140
|
+
// false` means the manifest DECLARES this endpoint and kosi read none
|
|
1141
|
+
// of the code behind it (a library component, or a run that discovered
|
|
1142
|
+
// no sources). The endpoint is still published — the manifest is real
|
|
1143
|
+
// — but as `false` on the service, so the SaaSBOM no longer renders an
|
|
1144
|
+
// unread attack surface identical to a fully-analysed one. Only false
|
|
1145
|
+
// is stamped: absence keeps meaning "read", which is every other
|
|
1146
|
+
// endpoint, and a positive property on all of them is noise.
|
|
1147
|
+
if (endpoint.substantiated === false) {
|
|
1148
|
+
appendUniqueProperty(
|
|
1149
|
+
definition.properties,
|
|
1150
|
+
"cdx:kosi:endpoint:substantiated",
|
|
1151
|
+
"false",
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
for (const mediaType of endpoint.consumes || []) {
|
|
1155
|
+
appendUniqueProperty(
|
|
1156
|
+
definition.properties,
|
|
1157
|
+
"cdx:kosi:endpoint:consumes",
|
|
1158
|
+
mediaType,
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
for (const mediaType of endpoint.produces || []) {
|
|
1162
|
+
appendUniqueProperty(
|
|
1163
|
+
definition.properties,
|
|
1164
|
+
"cdx:kosi:endpoint:produces",
|
|
1165
|
+
mediaType,
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return servicesMap;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Extracts and maps CycloneDX evidence structures from a kosi report.
|
|
1175
|
+
*
|
|
1176
|
+
* @param {Object} kosiReport The parsed JSON generated by kosi.
|
|
1177
|
+
* @param {Array} components The components present in the SBOM.
|
|
1178
|
+
* @returns {Object} Maps representing evidence structures.
|
|
1179
|
+
*/
|
|
1180
|
+
function addImportEvidence(
|
|
1181
|
+
kosiReport,
|
|
1182
|
+
purlAliasMap,
|
|
1183
|
+
nameIndex,
|
|
1184
|
+
purlLocationMap,
|
|
1185
|
+
dataFlowFrames,
|
|
1186
|
+
) {
|
|
1187
|
+
for (const imported of kosiReport.imports || []) {
|
|
1188
|
+
const purl = resolveKosiPurl(imported.purl, purlAliasMap, nameIndex);
|
|
1189
|
+
if (!purl) {
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
addSetValue(purlLocationMap, purl, positionLocation(imported.position));
|
|
1193
|
+
addFrame(dataFlowFrames, purl, {
|
|
1194
|
+
package: imported.purl || "",
|
|
1195
|
+
module: "import",
|
|
1196
|
+
function: imported.name || "",
|
|
1197
|
+
line: imported.position?.line || undefined,
|
|
1198
|
+
column: imported.position?.column || undefined,
|
|
1199
|
+
fullFilename: imported.position?.filename,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
export function collectKosiEvidence(kosiReport = {}, components = []) {
|
|
1205
|
+
const { purlAliasMap, nameIndex } = createPurlAliasMap(components);
|
|
1206
|
+
const purlLocationMap = {};
|
|
1207
|
+
const dataFlowFrames = {};
|
|
1208
|
+
const componentPropertiesMap = {};
|
|
1209
|
+
const metadataProperties = [];
|
|
1210
|
+
const cryptoComponentsByRef = new Map();
|
|
1211
|
+
|
|
1212
|
+
addMetadataProperties(metadataProperties, kosiReport);
|
|
1213
|
+
addImportEvidence(
|
|
1214
|
+
kosiReport,
|
|
1215
|
+
purlAliasMap,
|
|
1216
|
+
nameIndex,
|
|
1217
|
+
purlLocationMap,
|
|
1218
|
+
dataFlowFrames,
|
|
1219
|
+
);
|
|
1220
|
+
addUsageEvidence(
|
|
1221
|
+
kosiReport,
|
|
1222
|
+
purlAliasMap,
|
|
1223
|
+
nameIndex,
|
|
1224
|
+
purlLocationMap,
|
|
1225
|
+
dataFlowFrames,
|
|
1226
|
+
);
|
|
1227
|
+
addDataFlowEvidence(
|
|
1228
|
+
kosiReport,
|
|
1229
|
+
purlAliasMap,
|
|
1230
|
+
nameIndex,
|
|
1231
|
+
purlLocationMap,
|
|
1232
|
+
dataFlowFrames,
|
|
1233
|
+
componentPropertiesMap,
|
|
1234
|
+
);
|
|
1235
|
+
addCryptoEvidence(
|
|
1236
|
+
kosiReport,
|
|
1237
|
+
purlAliasMap,
|
|
1238
|
+
nameIndex,
|
|
1239
|
+
cryptoComponentsByRef,
|
|
1240
|
+
componentPropertiesMap,
|
|
1241
|
+
);
|
|
1242
|
+
addSecuritySignalEvidence(
|
|
1243
|
+
kosiReport,
|
|
1244
|
+
purlAliasMap,
|
|
1245
|
+
nameIndex,
|
|
1246
|
+
purlLocationMap,
|
|
1247
|
+
componentPropertiesMap,
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
return {
|
|
1251
|
+
componentPropertiesMap,
|
|
1252
|
+
cryptoComponents: Array.from(cryptoComponentsByRef.values()).sort(
|
|
1253
|
+
(left, right) =>
|
|
1254
|
+
`${left.name}:${left["bom-ref"]}`.localeCompare(
|
|
1255
|
+
`${right.name}:${right["bom-ref"]}`,
|
|
1256
|
+
),
|
|
1257
|
+
),
|
|
1258
|
+
cryptoGeneratePurls: {},
|
|
1259
|
+
dataFlowFrames,
|
|
1260
|
+
metadataProperties,
|
|
1261
|
+
purlLocationMap,
|
|
1262
|
+
};
|
|
1263
|
+
}
|