@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.
@@ -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 };