@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 +22 -0
- package/package.json +2 -2
- package/readme.md +109 -0
- package/release-notes.md +75 -0
- package/src/cli.js +4 -0
- package/src/rollup-plugin-sbom.js +511 -0
- package/src/rollup.config.prod.js +11 -3
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.
|
|
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": "
|
|
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
|
|
12
|
-
|
|
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
|
-
|
|
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;
|