@citolab/tspci 2.10.0 → 2.12.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/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [2.12.0](/compare/v2.11.0...v2.12.0) (2026-08-19)
7
+
8
+
9
+ ### Features
10
+
11
+ * include the SBOM in the exported package by default 376b89a
12
+
13
+
14
+
15
+
16
+
17
+ # [2.11.0](/compare/v2.10.0...v2.11.0) (2026-08-19)
18
+
19
+
20
+ ### Features
21
+
22
+ * generate a CycloneDX SBOM of the bundle fdc8cf7
23
+
24
+
25
+
26
+
27
+
6
28
  # [2.10.0](/compare/v2.9.13...v2.10.0) (2026-08-18)
7
29
 
8
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citolab/tspci",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "an opiniated typescript ims-pci builder with tao integration",
5
5
  "author": "patrick <patrick.deklein@cito.nl>",
6
6
  "repository": "@citolab/tspci",
@@ -55,5 +55,5 @@
55
55
  "access": "public",
56
56
  "registry": "https://registry.npmjs.org/"
57
57
  },
58
- "gitHead": "7dbead5cfa41583d47bb503d40970df2d3444a1e"
58
+ "gitHead": "eb7676525e9bf75daa9b2f42f035faed9b8e9a70"
59
59
  }
package/readme.md CHANGED
@@ -20,6 +20,7 @@
20
20
  - Postcss support, use CSS nesting out of the box.
21
21
  - First class (p)react and tailwind support
22
22
  - Extension for TAO, configuring icon, label, support for setting correct response and pci-properties
23
+ - Generates a CycloneDX SBOM of the components that are actually in your bundle
23
24
 
24
25
  ## 🔧 Installation & Setup
25
26
 
@@ -81,6 +82,112 @@ Then build the package:
81
82
 
