@cdxgen/cdxgen 13.0.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +26 -13
  2. package/bin/audit.js +6 -0
  3. package/bin/cdxgen.js +140 -8
  4. package/bin/convert.js +7 -0
  5. package/bin/evinse.js +1 -1
  6. package/bin/hbom.js +13 -4
  7. package/bin/tracebom.js +9 -1
  8. package/data/component-tags.json +8 -1
  9. package/data/frameworks-list.json +86 -1
  10. package/data/remediations.json +1144 -0
  11. package/data/rules/asar-archives.yaml +1 -1
  12. package/data/rules/build-fidelity.yaml +782 -0
  13. package/data/rules/package-integrity.yaml +13 -13
  14. package/lib/audit/index.js +48 -4
  15. package/lib/audit/scoring.js +2 -8
  16. package/lib/cli/bomAssembly.js +83 -15
  17. package/lib/cli/cliOptions.js +124 -7
  18. package/lib/cli/index.js +76 -48
  19. package/lib/cli/jsBom.js +32 -3
  20. package/lib/cli/jvmBom.js +1494 -1315
  21. package/lib/cli/managedBom.js +97 -28
  22. package/lib/cli/nativeBom.js +189 -10
  23. package/lib/core/buildLedger.js +799 -0
  24. package/lib/core/deferredExit.js +141 -0
  25. package/lib/core/env.js +69 -1
  26. package/lib/core/fs.js +60 -0
  27. package/lib/core/logger.js +76 -25
  28. package/lib/core/severity.js +16 -0
  29. package/lib/core/syncFileWriter.js +106 -0
  30. package/lib/core/ui.js +46 -2
  31. package/lib/ecosystems/ecosystems.js +9 -0
  32. package/lib/ecosystems/gradleutils.js +355 -15
  33. package/lib/ecosystems/parsers-js.js +26 -0
  34. package/lib/ecosystems/parsers-jvm.js +9 -0
  35. package/lib/ecosystems/parsers-misc.js +16 -2
  36. package/lib/ecosystems/parsers-python.js +171 -35
  37. package/lib/ecosystems/parsers-rust.js +12 -2
  38. package/lib/ecosystems/pypiClassifiers.js +326 -0
  39. package/lib/ecosystems/pythonutils.js +49 -1
  40. package/lib/ecosystems/rubyutils.js +13 -0
  41. package/lib/ecosystems/utils.js +9 -1
  42. package/lib/evinser/evinser.js +30 -12
  43. package/lib/helpers/remote/dependency-track.js +81 -14
  44. package/lib/inventory/auditCategories.js +9 -0
  45. package/lib/inventory/depsUtils.js +72 -0
  46. package/lib/inventory/dosai.js +431 -22
  47. package/lib/inventory/envcontext.js +211 -19
  48. package/lib/inventory/fetchBatch.js +57 -0
  49. package/lib/inventory/jvmToolEnv.js +593 -0
  50. package/lib/inventory/osinfo.js +123 -16
  51. package/lib/inventory/table.js +9 -5
  52. package/lib/inventory/toolRequirements.js +903 -0
  53. package/lib/managers/binary.js +216 -40
  54. package/lib/managers/docker.js +26 -5
  55. package/lib/server/server.js +1 -1
  56. package/lib/stages/postgen/annotationBuilder.js +97 -0
  57. package/lib/stages/postgen/auditBom.js +76 -79
  58. package/lib/stages/postgen/introspection/annotate.js +320 -0
  59. package/lib/stages/postgen/introspection/commandFacts.js +293 -0
  60. package/lib/stages/postgen/introspection/emit.js +192 -0
  61. package/lib/stages/postgen/introspection/formulationEvidence.js +427 -0
  62. package/lib/stages/postgen/introspection/markers.js +227 -0
  63. package/lib/stages/postgen/introspection/reflect.js +1214 -0
  64. package/lib/stages/postgen/introspection/report.js +1208 -0
  65. package/lib/stages/postgen/introspection/score.js +1067 -0
  66. package/lib/stages/postgen/introspection/shapeCommand.js +304 -0
  67. package/lib/stages/postgen/parentEdge.js +156 -0
  68. package/lib/stages/postgen/postgen.js +115 -2
  69. package/lib/stages/postgen/ruleEngine.js +17 -0
  70. package/lib/stages/pregen/envAudit.js +42 -2
  71. package/lib/stages/pregen/pregen.js +1171 -6
  72. package/lib/validator/bomValidator.js +11 -2
  73. package/package.json +14 -14
  74. package/types/lib/audit/index.d.ts.map +1 -1
  75. package/types/lib/audit/scoring.d.ts +2 -8
  76. package/types/lib/audit/scoring.d.ts.map +1 -1
  77. package/types/lib/cli/bomAssembly.d.ts +14 -0
  78. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  79. package/types/lib/cli/cliOptions.d.ts +2 -1
  80. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  81. package/types/lib/cli/index.d.ts +8 -0
  82. package/types/lib/cli/index.d.ts.map +1 -1
  83. package/types/lib/cli/jsBom.d.ts.map +1 -1
  84. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  85. package/types/lib/cli/managedBom.d.ts.map +1 -1
  86. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  87. package/types/lib/core/buildLedger.d.ts +272 -0
  88. package/types/lib/core/buildLedger.d.ts.map +1 -0
  89. package/types/lib/core/deferredExit.d.ts +84 -0
  90. package/types/lib/core/deferredExit.d.ts.map +1 -0
  91. package/types/lib/core/env.d.ts +22 -1
  92. package/types/lib/core/env.d.ts.map +1 -1
  93. package/types/lib/core/fs.d.ts +21 -0
  94. package/types/lib/core/fs.d.ts.map +1 -1
  95. package/types/lib/core/logger.d.ts +14 -1
  96. package/types/lib/core/logger.d.ts.map +1 -1
  97. package/types/lib/core/severity.d.ts +11 -0
  98. package/types/lib/core/severity.d.ts.map +1 -0
  99. package/types/lib/core/syncFileWriter.d.ts +33 -0
  100. package/types/lib/core/syncFileWriter.d.ts.map +1 -0
  101. package/types/lib/core/ui.d.ts +1 -1
  102. package/types/lib/core/ui.d.ts.map +1 -1
  103. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  104. package/types/lib/ecosystems/gradleutils.d.ts +33 -0
  105. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  106. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  107. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  108. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  109. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  110. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  111. package/types/lib/ecosystems/pypiClassifiers.d.ts +80 -0
  112. package/types/lib/ecosystems/pypiClassifiers.d.ts.map +1 -0
  113. package/types/lib/ecosystems/pythonutils.d.ts +20 -0
  114. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  115. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  116. package/types/lib/ecosystems/utils.d.ts +4 -4
  117. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  118. package/types/lib/evinser/evinser.d.ts +5 -1
  119. package/types/lib/evinser/evinser.d.ts.map +1 -1
  120. package/types/lib/helpers/remote/dependency-track.d.ts +45 -4
  121. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  122. package/types/lib/inventory/auditCategories.d.ts +8 -0
  123. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  124. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  125. package/types/lib/inventory/dosai.d.ts +20 -0
  126. package/types/lib/inventory/dosai.d.ts.map +1 -1
  127. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  128. package/types/lib/inventory/fetchBatch.d.ts +9 -0
  129. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  130. package/types/lib/inventory/jvmToolEnv.d.ts +189 -0
  131. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -0
  132. package/types/lib/inventory/osinfo.d.ts +83 -2
  133. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  134. package/types/lib/inventory/table.d.ts +4 -1
  135. package/types/lib/inventory/table.d.ts.map +1 -1
  136. package/types/lib/inventory/toolRequirements.d.ts +216 -0
  137. package/types/lib/inventory/toolRequirements.d.ts.map +1 -0
  138. package/types/lib/managers/binary.d.ts +71 -0
  139. package/types/lib/managers/binary.d.ts.map +1 -1
  140. package/types/lib/managers/docker.d.ts.map +1 -1
  141. package/types/lib/stages/postgen/annotationBuilder.d.ts +53 -0
  142. package/types/lib/stages/postgen/annotationBuilder.d.ts.map +1 -0
  143. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  144. package/types/lib/stages/postgen/introspection/annotate.d.ts +81 -0
  145. package/types/lib/stages/postgen/introspection/annotate.d.ts.map +1 -0
  146. package/types/lib/stages/postgen/introspection/commandFacts.d.ts +32 -0
  147. package/types/lib/stages/postgen/introspection/commandFacts.d.ts.map +1 -0
  148. package/types/lib/stages/postgen/introspection/emit.d.ts +71 -0
  149. package/types/lib/stages/postgen/introspection/emit.d.ts.map +1 -0
  150. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts +154 -0
  151. package/types/lib/stages/postgen/introspection/formulationEvidence.d.ts.map +1 -0
  152. package/types/lib/stages/postgen/introspection/markers.d.ts +57 -0
  153. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -0
  154. package/types/lib/stages/postgen/introspection/reflect.d.ts +77 -0
  155. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -0
  156. package/types/lib/stages/postgen/introspection/report.d.ts +85 -0
  157. package/types/lib/stages/postgen/introspection/report.d.ts.map +1 -0
  158. package/types/lib/stages/postgen/introspection/score.d.ts +149 -0
  159. package/types/lib/stages/postgen/introspection/score.d.ts.map +1 -0
  160. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts +79 -0
  161. package/types/lib/stages/postgen/introspection/shapeCommand.d.ts.map +1 -0
  162. package/types/lib/stages/postgen/parentEdge.d.ts +35 -0
  163. package/types/lib/stages/postgen/parentEdge.d.ts.map +1 -0
  164. package/types/lib/stages/postgen/postgen.d.ts +2 -2
  165. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  166. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  167. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  168. package/types/lib/stages/pregen/pregen.d.ts +131 -0
  169. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  170. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { basename, delimiter, join, resolve } from "node:path";
