@citolab/tspci 2.10.0 → 2.11.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,17 @@
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.11.0](/compare/v2.10.0...v2.11.0) (2026-08-19)
7
+
8
+
9
+ ### Features
10
+
11
+ * generate a CycloneDX SBOM of the bundle fdc8cf7
12
+
13
+
14
+
15
+
16
+
6
17
  # [2.10.0](/compare/v2.9.13...v2.10.0) (2026-08-18)
7
18
 
8
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citolab/tspci",
3
- "version": "2.10.0",
3
+ "version": "2.11.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": "ad0858f9b665b6778cdf19685e858e167847e930"
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,97 @@ 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`. When you export a package, a copy is placed next to the package as
89
+ `<package-name>.sbom.cdx.json`, so you keep one SBOM per released version.
90
+
91
+ The SBOM is built from the rollup module graph, so it lists the packages whose code actually ended up
92
+ in the bundle. That is different from what `npm sbom`, `cyclonedx-npm` or Syft produce for a PCI: those
93
+ read the dependency tree and would list build-time packages (rollup, typescript, tailwind, terser) that
94
+ are never delivered, while missing dependencies that got inlined into the bundle.
95
+
96
+ It also records a SHA-512 hash of the built bundle. A customer who integrates your PCI is the
97
+ manufacturer of the complete product and has to do due diligence on integrated third party components;
98
+ the hash lets them verify that the SBOM belongs to the bundle they received.
99
+
100
+ The Cyber Resilience Act asks for an SBOM as part of the technical documentation, not as something you
101
+ deliver with the product, so it is not put inside the package by default. Add `--include-sbom` if a
102
+ customer wants it in the package anyway:
103
+
104
+ ```sh
105
+ tspci --target qti3 --include-sbom
106
+ ```
107
+
108
+ Generating the SBOM needs no flag, it happens on every production build. Use `--no-sbom` to skip it for
109
+ a single build, or `config.tspci.sbom.enabled: false` to switch it off for a project:
110
+
111
+ ```sh
112
+ tspci --target qti3 --no-sbom
113
+ ```
114
+
115
+ #### Known limitations
116
+
117
+ The SBOM can only describe what passes through the bundler. It does not cover:
118
+
119
+ - code loaded at runtime from a CDN (MathJax, MathLive, chart libraries)
120
+ - third party code that was copied into your own `src` folder
121
+ - the delivery engine itself (`qtiCustomInteractionContext` is external on purpose)
122
+
123
+ The build warns about the first two when it can detect them (a script url in the bundle or in
124
+ `src/index.html`, a path that looks like `src/vendor/...` or a `*.min.js` file). Declare those
125
+ components manually so they end up in the SBOM.
126
+
127
+ #### Configuration
128
+
129
+ All settings are optional and live in `config.tspci.sbom` in your `package.json`:
130
+
131
+ ```json
132
+ {
133
+ "config": {
134
+ "tspci": {
135
+ "typeIdentifier": "myPci",
136
+ "sbom": {
137
+ "supplier": { "name": "Cito", "url": "https://www.cito.nl" },
138
+ "supportPeriodEnd": "2032-12-31",
139
+ "externalReferences": [
140
+ { "type": "vulnerability-disclosure", "url": "https://www.cito.nl/security" },
141
+ { "type": "security-contact", "url": "mailto:security@example.org" }
142
+ ],
143
+ "additionalComponents": [
144
+ {
145
+ "name": "mathjax",
146
+ "version": "3.2.2",
147
+ "license": "Apache-2.0",
148
+ "purl": "pkg:npm/mathjax@3.2.2",
149
+ "externalReferences": [
150
+ { "type": "distribution", "url": "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js" }
151
+ ]
152
+ }
153
+ ]
154
+ }
155
+ }
156
+ }
157
+ }
158
+ ```
159
+
160
+ | Option | Default | Description |
161
+ | --- | --- | --- |
162
+ | `enabled` | `true` | Set to `false` to skip SBOM generation |
163
+ | `fileName` | `sbom.cdx.json` | Name of the generated file |
164
+ | `supplier` | `author` from `package.json` | Supplier of the PCI, as name or `{ name, url, contact }` |
165
+ | `supportPeriodEnd` | - | Recorded as a property on the PCI component |
166
+ | `externalReferences` | - | CycloneDX external references for the PCI, e.g. your disclosure policy |
167
+ | `additionalComponents` | `[]` | Components the bundler cannot see, e.g. CDN or vendored code |
168
+ | `includeInPackage` | `false` | Always include the SBOM in the exported package |
169
+ | `properties` | `[]` | Extra CycloneDX properties on the PCI component |
170
+ | `warnings` | `true` | Set to `false` to silence the warnings about gaps |
171
+ | `timestamp` | `false` | Include a build timestamp. Off by default so a rebuild of the same sources gives an identical document |
172
+
173
+ Declaring a component in `additionalComponents` also silences the warning about the matching runtime
174
+ url. Set `TSPCI_SBOM_TIMESTAMP=1` to add a timestamp for a single build.
175
+
84
176
  ### Manual
85
177
 
86
178
  1️. **Install** by running: `npm i -D @citolab/tspci`
@@ -199,6 +291,8 @@ Available Commands
199
291
  --targetExt -tx Same as -target but reffering to a fully qualified package (not in @citolab)
200
292
  init Init PCI development environment.
201
293
  add --target Add specific implementation to the PCI.
294
+ --include-sbom Include the generated SBOM in the exported package
295
+ --no-sbom Do not generate an SBOM for this build
202
296
 
203
297
  Examples package.json scripts
204
298
  $ "dev": "tspci --dev",
package/release-notes.md CHANGED
@@ -1,3 +1,67 @@
1
+ # 2.11.0
2
+
3
+ - 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`
4
+ - 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
5
+ - `--include-sbom` also puts the SBOM inside the exported package, `--no-sbom` skips generating one
6
+ - configurable with `config.tspci.sbom` in package.json: supplier, support period, external references (disclosure policy, security contact) and manually declared components
7
+ - the build warns about code the bundler cannot see: vendored files, `*.min.js` and scripts that are loaded from a cdn at runtime
8
+
9
+ _The notes for 2.4.0 up to and including 2.10.0 were reconstructed from the git history afterwards, so
10
+ they list the highlights per minor version. The patch versions in between are mostly lockstep publishes
11
+ of all packages in this repository; `CHANGELOG.md` has the complete list of published versions._
12
+
13
+ # 2.10.0
14
+
15
+ - 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)
16
+ - the cli detects a missing `tslib` dependency and reports it with an install hint
17
+ - added `generate:pcis` and a dev startup smoke test to develop and test tspci changes against real PCIs
18
+ - improved layout and styling of the generated example interaction
19
+ - updated dependencies, added npm-check-updates settings
20
+
21
+ # 2.9.0
22
+
23
+ - added a local development app to test changes in @citolab/tspci against generated PCI variants
24
+ - replaced inquirer with @inquirer/prompts
25
+ - test scripts and npm path handling work on Windows, CI runs on a matrix of operating systems
26
+
27
+ # 2.8.0
28
+
29
+ - `tspci init` can run without prompts using `--name`, `--description`, `--project-type` and `--ci`
30
+ - `--path` accepts both `--path=folder` and `--path folder` (or `-p`) and is resolved relative to the current directory, nested paths are created automatically
31
+ - the project type is now a single choice instead of a multi select
32
+ - added local test scripts to initialize and smoke test all project variants
33
+
34
+ # 2.7.0
35
+
36
+ - added QTI 3 package export: `tspci --target qti3` creates a QTI 3 package with an assessment item, an assessment test and a manifest
37
+ - added `setResponse`, so a delivery engine can push a response into the PCI
38
+ - completed the QTI type definitions
39
+ - the preact preset uses a factory pattern with improved state management
40
+ - added a GPL-3.0 LICENSE file to the package
41
+ - fixed the tailwindcss version in the preset
42
+
43
+ # 2.6.0
44
+
45
+ - added a javascript project type next to typescript and preact+tailwind, including TAO support for javascript PCIs with `onCompleted` implemented by default
46
+ - added `--targetExt` (`-tx`) to export with a plugin that is not published under @citolab
47
+ - the PCI dispatches a `qti-interaction-changed` event
48
+ - renamed the `Configuration` type to `ConfigProperties` and improved type safety of the PCI interfaces
49
+ - tailwindcss is added to devDependencies with the version that matches @tailwindcss/postcss
50
+ - switched to chokidar for watching, fixed watching assets and building for the selected target
51
+
52
+ # 2.5.0
53
+
54
+ - removed node-builtins and node-globals from the rollup configuration
55
+ - TAO specific functions are ignored when a PCI only implements the IMS interface
56
+ - fixed actions in the state of the development app
57
+
58
+ # 2.4.0
59
+
60
+ - added replay functionality with a scrubber to the development app
61
+ - refactored the store, the presets are based on the new store
62
+ - the development environment uses an html template
63
+ - `typeIdentifier` is restricted to alphanumeric characters, because a hyphen breaks the PCI in TAO
64
+ - fixed installing the right version of the dependencies
1
65
 
2
66
  # 2.3.0
3
67
  - 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("--include-sbom")} include the generated sbom (dist/sbom.cdx.json) in the package itself`
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;