Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions skills/sbom-maker/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
name: sbom-maker
description: Fetches a pinned npm lockfile, emits a grounded CycloneDX SBOM with license risks, and stores it as a project-version event that downstream runs can read.
---

# SBOM Maker

Use this skill when security review needs a reproducible bill of materials from a real public project. One governed graph reads an immutable lockfile URL, resolves pinned npm components, appends the result to `data.source`, reads it back, and emits typed outputs only after storage is verified.

## Inputs

- `source_handle`: An immutable raw GitHub URL or public GitHub Contents API file URL containing a real `package-lock.json` or `npm-shrinkwrap.json`. The Contents API form must use a hexadecimal commit in its sole `ref` query parameter. Bundled `fixture://` handles are reserved for the harness.
- `lockfile_type`: `package-lock` or `npm-shrinkwrap`.
- `data_source_ref`: Optional logical data-source reference. It defaults to `local://sbom-maker/artifacts`.
- `store_id`: Optional deterministic local fixture store ID.

## Outputs

- `source_read`: HTTP or fixture provenance with final URL, status, byte count, timestamp, and SHA-256 content digest.
- `sbom`: CycloneDX 1.5 document for the named project and version.
- `components`: Pinned name, version, license, and exact lockfile evidence location for each dependency.
- `license_summary`: Component total and license counts derived from the lockfile.
- `license_risks`: Strong or weak copyleft findings plus dependencies with missing license evidence.
- `stored_artifact_ref`: Data-source, `software_boms` resource, project-version aggregate ID, idempotency key, and verified readback state.

## Runtime Contract

The HTTPS reader only admits `raw.githubusercontent.com` and `api.github.com` file URLs pinned to a hexadecimal commit and caps decoded source bodies at 5 MB. GitHub Contents responses must identify a Base64-encoded file; the output records the repository file URL and blob SHA. Malformed files, unsupported lockfile types, unavailable sources, and unapproved hosts fail before the append step and emit no SBOM.

Successful runs append a `sbom.generated` event to `software_boms`, keyed by `<project>@<version>`. The idempotency key binds the project-version key to the fetched lockfile digest. The graph then reads that stream and refuses to finalize unless the event is present.

The package carries the canonical runx `data.local` and `data.sqlite` adapters so registry installs can execute both deterministic harness storage and durable local SQLite storage without private tool catalogs.

## Harness

- `supported-source-stored` reads a bundled npm v3 lockfile, generates four typed SBOM outputs, appends the event, reads it back, and seals.
- `malformed-source-refused` reads a malformed fixture and fails at `generate`; no append, readback, or SBOM emit occurs.

Run locally with `runx harness ./skills/sbom-maker`. A production run should pass an immutable raw lockfile URL and retain the emitted receipt for `runx verify`.
194 changes: 194 additions & 0 deletions skills/sbom-maker/X.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
skill: sbom-maker
version: "1.0.0"

catalog:
kind: graph
audience: public
visibility: public
role: canonical

policy:
allow:
- provider: data-source
method: READ
scope: runx:data:read
- provider: data-source
method: APPEND
scope: runx:data:append

harness:
cases:
- name: supported-source-stored
runner: default
inputs:
source_handle: fixture://supported-package-lock.json
lockfile_type: package-lock
data_source_ref: local://sbom-maker/harness
store_id: sbom-maker-supported-v2
expect:
status: sealed
receipt:
schema: runx.receipt.v1
state: sealed
disposition: closed
steps:
- generate
- append
- readback
- finalize
- name: malformed-source-refused
runner: validate
inputs:
source_handle: fixture://malformed-lockfile.json
lockfile_type: package-lock
data_source_ref: local://sbom-maker/harness
store_id: sbom-maker-malformed-v1
expect:
status: failure
receipt:
schema: runx.receipt.v1
state: failure
disposition: closed