82
83
  For more info: [@citolab/tspci-qti3](https://github.com/Citolab/tspci/tree/main/lib/tspci-qti3)
83
84
 
85
+ ### SBOM
86
+
87
+ Every production build (`tspci`, `tspci --target ...`) writes a [CycloneDX](https://cyclonedx.org/) 1.6
88
+ SBOM to `dist/sbom.cdx.json`. A package export puts it in two places: inside the package next to the
89
+ bundle it describes, and next to the package as `<package-name>.sbom.cdx.json` so you keep one SBOM per
90
+ released version for your own technical documentation.
91
+
92
+ The SBOM is built from the rollup module graph, so it lists the packages whose code actually ended up
93
+ in the bundle. That is different from what `npm sbom`, `cyclonedx-npm` or Syft produce for a PCI: those
94
+ read the dependency tree and would list build-time packages (rollup, typescript, tailwind, terser) that
95
+ are never delivered, while missing dependencies that got inlined into the bundle.
96
+
97
+ It also records a SHA-512 hash of the built bundle. A customer who integrates your PCI is the
98
+ manufacturer of the complete product and has to do due diligence on integrated third party components;
99
+ the hash lets them verify that the SBOM belongs to the bundle they received.
100
+
101
+ The SBOM ships inside the package because a file next to the zip does not survive an import into an
102
+ authoring system, and that system is exactly the party that needs it: it is the manufacturer of the
103
+ complete product and has to document the components it integrates. The Cyber Resilience Act does not
104
+ require in band delivery, so `--no-include-sbom` (or `config.tspci.sbom.includeInPackage: false`) keeps
105
+ it out of the package while still writing it to `dist` and next to the package:
106
+
107
+ ```sh
108
+ tspci --target qti3 --no-include-sbom
109
+ ```
110
+
111
+ Where it lands inside the package differs per target, always next to the bundle:
112
+
113
+ | target | in the package |
114
+ | --- | --- |
115
+ | qti3 | `resources/pci/sbom.cdx.json`, listed as a file of the PCI resource in the manifest |
116
+ | tao | `interaction/runtime/js/sbom.cdx.json` |
117
+ | qbci | `ref/script/sbom.cdx.json` |
118
+
119
+ For qti3 it is a `<file>` of the existing PCI resource rather than a resource of its own. QTI has no
120
+ resource type for a bill of materials, and an unreferenced resource is what manifest driven tooling
121
+ drops first.
122
+
123
+ Generating the SBOM needs no flag either, it happens on every production build. Use `--no-sbom` to skip
124
+ it for a single build, or `config.tspci.sbom.enabled: false` to switch it off for a project:
125
+
126
+ ```sh
127
+ tspci --target qti3 --no-sbom
128
+ ```
129
+
130
+ #### Known limitations
131
+
132
+ The SBOM can only describe what passes through the bundler. It does not cover:
133
+
134
+ - code loaded at runtime from a CDN (MathJax, MathLive, chart libraries)
135
+ - third party code that was copied into your own `src` folder
136
+ - the delivery engine itself (`qtiCustomInteractionContext` is external on purpose)
137
+
138
+ The build warns about the first two when it can detect them (a script url in the bundle or in
139
+ `src/index.html`, a path that looks like `src/vendor/...` or a `*.min.js` file). Declare those
140
+ components manually so they end up in the SBOM.
141
+
142
+ #### Configuration
143
+
144
+ All settings are optional and live in `config.tspci.sbom` in your `package.json`:
145
+
146
+ ```json
147
+ {
148
+ "config": {
149
+ "tspci": {
150
+ "typeIdentifier": "myPci",
151
+ "sbom": {
152
+ "supplier": { "name": "Cito", "url": "https://www.cito.nl" },
153
+ "supportPeriodEnd": "2032-12-31",
154
+ "externalReferences": [
155
+ { "type": "vulnerability-disclosure", "url": "https://www.cito.nl/security" },
156
+ { "type": "security-contact", "url": "mailto:security@example.org" }
157
+ ],
158
+ "additionalComponents": [
159
+ {
160
+ "name": "mathjax",
161
+ "version": "3.2.2",
162
+ "license": "Apache-2.0",
163
+ "purl": "pkg:npm/mathjax@3.2.2",
164
+ "externalReferences": [
165
+ { "type": "distribution", "url": "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js" }
166
+ ]
167
+ }
168
+ ]
169
+ }
170
+ }
171
+ }
172
+ }
173
+ ```
174
+
175
+ | Option | Default | Description |
176
+ | --- | --- | --- |
177
+ | `enabled` | `true` | Set to `false` to skip SBOM generation |
178
+ | `fileName` | `sbom.cdx.json` | Name of the generated file |
179
+ | `supplier` | `author` from `package.json` | Supplier of the PCI, as name or `{ name, url, contact }` |
180
+ | `supportPeriodEnd` | - | Recorded as a property on the PCI component |
181
+ | `externalReferences` | - | CycloneDX external references for the PCI, e.g. your disclosure policy |
182
+ | `additionalComponents` | `[]` | Components the bundler cannot see, e.g. CDN or vendored code |
183
+ | `includeInPackage` | `true` | Set to `false` to keep the SBOM out of the exported package |
184
+ | `properties` | `[]` | Extra CycloneDX properties on the PCI component |
185
+ | `warnings` | `true` | Set to `false` to silence the warnings about gaps |
186
+ | `timestamp` | `false` | Include a build timestamp. Off by default so a rebuild of the same sources gives an identical document |
187
+
188
+ Declaring a component in `additionalComponents` also silences the warning about the matching runtime
189
+ url. Set `TSPCI_SBOM_TIMESTAMP=1` to add a timestamp for a single build.
190
+
84
191
  ### Manual
85
192
 
86
193
  1️. **Install** by running: `npm i -D @citolab/tspci`
@@ -199,6 +306,8 @@ Available Commands
199
306
  --targetExt -tx Same as -target but reffering to a fully qualified package (not in @citolab)
200
307
  init Init PCI development environment.
201
308
  add --target Add specific implementation to the PCI.
309
+ --no-include-sbom Keep the SBOM out of the exported package (included by default)
310
+ --no-sbom Do not generate an SBOM for this build
202
311
 
203
312
  Examples package.json scripts
204
313
  $ "dev": "tspci --dev",
package/release-notes.md CHANGED
@@ -1,3 +1,78 @@
1
+ # 2.12.0
2
+
3
+ - the SBOM is now included in the exported package by default, next to the bundle it describes: `resources/pci/sbom.cdx.json` (qti3), `interaction/runtime/js/sbom.cdx.json` (tao), `ref/script/sbom.cdx.json` (qbci)
4
+ - a copy is still written next to the package as `<package-name>.sbom.cdx.json` for your own technical documentation
5
+ - `--no-include-sbom` (or `config.tspci.sbom.includeInPackage: false`) keeps it out of the package, `--no-sbom` still skips it altogether
6
+ - replaces `--include-sbom` from 2.11.0, which is now the default. The flag is still accepted and does nothing
7
+
8
+ A file next to the zip does not survive an import into an authoring system, and that system is exactly
9
+ the party that needs the SBOM: it is the manufacturer of the complete product and has to document the
10
+ components it integrates.
11
+
12
+ # 2.11.0
13
+
14
+ - every production build writes a CycloneDX 1.6 SBOM of the bundle to `dist/sbom.cdx.json`, and a package export copies it next to the package as `<package-name>.sbom.cdx.json`
15
+ - the SBOM is built from the rollup module graph, so it lists the packages whose code is actually in the bundle instead of the build time dependency tree
16
+ - `--include-sbom` also puts the SBOM inside the exported package, `--no-sbom` skips generating one
17
+ - configurable with `config.tspci.sbom` in package.json: supplier, support period, external references (disclosure policy, security contact) and manually declared components
18
+ - the build warns about code the bundler cannot see: vendored files, `*.min.js` and scripts that are loaded from a cdn at runtime
19
+
20
+ _The notes for 2.4.0 up to and including 2.10.0 were reconstructed from the git history afterwards, so
21
+ they list the highlights per minor version. The patch versions in between are mostly lockstep publishes
22
+ of all packages in this repository; `CHANGELOG.md` has the complete list of published versions._
23
+
24
+ # 2.10.0
25
+
26
+ - a delivery engine can pass the response declaration of the response variable a PCI is bound to in the `getInstance` configuration, so the PCI can render the correct response itself in solution status (see 1EdTech/qti-project-management#210)
27
+ - the cli detects a missing `tslib` dependency and reports it with an install hint
28
+ - added `generate:pcis` and a dev startup smoke test to develop and test tspci changes against real PCIs
29
+ - improved layout and styling of the generated example interaction
30
+ - updated dependencies, added npm-check-updates settings
31
+
32
+ # 2.9.0
33
+
34
+ - added a local development app to test changes in @citolab/tspci against generated PCI variants
35
+ - replaced inquirer with @inquirer/prompts
36
+ - test scripts and npm path handling work on Windows, CI runs on a matrix of operating systems
37
+
38
+ # 2.8.0
39
+
40
+ - `tspci init` can run without prompts using `--name`, `--description`, `--project-type` and `--ci`
41
+ - `--path` accepts both `--path=folder` and `--path folder` (or `-p`) and is resolved relative to the current directory, nested paths are created automatically
42
+ - the project type is now a single choice instead of a multi select
43
+ - added local test scripts to initialize and smoke test all project variants
44
+
45
+ # 2.7.0
46
+
47
+ - added QTI 3 package export: `tspci --target qti3` creates a QTI 3 package with an assessment item, an assessment test and a manifest
48
+ - added `setResponse`, so a delivery engine can push a response into the PCI
49
+ - completed the QTI type definitions
50
+ - the preact preset uses a factory pattern with improved state management
51
+ - added a GPL-3.0 LICENSE file to the package
52
+ - fixed the tailwindcss version in the preset
53
+
54
+ # 2.6.0
55
+
56
+ - added a javascript project type next to typescript and preact+tailwind, including TAO support for javascript PCIs with `onCompleted` implemented by default
57
+ - added `--targetExt` (`-tx`) to export with a plugin that is not published under @citolab
58
+ - the PCI dispatches a `qti-interaction-changed` event
59
+ - renamed the `Configuration` type to `ConfigProperties` and improved type safety of the PCI interfaces
60
+ - tailwindcss is added to devDependencies with the version that matches @tailwindcss/postcss
61
+ - switched to chokidar for watching, fixed watching assets and building for the selected target
62
+
63
+ # 2.5.0
64
+
65
+ - removed node-builtins and node-globals from the rollup configuration
66
+ - TAO specific functions are ignored when a PCI only implements the IMS interface
67
+ - fixed actions in the state of the development app
68
+
69
+ # 2.4.0
70
+
71
+ - added replay functionality with a scrubber to the development app
72
+ - refactored the store, the presets are based on the new store
73
+ - the development environment uses an html template
74
+ - `typeIdentifier` is restricted to alphanumeric characters, because a hyphen breaks the PCI in TAO
75
+ - fixed installing the right version of the dependencies
1
76
 
2
77
  # 2.3.0
3
78
  - added a cli to initialize a PCI workspace (```tspci init```)
package/src/cli.js CHANGED
@@ -269,6 +269,10 @@ const run = async () => {
269
269
  console.log(`•\t${chalk.blueBright("--build")} build the project`);
270
270
  console.log(`•\t${chalk.blueBright("--watch")} watch the files`);
271
271
  console.log(`•\t${chalk.blueBright("--target")} build a prod item and bundle for platform`);
272
+ console.log(
273
+ `•\t${chalk.blueBright("--no-include-sbom")} keep the generated sbom out of the exported package (it is included by default)`
274
+ );
275
+ console.log(`•\t${chalk.blueBright("--no-sbom")} do not generate an sbom for this build`);
272
276
  console.log("\n");
273
277
  console.log(
274
278
  `${chalk.blueBright("tspci init")} creates a new project \n•\t${chalk.blueBright(
@@ -0,0 +1,511 @@
1
+ import chalk from "chalk";
2
+ import crypto from "crypto";
3
+ import fs from "fs";
4
+ import path from "path";
5
+
6
+ // Generates a CycloneDX 1.6 SBOM from the modules that actually ended up in the
7
+ // bundle. This is deliberately based on the rollup module graph and not on
8
+ // package.json: a PCI ships as a single inlined UMD file, so a dependency-tree
9
+ // based SBOM would list build-time packages (rollup, typescript, tailwind, ...)
10
+ // that are never delivered, while missing what got inlined.
11
+ //
12
+ // See readme.md ("SBOM") for the configuration options and known limitations.
13
+
14
+ const NODE_MODULES_SEGMENT = `${path.sep}node_modules${path.sep}`;
15
+ const DEFAULT_FILE_NAME = "sbom.cdx.json";
16
+ const SPDX_ID_PATTERN = /^[A-Za-z0-9-.+]+$/;
17
+ const VENDORED_PATTERNS = [/[\\/]vendors?[\\/]/i, /[\\/]vendored[\\/]/i, /[\\/](?:3rd|third)[-_]?party[\\/]/i, /\.min\.[cm]?js$/i];
18
+ const REMOTE_SCRIPT_PATTERN = /https?:\/\/[^\s"'`)]+?\.[cm]?js\b/g;
19
+
20
+ const toPosix = (value) => value.split(path.sep).join("/");
21
+
22
+ const readJsonSafe = (file) => {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(file, "utf8"));
25
+ } catch {
26
+ return null;
27
+ }
28
+ };
29
+
30
+ // Resolve the owning package directory of a module inside node_modules by
31
+ // taking one path segment after the last node_modules (two for scoped
32
+ // packages). Walking up to the nearest package.json is unreliable there:
33
+ // many packages ship sub-folder package.json files with only "main"/"module".
34
+ const resolveNodeModulesPackageDir = (modulePath) => {
35
+ const index = modulePath.lastIndexOf(NODE_MODULES_SEGMENT);
36
+ if (index === -1) {
37
+ return null;
38
+ }
39
+ const base = modulePath.slice(0, index + NODE_MODULES_SEGMENT.length);
40
+ const parts = modulePath
41
+ .slice(index + NODE_MODULES_SEGMENT.length)
42
+ .split(path.sep)
43
+ .filter(Boolean);
44
+ if (parts.length === 0) {
45
+ return null;
46
+ }
47
+ const segments = parts[0].startsWith("@") ? 2 : 1;
48
+ if (parts.length < segments) {
49
+ return null;
50
+ }
51
+ return path.join(base, ...parts.slice(0, segments));
52
+ };
53
+
54
+ // Fallback for modules outside node_modules: linked workspace packages resolve
55
+ // to their real path, so walk up until a package.json shows up.
56
+ const resolveLinkedPackageDir = (modulePath, projectRoot) => {
57
+ let dir = path.dirname(modulePath);
58
+ for (;;) {
59
+ if (fs.existsSync(path.join(dir, "package.json"))) {
60
+ return dir;
61
+ }
62
+ const parent = path.dirname(dir);
63
+ if (parent === dir || dir === projectRoot) {
64
+ return null;
65
+ }
66
+ dir = parent;
67
+ }
68
+ };
69
+
70
+ const purlFor = (name, version) => {
71
+ const encodedName = name.startsWith("@")
72
+ ? `${encodeURIComponent(name.slice(0, name.indexOf("/")))}/${name.slice(name.indexOf("/") + 1)}`
73
+ : name;
74
+ return version ? `pkg:npm/${encodedName}@${version}` : `pkg:npm/${encodedName}`;
75
+ };
76
+
77
+ const licensesFor = (packageJson) => {
78
+ const raw = packageJson.license || packageJson.licenses;
79
+ const values = Array.isArray(raw)
80
+ ? raw.map((entry) => (typeof entry === "string" ? entry : entry?.type)).filter(Boolean)
81
+ : [typeof raw === "string" ? raw : raw?.type].filter(Boolean);
82
+ if (values.length === 0) {
83
+ return null;
84
+ }
85
+ return values.map((value) =>
86
+ SPDX_ID_PATTERN.test(value) ? { license: { id: value } } : { expression: value }
87
+ );
88
+ };
89
+
90
+ // package.json repository values are often shorthand ("preactjs/preact",
91
+ // "github:preactjs/preact"), which is not a usable url in an SBOM.
92
+ const normalizeRepositoryUrl = (value) => {
93
+ if (typeof value !== "string" || value.length === 0) {
94
+ return null;
95
+ }
96
+ const shorthand = value.match(/^(?:(github|gitlab|bitbucket):)?([\w.-]+\/[\w.-]+)$/);
97
+ if (shorthand) {
98
+ const host = { gitlab: "gitlab.com", bitbucket: "bitbucket.org" }[shorthand[1]] || "github.com";
99
+ return `https://${host}/${shorthand[2]}`;
100
+ }
101
+ return value;
102
+ };
103
+
104
+ const externalReferencesFor = (packageJson, lockEntry) => {
105
+ const references = [];
106
+ const repositoryUrl = normalizeRepositoryUrl(
107
+ typeof packageJson.repository === "string" ? packageJson.repository : packageJson.repository?.url
108
+ );
109
+ if (repositoryUrl) {
110
+ references.push({ type: "vcs", url: repositoryUrl });
111
+ }
112
+ if (packageJson.homepage) {
113
+ references.push({ type: "website", url: packageJson.homepage });
114
+ }
115
+ if (lockEntry?.resolved) {
116
+ references.push({ type: "distribution", url: lockEntry.resolved });
117
+ }
118
+ return references.length > 0 ? references : null;
119
+ };
120
+
121
+ // npm records integrity as "<alg>-<base64>", CycloneDX expects hex.
122
+ const hashesFromIntegrity = (integrity) => {
123
+ if (typeof integrity !== "string") {
124
+ return null;
125
+ }
126
+ const hashes = [];
127
+ for (const entry of integrity.split(/\s+/).filter(Boolean)) {
128
+ const separator = entry.indexOf("-");
129
+ if (separator === -1) {
130
+ continue;
131
+ }
132
+ const algorithm = entry.slice(0, separator).toLowerCase();
133
+ const alg = { sha512: "SHA-512", sha384: "SHA-384", sha256: "SHA-256", sha1: "SHA-1" }[algorithm];
134
+ if (!alg) {
135
+ continue;
136
+ }
137
+ const content = Buffer.from(entry.slice(separator + 1), "base64").toString("hex");
138
+ hashes.push({ alg, content });
139
+ }
140
+ return hashes.length > 0 ? hashes : null;
141
+ };
142
+
143
+ const loadLockPackages = (projectRoot) => {
144
+ const lock = readJsonSafe(path.join(projectRoot, "package-lock.json"));
145
+ return lock?.packages && typeof lock.packages === "object" ? lock.packages : {};
146
+ };
147
+
148
+ // Deterministic serial number: a rebuild of the same sources gives the same
149
+ // document, so an SBOM in version control does not produce diff noise.
150
+ const serialNumberFor = (seed) => {
151
+ const digest = crypto.createHash("sha256").update(seed).digest("hex");
152
+ return `urn:uuid:${digest.slice(0, 8)}-${digest.slice(8, 12)}-${digest.slice(12, 16)}-${digest.slice(
153
+ 16,
154
+ 20
155
+ )}-${digest.slice(20, 32)}`;
156
+ };
157
+
158
+ const normalizeAdditionalComponent = (component) => {
159
+ if (!component || typeof component !== "object" || !component.name) {
160
+ return null;
161
+ }
162
+ const licenses =
163
+ component.licenses ||
164
+ (component.license
165
+ ? SPDX_ID_PATTERN.test(component.license)
166
+ ? [{ license: { id: component.license } }]
167
+ : [{ expression: component.license }]
168
+ : null);
169
+ const normalized = {
170
+ type: component.type || "library",
171
+ "bom-ref": component["bom-ref"] || component.purl || purlFor(component.name, component.version),
172
+ name: component.name,
173
+ ...(component.version ? { version: component.version } : {}),
174
+ ...(component.description ? { description: component.description } : {}),
175
+ ...(component.purl ? { purl: component.purl } : {}),
176
+ ...(licenses ? { licenses } : {}),
177
+ ...(component.hashes ? { hashes: component.hashes } : {}),
178
+ ...(component.externalReferences ? { externalReferences: component.externalReferences } : {}),
179
+ };
180
+ const declaredManually = { name: "tspci:origin", value: "declared-manually" };
181
+ normalized.properties = [...(component.properties || []), declaredManually];
182
+ return normalized;
183
+ };
184
+
185
+ const supplierFrom = (value) => {
186
+ if (!value) {
187
+ return null;
188
+ }
189
+ if (typeof value === "string") {
190
+ return { name: value };
191
+ }
192
+ const name = value.name;
193
+ if (!name) {
194
+ return null;
195
+ }
196
+ return {
197
+ name,
198
+ ...(value.url ? { url: Array.isArray(value.url) ? value.url : [value.url] } : {}),
199
+ ...(value.contact ? { contact: Array.isArray(value.contact) ? value.contact : [value.contact] } : {}),
200
+ };
201
+ };
202
+
203
+ export function sbom(options = {}) {
204
+ const projectRoot = options.projectRoot || process.cwd();
205
+ const cliPackageJson = options.cliPackageJson || null;
206
+
207
+ return {
208
+ name: "sbom",
209
+ writeBundle(outputOptions, bundle) {
210
+ const projectPackageJson = readJsonSafe(path.join(projectRoot, "package.json"));
211
+ if (!projectPackageJson) {
212
+ warn("cannot read package.json, skipping SBOM generation");
213
+ return;
214
+ }
215
+ const settings = projectPackageJson.config?.tspci?.sbom || {};
216
+ // an SBOM is generated for every production build, --no-sbom opts out
217
+ if (settings.enabled === false || process.argv.includes("--no-sbom")) {
218
+ return;
219
+ }
220
+
221
+ const outputDir = outputOptions.dir
222
+ ? path.resolve(projectRoot, outputOptions.dir)
223
+ : path.dirname(path.resolve(projectRoot, outputOptions.file));
224
+
225
+ // Collect the modules that contributed code to the output.
226
+ const includedModules = new Set();
227
+ const entryChunks = [];
228
+ for (const chunk of Object.values(bundle)) {
229
+ if (chunk.type !== "chunk") {
230
+ continue;
231
+ }
232
+ if (chunk.isEntry) {
233
+ entryChunks.push(chunk);
234
+ }
235
+ for (const [moduleId, moduleInfo] of Object.entries(chunk.modules || {})) {
236
+ if ((moduleInfo?.renderedLength ?? 0) > 0) {
237
+ includedModules.add(moduleId);
238
+ }
239
+ }
240
+ }
241
+
242
+ const packages = new Map(); // package dir -> { packageJson, packageDir }
243
+ const moduleOwner = new Map(); // module id -> bom-ref
244
+ const projectModules = [];
245
+ const unknownModules = [];
246
+ const lockPackages = loadLockPackages(projectRoot);
247
+
248
+ const rootRef = purlFor(
249
+ projectPackageJson.name || "pci",
250
+ projectPackageJson.version || "0.0.0"
251
+ );
252
+
253
+ for (const moduleId of includedModules) {
254
+ if (!path.isAbsolute(moduleId) || !fs.existsSync(moduleId)) {
255
+ continue; // virtual modules and plugin helpers, no upstream component
256
+ }
257
+ const packageDir = moduleId.includes(NODE_MODULES_SEGMENT)
258
+ ? resolveNodeModulesPackageDir(moduleId)
259
+ : resolveLinkedPackageDir(moduleId, projectRoot);
260
+
261
+ if (!packageDir || packageDir === projectRoot) {
262
+ moduleOwner.set(moduleId, rootRef);
263
+ projectModules.push(moduleId);
264
+ continue;
265
+ }
266
+ let entry = packages.get(packageDir);
267
+ if (!entry) {
268
+ const packageJson = readJsonSafe(path.join(packageDir, "package.json"));
269
+ if (!packageJson?.name) {
270
+ moduleOwner.set(moduleId, rootRef);
271
+ unknownModules.push(moduleId);
272
+ continue;
273
+ }
274
+ entry = { packageJson, packageDir };
275
+ packages.set(packageDir, entry);
276
+ }
277
+ moduleOwner.set(moduleId, purlFor(entry.packageJson.name, entry.packageJson.version));
278
+ }
279
+
280
+ const components = [];
281
+ const seenRefs = new Set();
282
+ for (const { packageJson, packageDir } of [...packages.values()].sort((a, b) =>
283
+ a.packageJson.name.localeCompare(b.packageJson.name)
284
+ )) {
285
+ // the same name and version can be installed in more than one place,
286
+ // a bom-ref has to stay unique
287
+ const ref = purlFor(packageJson.name, packageJson.version);
288
+ if (seenRefs.has(ref)) {
289
+ continue;
290
+ }
291
+ seenRefs.add(ref);
292
+ const lockKey = toPosix(path.relative(projectRoot, packageDir));
293
+ const lockEntry = lockPackages[lockKey] || null;
294
+ const licenses = licensesFor(packageJson);
295
+ const references = externalReferencesFor(packageJson, lockEntry);
296
+ const hashes = hashesFromIntegrity(lockEntry?.integrity);
297
+ components.push({
298
+ type: "library",
299
+ "bom-ref": ref,
300
+ name: packageJson.name,
301
+ ...(packageJson.version ? { version: packageJson.version } : {}),
302
+ ...(packageJson.description ? { description: packageJson.description } : {}),
303
+ purl: ref,
304
+ ...(licenses ? { licenses } : {}),
305
+ ...(hashes ? { hashes } : {}),
306
+ ...(references ? { externalReferences: references } : {}),
307
+ properties: [
308
+ { name: "tspci:origin", value: lockKey.startsWith("node_modules/") ? "bundled" : "bundled-linked" },
309
+ ],
310
+ });
311
+ }
312
+
313
+ for (const additional of settings.additionalComponents || []) {
314
+ const normalized = normalizeAdditionalComponent(additional);
315
+ if (normalized) {
316
+ components.push(normalized);
317
+ } else {
318
+ warn("ignoring an entry in config.tspci.sbom.additionalComponents without a name");
319
+ }
320
+ }
321
+
322
+ // Package level dependency graph, derived from the module level imports.
323
+ const edges = new Map([[rootRef, new Set()]]);
324
+ for (const ref of components.map((component) => component["bom-ref"])) {
325
+ if (!edges.has(ref)) {
326
+ edges.set(ref, new Set());
327
+ }
328
+ }
329
+ for (const moduleId of includedModules) {
330
+ const from = moduleOwner.get(moduleId);
331
+ if (!from) {
332
+ continue;
333
+ }
334
+ const moduleInfo = this.getModuleInfo(moduleId);
335
+ const imported = [
336
+ ...(moduleInfo?.importedIds || []),
337
+ ...(moduleInfo?.dynamicallyImportedIds || []),
338
+ ];
339
+ for (const importedId of imported) {
340
+ const to = moduleOwner.get(importedId);
341
+ if (to && to !== from && edges.has(from)) {
342
+ edges.get(from).add(to);
343
+ }
344
+ }
345
+ }
346
+ const dependencies = [...edges.entries()]
347
+ .map(([ref, dependsOn]) => ({ ref, dependsOn: [...dependsOn].sort() }))
348
+ .sort((a, b) => (a.ref === rootRef ? -1 : b.ref === rootRef ? 1 : a.ref.localeCompare(b.ref)));
349
+
350
+ // Hash the delivered artefacts so a consumer can verify which build this
351
+ // SBOM describes.
352
+ const bundleHashes = [];
353
+ for (const chunk of entryChunks) {
354
+ bundleHashes.push({
355
+ fileName: chunk.fileName,
356
+ alg: "SHA-512",
357
+ content: crypto.createHash("sha512").update(chunk.code, "utf8").digest("hex"),
358
+ });
359
+ }
360
+
361
+ const typeIdentifier = projectPackageJson.config?.tspci?.typeIdentifier;
362
+ const componentProperties = [
363
+ ...(typeIdentifier ? [{ name: "tspci:typeIdentifier", value: typeIdentifier }] : []),
364
+ ...bundleHashes.map((hash) => ({ name: "tspci:bundleFile", value: hash.fileName })),
365
+ ...(settings.supportPeriodEnd
366
+ ? [{ name: "tspci:supportPeriodEnd", value: String(settings.supportPeriodEnd) }]
367
+ : []),
368
+ ...(settings.properties || []),
369
+ ];
370
+ const supplier = supplierFrom(settings.supplier || projectPackageJson.author);
371
+ const projectLicenses = licensesFor(projectPackageJson);
372
+
373
+ const includeTimestamp =
374
+ settings.timestamp === true || process.env.TSPCI_SBOM_TIMESTAMP === "1";
375
+
376
+ const document = {
377
+ bomFormat: "CycloneDX",
378
+ specVersion: "1.6",
379
+ version: 1,
380
+ serialNumber: serialNumberFor(
381
+ `${rootRef}:${bundleHashes.map((hash) => hash.content).join(":")}:${components.length}`
382
+ ),
383
+ metadata: {
384
+ ...(includeTimestamp ? { timestamp: new Date().toISOString() } : {}),
385
+ tools: {
386
+ components: [
387
+ {
388
+ type: "application",
389
+ name: cliPackageJson?.name || "@citolab/tspci",
390
+ ...(cliPackageJson?.version ? { version: cliPackageJson.version } : {}),
391
+ },
392
+ ],
393
+ },
394
+ component: {
395
+ type: "library",
396
+ "bom-ref": rootRef,
397
+ name: projectPackageJson.name || "pci",
398
+ version: projectPackageJson.version || "0.0.0",
399
+ ...(projectPackageJson.description ? { description: projectPackageJson.description } : {}),
400
+ purl: rootRef,
401
+ ...(supplier ? { supplier } : {}),
402
+ ...(projectLicenses ? { licenses: projectLicenses } : {}),
403
+ ...(bundleHashes.length > 0
404
+ ? { hashes: bundleHashes.map(({ alg, content }) => ({ alg, content })) }
405
+ : {}),
406
+ ...(settings.externalReferences ? { externalReferences: settings.externalReferences } : {}),
407
+ ...(componentProperties.length > 0 ? { properties: componentProperties } : {}),
408
+ },
409
+ },
410
+ components,
411
+ dependencies,
412
+ };
413
+
414
+ const fileName = settings.fileName || DEFAULT_FILE_NAME;
415
+ const outputFile = path.join(outputDir, fileName);
416
+ fs.mkdirSync(outputDir, { recursive: true });
417
+ fs.writeFileSync(outputFile, `${JSON.stringify(document, null, 2)}\n`, "utf8");
418
+ console.log(
419
+ `SBOM written to ${path.relative(projectRoot, outputFile)} (${components.length} bundled component${
420
+ components.length === 1 ? "" : "s"
421
+ })`
422
+ );
423
+
424
+ warnAboutGaps({
425
+ projectRoot,
426
+ settings,
427
+ unknownModules,
428
+ projectModules,
429
+ entryChunks,
430
+ components,
431
+ });
432
+ },
433
+ };
434
+ }
435
+
436
+ // Everything the module graph cannot see stays invisible in the SBOM. Making
437
+ // that explicit is the point: a silently missing component is worse than a
438
+ // warning at build time.
439
+ // Rollup warnings raised from an output hook are collected by loadConfigFile
440
+ // and never flushed by the cli, so these are printed directly.
441
+ const warn = (message) => console.warn(chalk.yellow(`SBOM: ${message}`));
442
+
443
+ function warnAboutGaps({ projectRoot, settings, unknownModules, projectModules, entryChunks, components }) {
444
+ if (settings.warnings === false) {
445
+ return;
446
+ }
447
+ const relative = (moduleId) => toPosix(path.relative(projectRoot, moduleId));
448
+
449
+ for (const moduleId of unknownModules) {
450
+ warn(
451
+ `cannot determine the package of ${relative(
452
+ moduleId
453
+ )}, its code is bundled but not listed as a component`
454
+ );
455
+ }
456
+
457
+ const vendored = projectModules.filter((moduleId) =>
458
+ VENDORED_PATTERNS.some((pattern) => pattern.test(moduleId))
459
+ );
460
+ for (const moduleId of vendored) {
461
+ warn(
462
+ `${relative(
463
+ moduleId
464
+ )} looks like vendored third party code. Add it to config.tspci.sbom.additionalComponents so it is documented.`
465
+ );
466
+ }
467
+
468
+ const remoteScripts = new Set();
469
+ for (const chunk of entryChunks) {
470
+ for (const match of chunk.code.matchAll(REMOTE_SCRIPT_PATTERN)) {
471
+ remoteScripts.add(match[0]);
472
+ }
473
+ }
474
+ const indexHtml = path.join(projectRoot, "src", "index.html");
475
+ if (fs.existsSync(indexHtml)) {
476
+ const html = fs.readFileSync(indexHtml, "utf8");
477
+ for (const match of html.matchAll(/<script[^>]+src=["'](https?:\/\/[^"']+)["']/gi)) {
478
+ remoteScripts.add(match[1]);
479
+ }
480
+ }
481
+ // A runtime dependency that is already declared manually needs no warning.
482
+ const declared = new Set();
483
+ for (const component of settings.additionalComponents || []) {
484
+ if (component?.name) {
485
+ declared.add(String(component.name).toLowerCase());
486
+ }
487
+ for (const reference of component?.externalReferences || []) {
488
+ if (reference?.url) {
489
+ declared.add(String(reference.url).toLowerCase());
490
+ }
491
+ }
492
+ }
493
+ for (const url of remoteScripts) {
494
+ const lowered = url.toLowerCase();
495
+ if ([...declared].some((value) => lowered === value || lowered.includes(value))) {
496
+ continue;
497
+ }
498
+ warn(
499
+ `${url} is loaded at runtime and is not part of the bundle. Add it to config.tspci.sbom.additionalComponents so it is documented.`
500
+ );
501
+ }
502
+
503
+ const withoutLicense = components.filter((component) => !component.licenses).length;
504
+ if (withoutLicense > 0) {
505
+ warn(
506
+ `${withoutLicense} bundled component${
507
+ withoutLicense === 1 ? " has" : "s have"
508
+ } no license information in their package.json`
509
+ );
510
+ }
511
+ }
@@ -1,17 +1,25 @@
1
1
  import terser from '@rollup/plugin-terser'
2
2
  import rollupConfig from "./rollup.config.js";
3
+ import { sbom } from "./rollup-plugin-sbom.js";
3
4
 
4
5
  // PK: WORKADOUND FOR ERROR IN TERSER
5
6
  // PK: REMOVE when fixed
6
7
  // PK: see https://github.com/rollup/plugins/issues/1366#issuecomment-1345358157
7
8
  import { fileURLToPath } from 'url';
9
+ import fs from 'fs';
10
+ import path from 'path';
8
11
  const __filename = fileURLToPath(import.meta.url);
9
12
  global['__filename'] = __filename;
10
13
 
11
- const newRollupConfig = {
12
- ...rollupConfig,
14
+ const cliPackageJson = JSON.parse(
15
+ fs.readFileSync(path.resolve(path.dirname(__filename), "../package.json"), "utf-8")
16
+ );
17
+
18
+ const newRollupConfig = {
19
+ ...rollupConfig,
13
20
  output: rollupConfig.output.map((output) => ({ ...output, sourcemap: false })),
14
- plugins: [...rollupConfig.plugins, terser()],
21
+ // sbom() runs after terser so it hashes the code that is actually delivered
22
+ plugins: [...rollupConfig.plugins, terser(), sbom({ cliPackageJson })],
15
23
  };
16
24
 
17
25
  export default newRollupConfig;