3
3
 
4
4
  import { DEBUG_MODE } from "../core/activity.js";
5
+ import { recordDegradation } from "../core/buildLedger.js";
5
6
  import {
6
7
  getTmpDir,
7
8
  safeExistsSync,
@@ -59,6 +60,39 @@ function frameFromDosaiNode(node) {
59
60
  };
60
61
  }
61
62
 
63
+ /**
64
+ * Whether this slice's ApiEndpoints carry a resolved route in `Path`.
65
+ *
66
+ * Schema 4.0.0 introduced `Path` as the resolved route ([controller] substituted, constraints
67
+ * stripped) alongside the verbatim `Route`. In every earlier schema `Path` is the **source file
68
+ * path**, so preferring it unconditionally emits file paths as service endpoints. cdxgen ships a
69
+ * pinned dosai via @cdxgen/cdxgen-plugins-bin and users can point DOSAI_CMD at any build, so both
70
+ * shapes are live and the version has to be checked.
71
+ *
72
+ * @param {Object} methodsSlice Parsed dosai methods slice JSON
73
+ * @returns {boolean} True when `Path` holds a resolved route
74
+ */
75
+ function hasResolvedEndpointPaths(methodsSlice) {
76
+ const version = methodsSlice?.Metadata?.SchemaVersion;
77
+ if (typeof version !== "string") {
78
+ return false;
79
+ }
80
+ const major = Number.parseInt(version.split(".")[0], 10);
81
+ return Number.isFinite(major) && major >= 4;
82
+ }
83
+
84
+ /**
85
+ * Coerce a dosai string list into the array-of-strings shape serviceData.source/destination require.
86
+ * Returns undefined rather than an empty or malformed array so the key is omitted entirely.
87
+ */
88
+ function toStringArray(value) {
89
+ if (!Array.isArray(value)) {
90
+ return undefined;
91
+ }
92
+ const strings = value.filter((entry) => typeof entry === "string" && entry);
93
+ return strings.length ? strings : undefined;
94
+ }
95
+
62
96
  function appendUniqueProperty(properties, name, value) {
63
97
  if (value === undefined || value === null || value === "") {
64
98
  return;
@@ -219,6 +253,14 @@ export function runDosaiCommand(command, src, outputFile, options = {}) {
219
253
  shell: false,
220
254
  });
221
255
  if (dosaiSdkMessage(result)) {
256
+ recordDegradation("dotnet.sdk.missing", {
257
+ ecosystem: "csharp",
258
+ tool: "dotnet",
259
+ impact: "none",
260
+ command: `${basename(executable)} ${args.join(" ")}`,
261
+ detail:
262
+ "dosai reported that no .NET SDK is installed, so the requested dosai analysis could not run.",
263
+ });
222
264
  console.log(
223
265
  "Dotnet SDK is not installed. Please use the cdxgen dotnet container images to analyze this project with dosai.",
224
266
  );
@@ -348,7 +390,21 @@ export function persistDosaiSemanticsReport(
348
390
  return undefined;
349
391
  }
350
392
  const combined = buildCombinedDosaiReport(methodsSlice, dataFlowSlice);
351
- safeWriteSync(durablePath, JSON.stringify(combined));
393
+ try {
394
+ safeWriteSync(durablePath, JSON.stringify(combined));
395
+ } catch (err) {
396
+ // Combined methods + data-flow reports for large solutions can exceed the
397
+ // JS maximum string length. The SBOM evidence is already collected by this
398
+ // point, so skip the durable copy instead of losing the whole analysis.
399
+ recordDegradation("dosai.semantics.persistFailed", {
400
+ ecosystem: "csharp",
401
+ tool: "dosai",
402
+ impact: "minor",
403
+ command: `persistDosaiSemanticsReport ${durablePath}`,
404
+ detail: `The combined dosai report could not be persisted: ${err.message}. The SBOM evidence is unaffected.`,
405
+ });
406
+ return undefined;
407
+ }
352
408
  return durablePath;
353
409
  }
354
410
 
@@ -498,6 +554,288 @@ export function collectDosaiDataFlowFrames(dataFlowResult, components = []) {
498
554
  return dataFlowFrames;
499
555
  }
500
556
 
557
+ /**
558
+ * Consume dosai's AiComponents[] inventory (schema 4.0.0): model identifiers,
559
+ * on-disk model artifacts with hashes, MCP tools, prompts (redacted), and agents
560
+ * become CycloneDX machine-learning-model / data components with modelCard data.
561
+ *
562
+ * @param {Object} methodsSlice Parsed dosai methods slice JSON
563
+ * @param {Array} [components] Component list to mutate in place
564
+ * @returns {Array} The updated component list
565
+ */
566
+ export function collectDosaiAiComponents(methodsSlice, components = []) {
567
+ for (const ai of methodsSlice?.AiComponents || []) {
568
+ if (!ai?.Id || !ai?.Name) {
569
+ continue;
570
+ }
571
+ if (components.some((c) => c["bom-ref"] === ai.Id)) {
572
+ continue;
573
+ }
574
+ if (ai.Kind === "model") {
575
+ const modelCard = {
576
+ modelParameters: {},
577
+ };
578
+ if (ai.Task) {
579
+ modelCard.modelParameters.task = ai.Task;
580
+ }
581
+ if (ai.ArchitectureFamily) {
582
+ modelCard.modelParameters.architectureFamily = ai.ArchitectureFamily;
583
+ }
584
+ if (ai.ModelArchitecture) {
585
+ // modelArchitecture lives under modelParameters; modelCard itself is
586
+ // additionalProperties:false, so placing it at the top level fails validation.
587
+ modelCard.modelParameters.modelArchitecture = ai.ModelArchitecture;
588
+ }
589
+ if (Array.isArray(ai.InputFormats) && ai.InputFormats.length) {
590
+ modelCard.modelParameters.inputs = ai.InputFormats.map((format) => ({
591
+ format,
592
+ }));
593
+ }
594
+ if (Array.isArray(ai.OutputFormats) && ai.OutputFormats.length) {
595
+ modelCard.modelParameters.outputs = ai.OutputFormats.map((format) => ({
596
+ format,
597
+ }));
598
+ }
599
+ const component = {
600
+ "bom-ref": ai.Id,
601
+ type: "machine-learning-model",
602
+ name: ai.Name,
603
+ version: ai.Version,
604
+ purl: ai.Purl,
605
+ modelCard,
606
+ properties: [
607
+ { name: "cdx:ai:provider", value: ai.Provider || "unknown" },
608
+ { name: "cdx:ai:deployment", value: ai.Deployment || "unknown" },
609
+ ],
610
+ };
611
+ if (ai.Sha256) {
612
+ component.hashes = [{ alg: "SHA-256", content: ai.Sha256 }];
613
+ }
614
+ if (ai.FilePath) {
615
+ component.properties.push({
616
+ name: "cdx:ai:modelFile",
617
+ value: ai.FilePath,
618
+ });
619
+ }
620
+ components.push(component);
621
+ } else if (ai.Kind === "prompt" || ai.Kind === "dataset") {
622
+ components.push({
623
+ "bom-ref": ai.Id,
624
+ type: "data",
625
+ name: ai.Name,
626
+ properties: [
627
+ { name: "cdx:ai:kind", value: ai.Kind },
628
+ ...(ai.PromptText
629
+ ? [{ name: "cdx:ai:promptText", value: ai.PromptText }]
630
+ : []),
631
+ ...Object.entries(ai.Properties || {}).map(([name, value]) => ({
632
+ name: `cdx:ai:${name}`,
633
+ value: String(value),
634
+ })),
635
+ ],
636
+ });
637
+ } else if (
638
+ ai.Kind === "tool" ||
639
+ ai.Kind === "agent" ||
640
+ ai.Kind === "guardrail" ||
641
+ ai.Kind === "embedding"
642
+ ) {
643
+ const properties = [
644
+ { name: "cdx:ai:kind", value: ai.Kind },
645
+ { name: "cdx:ai:provider", value: ai.Provider || "unknown" },
646
+ ];
647
+ if (ai.ToolSchema) {
648
+ properties.push({ name: "cdx:ai:toolSchema", value: ai.ToolSchema });
649
+ }
650
+ components.push({
651
+ "bom-ref": ai.Id,
652
+ type: "data",
653
+ name: ai.Name,
654
+ properties,
655
+ });
656
+ }
657
+ }
658
+ return components;
659
+ }
660
+
661
+ /**
662
+ * Consume dosai's first-class Services[] inventory (schema 4.0.0) directly: richer
663
+ * than deriving services from ApiEndpoints alone — stable bom-refs, trust zones,
664
+ * data classifications, providers, and per-service evidence occurrences.
665
+ *
666
+ * @param {Object} methodsSlice Parsed dosai methods slice JSON
667
+ * @param {Object} [servicesMap] Map of service key to service definition, mutated in place
668
+ * @returns {Object} The updated services map
669
+ */
670
+ export function collectDosaiServiceComponents(methodsSlice, servicesMap = {}) {
671
+ for (const service of methodsSlice?.Services || []) {
672
+ if (!service?.Id || !service?.Name) {
673
+ continue;
674
+ }
675
+ const key = service.Id;
676
+ const definition = (servicesMap[key] ??= {
677
+ name: service.Name,
678
+ bomRef: service.Id,
679
+ endpoints: new Set(),
680
+ properties: [],
681
+ });
682
+ definition.group = definition.group || service.Group;
683
+ definition.version = definition.version || service.Version;
684
+ // The same service Id can appear in several Services[] entries (one per
685
+ // implementing type or call site). Scalar facts left unset by earlier
686
+ // entries must still be filled from later ones, in particular the
687
+ // outbound provider: an entry that is only inbound must not lock the
688
+ // definition into an anonymous egress.
689
+ if (
690
+ !definition.provider &&
691
+ service.Provider &&
692
+ service.Direction === "outbound"
693
+ ) {
694
+ definition.provider = { name: service.Provider };
695
+ }
696
+ if (typeof definition.authenticated === "undefined") {
697
+ definition.authenticated = service.Authenticated ?? undefined;
698
+ }
699
+ if (!definition.trustZone) {
700
+ definition.trustZone = service.TrustZone;
701
+ }
702
+ if (!definition["x-trust-boundary"]) {
703
+ definition["x-trust-boundary"] =
704
+ service.CrossesTrustBoundary === true ? true : undefined;
705
+ }
706
+ for (const endpoint of service.Endpoints || []) {
707
+ const sanitized = sanitizeEndpoint(endpoint);
708
+ if (sanitized) {
709
+ definition.endpoints.add(sanitized);
710
+ }
711
+ }
712
+ if (Array.isArray(service.Operations) && service.Operations.length) {
713
+ appendUniqueProperty(
714
+ definition.properties,
715
+ "cdx:dosai:operationCount",
716
+ service.Operations.length,
717
+ );
718
+ for (const operation of service.Operations) {
719
+ // Endpoints[] can miss routes that only Operations carry (e.g. gRPC and
720
+ // messaging operations resolved per method), so both are collected.
721
+ const sanitized = sanitizeEndpoint(operation.Path);
722
+ if (sanitized) {
723
+ definition.endpoints.add(sanitized);
724
+ }
725
+ // Verbs are small enum values; they keep the HTTP surface auditable
726
+ // per endpoint without copying request payloads.
727
+ appendUniqueProperty(
728
+ definition.properties,
729
+ "cdx:service:httpMethod",
730
+ operation.HttpMethod,
731
+ );
732
+ if (operation.RouteTemplate && operation.Path) {
733
+ appendUniqueProperty(
734
+ definition.properties,
735
+ "cdx:service:pathTemplate",
736
+ operation.RouteTemplate,
737
+ );
738
+ }
739
+ }
740
+ }
741
+ if (Array.isArray(service.Data) && service.Data.length) {
742
+ // First population of services[].data[] for .NET: flow + classification
743
+ // with auditable descriptions from dosai.
744
+ definition.data = (definition.data || []).concat(
745
+ service.Data
746
+ // `flow` and `classification` are both required by serviceData in the 1.7 schema; an entry
747
+ // missing either fails validation and would fail the whole BOM, so drop it instead.
748
+ .filter((entry) => entry?.Classification && entry?.Flow)
749
+ .map((entry) => ({
750
+ classification: entry.Classification,
751
+ // "unknown" is a legitimate dataFlowDirection member. Coercing it to "bi-directional"
752
+ // turned an absence of knowledge into a positive claim that data flows both ways.
753
+ flow: ["inbound", "outbound", "bi-directional", "unknown"].includes(
754
+ entry.Flow,
755
+ )
756
+ ? entry.Flow
757
+ : "unknown",
758
+ name: entry.Name,
759
+ description: entry.Description,
760
+ // source/destination are arrays of strings (iri-reference or BOM-link) in the schema.
761
+ source: toStringArray(entry.Source),
762
+ destination: toStringArray(entry.Destination),
763
+ })),
764
+ );
765
+ }
766
+ const properties = definition.properties;
767
+ appendUniqueProperty(properties, "cdx:service:kind", service.ServiceKind);
768
+ appendUniqueProperty(
769
+ properties,
770
+ "cdx:service:direction",
771
+ service.Direction,
772
+ );
773
+ appendUniqueProperty(
774
+ properties,
775
+ "cdx:service:framework",
776
+ service.Framework,
777
+ );
778
+ appendUniqueProperty(
779
+ properties,
780
+ "cdx:dosai:confidence",
781
+ service.Confidence,
782
+ );
783
+ for (const tag of service.Tags || []) {
784
+ appendUniqueProperty(properties, "cdx:dosai:tag", tag);
785
+ }
786
+ // Auth surface as counts and scheme categories only; policies, roles, and
787
+ // claim values never cross into the BOM verbatim.
788
+ appendUniqueProperty(
789
+ properties,
790
+ "cdx:dosai:allowAnonymous",
791
+ service.AllowAnonymous,
792
+ );
793
+ for (const scheme of service.AuthenticationSchemes || []) {
794
+ appendUniqueProperty(properties, "cdx:dosai:authScheme", scheme);
795
+ }
796
+ appendUniqueProperty(
797
+ properties,
798
+ "cdx:dosai:authorizationPolicyCount",
799
+ service.AuthorizationPolicies?.length,
800
+ );
801
+ appendUniqueProperty(
802
+ properties,
803
+ "cdx:dosai:roleCount",
804
+ service.Roles?.length,
805
+ );
806
+ if (service.Location?.Path) {
807
+ // Accumulate call sites: a service implemented across files must keep
808
+ // every occurrence, not just the last one processed.
809
+ definition.evidence ??= { occurrences: [] };
810
+ const location = {
811
+ path: service.Location.Path,
812
+ line: service.Location.LineNumber || undefined,
813
+ column: service.Location.ColumnNumber || undefined,
814
+ };
815
+ const occurrenceKey = `${location.path}#${location.line}#${location.column}`;
816
+ const knownOccurrences = definition.evidence.occurrences;
817
+ if (
818
+ !knownOccurrences.some(
819
+ (known) =>
820
+ `${known?.location?.path}#${known?.location?.line}#${known?.location?.column}` ===
821
+ occurrenceKey,
822
+ )
823
+ ) {
824
+ knownOccurrences.push({ location });
825
+ }
826
+ // services[].evidence is a CycloneDX 2.0 field that spec-version
827
+ // compatibility strips below 2.0, so the location also lands in a
828
+ // property where it survives at 1.6 and 1.7.
829
+ appendUniqueProperty(
830
+ definition.properties,
831
+ "cdx:dosai:location",
832
+ `${location.path}:${location.line || 0}:${location.column || 0}`,
833
+ );
834
+ }
835
+ }
836
+ return servicesMap;
837
+ }
838
+
501
839
  /**
502
840
  * Infer service and endpoint definitions from a dosai methods slice.
503
841
  *
@@ -513,21 +851,40 @@ export function collectDosaiServicesFromMethods(
513
851
  methodsSlice,
514
852
  servicesMap = {},
515
853
  ) {
854
+ const resolvedPaths = hasResolvedEndpointPaths(methodsSlice);
516
855
  for (const endpoint of methodsSlice?.ApiEndpoints || []) {
517
- const route = sanitizeEndpoint(endpoint.Route || endpoint.Path);
518
- if (!route) {
856
+ // Endpoints owned by a provider service (ServiceId set and already
857
+ // collected) enrich the owner instead of being re-derived into a duplicate
858
+ // service. Their method and auth metadata still lands on the owner via the
859
+ // shared property recording below.
860
+ const ownedDefinition =
861
+ endpoint.ServiceId && servicesMap[endpoint.ServiceId]
862
+ ? servicesMap[endpoint.ServiceId]
863
+ : undefined;
864
+ // Schema 4.0.0: Path is the resolved route (tokens substituted, constraints
865
+ // stripped); Route keeps the verbatim template. Preferring Path is the fix for
866
+ // cdxgen discussion #4333, where [controller] shipped as %5Bcontroller%5D.
867
+ const route = sanitizeEndpoint(
868
+ resolvedPaths ? endpoint.Path || endpoint.Route : endpoint.Route,
869
+ );
870
+ if (!ownedDefinition && !route) {
519
871
  continue;
520
872
  }
521
- const serviceName = serviceNameFromEndpoint(endpoint);
522
- servicesMap[serviceName] ??= {
523
- endpoints: new Set(),
524
- authenticated: endpoint.AuthorizationRequired,
525
- xTrustBoundary:
526
- endpoint.AuthorizationRequired === true ? true : undefined,
527
- properties: [],
528
- };
529
- servicesMap[serviceName].endpoints.add(route);
530
- const properties = servicesMap[serviceName].properties;
873
+ let definition = ownedDefinition;
874
+ if (!definition) {
875
+ const serviceName = serviceNameFromEndpoint(endpoint);
876
+ definition = servicesMap[serviceName] ??= {
877
+ endpoints: new Set(),
878
+ authenticated: endpoint.AuthorizationRequired,
879
+ xTrustBoundary:
880
+ endpoint.AuthorizationRequired === true ? true : undefined,
881
+ properties: [],
882
+ };
883
+ }
884
+ if (route) {
885
+ definition.endpoints.add(route);
886
+ }
887
+ const properties = definition.properties;
531
888
  appendUniqueProperty(
532
889
  properties,
533
890
  "cdx:service:httpMethod",
@@ -571,13 +928,30 @@ export function collectDosaiServicesFromMethods(
571
928
  appendUniqueProperty(
572
929
  properties,
573
930
  "internal:SrcFile",
574
- endpoint.Path || endpoint.FileName,
931
+ endpoint.FilePath || endpoint.FileName,
575
932
  );
576
933
  if (endpoint.LineNumber) {
577
934
  appendUniqueProperty(
578
935
  properties,
579
936
  "cdx:dosai:location",
580
- `${endpoint.Path || endpoint.FileName}:${endpoint.LineNumber}:${endpoint.ColumnNumber || 0}`,
937
+ // Schema 4.0.0 renamed the ApiEndpoint source file field to FilePath;
938
+ // Path carries the resolved route there, not a location.
939
+ `${endpoint.FilePath || endpoint.FileName}:${endpoint.LineNumber}:${endpoint.ColumnNumber || 0}`,
940
+ );
941
+ }
942
+ if (endpoint.Route && endpoint.Path && endpoint.Route !== endpoint.Path) {
943
+ // The verbatim template (e.g. api/[controller]/{id:int}) alongside the resolved path.
944
+ appendUniqueProperty(
945
+ properties,
946
+ "cdx:service:pathTemplate",
947
+ endpoint.Route,
948
+ );
949
+ }
950
+ if (endpoint.Confidence) {
951
+ appendUniqueProperty(
952
+ properties,
953
+ "cdx:dosai:confidence",
954
+ endpoint.Confidence,
581
955
  );
582
956
  }
583
957
  }
@@ -591,11 +965,46 @@ export function collectDosaiServicesFromMethods(
591
965
  * @returns {Object[]} Array of service objects with sorted endpoint arrays and properties
592
966
  */
593
967
  export function normalizeDosaiServiceMap(servicesMap = {}) {
594
- return Object.keys(servicesMap).map((serviceName) => ({
595
- name: serviceName || `dosai-${basename(serviceName)}-service`,
596
- endpoints: Array.from(servicesMap[serviceName].endpoints || []).sort(),
597
- authenticated: servicesMap[serviceName].authenticated,
598
- "x-trust-boundary": servicesMap[serviceName].xTrustBoundary,
599
- properties: servicesMap[serviceName].properties,
600
- }));
968
+ return Object.keys(servicesMap)
969
+ .map((serviceName) => {
970
+ const definition = servicesMap[serviceName];
971
+ const service = {
972
+ name:
973
+ definition.name ||
974
+ serviceName ||
975
+ `dosai-${basename(serviceName)}-service`,
976
+ endpoints: Array.from(definition.endpoints || []).sort(),
977
+ authenticated: definition.authenticated,
978
+ // Accept both spellings. Every pre-4.0.0 producer — collectDosaiServicesFromMethods here, and
979
+ // the OpenAPI/Java/Python paths in evinser.js — sets the camelCase `xTrustBoundary`; only the
980
+ // new Services[] path sets the hyphenated CycloneDX key. Reading just the latter silently
981
+ // dropped the flag for all of them, including languages dosai has nothing to do with.
982
+ "x-trust-boundary":
983
+ definition["x-trust-boundary"] ?? definition.xTrustBoundary,
984
+ properties: definition.properties,
985
+ };
986
+ if (definition.bomRef) {
987
+ service["bom-ref"] = definition.bomRef;
988
+ }
989
+ if (definition.group) {
990
+ service.group = definition.group;
991
+ }
992
+ if (definition.version) {
993
+ service.version = definition.version;
994
+ }
995
+ if (definition.trustZone) {
996
+ service.trustZone = definition.trustZone;
997
+ }
998
+ if (definition.provider) {
999
+ service.provider = definition.provider;
1000
+ }
1001
+ if (definition.data) {
1002
+ service.data = definition.data;
1003
+ }
1004
+ if (definition.evidence) {
1005
+ service.evidence = definition.evidence;
1006
+ }
1007
+ return service;
1008
+ })
1009
+ .filter((service) => service.name);
601
1010
  }