runners:
validate:
type: cli-tool
command: node
args:
- run.mjs
inputs:
source_handle:
type: string
required: true
lockfile_type:
type: string
required: true
data_source_ref:
type: string
required: false
default: local://sbom-maker/artifacts
store_id:
type: string
required: false
outputs:
sbom_result: object
default:
default: true
type: graph
inputs:
source_handle:
type: string
required: true
description: Immutable raw GitHub or GitHub Contents API lockfile URL, or a bundled fixture source during harness execution.
lockfile_type:
type: string
required: true
description: package-lock or npm-shrinkwrap.
data_source_ref:
type: string
required: false
default: local://sbom-maker/artifacts
description: Logical data source where the addressable SBOM event is appended.
store_id:
type: string
required: false
description: Optional deterministic local fixture store id.
outputs:
source_read: object
sbom: object
components: array
license_summary: object
license_risks: array
stored_artifact_ref: object
graph:
name: sbom-maker-fetch-build-store
steps:
- id: generate
label: fetch pinned lockfile and build grounded SBOM
inputs:
source_handle: "$input.source_handle"
lockfile_type: "$input.lockfile_type"
data_source_ref: "$input.data_source_ref"
store_id: "$input.store_id"
run:
type: cli-tool
command: node
args:
- run.mjs
timeout_seconds: 150
outputs:
sbom_result: object
sandbox:
profile: network
cwd_policy: skill-directory
network: true
writable_paths: []
require_enforcement: false
scopes:
- net:allowlist
artifacts:
named_emits:
sbom_result: sbom_result
packets:
sbom_result: runx.sbom.result.v1
- id: append
label: append SBOM as an addressable project-version event
tool: data.source
scopes:
- runx:data:append
inputs:
operation: append_event
data_source_ref: "$input.data_source_ref"
store_id: "$input.store_id"
context:
resource: generate.sbom_result.data.stored_artifact_ref.resource
aggregate_id: generate.sbom_result.data.stored_artifact_ref.aggregate_id
expected_version: generate.sbom_result.data.stored_artifact_ref.expected_version
idempotency_key: generate.sbom_result.data.stored_artifact_ref.idempotency_key
event: generate.sbom_result.data.storage_event
- id: readback
label: read stored SBOM event for downstream consumption
tool: data.source
scopes:
- runx:data:read
inputs:
operation: read_events
data_source_ref: "$input.data_source_ref"
store_id: "$input.store_id"
limit: 10
context:
resource: generate.sbom_result.data.stored_artifact_ref.resource
aggregate_id: generate.sbom_result.data.stored_artifact_ref.aggregate_id
- id: finalize
label: emit typed SBOM outputs only after verified readback
context:
generated: generate.sbom_result.data
append_result: append.data_operation_result.data
readback_result: readback.data_operation_result.data
run:
type: cli-tool
command: node
args:
- finalize.mjs
outputs:
source_read: object
sbom: object
components: array
license_summary: object
license_risks: array
stored_artifact_ref: object
artifacts:
named_emits:
source_read: source_read
sbom: sbom
components: components
license_summary: license_summary
license_risks: license_risks
stored_artifact_ref: stored_artifact_ref
packets:
source_read: runx.source.read.v1
sbom: runx.sbom.cyclonedx.v1
components: runx.sbom.components.v1
license_summary: runx.sbom.license_summary.v1
license_risks: runx.sbom.license_risks.v1
stored_artifact_ref: runx.sbom.stored_artifact_ref.v1
33 changes: 33 additions & 0 deletions skills/sbom-maker/evidence/harness-local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"schema": "runx.sbom-maker.harness_evidence.v1",
"recorded_at": "2026-07-15T11:25:57Z",
"source_revision_base": "c69bff9dde07f305408013c9aefec7406702d12b",
"runx_version": "runx-cli 0.7.1",
"unit_tests": {
"command": "node --test skills/sbom-maker/sbom-maker.test.mjs",
"status": "passed",
"passed": 14,
"failed": 0
},
"harness": {
"command": "runx harness ./skills/sbom-maker",
"status": "passed",
"case_count": 2,
"assertion_error_count": 0,
"graph_case_count": 1,
"cases": [
{
"name": "supported-source-stored",
"status": "sealed",
"receipt_id": "sha256:e4fca22e49e060c6c880e72be025692e33ca05f73d9356bb42171484eb190abe"
},
{
"name": "malformed-source-refused",
"status": "refused",
"runtime_status": "failure",
"receipt_id": "sha256:d2fd0d096064f24f3a8b87bda4b33ca10a743fef3167242edfd20dc13ccf43d0",
"reason": "lockfile has no dependency map with pinned components"
}
]
}
}
33 changes: 33 additions & 0 deletions skills/sbom-maker/finalize.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env node

import { readFileSync } from "node:fs";

import { finalizeStoredResult } from "./runtime/run.mjs";

try {
const inputs = parseInputs();
const generated = requiredObject(inputs.generated, "generated");
const appendResult = requiredObject(inputs.append_result, "append_result");
const readbackResult = requiredObject(inputs.readback_result, "readback_result");
process.stdout.write(JSON.stringify(finalizeStoredResult({ generated, appendResult, readbackResult })));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
process.stderr.write(`${JSON.stringify({ error: { reason } })}\n`);
process.exitCode = 1;
}

function parseInputs() {
const raw = process.env.RUNX_INPUTS_PATH
? readFileSync(process.env.RUNX_INPUTS_PATH, "utf8")
: process.env.RUNX_INPUTS_JSON ?? "{}";
const value = JSON.parse(raw);
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("RUNX_INPUTS_JSON must be an object");
}
return value;
}

function requiredObject(value, name) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} is required`);
return value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"invalid": true,
"reason": "no dependency map"
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions skills/sbom-maker/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env node

import { buildSbomResult, fetchSource } from "./runtime/run.mjs";

try {
const inputs = parseInputs();
const sourceHandle = requiredString(inputs.source_handle, "source_handle");
const lockfileType = requiredString(inputs.lockfile_type, "lockfile_type");
const dataSourceRef = requiredString(inputs.data_source_ref, "data_source_ref");
const storeId = optionalString(inputs.store_id);
const read = await fetchSource(sourceHandle);
const { content, ...sourceRead } = read;
const result = buildSbomResult({
sourceHandle,
lockfileType,
content,
contentDigest: read.content_digest,
fetchedAt: read.fetched_at,
bytes: read.bytes,
status: read.status,
sourceKind: read.source_kind,
repositoryFileUrl: read.repository_file_url,
blobSha: read.blob_sha,
});

result.source_read = sourceRead;
result.stored_artifact_ref = {
data_source_ref: dataSourceRef,
...(storeId ? { store_id: storeId } : {}),
...result.stored_artifact_ref,
};

process.stdout.write(JSON.stringify({ sbom_result: result }));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
process.stdout.write(JSON.stringify({ sbom_result: { status: "refused", reason, sbom_emitted: false } }));
process.stderr.write(`${JSON.stringify({ refusal: { reason, sbom_emitted: false } })}\n`);
process.exitCode = 1;
}

function parseInputs() {
const raw = process.env.RUNX_INPUTS_JSON;
if (!raw) throw new Error("RUNX_INPUTS_JSON is missing");
const value = JSON.parse(raw);
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("RUNX_INPUTS_JSON must be an object");
}
return value;
}

function requiredString(value, name) {
if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} is required`);
return value.trim();
}

function optionalString(value) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
Loading