@effected/sbom 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IdentityToken.js +65 -0
- package/InTotoStatement.js +159 -0
- package/LICENSE +21 -0
- package/NtiaReport.js +142 -0
- package/Sbom.js +100 -0
- package/SbomDocument.js +223 -0
- package/SbomMetadataSource.js +202 -0
- package/SigstoreBundle.js +48 -0
- package/SigstoreSigner.js +150 -0
- package/SlsaProvenance.js +145 -0
- package/index.d.ts +1034 -0
- package/index.js +11 -0
- package/package.json +53 -0
- package/tsdoc-metadata.json +11 -0
package/IdentityToken.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Redacted, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/IdentityToken.ts
|
|
4
|
+
/**
|
|
5
|
+
* Raised when an identity token cannot be obtained.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* The audience is on the error because "which audience" is the first thing a
|
|
9
|
+
* caller checks when an exchange is refused — a token minted for the wrong one
|
|
10
|
+
* fails at the certificate authority, far from here.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
var IdentityTokenError = class extends Schema.TaggedErrorClass()("IdentityTokenError", {
|
|
15
|
+
/** The audience the token was requested for. */
|
|
16
|
+
audience: Schema.String,
|
|
17
|
+
/** The underlying failure, preserved structurally. */
|
|
18
|
+
cause: Schema.Defect()
|
|
19
|
+
}) {
|
|
20
|
+
get message() {
|
|
21
|
+
return `Could not obtain an identity token for the "${this.audience}" audience`;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** The token every double answers with unless a test says otherwise. */
|
|
25
|
+
const TEST_TOKEN = "test-identity-token";
|
|
26
|
+
/**
|
|
27
|
+
* A source of workload identity tokens.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { IdentityToken, SigstoreSigner } from "@effected/sbom";
|
|
32
|
+
* import { Layer } from "effect";
|
|
33
|
+
*
|
|
34
|
+
* const layer = SigstoreSigner.layer.pipe(Layer.provide(IdentityToken.layerStatic(token)));
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
var IdentityToken = class IdentityToken extends Context.Service()("@effected/sbom/IdentityToken") {
|
|
40
|
+
/**
|
|
41
|
+
* A layer answering with a token the caller already holds.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* For a consumer that obtained a token by some other route — a CI system
|
|
45
|
+
* that is not GitHub Actions, or a script that exchanged one itself. The
|
|
46
|
+
* audience is **ignored**, so it is the caller's job to have minted the token
|
|
47
|
+
* for the audience it will be used with; a layer cannot check that, and
|
|
48
|
+
* pretending otherwise would be theatre.
|
|
49
|
+
*/
|
|
50
|
+
static layerStatic = (token) => Layer.succeed(IdentityToken, { token: () => Effect.succeed(typeof token === "string" ? Redacted.make(token) : token) });
|
|
51
|
+
/**
|
|
52
|
+
* An in-memory double.
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* Unlike {@link (SigstoreSigner:class).makeTest}, this one **answers** rather than
|
|
56
|
+
* dying: a fabricated OIDC token is a real answer to "give me a token" in a
|
|
57
|
+
* test, where a fabricated signature would be a lie about cryptography.
|
|
58
|
+
*/
|
|
59
|
+
static makeTest = (overrides = {}) => ({ token: overrides.token ?? (() => Effect.succeed(Redacted.make(TEST_TOKEN))) });
|
|
60
|
+
/** {@link (IdentityToken:class).makeTest} behind a `Layer`. */
|
|
61
|
+
static layerTest = (overrides = {}) => Layer.succeed(IdentityToken, IdentityToken.makeTest(overrides));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
//#endregion
|
|
65
|
+
export { IdentityToken, IdentityTokenError };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Effect, Result, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/InTotoStatement.ts
|
|
4
|
+
/**
|
|
5
|
+
* The in-toto Statement v1 type URI, stamped onto every statement this package
|
|
6
|
+
* emits.
|
|
7
|
+
*
|
|
8
|
+
* @see {@link https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md | in-toto Statement v1}
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
const IN_TOTO_STATEMENT_V1 = "https://in-toto.io/Statement/v1";
|
|
13
|
+
/**
|
|
14
|
+
* The CycloneDX BOM predicate type, for attesting an SBOM.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
const CYCLONEDX_BOM_PREDICATE = "https://cyclonedx.org/bom";
|
|
19
|
+
/**
|
|
20
|
+
* Raised when a string is not a SHA-256 digest.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
var InvalidSha256DigestError = class extends Schema.TaggedErrorClass()("InvalidSha256DigestError", {
|
|
25
|
+
/** The offending input, preserved verbatim. */
|
|
26
|
+
input: Schema.String }) {
|
|
27
|
+
get message() {
|
|
28
|
+
return `Invalid SHA-256 digest "${this.input}": expected 64 hexadecimal characters`;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/** 64 hex characters, lowercase — the normalized form. */
|
|
32
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
33
|
+
/** The `sha256:` prefix a caller may have carried in from a digest reference. */
|
|
34
|
+
const SHA256_PREFIX_RE = /^sha256:/i;
|
|
35
|
+
const normalizeDigest = (value) => value.replace(SHA256_PREFIX_RE, "").toLowerCase();
|
|
36
|
+
const parseResult = (value) => {
|
|
37
|
+
const normalized = normalizeDigest(value);
|
|
38
|
+
return SHA256_RE.test(normalized) ? Result.succeed(normalized) : Result.fail(new InvalidSha256DigestError({ input: value }));
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* A SHA-256 digest as 64 lowercase hexadecimal characters, without an
|
|
42
|
+
* algorithm prefix.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* A deliberate small duplication rather than a shared package: `@effected/github`
|
|
46
|
+
* types the same value structurally on its attestation surface, and dragging a
|
|
47
|
+
* package across that seam to share one branded string would cost more than the
|
|
48
|
+
* duplication does. Recorded under the program's shared-vocabulary rule.
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
const Sha256Digest = Object.assign(Schema.String.pipe(Schema.check(Schema.isPattern(SHA256_RE)), Schema.brand("Sha256Digest")), {
|
|
53
|
+
isValid: (value) => SHA256_RE.test(normalizeDigest(value)),
|
|
54
|
+
parseResult,
|
|
55
|
+
parse: Effect.fn("Sha256Digest.parse")((value) => Effect.fromResult(parseResult(value)))
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* A content-addressed artifact an attestation is about.
|
|
59
|
+
*
|
|
60
|
+
* @remarks
|
|
61
|
+
* `name` is conventionally a package URL (`pkg:npm/%40scope/name@1.0.0`), but
|
|
62
|
+
* the specification requires only that it be unique within the statement.
|
|
63
|
+
* `digest` is an open algorithm → hex map because in-toto permits several; this
|
|
64
|
+
* package writes `sha256`.
|
|
65
|
+
*
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
var InTotoSubject = class InTotoSubject extends Schema.Class("InTotoSubject")({
|
|
69
|
+
/** How the subject is identified — a purl for an npm package. */
|
|
70
|
+
name: Schema.String,
|
|
71
|
+
/** Algorithm to hex digest. */
|
|
72
|
+
digest: Schema.Record(Schema.String, Schema.String)
|
|
73
|
+
}) {
|
|
74
|
+
/**
|
|
75
|
+
* A subject identified by a SHA-256 digest.
|
|
76
|
+
*
|
|
77
|
+
* @remarks
|
|
78
|
+
* **Total** — the digest is already validated, which is what
|
|
79
|
+
* {@link (Sha256Digest:variable).parseResult} is for.
|
|
80
|
+
*/
|
|
81
|
+
static forSha256(name, digest) {
|
|
82
|
+
return InTotoSubject.make({
|
|
83
|
+
name,
|
|
84
|
+
digest: { sha256: digest }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* An in-toto Statement v1.
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* `predicate` is `unknown` by design — SLSA provenance, a CycloneDX BOM and a
|
|
93
|
+
* caller's own predicate all travel here, and the statement layer has no reason
|
|
94
|
+
* to introspect any of them.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* import { InTotoStatement, Sha256Digest, SlsaProvenance } from "@effected/sbom";
|
|
99
|
+
*
|
|
100
|
+
* const statement = InTotoStatement.forSubject({
|
|
101
|
+
* name: "pkg:npm/%40scope/pkg@1.0.0",
|
|
102
|
+
* digest,
|
|
103
|
+
* predicateType: SlsaProvenance.predicateType,
|
|
104
|
+
* predicate: provenance,
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
107
|
+
*
|
|
108
|
+
* @public
|
|
109
|
+
*/
|
|
110
|
+
var InTotoStatement = class InTotoStatement extends Schema.Class("InTotoStatement")({
|
|
111
|
+
/** Always the in-toto Statement v1 URI. */
|
|
112
|
+
_type: Schema.Literal(IN_TOTO_STATEMENT_V1),
|
|
113
|
+
/** The artifacts attested. */
|
|
114
|
+
subject: Schema.Array(InTotoSubject),
|
|
115
|
+
/** What is being asserted about them. */
|
|
116
|
+
predicateType: Schema.String,
|
|
117
|
+
/** The assertion body. */
|
|
118
|
+
predicate: Schema.Unknown
|
|
119
|
+
}) {
|
|
120
|
+
/** A statement over any number of subjects. **Total.** */
|
|
121
|
+
static of(input) {
|
|
122
|
+
return InTotoStatement.make({
|
|
123
|
+
_type: IN_TOTO_STATEMENT_V1,
|
|
124
|
+
subject: input.subject,
|
|
125
|
+
predicateType: input.predicateType,
|
|
126
|
+
predicate: input.predicate
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** A statement over a single artifact — the common case. **Total.** */
|
|
130
|
+
static forSubject(input) {
|
|
131
|
+
return InTotoStatement.of({
|
|
132
|
+
subject: [InTotoSubject.forSha256(input.name, input.digest)],
|
|
133
|
+
predicateType: input.predicateType,
|
|
134
|
+
predicate: input.predicate
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The statement as JSON — the bytes a DSSE envelope carries as its payload.
|
|
139
|
+
*
|
|
140
|
+
* @remarks
|
|
141
|
+
* Compact and in a fixed key order by default, so the same statement
|
|
142
|
+
* serializes to the same bytes on every run. Pass `space` for a form meant to
|
|
143
|
+
* be read by a person.
|
|
144
|
+
*/
|
|
145
|
+
toJson(options) {
|
|
146
|
+
return JSON.stringify({
|
|
147
|
+
_type: this._type,
|
|
148
|
+
subject: this.subject.map((subject) => ({
|
|
149
|
+
name: subject.name,
|
|
150
|
+
digest: subject.digest
|
|
151
|
+
})),
|
|
152
|
+
predicateType: this.predicateType,
|
|
153
|
+
predicate: this.predicate
|
|
154
|
+
}, null, options?.space ?? 0);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
//#endregion
|
|
159
|
+
export { CYCLONEDX_BOM_PREDICATE, IN_TOTO_STATEMENT_V1, InTotoStatement, InTotoSubject, InvalidSha256DigestError, Sha256Digest };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NtiaReport.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/NtiaReport.ts
|
|
4
|
+
/**
|
|
5
|
+
* The seven NTIA minimum elements, by stable identifier.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* A literal union rather than free text: this is what a consumer branches on,
|
|
9
|
+
* and a display name is what it renders afterwards.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
const NtiaElementId = Schema.Literals([
|
|
14
|
+
"supplierName",
|
|
15
|
+
"componentName",
|
|
16
|
+
"componentVersion",
|
|
17
|
+
"uniqueIdentifier",
|
|
18
|
+
"dependencyRelationship",
|
|
19
|
+
"sbomAuthor",
|
|
20
|
+
"timestamp"
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* One element's verdict.
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
var NtiaElement = class extends Schema.Class("NtiaElement")({
|
|
28
|
+
/** Which element this is. */
|
|
29
|
+
id: NtiaElementId,
|
|
30
|
+
/** Whether the document satisfies it. */
|
|
31
|
+
satisfied: Schema.Boolean,
|
|
32
|
+
/** The value that satisfied it, when one did. */
|
|
33
|
+
value: Schema.optionalKey(Schema.String)
|
|
34
|
+
}) {};
|
|
35
|
+
const element = (id, value) => NtiaElement.make({
|
|
36
|
+
id,
|
|
37
|
+
satisfied: value !== void 0,
|
|
38
|
+
...value !== void 0 && { value }
|
|
39
|
+
});
|
|
40
|
+
/** A string that carries something, or nothing. */
|
|
41
|
+
const present = (value) => {
|
|
42
|
+
if (value === void 0) return void 0;
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
45
|
+
};
|
|
46
|
+
/** Element 1: the entity that supplies the software. */
|
|
47
|
+
const supplierName = (document) => element("supplierName", present(document.metadata?.supplier?.name));
|
|
48
|
+
/** Element 2: what the software is called. */
|
|
49
|
+
const componentName = (document) => element("componentName", present(document.metadata?.component?.name));
|
|
50
|
+
/** Element 3: which release it is. */
|
|
51
|
+
const componentVersion = (document) => element("componentVersion", present(document.metadata?.component?.version));
|
|
52
|
+
/**
|
|
53
|
+
* Element 4: an identifier that is unique across suppliers — a package URL.
|
|
54
|
+
*
|
|
55
|
+
* Present-and-non-empty is not enough: a homepage URL in the `purl` field is a
|
|
56
|
+
* string, and identifies the component to nobody.
|
|
57
|
+
*/
|
|
58
|
+
const uniqueIdentifier = (document) => {
|
|
59
|
+
const purl = present(document.metadata?.component?.purl);
|
|
60
|
+
return element("uniqueIdentifier", purl?.startsWith("pkg:") === true ? purl : void 0);
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Element 5: how the components relate to the thing the BOM is about.
|
|
64
|
+
*
|
|
65
|
+
* A flat component list plus a declared root IS that relationship in this
|
|
66
|
+
* version — the CycloneDX `dependencies` graph is deferred until a consumer
|
|
67
|
+
* needs one. What the element therefore requires is a declared **subject**: a
|
|
68
|
+
* list of components with nothing saying what they are components OF relates
|
|
69
|
+
* nothing to anything.
|
|
70
|
+
*
|
|
71
|
+
* An empty list is compliant. "This package has no dependencies" is an
|
|
72
|
+
* assertion, not a gap.
|
|
73
|
+
*/
|
|
74
|
+
const dependencyRelationship = (document) => {
|
|
75
|
+
const count = document.components.length;
|
|
76
|
+
return element("dependencyRelationship", document.metadata?.component === void 0 ? void 0 : `${count} component${count === 1 ? "" : "s"}`);
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Element 6: who assembled the BOM.
|
|
80
|
+
*
|
|
81
|
+
* Named authors first; a supplier or a publisher is the honest fallback, since
|
|
82
|
+
* both identify an entity that stood behind the document.
|
|
83
|
+
*/
|
|
84
|
+
const sbomAuthor = (document) => {
|
|
85
|
+
const author = document.metadata?.authors?.map((contact) => present(contact.name)).find((name) => name !== void 0);
|
|
86
|
+
const supplier = present(document.metadata?.supplier?.name);
|
|
87
|
+
const publisher = present(document.metadata?.component?.publisher);
|
|
88
|
+
return element("sbomAuthor", author ?? supplier ?? publisher);
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Element 7: when the BOM was assembled.
|
|
92
|
+
*
|
|
93
|
+
* Parsed, not merely present — a field holding `last tuesday` records nothing,
|
|
94
|
+
* and this is the cheapest place to notice.
|
|
95
|
+
*/
|
|
96
|
+
const timestamp = (document) => {
|
|
97
|
+
const stamped = present(document.metadata?.timestamp);
|
|
98
|
+
return element("timestamp", stamped !== void 0 && !Number.isNaN(Date.parse(stamped)) ? stamped : void 0);
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* A document's standing against the NTIA minimum elements.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* import { NtiaReport } from "@effected/sbom";
|
|
106
|
+
*
|
|
107
|
+
* const report = NtiaReport.of(document);
|
|
108
|
+
* if (!report.compliant) yield* Effect.logWarning(`SBOM missing: ${report.missing.join(", ")}`);
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* @public
|
|
112
|
+
*/
|
|
113
|
+
var NtiaReport = class NtiaReport extends Schema.Class("NtiaReport")({
|
|
114
|
+
/** One verdict per element, in the published order. */
|
|
115
|
+
elements: Schema.Array(NtiaElement) }) {
|
|
116
|
+
/** Whether every element is satisfied. */
|
|
117
|
+
get compliant() {
|
|
118
|
+
return this.elements.every((entry) => entry.satisfied);
|
|
119
|
+
}
|
|
120
|
+
/** The elements the document does not satisfy, by id. */
|
|
121
|
+
get missing() {
|
|
122
|
+
return this.elements.filter((entry) => !entry.satisfied).map((entry) => entry.id);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Check a document. **Total** — a report is the answer for every input,
|
|
126
|
+
* including a document that satisfies nothing.
|
|
127
|
+
*/
|
|
128
|
+
static of(document) {
|
|
129
|
+
return NtiaReport.make({ elements: [
|
|
130
|
+
supplierName(document),
|
|
131
|
+
componentName(document),
|
|
132
|
+
componentVersion(document),
|
|
133
|
+
uniqueIdentifier(document),
|
|
134
|
+
dependencyRelationship(document),
|
|
135
|
+
sbomAuthor(document),
|
|
136
|
+
timestamp(document)
|
|
137
|
+
] });
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
export { NtiaElement, NtiaElementId, NtiaReport };
|
package/Sbom.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { BOM_FORMAT, SbomDocument, SbomMetadata, documentJson } from "./SbomDocument.js";
|
|
2
|
+
import { Effect, FileSystem, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/Sbom.ts
|
|
5
|
+
/**
|
|
6
|
+
* Raised when a BOM cannot be written to disk.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* The package's **only** error, and it is the filesystem's rather than the
|
|
10
|
+
* emitter's — assembling and serializing a document cannot fail.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
var SbomWriteError = class extends Schema.TaggedErrorClass()("SbomWriteError", {
|
|
15
|
+
/** The path that could not be written. */
|
|
16
|
+
path: Schema.String,
|
|
17
|
+
/** The underlying failure, preserved structurally. */
|
|
18
|
+
cause: Schema.Defect()
|
|
19
|
+
}) {
|
|
20
|
+
get message() {
|
|
21
|
+
return `Failed to write the SBOM to ${this.path}`;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const generate = (input) => SbomDocument.make({
|
|
25
|
+
bomFormat: BOM_FORMAT,
|
|
26
|
+
specVersion: "1.6",
|
|
27
|
+
version: 1,
|
|
28
|
+
metadata: metadataWithRoot(input),
|
|
29
|
+
components: [...input.components].sort((a, b) => a.name.localeCompare(b.name))
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Thread the root component onto the caller's metadata, or synthesize metadata
|
|
33
|
+
* carrying it.
|
|
34
|
+
*
|
|
35
|
+
* Rebuilt through `SbomMetadata.make` rather than spread into a plain object:
|
|
36
|
+
* a spread of a `Schema.Class` instance loses its prototype, and a plain object
|
|
37
|
+
* standing in for a class field is the kind of lie that works until something
|
|
38
|
+
* asks whether it is an instance. The conditional spreads are required by
|
|
39
|
+
* `exactOptionalPropertyTypes` — an explicit `undefined` does not satisfy an
|
|
40
|
+
* `optionalKey` field.
|
|
41
|
+
*/
|
|
42
|
+
const metadataWithRoot = (input) => SbomMetadata.make({
|
|
43
|
+
component: input.root,
|
|
44
|
+
...input.metadata?.timestamp !== void 0 && { timestamp: input.metadata.timestamp },
|
|
45
|
+
...input.metadata?.authors !== void 0 && { authors: input.metadata.authors },
|
|
46
|
+
...input.metadata?.supplier !== void 0 && { supplier: input.metadata.supplier }
|
|
47
|
+
});
|
|
48
|
+
const toJson = (document, options) => JSON.stringify(documentJson(document), null, options?.space ?? 2);
|
|
49
|
+
const write = Effect.fn("Sbom.write")(function* (document, path, options) {
|
|
50
|
+
yield* (yield* FileSystem.FileSystem).writeFileString(path, toJson(document, options)).pipe(Effect.mapError((cause) => new SbomWriteError({
|
|
51
|
+
path,
|
|
52
|
+
cause
|
|
53
|
+
})));
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* The SBOM emitter: assemble, serialize, write.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* import { Sbom } from "@effected/sbom";
|
|
61
|
+
*
|
|
62
|
+
* const document = Sbom.generate({ root, components });
|
|
63
|
+
* const json = Sbom.toJson(document);
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* @public
|
|
67
|
+
*/
|
|
68
|
+
var Sbom = class {
|
|
69
|
+
constructor() {}
|
|
70
|
+
/**
|
|
71
|
+
* Assemble a CycloneDX 1.6 document.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* **Total** — no error channel, because there is nothing here that can fail.
|
|
75
|
+
* Components are sorted by name so two runs over the same inputs produce the
|
|
76
|
+
* same bytes: an SBOM's digest becomes an attestation subject, and a document
|
|
77
|
+
* that reordered itself between runs would change that digest for no reason.
|
|
78
|
+
*/
|
|
79
|
+
static generate = generate;
|
|
80
|
+
/**
|
|
81
|
+
* Serialize a document to canonical CycloneDX 1.6 JSON.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* **Total.** Absent optional fields are omitted rather than emitted as `null`,
|
|
85
|
+
* and `bomRef` becomes the specification's hyphenated `bom-ref`.
|
|
86
|
+
*/
|
|
87
|
+
static toJson = toJson;
|
|
88
|
+
/**
|
|
89
|
+
* Write a document to `path` as canonical JSON.
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* The one fallible member. It does not create parent directories — a caller
|
|
93
|
+
* that wants one creates it, so the failure mode stays "the path you gave me
|
|
94
|
+
* is not writable" rather than "something was created somewhere".
|
|
95
|
+
*/
|
|
96
|
+
static write = write;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
export { Sbom, SbomWriteError };
|