Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
48 changes: 48 additions & 0 deletions .github/workflows/kilo-app-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,21 @@ jobs:
working-directory: apps/mobile
run: node ../../scripts/inspect-mobile-artifacts.mjs artifacts/app.ipa artifacts/app.aab build.json

# A plain step with no `if:` and no `continue-on-error`, placed before the
# first submission: a build that cannot be documented is never submitted.
- name: Generate SBOMs
working-directory: apps/mobile
run: node ../../scripts/mobile-sbom.mjs --ipa artifacts/app.ipa --aab artifacts/app.aab --build-json build.json --out-dir artifacts

# The retained second copy, matching sbom.yml's cloud-sbom-<sha> pattern.
- name: Upload SBOMs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: mobile-sbom-${{ github.sha }}
path: apps/mobile/artifacts/*.cyclonedx.json
retention-days: 90
if-no-files-found: error

- name: Submit iOS
working-directory: apps/mobile
env:
Expand All @@ -234,6 +249,39 @@ jobs:
TAG="kilo-app-release/$(date -u +%Y-%m-%d)-$(git rev-parse --short=7 HEAD)"
git tag "$TAG"
git push origin "$TAG"
echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV"

# The SBOMs live with the build, so a user who holds an artifact can
# retrieve the matching inventory. The notes carry the two artifact
# SHA-256 values so the SBOM can be checked against the binary.
- name: Publish SBOM release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SUMMARY=apps/mobile/artifacts/mobile-sbom-summary.json
APP_VERSION=$(jq -r '.ios.appVersion' "$SUMMARY")
IOS_BUILD=$(jq -r '.ios.appBuildVersion' "$SUMMARY")
ANDROID_BUILD=$(jq -r '.android.appBuildVersion' "$SUMMARY")
IOS_BUILD_ID=$(jq -r '.ios.easBuildId' "$SUMMARY")
ANDROID_BUILD_ID=$(jq -r '.android.easBuildId' "$SUMMARY")

NOTES=apps/mobile/artifacts/mobile-sbom-notes.md
jq -r '
"## kilo-app " + .ios.appVersion + " SBOMs\n\n" +
"| Platform | Artifact | SHA-256 | Components |\n" +
"| --- | --- | --- | --- |\n" +
([.ios, .android] | map(
"| " + .platform + " | " + .artifactName + " | `" + .artifactSha256 + "` | " +
([.counts | to_entries[] | select(.value > 0) | "\(.key)=\(.value)"] | join(", ")) +
" |"
) | join("\n")) +
"\n\nThe SHA-256 names the artifact submitted to the store, the same bytes the SBOM describes.\n"
' "$SUMMARY" > "$NOTES"

gh release create "$RELEASE_TAG" \
Comment thread
iscekic marked this conversation as resolved.
Outdated
--title "kilo-app $APP_VERSION (iOS build $IOS_BUILD, Android build $ANDROID_BUILD) — EAS builds $IOS_BUILD_ID, $ANDROID_BUILD_ID" \
--notes-file "$NOTES" \
apps/mobile/artifacts/*.cyclonedx.json

# A store release needs a version string that was not released before, so the
# marketing version is patch-bumped for the next one. This runs beside
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ supabase/.temp
*.cyclonedx.json
*.cyclonedx.json.gz
*.spdx.json
# where the release workflow writes the SBOMs and the downloaded IPA/AAB
/apps/mobile/artifacts/

# misc
TMP_CI_commit_msg.txt
Expand Down
88 changes: 86 additions & 2 deletions docs/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,105 @@ We generate SBOMs automatically as part of the release flow so every shipped art
traceable, verifiable inventory of its components. This complements CodeQL, Dependabot, and
Trufflehog — none of which emit an SBOM.

All SBOMs are **CycloneDX JSON**, generated with [syft](https://github.com/anchore/syft) via the
official `anchore/sbom-action`. SBOMs are **never committed** to the repo.
All SBOMs are **CycloneDX JSON**. The repo-wide source dependency tree from
`.github/workflows/sbom.yml` and the KiloClaw image attestation from
`.github/workflows/deploy-kiloclaw.yml` are generated with [syft](https://github.com/anchore/syft)
via the official `anchore/sbom-action`. The per-artifact mobile SBOMs are generated by
`scripts/mobile-sbom.mjs`, with no syft on that path (the coverage limits below explain why a syft
scan does not reproduce their component list). SBOMs are **never committed** to the repo.

## Coverage limits

A mobile SBOM is an inventory of what the tooling can observe, and that observation has edges. Read
these limits before relying on a count:

- **Statically linked iOS pods are invisible.** Pods compiled into the app binary leave no file or
load-command trace, so the IPA's pod list is a **lower bound**, not the resolved graph.
- **A dynamic framework the build links but does not report is invisible.** If the linker records no
load command and no framework file ships, nothing in the IPA names it.
- **The Android metadata lists resolved Gradle/Maven modules.** It can omit dependencies that were
never resolved or that do not come from Maven, and it names modules, not the classes inside them.
- **The JavaScript list is the declared production closure.** It comes from `pnpm-lock.yaml`, so a
package Metro bundles but the importer graph does not declare is not listed.
- **The shipped JS bundle is minified and carries no package metadata.** That is why the lockfile
closure, not the bundle, is the honest source.
- **syft cannot see inside a signed binary.** A `syft scan` of an `.ipa` or `.aab` will not reproduce
this component list.
- **This is an inventory, not a vulnerability report.** There is no CVE threshold and no `--fail-on`.

The per-ecosystem component counts measured on a shipped artifact are reported in the release notes
for each build. Treat them as measurements of one artifact, not as a guarantee about the next.

## What we generate, where it lives, and when

| Target | Tooling | When | Stored as |
|---|---|---|---|
| **Source dependency tree** (repo-wide pnpm graph) | `.github/workflows/sbom.yml` | push to `main`, weekly cron, manual dispatch | Retained CI **workflow artifact** (`cloud-sbom-<sha>`) |
| **KiloClaw container image** (OS packages + Go + Node + OpenClaw + npm) | `.github/workflows/deploy-kiloclaw.yml` | at image **build time** (only when content changes) | Signed **attestation in GHCR**, bound to the image digest |
| **Kilo mobile app artifacts** (one SBOM per shipped IPA and AAB) | `scripts/mobile-sbom.mjs` in `.github/workflows/kilo-app-release.yml` job `build-and-submit` | on **every** production build (push to `main` touching `apps/mobile/**` and its workspace inputs, or `workflow_dispatch`) | **GitHub Release** assets on the `kilo-app-release/<date>-<sha>` tag, plus the retained workflow artifact `mobile-sbom-<sha>` |

The image SBOM uses the richest source available — the built image — so it captures the OS-package
and multi-ecosystem footprint that a lockfile-only SBOM misses. The image step is gated to the
build path: the content-hash skip logic means an unchanged image keeps the attestation produced when
its content was last built, so we never pull a large unchanged image on a no-op deploy.

The repo-wide pnpm SBOM from `sbom.yml` stays a **separate family**: it describes the whole
monorepo's dependency tree, not any app that ships. The mobile SBOMs are per-artifact and describe
only the IPA or AAB they accompany.

## Downloading a mobile app SBOM

Each mobile SBOM is named for the platform, version and build it describes:

```
kilo-app-<platform>-<version>-build<buildNumber>.cyclonedx.json
```

`<platform>` is `ios` or `android`. Find the release for the build you hold, then download its SBOMs:

```sh
gh release download kilo-app-release/<date>-<sha> -p '*.cyclonedx.json'
```

Each document carries, in itself, the platform, the app version and build number, the EAS build ID
and the SHA-256 of the submitted artifact under `metadata.properties`. Compare that hash with the
artifact you hold to confirm the SBOM describes those bytes.

## Mobile app SBOM sources

- **npm (both platforms)** — the production dependency closure of `apps/mobile` from `pnpm-lock.yaml`.
It is scoped from `importers['apps/mobile'].dependencies` (plus `optionalDependencies`) and walked
through `snapshots`, following `link:`/`file:` entries into their workspace importers.
- **iOS** — the IPA's Mach-O `LC_LOAD_DYLIB`/weak/reexport load commands plus
`Payload/*.app/Frameworks/*`. The authoritative graph is `apps/mobile/ios/Podfile.lock`, but the
native project is generated by Expo CNG and is not in git (`git ls-files apps/mobile/ios` returns
0 files), and `pod install` needs macOS, which the release runner does not have, so the artifact is
the source. Load commands naming an OS-provided library (`/usr/lib/`, `/System/Library/`, including
`PrivateFrameworks`) are skipped: the OS supplies those, the IPA does not carry them, so they are
not listed as CocoaPods.
- **Android** — the AAB's own `BUNDLE-METADATA/com.android.tools.build.libraries/dependencies.pb`,
written by the Android Gradle Plugin, plus `base/lib/**/*.so`.

## Reproducing the mobile coverage numbers

Re-measure the limits above on any release. Run both commands from the repository root; the paths
under `apps/mobile/` are the ones the release workflow leaves behind. For the iOS pod gap, point the
generator at a real `Podfile.lock` and it prints how many pods the lockfile declares, how many the
IPA shows, and the names of those it does not:

```sh
node scripts/mobile-sbom.mjs \
--ipa apps/mobile/artifacts/app.ipa --aab apps/mobile/artifacts/app.aab \
--build-json apps/mobile/build.json --out-dir apps/mobile/artifacts \
--podfile-lock apps/mobile/ios/Podfile.lock
```

For Android, compare what the AAB's own metadata carries against what syft reports on its own:

```sh
syft scan apps/mobile/artifacts/app.aab -o cyclonedx-json
```

## Verifying an image SBOM attestation

```sh
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
"preinstall": "npx only-allow pnpm",
"typecheck": "scripts/typecheck-all.sh",
"build": "pnpm --filter web build",
"test": "pnpm --filter web test && pnpm run test:web-env && pnpm run test:dev-local && pnpm run test:mobile-artifacts",
"test": "pnpm --filter web test && pnpm run test:web-env && pnpm run test:dev-local && pnpm run test:mobile-artifacts && pnpm run test:mobile-sbom",
"test:web-env": "tsx --tsconfig scripts/web-env/tsconfig.json --test scripts/web-env/*.test.ts",
"test:setup-smoke": "pnpm --filter web run test:setup-smoke",
"test:mobile-artifacts": "node --test scripts/inspect-mobile-artifacts.test.mjs",
"test:mobile-sbom": "node --test scripts/mobile-sbom.test.mjs scripts/mobile-sbom-cyclonedx.test.mjs scripts/mobile-sbom-pnpm.test.mjs scripts/mobile-sbom-ipa.test.mjs scripts/mobile-sbom-aab.test.mjs scripts/mobile-sbom-workflow.test.mjs",
"lint": "scripts/lint-all.sh",
"format": "oxfmt",
"format:check": "oxfmt --list-different .",
Expand Down
Loading
Loading