From source to SBOM
Everything the previous chapters taught, capabilities in signatures, attenuation, @secret and declassify, exists so the compiler can answer audit questions mechanically. This chapter takes one small program and pulls every audit artefact out of it: the capability manifest, a CycloneDX SBOM, an SPDX SBOM, a VEX document, and a SLSA provenance attestation. All five come from the compiler itself, from the same source the type checker reads.
The program
Create a working directory with a log file to analyse:
$ mkdir report && cd report && mkdir logs
$ printf 'INFO boot\nERROR disk full\nINFO retry\nERROR disk full\nINFO ok\n' > logs/app.log(PowerShell: "INFO boot`nERROR disk full`nINFO retry`nERROR disk full`nINFO ok`n" | Set-Content -NoNewline logs/app.log.)
Then save this as report.capa. It deliberately exercises the distinctive machinery: a pure helper, a capability-holding function, an attenuated Fs, a secret from Env routed through declassify, and one @vex claim:
// report.capa: read a log file, count error lines, print a summary.
fun count_errors(lines: List<String>) -> Int
var n = 0
for line in lines
if line.contains("ERROR")
n = n + 1
return n
@vex(
cve: "CVE-2021-44228",
status: "not_affected",
justification: "code_not_reachable",
detail: "summarise declares no Net capability; a network-side exploit chain cannot be reached from this function. Statically enforced."
)
fun summarise(stdio: Stdio, fs: Fs, env: Env, path: String) -> Result<Unit, IoError>
let body = fs.read(path)?
let lines = body.split("\n")
let errors = count_errors(lines)
let token = env.get("REPORT_TOKEN") // @secret by default
match token
Some(t) ->
let tail = t.substring(t.length() - 4, t.length())
let masked = declassify(
"token ending ${tail}",
reason: "audit trail: last 4 chars identify the reporting token"
)
stdio.println("report by ${masked}")
None -> stdio.println("report (unauthenticated)")
stdio.println("${errors} error lines in ${lines.length()} total")
return Ok(())
fun main(stdio: Stdio, fs: Fs, env: Env)
let logs_fs = fs.restrict_to("logs/")
match summarise(stdio, logs_fs, env, "logs/app.log")
Ok(_) -> ()
Err(e) -> stdio.eprintln("failed: ${e}")Check that it runs:
$ REPORT_TOKEN=tk_9f3a77c2 capa --run report.capa
report by token ending 77c2
2 error lines in 6 total(PowerShell: $env:REPORT_TOKEN = "tk_9f3a77c2"; capa --run report.capa.)
A working program. Now stop being its developer and become its auditor.
Artefact 1: the capability manifest
capa --manifest emits Capa-native JSON describing every function's authority. Here is the entry for summarise, shortened with ellipses but otherwise verbatim:
$ capa --manifest report.capa
{
"capa_version": "1.15.1",
"schema_version": 1,
"filename": "report.capa",
"functions": [
...
{
"name": "summarise",
"pos": "report.capa:10:1",
"declared_capabilities": ["Stdio", "Fs", "Env"],
"transitively_reachable_capabilities": ["Env", "Fs", "Stdio"],
"provably_excluded_capabilities": [
"Clock", "Db", "Net", "Proc", "Random", "Unsafe"
],
"has_unsafe": false,
...
"declassifications": [
{
"reason": "audit trail: last 4 chars identify the reporting token",
"value": "\"token ending ${...}\"",
"pos": "24:26"
}
]
},
...
],
"summary": {
"total_functions": 3,
"functions_with_capabilities": 2,
"functions_with_attributes": 1,
"functions_crossing_unsafe": 0,
"declassification_sites": 1,
"protocol_states": 0,
"foreign_components": 0,
"functions_calling_foreign_components": 0
}
}Three fields carry the audit weight. declared_capabilities is the signature's claim. provably_excluded_capabilities is the type system's counter-claim: summarise can never touch Net or Proc, because those names are not in scope and the analyzer rejected every path to them. And declassifications lists every sanctioned secret disclosure with its stated reason, the record chapter 13 promised.
The manifest also captures attenuation. Inside main's entry, the call to summarise records that the Fs argument was narrowed before being handed down:
"args_flow": [
null,
{
"name": "logs_fs",
"attenuations": [
{ "method": "restrict_to", "args": ["\"logs/\""] }
]
},
null,
null
]An auditor reads this as: the filesystem authority that reaches summarise is not the program's full Fs, it is Fs restricted to logs/, and the compiler extracted that fact from the source, not from a questionnaire.
Artefact 2: the CycloneDX SBOM
capa --cyclonedx wraps the same information in CycloneDX 1.5, the SBOM format most compliance tooling ingests. Each function becomes a component, and the capability data rides in standard properties[] under a capa: namespace:
$ capa --cyclonedx report.capa
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
...
"components": [
...
{
"bom-ref": "capa:fn:report.capa:summarise",
"type": "library",
"name": "summarise",
"properties": [
{ "name": "capa:kind", "value": "function" },
{ "name": "capa:pos", "value": "report.capa:10:1" },
{ "name": "capa:declared_capability", "value": "Stdio" },
{ "name": "capa:declared_capability", "value": "Fs" },
{ "name": "capa:declared_capability", "value": "Env" },
{ "name": "capa:provably_excluded_capability", "value": "Net" },
...
{ "name": "capa:attribute:vex:cve", "value": "CVE-2021-44228" },
{ "name": "capa:attribute:vex:status", "value": "not_affected" },
...
]
}
]
}The point of the wrapper: any tool that already understands CycloneDX (dependency-track dashboards, policy engines, the sbom-watch program from the home page) can consume this without knowing anything about Capa. The capability claims are just properties on components. A scanner-produced SBOM tells you which packages are present; this one also tells you, per function, what each piece is allowed to do.
Artefact 3: the SPDX companion
capa --spdx emits the same content in SPDX 2.3, the Linux Foundation's format. Pick whichever your downstream consumer standardises on; the information is identical. Functions become packages related to the program by CONTAINS, capabilities by DEPENDS_ON, and the per-function metadata rides in annotations[]:
$ capa --spdx report.capa
{
"spdxVersion": "SPDX-2.3",
"name": "report.capa",
...
"packages": [ "report.capa", "Env", "Fs", "Stdio",
"count_errors", "summarise", "main" ],
"relationships": [
{ "spdxElementId": "SPDXRef-Package-report.capa",
"relationshipType": "DEPENDS_ON",
"relatedSpdxElement": "SPDXRef-Builtin-report.capa-Fs" },
{ "spdxElementId": "SPDXRef-Package-report.capa",
"relationshipType": "CONTAINS",
"relatedSpdxElement": "SPDXRef-Fn-report.capa-summarise" },
...
]
}(The packages array is abbreviated to names here; each entry is a full SPDX package object with annotations[] carrying the capa:* key-value pairs.)
Artefact 4: VEX, the exploitability claim
An SBOM says what is in the box. VEX (Vulnerability Exploitability eXchange) says how the box is affected by a known CVE. The @vex attribute you wrote on summarise becomes a CycloneDX VEX document:
$ capa --vex report.capa
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
...
"vulnerabilities": [
{
"bom-ref": "capa:vex:report.capa:summarise:CVE-2021-44228",
"id": "CVE-2021-44228",
"source": { "name": "NVD",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-44228" },
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"detail": "summarise declares no Net capability; a network-side
exploit chain cannot be reached from this function.
Statically enforced."
},
"affects": [ { "ref": "capa:fn:report.capa:summarise" } ]
}
]
}(The detail string is wrapped across three lines here for readability; the compiler emits it as a single line.)
Standard VEX is per-package: "our product bundles a vulnerable library, but we are not affected". Capa refines the claim to per-function, and gives it a machine-verifiable basis: the manifest's provably_excluded_capabilities for summarise includes Net, so the "code not reachable" justification is grounded in a type-system fact. If a later edit added net: Net to the signature, the claim's basis would visibly disappear from the next manifest diff.
Artefact 5: provenance
The last artefact answers "where did this come from?". capa --provenance emits a SLSA Build L1 attestation, an in-toto Statement v1 binding the artefact to the SHA-256 of its source:
$ capa --provenance report.capa
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "report.capa",
"digest": { "sha256": "f0878f4a6bdb56dbe9657a77c21b491a..." }
}
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://capa-language.com/build/transpile-to-python/v1",
"externalParameters": { "source": "report.capa" },
"internalParameters": { "capaVersion": "1.15.1",
"target": "python>=3.10" },
...
},
...
}
}(The digest, externalParameters, and internalParameters objects are compacted here for readability; the compiler emits each across multiple lines.)
Anyone holding the source can recompute the digest and confirm this attestation describes exactly this file, no more, no less. L1 is the unsigned tier of the SLSA ladder; signing and a hardened builder are deliberately left to CI infrastructure, where they belong.
What an auditor gets, in one table
| Artefact | The question it answers |
|---|---|
| Manifest | Which functions hold which authorities, what is provably out of reach, where secrets are disclosed and why |
| CycloneDX SBOM | The same, in the format compliance pipelines already ingest |
| SPDX SBOM | The same, for SPDX-standardised consumers |
| VEX | Per-function exploitability of known CVEs, grounded in excluded capabilities |
| Provenance | Which exact source produced this artefact, by digest |
None of these required a scanner, an agent, or a form. The discipline you learned in chapters 8 through 13 put the information in the type system; the five flags serialise it.
Scaling up: from one file to a whole product
The five artefacts above describe one file. But you do not ship a file, you ship a product: a root package wired over a tree of dependencies, some third-party, some not even written in Capa. The same discipline scales, and four more flags carry it up that tree. To keep this concrete, the rest of the chapter switches from report.capa to the flagship demo, capa_ci_pipeline: a build orchestrator that runs four untrusted third-party "actions" (fetch, parse, build, publish) as sandbox-confined foreign Wasm components. Every command and output below was run against it.
The signable manifest: --manifest-digest
Start with one refinement of Artefact 1. capa --manifest is for reading. capa --manifest-digest is the same content in canonical form, wrapped so it can be signed. It sorts every key (the capa-jcs-sorted-v1 canonicalisation), drops nothing, and appends a content_integrity envelope:
$ capa --manifest-digest report.capa
{"capa_version":"1.15.1", ... ,
"content_integrity":{
"canonicalization":"capa-jcs-sorted-v1",
"digest":{"algorithm":"sha256",
"value":"c8679d4dc1c511f4bf39ddf9304083c695d91edd30818944c05f040bdca487e8"},
"signature":{"algorithm":null,"key_id":null,"value":null}},
"filename":"report.capa", ... ,"schema_version":1, ... }(The compiler emits this as a single canonical line; it is reflowed here, and the function bodies are elided.) The digest is a SHA-256 over the canonical bytes with the content_integrity key removed: recompute it the same way and you have proven the manifest is exactly this one. The signature slot is empty by construction. The compiler holds no keys and never signs in-band (SLSA Build L1); it emits the digest an external signer signs. Every product-level artefact below carries this same envelope, so each is signable, not signed.
The composed product SBOM: --compose-sbom
capa --compose-sbom re-introduces the package boundary the loader flattens away and rolls each function's capabilities up the dependency graph, attributing them per package. On the demo, under the Wasm-sandbox posture:
$ capa --compose-sbom --wasm main.capa
{
...
"enforcement_posture": "wasm-sandbox",
"composed": {
"authority_unknown": false,
"capabilities": ["Fs", "Net", "Stdio"],
...
},
"packages": [
{ "name": "capa_ci_pipeline", "composed_capabilities": ["Fs", "Net", "Stdio"], ... },
{ "name": "build_action", "composed_capabilities": ["Fs"], ... },
{ "name": "fetch_action", "composed_capabilities": ["Net"], ... },
{ "name": "parse_action", "composed_capabilities": [], ... },
{ "name": "pipeline_core", "composed_capabilities": [], ... },
{ "name": "publish_action", "composed_capabilities": ["Net"], ... }
],
...
}(Reflowed and abbreviated from the single canonical line; each package object also carries attributed_capabilities and a reason string.) The attribution is precise, not a module-wide union: fetch and publish compose to Net, build to Fs, the parser to nothing, and the pure pipeline_core to nothing. That precision is the point. A coarse roll-up that credited every action the product's whole {Fs, Net, Stdio} would be sound but useless; the precise composed graph is what lets a policy forbid Net on the parser specifically.
The composed value lives in a small lattice: capability sets, plus one distinguished TOP element, "authority unknown". A dependency the compiler cannot read (a native library, an absent capa.toml, a package that crosses Unsafe) composes as TOP, and TOP is absorbing: one unknown anywhere marks the whole roll-up authority_unknown: true, and it is never treated as the empty set. That is the property no scanner has. A dependency scanner handed a subtree it cannot read attributes it nothing, and nothing reads as clean: an SBOM that is dishonestly clean in exactly the dangerous direction. Here the gap is a first-class verdict. The demo ships negatives/native_action, whose dependency carries a capa.toml but no Capa source; composing it yields authority_unknown: true. (The same boundary is why the default, non---wasm posture composes the four foreign actions as TOP too; only --wasm makes them bounded, which the last section explains.)
The per-package gate that reads this SBOM is capa --check-capabilities: each package that declares a [capabilities] ceiling in its capa.toml must compose within it, and an authority-unknown package fails closed. Wrap report.capa in a package that declares its ceiling:
[package]
name = "report"
version = "1.0.0"
[capabilities]
max = ["Stdio", "Fs", "Env"]$ capa --check-capabilities report.capa
capa: --check-capabilities: OK - every declared capability ceiling holds.Tighten the ceiling to forbid Env and the gate names the breach and exits non-zero:
$ capa --check-capabilities report.capa
capa: --check-capabilities: FAILED - 1 ceiling violation(s):
- package 'report' declares max=['Fs', 'Stdio'] but its own code introduces 'Env'Signed authority diffs: --capability-diff
A composed SBOM is a snapshot. The supply-chain risk is the delta between releases: the node-ipc 2022 pattern, where a package that was benign yesterday silently gains authority in an update. capa --capability-diff old.json new.json is that changelog. The demo's v2/ overlay is one release later with a compromised build action, whose run_build quietly gains Net (a build step that legitimately holds Fs can now read the checkout and ship it out). Diff the before and after manifests, failing the build on any widening:
$ capa --capability-diff build_action.v1.json build_action.v2.json --fail-on-widening
{
...
"functions": [
{ "name": "run_build",
"added": ["Net"],
"guarantee_lost": ["Net"],
"removed": [],
"classification": "widening" }
],
"summary": { "widenings": 1, "narrowings": 0, ... },
"from_digest": { "algorithm": "sha256", "value": "8a4dab94..." },
"to_digest": { "algorithm": "sha256", "value": "34abc8d3..." },
"content_integrity": { "digest": { "value": "5052f04c..." },
"signature": { "value": null } }
}
capa: --capability-diff: FAILED --fail-on-widening: 1 widening(s)(JSON reflowed and abbreviated; the gate line is the last one, on stderr, and the process exits 1.) Two set differences make the case. run_build's reachable set gained Net (added: ["Net"]), and its exclusion proof for Net is gone (guarantee_lost: ["Net"]), so the delta is classified a widening. Functions are matched by their stable (container, name) identity, never by source position, so a function that only moved lines produces no entry. The changelog records both inputs' digests (from_digest / to_digest) so it is provably about those two exact artefacts, and it carries the same empty-slot envelope, so the changelog is itself signable.
Compliance policies: --check-policies and --conformance-report
A diff needs a previous release to compare against. A policy needs only a rule. An organization writes a product-level capa-policy.toml, owned by the security team and distinct from any per-package ceiling, from a fixed, enumerated set of predicate kinds: exclusion, product-subset, purity, forbid-capability, forbid-dependency, and no-unresolved-dependencies (an unknown kind is a hard parse error, never a silently-ignored rule). The demo's file declares four: the core library is pure, the parse action must not hold Net (the confined-parser rule), the build action must not hold Net and Fs together (the exfil vector), and product authority stays within {Net, Fs, Stdio}. On the clean product every rule holds:
$ capa --check-policies --wasm main.capa
capa: --check-policies: OK - every declared compliance policy holds.capa --conformance-report --wasm main.capa emits the same result as signable evidence: a canonical report with pass: true, one entry per policy, and the content-integrity envelope. Now run the gate on the same compromised build action from the diff:
$ cd v2 && capa --check-policies --wasm main.capa
capa: --check-policies: FAILED - 1 policy(ies), 1 violation(s):
policy 'build-no-net-and-fs' (kind exclusion):
- [violation] package 'build_action' holds all of ['Fs', 'Net'] simultaneously (composed capabilities), which policy 'build-no-net-and-fs' forbidsOne change, caught twice, by two independent controls on the same SBOM. The diff caught it as a release-over-release widening with no policy file present; the policy catches it as a standing-rule violation with no previous release needed. A regression that evaded one (a first release with no baseline, or a widening the org had not thought to forbid) is caught by the other. And the fail-closed discipline carries into the verdicts. Point the gate at the native-dependency negative and the product-subset policy returns a distinct authority_unknown verdict, not a pass:
$ cd negatives/native_action && capa --check-policies --wasm main.capa
capa: --check-policies: FAILED - 1 policy(ies), 1 violation(s):
policy 'product-authority' (kind product-subset):
- [authority_unknown] the product's composed authority is UNKNOWN: 'legacy_action' of 'native_prod' (package has a capa.toml but no Capa source (native / non-Capa dependency; its authority cannot be derived)) (via native_prod); a product-subset policy cannot be proven over an unanalyzable subtree. Set allow_unknown = true to waive."Part of this product is unanalyzable, so the policy is unverifiable" is a different verdict from "this product reaches a forbidden capability", and neither reads as clean. The compliance gate distinguishes them.
Confining foreign code: extern component
One question remains. Those four actions are untrusted, separately-compiled Wasm. What stops a hostile one from doing more than it declares, and what turned its TOP node into a bounded node above? Each action is declared as a typed foreign component:
extern component Fetch from "fetch.wasm"
fun fetch(net: Net, url: String) -> StringUnder --wasm the host instantiates the child in a fresh store, behind a Component-Model linker that binds only the capability interfaces the call grants. A child that imports anything else cannot be linked and is refused before its first instruction runs. That is what turns a foreign action's TOP node into a bounded node in the composed SBOM: the wasm-sandbox posture is a physically enforced bound, not just an audit claim. The pipeline runs, each stage confined to the one capability it declares:
$ capa --wasm --run main.capa
== stage: fetch ==
fetched: ok:app-1.2.3.tar
== stage: parse ==
spec: #ok:app-1.2.3.tar
== stage: build ==
artifact: ok:#ok:app-1.2.3.tar
== stage: publish ==
published: ok:ok:#ok:app-1.2.3.tar
pipeline ok: trueThe demo's first negative proves the boundary. negatives/ungranted_cap ships a parser that looks honest but secretly imports capa:host/net; its declaration grants it no capability, so the sandbox refuses to instantiate it, and it never runs:
$ cd negatives/ungranted_cap && capa --wasm --run prog.capa
capa: foreign component Parse.parse: instantiation denied -- the component imports a capability interface the call did not grant (granted: none). Underlying: component imports instance `capa:host/net`, but a matching implementation was not found in the linkerTwo honest limits. The confinement is for the capability set: Unsafe, the Python-only FFI escape, can never be a foreign-component parameter and stays TOP under every posture. And this is a boundary, not an analysis of the foreign code: Capa marks where its guarantee ends, it does not read inside the .wasm. For the whole worked example, all four actions, the policies, the release diff, and the three guarantee-proving negatives, read the capa_ci_pipeline demo end to end.
Where you go next
That is the whole tour, source to evidence. To see the same artefacts generated for a real codebase, read capa_paymentguard: its conformity/ directory ships the manifest, both SBOMs, the VEX, the provenance, and a CONFORMITY.md that walks an auditor through them. To start a project with that layout from day one, capa_cra_template is the scaffold. And the clause-by-clause mapping of these artefacts onto CRA, NIS2, DORA, NIST SSDF, and OWASP SCVS lives on the regulatory page.
If you build something with Capa, send it. Real use is the best feedback.