From c3a6f1716fb85450b98ece8d12388b3f0240cc8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 03:55:34 +0200 Subject: [PATCH 01/11] fix(kilo-app): tag the iOS upload marker at the tip of main GitHub refuses a GITHUB_TOKEN tag push when the tagged commit's .github/workflows differs from every branch tip. A workflow change that merges during a release run made the marker push fail before Submit iOS (runs 36206309918 and 36207505706). The cap reads only the marker name and creatordate, so the marker now points at the fetched tip of main. --- .github/workflows/kilo-app-release.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/kilo-app-release.yml b/.github/workflows/kilo-app-release.yml index af26d533a2..c144dae4bb 100644 --- a/.github/workflows/kilo-app-release.yml +++ b/.github/workflows/kilo-app-release.yml @@ -331,12 +331,18 @@ jobs: # same commit pushes a second, distinct marker instead of failing on a # name that already exists. An annotated tag needs a tagger identity; the # runner has none. + # The marker points at the current tip of main, not at this run's commit: + # only its name and creatordate are read. GitHub refuses a GITHUB_TOKEN + # tag push when the tagged commit's .github/workflows differs from every + # branch tip, so tagging HEAD fails whenever a workflow change merges + # while this run builds. - name: Mark the iOS upload run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" MARKER="kilo-app-upload/$(date -u +%Y-%m-%d)-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - git tag -a "$MARKER" -m "$MARKER" + git fetch --no-tags --depth=1 origin main + git tag -a "$MARKER" -m "$MARKER" FETCH_HEAD git push origin "$MARKER" - name: Submit iOS From 01c36d470872a077bb179cf7e3b2c242ffd2b7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 04:13:44 +0200 Subject: [PATCH 02/11] fix(kilo-app): push release tags with a token that has Workflows: write Replace the marker retarget with a checkout token. The tag pushes in build-and-submit now use KILO_APP_RELEASE_TOKEN, a fine-grained PAT with Contents: write and Workflows: write, so a workflow change that merges during a run no longer blocks the marker or the release tag. Both tags stay on HEAD. --- .github/workflows/kilo-app-release.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/kilo-app-release.yml b/.github/workflows/kilo-app-release.yml index c144dae4bb..7094824264 100644 --- a/.github/workflows/kilo-app-release.yml +++ b/.github/workflows/kilo-app-release.yml @@ -233,6 +233,11 @@ jobs: - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 with: lfs: true + # The tag pushes below need Workflows: write. GitHub refuses a + # GITHUB_TOKEN tag push when the tagged commit's .github/workflows + # differs from every branch tip, which happens whenever a workflow + # change merges while this job runs. + token: ${{ secrets.KILO_APP_RELEASE_TOKEN }} - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -331,18 +336,12 @@ jobs: # same commit pushes a second, distinct marker instead of failing on a # name that already exists. An annotated tag needs a tagger identity; the # runner has none. - # The marker points at the current tip of main, not at this run's commit: - # only its name and creatordate are read. GitHub refuses a GITHUB_TOKEN - # tag push when the tagged commit's .github/workflows differs from every - # branch tip, so tagging HEAD fails whenever a workflow change merges - # while this run builds. - name: Mark the iOS upload run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" MARKER="kilo-app-upload/$(date -u +%Y-%m-%d)-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - git fetch --no-tags --depth=1 origin main - git tag -a "$MARKER" -m "$MARKER" FETCH_HEAD + git tag -a "$MARKER" -m "$MARKER" git push origin "$MARKER" - name: Submit iOS From 3daf005c4c070b436bc4a9d13c2a76e6d1994889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 04:14:42 +0200 Subject: [PATCH 03/11] ci: temporary check of KILO_APP_RELEASE_TOKEN (revert before merge) --- .github/workflows/tmp-release-token-check.yml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/tmp-release-token-check.yml diff --git a/.github/workflows/tmp-release-token-check.yml b/.github/workflows/tmp-release-token-check.yml new file mode 100644 index 0000000000..109aa6fa0d --- /dev/null +++ b/.github/workflows/tmp-release-token-check.yml @@ -0,0 +1,40 @@ +name: tmp release token check + +on: + push: + branches: [fix/kilo-app-upload-marker-race] + paths: ['.github/workflows/tmp-release-token-check.yml'] + +permissions: + contents: write + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + with: + token: ${{ secrets.KILO_APP_RELEASE_TOKEN }} + - name: Push a tag on a commit whose workflows match no branch tip + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + echo "# token check ${GITHUB_RUN_ID}" >> .github/workflows/kilo-app-release.yml + git commit -qam "token check ${GITHUB_RUN_ID}" + TAG="tmp-token-check/${GITHUB_RUN_ID}" + git tag -a "$TAG" -m "$TAG" + git push origin "$TAG" + git push origin --delete "$TAG" + - name: Control - the same push with GITHUB_TOKEN must be refused + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="tmp-token-check/${GITHUB_RUN_ID}-control" + git tag -a "$TAG" -m "$TAG" + if git -c http.https://github.com/.extraheader= push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$TAG"; then + git push origin --delete "$TAG" + echo "::error::GITHUB_TOKEN push was accepted, so this check proves nothing" + exit 1 + fi + echo "GITHUB_TOKEN push refused as expected" From bf76c8e09cd47dacf93b52d151546afa4677e9ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 04:27:59 +0200 Subject: [PATCH 04/11] ci: remove the temporary KILO_APP_RELEASE_TOKEN check --- .github/workflows/tmp-release-token-check.yml | 40 ------------------- 1 file changed, 40 deletions(-) delete mode 100644 .github/workflows/tmp-release-token-check.yml diff --git a/.github/workflows/tmp-release-token-check.yml b/.github/workflows/tmp-release-token-check.yml deleted file mode 100644 index 109aa6fa0d..0000000000 --- a/.github/workflows/tmp-release-token-check.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: tmp release token check - -on: - push: - branches: [fix/kilo-app-upload-marker-race] - paths: ['.github/workflows/tmp-release-token-check.yml'] - -permissions: - contents: write - -jobs: - check: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 - with: - token: ${{ secrets.KILO_APP_RELEASE_TOKEN }} - - name: Push a tag on a commit whose workflows match no branch tip - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - echo "# token check ${GITHUB_RUN_ID}" >> .github/workflows/kilo-app-release.yml - git commit -qam "token check ${GITHUB_RUN_ID}" - TAG="tmp-token-check/${GITHUB_RUN_ID}" - git tag -a "$TAG" -m "$TAG" - git push origin "$TAG" - git push origin --delete "$TAG" - - name: Control - the same push with GITHUB_TOKEN must be refused - env: - GH_TOKEN: ${{ github.token }} - run: | - TAG="tmp-token-check/${GITHUB_RUN_ID}-control" - git tag -a "$TAG" -m "$TAG" - if git -c http.https://github.com/.extraheader= push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$TAG"; then - git push origin --delete "$TAG" - echo "::error::GITHUB_TOKEN push was accepted, so this check proves nothing" - exit 1 - fi - echo "GITHUB_TOKEN push refused as expected" From 4ae64430602493d257c0997240ee15be53a7d2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 04:36:42 +0200 Subject: [PATCH 05/11] fix(kilo-app): give the release token only to the two tag pushes The checkout persisted KILO_APP_RELEASE_TOKEN in .git/config for the whole job, so pnpm install and eas-cli could read it. The checkout keeps GITHUB_TOKEN again. The marker and release tag steps get the token as a step env value and send it through GIT_CONFIG_*, which drops the persisted header. --- .github/workflows/kilo-app-release.yml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/kilo-app-release.yml b/.github/workflows/kilo-app-release.yml index 7094824264..cdfce0702f 100644 --- a/.github/workflows/kilo-app-release.yml +++ b/.github/workflows/kilo-app-release.yml @@ -233,11 +233,6 @@ jobs: - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 with: lfs: true - # The tag pushes below need Workflows: write. GitHub refuses a - # GITHUB_TOKEN tag push when the tagged commit's .github/workflows - # differs from every branch tip, which happens whenever a workflow - # change merges while this job runs. - token: ${{ secrets.KILO_APP_RELEASE_TOKEN }} - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -336,8 +331,22 @@ jobs: # same commit pushes a second, distinct marker instead of failing on a # name that already exists. An annotated tag needs a tagger identity; the # runner has none. + # Both tag pushes use KILO_APP_RELEASE_TOKEN (workflow scope): GitHub + # refuses a GITHUB_TOKEN tag push when the tagged commit's + # .github/workflows differs from every branch tip, which happens whenever + # a workflow change merges while this job runs. The token reaches only + # these two steps, never .git/config, so pnpm and eas-cli cannot read it. + # GIT_CONFIG_* is command-line level: the empty value drops the persisted + # GITHUB_TOKEN header, and the second entry sends the release token. - name: Mark the iOS upload + env: + RELEASE_TOKEN: ${{ secrets.KILO_APP_RELEASE_TOKEN }} run: | + AUTH=$(printf 'x-access-token:%s' "$RELEASE_TOKEN" | base64 -w0) + echo "::add-mask::$AUTH" + export GIT_CONFIG_COUNT=2 + export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0= + export GIT_CONFIG_KEY_1=http.https://github.com/.extraheader GIT_CONFIG_VALUE_1="AUTHORIZATION: basic $AUTH" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" MARKER="kilo-app-upload/$(date -u +%Y-%m-%d)-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" @@ -359,7 +368,14 @@ jobs: # Release detection only: the next run reads this to find the last release. # The upload cap never counts it (a partial run writes no release tag). - name: Tag release + env: + RELEASE_TOKEN: ${{ secrets.KILO_APP_RELEASE_TOKEN }} run: | + AUTH=$(printf 'x-access-token:%s' "$RELEASE_TOKEN" | base64 -w0) + echo "::add-mask::$AUTH" + export GIT_CONFIG_COUNT=2 + export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0= + export GIT_CONFIG_KEY_1=http.https://github.com/.extraheader GIT_CONFIG_VALUE_1="AUTHORIZATION: basic $AUTH" TAG="kilo-app-release/$(date -u +%Y-%m-%d)-$(git rev-parse --short=7 HEAD)" # The tag name is deterministic, so a rerun of this commit recomputes # the tag a failed attempt already pushed. Reuse it: the failure that From 7958b8b1294488d06d8ce1337ed436d7af0571d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 04:36:42 +0200 Subject: [PATCH 06/11] ci: temporary check of the scoped release token push (revert before merge) --- .github/workflows/tmp-release-token-check.yml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/tmp-release-token-check.yml diff --git a/.github/workflows/tmp-release-token-check.yml b/.github/workflows/tmp-release-token-check.yml new file mode 100644 index 0000000000..a42d121db0 --- /dev/null +++ b/.github/workflows/tmp-release-token-check.yml @@ -0,0 +1,35 @@ +name: tmp release token check + +on: + push: + branches: [fix/kilo-app-upload-marker-race] + paths: ['.github/workflows/tmp-release-token-check.yml'] + +permissions: + contents: write + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + - name: Persisted credential is GITHUB_TOKEN, not the PAT + run: git config --show-origin --get-regexp 'extraheader' | sed -E 's/basic .*/basic /' + - name: Scoped PAT push of a tag on a commit whose workflows match no branch tip + env: + RELEASE_TOKEN: ${{ secrets.KILO_APP_RELEASE_TOKEN }} + run: | + AUTH=$(printf 'x-access-token:%s' "$RELEASE_TOKEN" | base64 -w0) + echo "::add-mask::$AUTH" + export GIT_CONFIG_COUNT=2 + export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0= + export GIT_CONFIG_KEY_1=http.https://github.com/.extraheader GIT_CONFIG_VALUE_1="AUTHORIZATION: basic $AUTH" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + echo "# token check ${GITHUB_RUN_ID}" >> .github/workflows/kilo-app-release.yml + git commit -qam "token check ${GITHUB_RUN_ID}" + TAG="tmp-token-check/${GITHUB_RUN_ID}" + git tag -a "$TAG" -m "$TAG" + git push origin "$TAG" + git push origin --delete "$TAG" From 9d67659a77859217940e780bb6d09705d829d311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 04:37:26 +0200 Subject: [PATCH 07/11] ci: remove the temporary release token check --- .github/workflows/tmp-release-token-check.yml | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/tmp-release-token-check.yml diff --git a/.github/workflows/tmp-release-token-check.yml b/.github/workflows/tmp-release-token-check.yml deleted file mode 100644 index a42d121db0..0000000000 --- a/.github/workflows/tmp-release-token-check.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: tmp release token check - -on: - push: - branches: [fix/kilo-app-upload-marker-race] - paths: ['.github/workflows/tmp-release-token-check.yml'] - -permissions: - contents: write - -jobs: - check: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 - - name: Persisted credential is GITHUB_TOKEN, not the PAT - run: git config --show-origin --get-regexp 'extraheader' | sed -E 's/basic .*/basic /' - - name: Scoped PAT push of a tag on a commit whose workflows match no branch tip - env: - RELEASE_TOKEN: ${{ secrets.KILO_APP_RELEASE_TOKEN }} - run: | - AUTH=$(printf 'x-access-token:%s' "$RELEASE_TOKEN" | base64 -w0) - echo "::add-mask::$AUTH" - export GIT_CONFIG_COUNT=2 - export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0= - export GIT_CONFIG_KEY_1=http.https://github.com/.extraheader GIT_CONFIG_VALUE_1="AUTHORIZATION: basic $AUTH" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - echo "# token check ${GITHUB_RUN_ID}" >> .github/workflows/kilo-app-release.yml - git commit -qam "token check ${GITHUB_RUN_ID}" - TAG="tmp-token-check/${GITHUB_RUN_ID}" - git tag -a "$TAG" -m "$TAG" - git push origin "$TAG" - git push origin --delete "$TAG" From d1179257cda0a63d3fe5cab315c8e3312bbcb867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 04:57:56 +0200 Subject: [PATCH 08/11] fix(kilo-app): list every CocoaPod in the iOS SBOM The IPA Mach-O scan sees only dynamic frameworks, so the iOS SBOM listed 2 native components and missed every statically linked pod. EAS now uploads the build's ios/Podfile.lock (eas.json buildArtifactPaths). The release job downloads it with the IPA, and mobile-sbom.mjs requires --podfile-lock and writes one component per root pod with its version and SPEC CHECKSUMS SHA-1. The report-only comparePodfileLock gap check is removed. The mobile-sbom workflow contract now allows the github.com git config key that the Tag release step uses for its push token. --- .github/workflows/kilo-app-release.yml | 21 ++- apps/mobile/eas.json | 5 +- docs/sbom.md | 39 +++-- scripts/inspect-mobile-artifacts.mjs | 17 +- scripts/inspect-mobile-artifacts.test.mjs | 48 ++++++ scripts/mobile-sbom-ipa.mjs | 148 +++++++++------- scripts/mobile-sbom-ipa.test.mjs | 144 +++++++++++----- scripts/mobile-sbom-workflow.test.mjs | 8 +- scripts/mobile-sbom.mjs | 70 ++------ scripts/mobile-sbom.test.mjs | 195 ++++++++++++++++------ 10 files changed, 475 insertions(+), 220 deletions(-) diff --git a/.github/workflows/kilo-app-release.yml b/.github/workflows/kilo-app-release.yml index cdfce0702f..587d59f030 100644 --- a/.github/workflows/kilo-app-release.yml +++ b/.github/workflows/kilo-app-release.yml @@ -277,6 +277,25 @@ jobs: } download "$IOS_URL" artifacts/app.ipa "iOS" download "$ANDROID_URL" artifacts/app.aab "Android" + # The Podfile.lock this iOS build resolved, uploaded by eas.json + # buildArtifactPaths. Its URL is signed, so it is read from urls.txt + # here and never exported or echoed. EAS uploads a single build artifact + # as the file itself and several as one tar.gz, so both are accepted. + download "$(sed -n '3p' urls.txt)" artifacts/ios-build-artifacts "iOS build artifacts" + if gzip -t artifacts/ios-build-artifacts 2>/dev/null; then + MEMBER=$(tar -tzf artifacts/ios-build-artifacts | grep -E '(^|/)Podfile\.lock$' || true) + if [ "$(printf '%s' "$MEMBER" | grep -c '')" -ne 1 ]; then + echo "::error::the iOS build artifacts archive must hold exactly one Podfile.lock, found: ${MEMBER:-none}" + exit 1 + fi + tar -xzf artifacts/ios-build-artifacts -O "$MEMBER" > artifacts/Podfile.lock + else + mv artifacts/ios-build-artifacts artifacts/Podfile.lock + fi + if ! grep -q '^PODS:' artifacts/Podfile.lock; then + echo "::error::the iOS build artifacts carry no Podfile.lock with a PODS: section" + exit 1 + fi - name: Setup Java uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 @@ -292,7 +311,7 @@ jobs: # 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 + run: node ../../scripts/mobile-sbom.mjs --ipa artifacts/app.ipa --aab artifacts/app.aab --build-json build.json --podfile-lock artifacts/Podfile.lock --out-dir artifacts # The retained second copy, matching sbom.yml's cloud-sbom- pattern. - name: Upload SBOMs diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index ba320184b1..462cced704 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -17,7 +17,10 @@ "production": { "extends": "base", "autoIncrement": true, - "environment": "production" + "environment": "production", + "ios": { + "buildArtifactPaths": ["ios/Podfile.lock"] + } }, "preview": { "extends": "base", diff --git a/docs/sbom.md b/docs/sbom.md index 25f01e0428..995ec1657a 100644 --- a/docs/sbom.md +++ b/docs/sbom.md @@ -16,10 +16,12 @@ scan does not reproduce their component list). SBOMs are **never committed** to 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 iOS pod list is what CocoaPods resolved for the build.** The build's `Podfile.lock` names + every pod, statically or dynamically linked, but a subspec is folded into its pod and the + `SPEC CHECKSUMS` hash identifies the podspec, not the compiled code in the IPA. +- **A framework outside CocoaPods that the build links but does not report is invisible.** If it is + not in the `Podfile.lock`, the linker records no load command, and no framework file ships, + nothing 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 @@ -73,28 +75,33 @@ artifact you hold to confirm the SBOM describes those bytes. - **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. +- **iOS** — the `Podfile.lock` EAS resolved for that exact build, plus a scan of the IPA. Expo CNG + generates `apps/mobile/ios` on the builder (nothing under it is in git), so the production profile + in `apps/mobile/eas.json` uploads `ios/Podfile.lock` through `buildArtifactPaths`, and the release + workflow downloads it from the build's `buildArtifactsUrl` next to the IPA. Every root pod under + `PODS:` becomes one component (subspecs such as `React-Core/Default` collapse into `React-Core`) + with its locked version, a `pkg:cocoapods/@` purl and, when listed, its + `SPEC CHECKSUMS` podspec SHA-1; `kilo:sbom:ios-kind` is `podfile-lock`. This is the only source + for pods statically linked into the executable. The IPA scan adds the Mach-O + `LC_LOAD_DYLIB`/weak/reexport load commands plus `Payload/*.app/Frameworks/*` (`kilo:sbom:ios-kind` + `dylib-load-command` or `dynamic-framework`). 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. A missing or unreadable + `Podfile.lock` fails the release before anything is submitted. - **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: +under `apps/mobile/` are the ones the release workflow leaves behind, including the build's +`Podfile.lock`: ```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 + --build-json apps/mobile/build.json --podfile-lock apps/mobile/artifacts/Podfile.lock \ + --out-dir apps/mobile/artifacts ``` For Android, compare what the AAB's own metadata carries against what syft reports on its own: diff --git a/scripts/inspect-mobile-artifacts.mjs b/scripts/inspect-mobile-artifacts.mjs index bdb30bca84..9faf6fa7d0 100644 --- a/scripts/inspect-mobile-artifacts.mjs +++ b/scripts/inspect-mobile-artifacts.mjs @@ -10,7 +10,10 @@ * with bundletool, checks debug symbols, and prints a signed-artifact size * table (JS bundles, fonts, and grammar modules). The --select mode validates * the EAS build.json (every build FINISHED, one IOS and one ANDROID entry with - * an applicationArchiveUrl) and prints the two archive URLs, one per line. + * an applicationArchiveUrl, and an IOS buildArtifactsUrl) and prints three + * lines: the iOS archive URL, the Android archive URL, and the iOS build + * artifacts URL (the archive eas.json `buildArtifactPaths` uploads, which + * carries the build's Podfile.lock). * * Exits 1 with a clear message on any contract violation. */ @@ -135,6 +138,10 @@ function artifactUrl(build) { return build?.artifacts?.applicationArchiveUrl ?? ''; } +function buildArtifactsUrl(build) { + return build?.artifacts?.buildArtifactsUrl ?? ''; +} + function selectMode(buildJsonPath) { const builds = parseBuildJson(buildJsonPath); assertAllFinished(builds); @@ -154,10 +161,16 @@ function selectMode(buildJsonPath) { if (!androidUrl) { failures.push('ANDROID build has no artifacts.applicationArchiveUrl'); } + const iosBuildArtifactsUrl = buildArtifactsUrl(ios); + if (!iosBuildArtifactsUrl) { + failures.push( + 'IOS build has no artifacts.buildArtifactsUrl (eas.json build.production.ios.buildArtifactPaths must upload ios/Podfile.lock)' + ); + } if (failures.length > 0) { reportAndExit(); } - process.stdout.write(`${iosUrl}\n${androidUrl}\n`); + process.stdout.write(`${iosUrl}\n${androidUrl}\n${iosBuildArtifactsUrl}\n`); process.exit(0); } diff --git a/scripts/inspect-mobile-artifacts.test.mjs b/scripts/inspect-mobile-artifacts.test.mjs index c5e3b8f867..a000d9f535 100644 --- a/scripts/inspect-mobile-artifacts.test.mjs +++ b/scripts/inspect-mobile-artifacts.test.mjs @@ -1,9 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { deflateRawSync } from 'node:zlib'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { checkResourceShrinking, @@ -164,3 +166,49 @@ test('inspectJsBundles reports hasDebugId false when debug-id markers are absent const result = withFixture([['index.jsbundle', INSPECT_JS_NEEDLES.join(' ')]], inspectJsBundles); assert.equal(result.hasDebugId, false); }); + +function runSelect(builds) { + const work = mkdtempSync(join(tmpdir(), 'kilo-select-test-')); + const buildJsonPath = join(work, 'build.json'); + try { + writeFileSync(buildJsonPath, JSON.stringify(builds)); + return spawnSync( + 'node', + [fileURLToPath(new URL('./inspect-mobile-artifacts.mjs', import.meta.url)), '--select', buildJsonPath], + { encoding: 'utf8' } + ); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +test('--select prints the iOS, Android and iOS build artifacts URLs, and requires the last', () => { + const ios = { + platform: 'IOS', + status: 'FINISHED', + artifacts: { + applicationArchiveUrl: 'https://example.invalid/app.ipa', + buildArtifactsUrl: 'https://example.invalid/build-artifacts.tar.gz', + }, + }; + const android = { + platform: 'ANDROID', + status: 'FINISHED', + artifacts: { applicationArchiveUrl: 'https://example.invalid/app.aab' }, + }; + + const selected = runSelect([android, ios]); + assert.equal(selected.status, 0, selected.stderr); + assert.equal( + selected.stdout, + 'https://example.invalid/app.ipa\nhttps://example.invalid/app.aab\nhttps://example.invalid/build-artifacts.tar.gz\n' + ); + + const missing = runSelect([ + { ...ios, artifacts: { applicationArchiveUrl: ios.artifacts.applicationArchiveUrl } }, + android, + ]); + assert.equal(missing.status, 1); + assert.equal(missing.stdout, ''); + assert.match(missing.stderr, /IOS build has no artifacts\.buildArtifactsUrl/); +}); diff --git a/scripts/mobile-sbom-ipa.mjs b/scripts/mobile-sbom-ipa.mjs index 4192163ad5..0cf3c53e79 100644 --- a/scripts/mobile-sbom-ipa.mjs +++ b/scripts/mobile-sbom-ipa.mjs @@ -2,16 +2,22 @@ /** * iOS ecosystem reader for the per-artifact SBOM. * - * The shipped IPA is the source, not apps/mobile/ios/Podfile.lock: Expo CNG - * generates apps/mobile/ios (nothing under it is tracked in git) and - * `pod install` needs macOS, which the ubuntu release runner does not have. + * Two sources, both scoped to one EAS build: + * + * - The Podfile.lock EAS resolved for that build (uploaded next to the IPA via + * eas.json `buildArtifactPaths`). Expo CNG generates apps/mobile/ios, so the + * lockfile exists only on the EAS builder. It is the only source that sees + * pods statically linked into the executable, which leave no file or load + * command in the IPA. + * - The shipped IPA: each executable's dylib load commands and each bundle's + * Frameworks/ directory, i.e. what the artifact demonstrably carries. * * Exports: - * parseMachODylibs(buffer) pure Mach-O LC_LOAD_DYLIB/weak/reexport/upward reader - * readIpaComponents({ ipaPath }) unzip the IPA, walk the app and every embedded - * app extension: each executable's load commands - * and each bundle's Frameworks/ directory - * comparePodfileLock(...) gap measurement against a real Podfile.lock (printed only) + * parseMachODylibs(buffer) pure Mach-O LC_LOAD_DYLIB/weak/reexport/upward reader + * readIpaComponents({ ipaPath }) unzip the IPA, walk the app and every embedded + * app extension: each executable's load commands + * and each bundle's Frameworks/ directory + * readPodfileLockComponents({ podfileLockPath }) one component per root pod in PODS: */ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; @@ -54,6 +60,14 @@ const MAX_FAT_DEPTH = 1; const KILO_IOS_KIND = 'kilo:sbom:ios-kind'; const KIND_LOAD_COMMAND = 'dylib-load-command'; const KIND_DYNAMIC_FRAMEWORK = 'dynamic-framework'; +const KIND_PODFILE_LOCK = 'podfile-lock'; +const SHA1_RE = /^[0-9a-f]{40}$/; +// A top-level PODS: entry, ` - Name (1.2.3)`, with a trailing `:` when it lists +// dependencies. Its dependency lines are indented further and never match. +const POD_ENTRY_RE = /^ {2}- (.+?):?$/; +// `Name (1.2.3)` or a subspec `Name/Sub/Spec (1.2.3)`, captured as root and version. +const POD_SPEC_RE = /^([^\s/()]+)(?:\/[^\s()]+)? \(([^\s()]+)\)$/; +const CHECKSUM_RE = /^ {2}(.+?): (.+)$/; // iOS resolves its own libraries from /usr/lib/ (libSystem, libc++, the Swift // runtime) and its system frameworks from /System/Library/, including @@ -414,54 +428,63 @@ export function readIpaComponents({ ipaPath } = {}) { } } -function parseDeclaredPods(text) { - const pods = []; - const seen = new Set(); - let inPodsSection = false; +// CocoaPods quotes a YAML scalar that would otherwise be misread. +function unquote(value) { + return value.length >= 2 && value.startsWith('"') && value.endsWith('"') + ? value.slice(1, -1) + : value; +} + +function parsePodfileLock(text, podfileLockPath) { + const versions = new Map(); + const checksums = new Map(); + let section = null; for (const line of text.split(/\r?\n/)) { - // Podfile.lock section headers are unindented; only PODS: holds the graph. - if (/^[A-Za-z]/.test(line)) { - inPodsSection = line === 'PODS:'; - continue; - } - if (!inPodsSection) { + // Section headers are unindented; PODS: holds the resolved pods and + // SPEC CHECKSUMS: the podspec SHA-1 of each root pod. + if (/^\S/.test(line)) { + section = line; continue; } - const match = /^ {2}- ([A-Za-z0-9_+./-]+)/.exec(line); - if (!match) { - continue; - } - // Sub-specs (`ExpoImagePicker/Core`) collapse to their pod. - const pod = match[1].split('/')[0]; - if (!seen.has(pod)) { - seen.add(pod); - pods.push(pod); + if (section === 'PODS:') { + const entry = POD_ENTRY_RE.exec(line); + if (!entry) { + continue; + } + const spec = POD_SPEC_RE.exec(unquote(entry[1])); + if (!spec) { + throw new Error( + `Podfile.lock ${podfileLockPath} has an unparseable PODS entry ${JSON.stringify(line.trim())}` + ); + } + // Subspecs (`React-Core/Default`) are parts of their root pod and share its version. + const [, pod, version] = spec; + const known = versions.get(pod); + if (known !== undefined && known !== version) { + throw new Error( + `Podfile.lock ${podfileLockPath} locks ${pod} at both ${known} and ${version}` + ); + } + versions.set(pod, version); + } else if (section === 'SPEC CHECKSUMS:') { + const entry = CHECKSUM_RE.exec(line); + if (entry) { + checksums.set(unquote(entry[1]), unquote(entry[2].trim())); + } } } - return pods; -} - -function normalizePodName(pod) { - return pod.toLowerCase().replace(/-/g, '_'); -} - -function normalizeComponentName(name) { - const withoutFramework = name.endsWith('.framework') ? name.slice(0, -'.framework'.length) : name; - return normalizePodName(withoutFramework); + return { versions, checksums }; } /** - * Measure how many pods a real Podfile.lock declares against the components the - * IPA carries. This is reported only; it is never written into an SBOM, because - * a lockfile pod that is statically linked into the executable is invisible to - * any file scan. + * Read one CocoaPods component per root pod the Podfile.lock resolved, in + * lockfile order: subspecs collapse into their root pod, the version is the + * locked one, and the hash is the pod's `SPEC CHECKSUMS` SHA-1 when listed. + * An unreadable lockfile, or one that declares no pods, throws. */ -export function comparePodfileLock({ podfileLockPath, components } = {}) { +export function readPodfileLockComponents({ podfileLockPath } = {}) { if (!isNonEmptyString(podfileLockPath)) { - throw new Error('comparePodfileLock: podfileLockPath must be a non-empty string'); - } - if (!Array.isArray(components)) { - throw new Error('comparePodfileLock: components must be an array'); + throw new Error('readPodfileLockComponents: podfileLockPath must be a non-empty string'); } let text; try { @@ -469,16 +492,25 @@ export function comparePodfileLock({ podfileLockPath, components } = {}) { } catch (error) { throw new Error(`cannot read Podfile.lock ${podfileLockPath}: ${error.message}`); } - const declared = parseDeclaredPods(text); - const visible = new Set( - components - .filter(component => component && isNonEmptyString(component.name)) - .map(component => normalizeComponentName(component.name)) - ); - const missing = declared.filter(pod => !visible.has(normalizePodName(pod))); - return { - declaredCount: declared.length, - visibleCount: declared.length - missing.length, - missing: [...missing].sort(), - }; + const { versions, checksums } = parsePodfileLock(text, podfileLockPath); + if (versions.size === 0) { + throw new Error(`Podfile.lock ${podfileLockPath} declares no pods under PODS:`); + } + const components = [...versions].map(([pod, version]) => { + const checksum = checksums.get(pod); + if (checksum !== undefined && !SHA1_RE.test(checksum)) { + throw new Error( + `Podfile.lock ${podfileLockPath} has a malformed SPEC CHECKSUMS entry for ${pod}: ${JSON.stringify(checksum)}` + ); + } + return { + ecosystem: 'cocoapods', + name: pod, + version, + purl: `pkg:cocoapods/${encodeURIComponent(pod)}@${encodeURIComponent(version)}`, + hashes: checksum === undefined ? [] : [{ alg: 'SHA-1', content: checksum }], + extraProperties: [{ name: KILO_IOS_KIND, value: KIND_PODFILE_LOCK }], + }; + }); + return { components }; } diff --git a/scripts/mobile-sbom-ipa.test.mjs b/scripts/mobile-sbom-ipa.test.mjs index 2f94424fe3..97c5b0345c 100644 --- a/scripts/mobile-sbom-ipa.test.mjs +++ b/scripts/mobile-sbom-ipa.test.mjs @@ -5,7 +5,11 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { comparePodfileLock, parseMachODylibs, readIpaComponents } from './mobile-sbom-ipa.mjs'; +import { + parseMachODylibs, + readIpaComponents, + readPodfileLockComponents, +} from './mobile-sbom-ipa.mjs'; const LC_LOAD_DYLIB = 0x0c; const LC_ID_DYLIB = 0x0d; @@ -398,54 +402,116 @@ test('readIpaComponents rejects an IPA without a Payload bundle', () => { ); }); -test('comparePodfileLock reports declared, visible and missing pods', () => { - const podfileLock = `PODS: - - ExpoModulesCore (1.5.0) - - MissingPod (9.9.9) - - React (0.72.0) - -DEPENDENCIES: - - React (from \`../node_modules/react-native\`) - - ExpoModulesCore (from \`../node_modules/expo-modules-core\`) - -COCOAPODS: 1.14.3 -`; +function withPodfileLock(text, run) { const work = mkdtempSync(join(tmpdir(), 'kilo-podfile-test-')); const podfileLockPath = join(work, 'Podfile.lock'); try { - writeFileSync(podfileLockPath, podfileLock); - const result = comparePodfileLock({ - podfileLockPath, - components: [ - { name: 'React.framework' }, - { name: 'ExpoModulesCore' }, - { name: 'Unrelated.framework' }, - ], - }); - assert.deepEqual(result, { declaredCount: 3, visibleCount: 2, missing: ['MissingPod'] }); + writeFileSync(podfileLockPath, text); + return run(podfileLockPath); } finally { rmSync(work, { recursive: true, force: true }); } -}); +} -test('comparePodfileLock collapses sub-specs and stops at the next section', () => { +test('readPodfileLockComponents lists one versioned, hashed component per root pod', () => { const podfileLock = `PODS: - - ExpoImagePicker/Core (1.0.0) - - ExpoImagePicker/Expo (1.0.0) + - EXConstants (17.0.8): + - ExpoModulesCore + - hermes-engine (0.76.9): + - hermes-engine/Pre-built (= 0.76.9) + - hermes-engine/Pre-built (0.76.9) + - React-Core (0.76.9): + - React-Core/Default (= 0.76.9) + - React-Core/Default (0.76.9): + - glog + - React-Core/RCTWebSocket (0.76.9) + - "RCT-Folly (2024.10.14.00)" + - Sentry/HybridSDK (8.48.0) DEPENDENCIES: - - NotInPods (1.0.0) + - NotAPod (from \`../node_modules/not-a-pod\`) + +SPEC CHECKSUMS: + EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b + hermes-engine: 06a9c6900587420b90accc394199527c64259db4 + RCT-Folly: 84578c8756030547307e4572ab1947de1685c599 + React-Core: 4f1ba1b2a3b94ba77d4b0c9d5ebcd2c9fd9d2d8e + +COCOAPODS: 1.15.2 `; - const work = mkdtempSync(join(tmpdir(), 'kilo-podfile-test-')); - const podfileLockPath = join(work, 'Podfile.lock'); - try { - writeFileSync(podfileLockPath, podfileLock); - const result = comparePodfileLock({ - podfileLockPath, - components: [{ name: 'ExpoImagePicker' }], - }); - assert.deepEqual(result, { declaredCount: 1, visibleCount: 1, missing: [] }); - } finally { - rmSync(work, { recursive: true, force: true }); + const { components } = withPodfileLock(podfileLock, podfileLockPath => + readPodfileLockComponents({ podfileLockPath }) + ); + + assert.deepEqual( + components.map(({ name, version, purl, hashes }) => ({ name, version, purl, hashes })), + [ + { + name: 'EXConstants', + version: '17.0.8', + purl: 'pkg:cocoapods/EXConstants@17.0.8', + hashes: [{ alg: 'SHA-1', content: 'fcfc75800824ac2d5c592b5bc74130bad17b146b' }], + }, + { + name: 'hermes-engine', + version: '0.76.9', + purl: 'pkg:cocoapods/hermes-engine@0.76.9', + hashes: [{ alg: 'SHA-1', content: '06a9c6900587420b90accc394199527c64259db4' }], + }, + { + name: 'React-Core', + version: '0.76.9', + purl: 'pkg:cocoapods/React-Core@0.76.9', + hashes: [{ alg: 'SHA-1', content: '4f1ba1b2a3b94ba77d4b0c9d5ebcd2c9fd9d2d8e' }], + }, + { + name: 'RCT-Folly', + version: '2024.10.14.00', + purl: 'pkg:cocoapods/RCT-Folly@2024.10.14.00', + hashes: [{ alg: 'SHA-1', content: '84578c8756030547307e4572ab1947de1685c599' }], + }, + // Listed only as a subspec and absent from SPEC CHECKSUMS. + { + name: 'Sentry', + version: '8.48.0', + purl: 'pkg:cocoapods/Sentry@8.48.0', + hashes: [], + }, + ] + ); + for (const component of components) { + assert.equal(component.ecosystem, 'cocoapods'); + assert.deepEqual(component.extraProperties, [ + { name: 'kilo:sbom:ios-kind', value: 'podfile-lock' }, + ]); } }); + +test('readPodfileLockComponents rejects a missing, empty or malformed Podfile.lock', () => { + assert.throws( + () => readPodfileLockComponents({ podfileLockPath: join(tmpdir(), 'kilo-missing-Podfile.lock') }), + /cannot read Podfile\.lock/ + ); + assert.throws( + () => + withPodfileLock('DEPENDENCIES:\n - React (0.76.9)\n', podfileLockPath => + readPodfileLockComponents({ podfileLockPath }) + ), + /declares no pods/ + ); + assert.throws( + () => + withPodfileLock( + 'PODS:\n - React (0.76.9)\n\nSPEC CHECKSUMS:\n React: not-a-sha1\n', + podfileLockPath => readPodfileLockComponents({ podfileLockPath }) + ), + /malformed SPEC CHECKSUMS entry for React/ + ); + assert.throws( + () => + withPodfileLock('PODS:\n - React (0.76.9)\n - React/Core (0.77.0)\n', podfileLockPath => + readPodfileLockComponents({ podfileLockPath }) + ), + /locks React at both 0\.76\.9 and 0\.77\.0/ + ); +}); diff --git a/scripts/mobile-sbom-workflow.test.mjs b/scripts/mobile-sbom-workflow.test.mjs index 8206e410dd..f2cc8d2cb0 100644 --- a/scripts/mobile-sbom-workflow.test.mjs +++ b/scripts/mobile-sbom-workflow.test.mjs @@ -60,6 +60,7 @@ test('every production build generates, retains and publishes a per-artifact SBO '--ipa artifacts/app.ipa', '--aab artifacts/app.aab', '--build-json build.json', + '--podfile-lock artifacts/Podfile.lock', '--out-dir artifacts', ]) { assert.ok((generator.run ?? '').includes(argument), `the generator must pass ${argument}`); @@ -137,7 +138,12 @@ test('every production build generates, retains and publishes a per-artifact SBO /Authorization/, `${step.name}: must not write an Authorization header` ); - assert.doesNotMatch(text, /https?:\/\//, `${step.name}: must not embed an artifact URL`); + // Tag release names the github.com git config key for its push token. + assert.doesNotMatch( + text, + /https?:\/\/(?!github\.com\/)/, + `${step.name}: must not embed an artifact URL` + ); } }); diff --git a/scripts/mobile-sbom.mjs b/scripts/mobile-sbom.mjs index cc8812a129..1bd50e854a 100644 --- a/scripts/mobile-sbom.mjs +++ b/scripts/mobile-sbom.mjs @@ -5,14 +5,17 @@ * One CycloneDX JSON document per shipped artifact, plus a summary linking each * document to the EAS build record it came from: * - * node scripts/mobile-sbom.mjs --ipa --aab --build-json --out-dir [--podfile-lock ] + * node scripts/mobile-sbom.mjs --ipa --aab --build-json --out-dir --podfile-lock * * The inputs are the bytes that get submitted to the stores, so each document * is scoped to one artifact (never the repo-wide pnpm tree) and carries the - * artifact's SHA-256, platform, version, build number and EAS build ID. + * artifact's SHA-256, platform, version, build number and EAS build ID. The + * Podfile.lock is the one EAS resolved for that iOS build (its build artifacts + * archive): it lists the pods statically linked into the executable, which no + * scan of the IPA can see. * - * The EAS build record is read for identity only. `artifacts.applicationArchiveUrl` - * carries a signed download token, so it is never read, printed or persisted. + * The EAS build record is read for identity only. Its `artifacts` URLs carry a + * signed download token, so they are never read, printed or persisted. * * Every artifact/record/metadata failure throws before anything is written: * both documents are built in memory first, so a failure can never leave a @@ -29,21 +32,21 @@ import { sha256File, toCycloneDxComponents, } from './mobile-sbom-cyclonedx.mjs'; -import { comparePodfileLock, readIpaComponents } from './mobile-sbom-ipa.mjs'; +import { readIpaComponents, readPodfileLockComponents } from './mobile-sbom-ipa.mjs'; import { readPnpmProductionClosure } from './mobile-sbom-pnpm.mjs'; const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const PNPM_LOCKFILE_PATH = join(REPO_ROOT, 'pnpm-lock.yaml'); const SUMMARY_FILE_NAME = 'mobile-sbom-summary.json'; const USAGE = - 'Usage: node scripts/mobile-sbom.mjs --ipa --aab --build-json --out-dir [--podfile-lock ]'; + 'Usage: node scripts/mobile-sbom.mjs --ipa --aab --build-json --out-dir --podfile-lock '; // Source of each ecosystem, written into each document that carries it. const ECOSYSTEM_ORDER = ['npm', 'cocoapods', 'maven', 'native-library']; const ECOSYSTEM_SOURCES = { npm: 'pnpm-lock.yaml production dependency closure of apps/mobile (the minified shipped JS bundle carries no package metadata)', cocoapods: - 'IPA scan: Mach-O LC_LOAD_DYLIB/weak/reexport install names plus Payload/*.app/Frameworks/ (OS-provided /usr/lib and /System/Library libraries excluded)', + 'Podfile.lock of the EAS build (one component per root pod in PODS:, subspecs collapsed, hash = SPEC CHECKSUMS podspec SHA-1) plus an IPA scan: Mach-O LC_LOAD_DYLIB/weak/reexport install names and Payload/*.app/Frameworks/ (OS-provided /usr/lib and /System/Library libraries excluded)', maven: 'AAB BUNDLE-METADATA/com.android.tools.build.libraries/dependencies.pb (Android Gradle Plugin resolved Maven artifacts)', 'native-library': 'AAB base/lib/**/*.so', @@ -83,8 +86,8 @@ function parseBuildJson(buildJsonPath) { return builds; } -// Identity only. `artifacts.applicationArchiveUrl` is deliberately never touched: -// it is a signed URL that carries a download token. +// Identity only. `artifacts` is deliberately never touched: its URLs are signed +// and carry a download token. function requireRecordField(record, field, platform) { const value = record[field]; if (isNonEmptyString(value)) { @@ -172,33 +175,9 @@ function buildPlatform({ }; } -function summaryEntry(entry) { - const { - platform, - artifactName, - artifactSha256, - appVersion, - appBuildVersion, - easBuildId, - sbomFile, - counts, - } = entry; - return { - platform, - artifactName, - artifactSha256, - appVersion, - appBuildVersion, - easBuildId, - sbomFile, - counts, - }; -} - /** * Generate the ios and android SBOM documents and the summary for one build. - * Returns `{ ios, android }`, each entry carrying its file name and counts (and, - * when a Podfile.lock was supplied, the iOS coverage gap under `podfileLock`). + * Returns `{ ios, android }`, each entry carrying its file name and counts. * Throws instead of writing anything if any input is missing or malformed. */ export function generateMobileSboms({ @@ -212,9 +191,11 @@ export function generateMobileSboms({ requirePath(aabPath, 'aabPath'); requirePath(buildJsonPath, 'buildJsonPath'); requirePath(outDir, 'outDir'); + requirePath(podfileLockPath, 'podfileLockPath'); const records = readBuildRecords(buildJsonPath); const npmClosure = readPnpmProductionClosure({ lockfilePath: PNPM_LOCKFILE_PATH }); + const pods = readPodfileLockComponents({ podfileLockPath }); const ipa = readIpaComponents({ ipaPath }); const aab = readAabComponents({ aabPath }); @@ -222,7 +203,7 @@ export function generateMobileSboms({ platform: 'ios', artifactPath: ipaPath, ...records.ios, - readerComponents: [...npmClosure.components, ...ipa.components], + readerComponents: [...npmClosure.components, ...pods.components, ...ipa.components], }); const android = buildPlatform({ platform: 'android', @@ -231,13 +212,6 @@ export function generateMobileSboms({ readerComponents: [...npmClosure.components, ...aab.components], }); - if (isNonEmptyString(podfileLockPath)) { - ios.entry.podfileLock = comparePodfileLock({ - podfileLockPath, - components: ipa.components, - }); - } - // Nothing has been written yet: a failure above this line leaves no file. mkdirSync(outDir, { recursive: true }); writeFileSync(join(outDir, ios.entry.sbomFile), `${JSON.stringify(ios.document, null, 2)}\n`); @@ -247,7 +221,7 @@ export function generateMobileSboms({ ); writeFileSync( join(outDir, SUMMARY_FILE_NAME), - `${JSON.stringify({ ios: summaryEntry(ios.entry), android: summaryEntry(android.entry) }, null, 2)}\n` + `${JSON.stringify({ ios: ios.entry, android: android.entry }, null, 2)}\n` ); return { ios: ios.entry, android: android.entry }; @@ -262,14 +236,6 @@ function printReport(entries) { `${entry.platform}: ${entry.artifactName} sha256=${entry.artifactSha256} sbom=${entry.sbomFile} ${ecosystems}` ); } - const ios = entries.find(entry => entry.platform === 'ios'); - if (ios && ios.podfileLock) { - const { declaredCount, visibleCount, missing } = ios.podfileLock; - const missingNames = missing.length > 0 ? ` missing=[${missing.join(', ')}]` : ' missing=[]'; - console.log( - `ios podfile-lock: declared=${declaredCount} visible=${visibleCount}${missingNames}` - ); - } } function parseArgs(argv) { @@ -294,7 +260,7 @@ function parseArgs(argv) { options[key] = value; index += 1; } - for (const flag of ['--ipa', '--aab', '--build-json', '--out-dir']) { + for (const flag of ['--ipa', '--aab', '--build-json', '--out-dir', '--podfile-lock']) { if (!options[flags[flag]]) { throw new UsageError(`${flag} is required`); } diff --git a/scripts/mobile-sbom.test.mjs b/scripts/mobile-sbom.test.mjs index 330b9ce099..e33daccae2 100644 --- a/scripts/mobile-sbom.test.mjs +++ b/scripts/mobile-sbom.test.mjs @@ -26,8 +26,8 @@ const IOS_BUILD_NUMBER = '42'; const ANDROID_BUILD_NUMBER = '43'; const IOS_BUILD_ID = '11111111-2222-3333-4444-555555555555'; const ANDROID_BUILD_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; -// Stands in for the signed token an applicationArchiveUrl carries; it must never -// reach an output file. +// Stands in for the signed token the build record's artifact URLs carry; it must +// never reach an output file or the console. const TOKEN = 'application-archive-token-secret'; const INFO_PLIST = ` @@ -42,13 +42,19 @@ const INFO_PLIST = ` `; +const REACT_CHECKSUM = '4f1ba1b2a3b94ba77d4b0c9d5ebcd2c9fd9d2d8e'; const PODFILE_LOCK = `PODS: - - React (0.72.0) - - MissingPod (9.9.9) + - React (0.72.0): + - React-Core (= 0.72.0) + - React-Core/Default (0.72.0) + - SDWebImage (5.19.0) DEPENDENCIES: - React (from \`../node_modules/react-native\`) +SPEC CHECKSUMS: + React: ${REACT_CHECKSUM} + COCOAPODS: 1.14.3 `; @@ -263,7 +269,10 @@ function buildRecords({ id: IOS_BUILD_ID, appVersion: APP_VERSION, appBuildVersion: IOS_BUILD_NUMBER, - artifacts: { applicationArchiveUrl: `https://example.invalid/${TOKEN}/app.ipa` }, + artifacts: { + applicationArchiveUrl: `https://example.invalid/${TOKEN}/app.ipa`, + buildArtifactsUrl: `https://example.invalid/${TOKEN}/build-artifacts.tar.gz`, + }, }; if (omitIosBuildNumber) { delete ios.appBuildVersion; @@ -289,13 +298,15 @@ function withFixture(options, run) { const ipaPath = join(work, 'app.ipa'); const aabPath = join(work, 'app.aab'); const buildJsonPath = join(work, 'build.json'); + const podfileLockPath = join(work, 'Podfile.lock'); const outDir = join(work, 'out'); mkdirSync(outDir, { recursive: true }); writeZip(ipaPath, ipaEntries()); writeZip(aabPath, aabEntries({ corruptAabMetadata: options.corruptAabMetadata })); writeFileSync(buildJsonPath, JSON.stringify(options.buildJson ?? buildRecords())); + writeFileSync(podfileLockPath, PODFILE_LOCK); try { - return run({ work, ipaPath, aabPath, buildJsonPath, outDir }); + return run({ work, ipaPath, aabPath, buildJsonPath, podfileLockPath, outDir }); } finally { rmSync(work, { recursive: true, force: true }); } @@ -334,8 +345,14 @@ function fileSha256(path) { } test('writes one CycloneDX document per platform with the ecosystems that platform ships', () => { - withFixture({}, ({ ipaPath, aabPath, buildJsonPath, outDir }) => { - const { ios, android } = generateMobileSboms({ ipaPath, aabPath, buildJsonPath, outDir }); + withFixture({}, ({ ipaPath, aabPath, buildJsonPath, podfileLockPath, outDir }) => { + const { ios, android } = generateMobileSboms({ + ipaPath, + aabPath, + buildJsonPath, + podfileLockPath, + outDir, + }); assert.equal(ios.sbomFile, 'kilo-app-ios-1.0.12-build42.cyclonedx.json'); assert.equal(android.sbomFile, 'kilo-app-android-1.0.12-build43.cyclonedx.json'); @@ -363,6 +380,41 @@ test('writes one CycloneDX document per platform with the ecosystems that platfo assert.equal(iosNames.includes(name), true, `ios document is missing ${name}`); } + // Every root pod of the build's Podfile.lock, including the statically + // linked ones the IPA scan cannot see (React-Core, SDWebImage). + const pods = iosDocument.components.filter(component => + component.properties.some( + property => property.name === 'kilo:sbom:ios-kind' && property.value === 'podfile-lock' + ) + ); + assert.deepEqual( + pods.map(({ name, version, purl, hashes }) => ({ name, version, purl, hashes })), + [ + { + name: 'React', + version: '0.72.0', + purl: 'pkg:cocoapods/React@0.72.0', + hashes: [{ alg: 'SHA-1', content: REACT_CHECKSUM }], + }, + { + name: 'React-Core', + version: '0.72.0', + purl: 'pkg:cocoapods/React-Core@0.72.0', + hashes: [], + }, + { + name: 'SDWebImage', + version: '5.19.0', + purl: 'pkg:cocoapods/SDWebImage@5.19.0', + hashes: [], + }, + ] + ); + assert.match( + metaProperty(iosDocument, 'kilo:sbom:source:cocoapods'), + /Podfile\.lock.*IPA scan/ + ); + // The EAS artifact URL carries a download token; it must never reach an output file. assert.equal(JSON.stringify(iosDocument).includes(TOKEN), false); assert.equal(JSON.stringify(androidDocument).includes(TOKEN), false); @@ -377,8 +429,14 @@ test('writes one CycloneDX document per platform with the ecosystems that platfo }); test('links each document to its artifact bytes and its build record', () => { - withFixture({}, ({ ipaPath, aabPath, buildJsonPath, outDir }) => { - const { ios, android } = generateMobileSboms({ ipaPath, aabPath, buildJsonPath, outDir }); + withFixture({}, ({ ipaPath, aabPath, buildJsonPath, podfileLockPath, outDir }) => { + const { ios, android } = generateMobileSboms({ + ipaPath, + aabPath, + buildJsonPath, + podfileLockPath, + outDir, + }); const iosDocument = JSON.parse(readFileSync(join(outDir, ios.sbomFile), 'utf8')); const androidDocument = JSON.parse(readFileSync(join(outDir, android.sbomFile), 'utf8')); @@ -433,7 +491,7 @@ test('links each document to its artifact bytes and its build record', () => { }); test('a missing artifact or an incomplete build record fails without writing an SBOM', () => { - withFixture({}, ({ work, aabPath, buildJsonPath, outDir }) => { + withFixture({}, ({ work, aabPath, buildJsonPath, podfileLockPath, outDir }) => { const missing = runCli([ '--ipa', join(work, 'missing.ipa'), @@ -441,6 +499,8 @@ test('a missing artifact or an incomplete build record fails without writing an aabPath, '--build-json', buildJsonPath, + '--podfile-lock', + podfileLockPath, '--out-dir', outDir, ]); @@ -456,6 +516,8 @@ test('a missing artifact or an incomplete build record fails without writing an fixture.aabPath, '--build-json', fixture.buildJsonPath, + '--podfile-lock', + fixture.podfileLockPath, '--out-dir', fixture.outDir, ]); @@ -472,6 +534,8 @@ test('a missing artifact or an incomplete build record fails without writing an fixture.aabPath, '--build-json', fixture.buildJsonPath, + '--podfile-lock', + fixture.podfileLockPath, '--out-dir', fixture.outDir, ]); @@ -488,6 +552,8 @@ test('a missing artifact or an incomplete build record fails without writing an fixture.aabPath, '--build-json', fixture.buildJsonPath, + '--podfile-lock', + fixture.podfileLockPath, '--out-dir', fixture.outDir, ]); @@ -498,26 +564,31 @@ test('a missing artifact or an incomplete build record fails without writing an }); test('corrupted dependencies.pb fails loudly and writes no document', () => { - withFixture({ corruptAabMetadata: true }, ({ ipaPath, aabPath, buildJsonPath, outDir }) => { - const result = runCli([ - '--ipa', - ipaPath, - '--aab', - aabPath, - '--build-json', - buildJsonPath, - '--out-dir', - outDir, - ]); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /dependencies\.pb/); - assert.deepEqual(cyclonedxFiles(outDir), []); - assert.equal(existsSync(join(outDir, 'mobile-sbom-summary.json')), false); - }); + withFixture( + { corruptAabMetadata: true }, + ({ ipaPath, aabPath, buildJsonPath, podfileLockPath, outDir }) => { + const result = runCli([ + '--ipa', + ipaPath, + '--aab', + aabPath, + '--build-json', + buildJsonPath, + '--podfile-lock', + podfileLockPath, + '--out-dir', + outDir, + ]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /dependencies\.pb/); + assert.deepEqual(cyclonedxFiles(outDir), []); + assert.equal(existsSync(join(outDir, 'mobile-sbom-summary.json')), false); + } + ); }); test('the CLI writes both documents, prints them, and rejects a missing --build-json', () => { - withFixture({}, ({ ipaPath, aabPath, buildJsonPath, outDir }) => { + withFixture({}, ({ ipaPath, aabPath, buildJsonPath, podfileLockPath, outDir }) => { const stdout = execFileSync( 'node', [ @@ -528,6 +599,8 @@ test('the CLI writes both documents, prints them, and rejects a missing --build- aabPath, '--build-json', buildJsonPath, + '--podfile-lock', + podfileLockPath, '--out-dir', outDir, ], @@ -540,33 +613,55 @@ test('the CLI writes both documents, prints them, and rejects a missing --build- 'kilo-app-ios-1.0.12-build42.cyclonedx.json', ]); - const missing = runCli(['--ipa', ipaPath, '--aab', aabPath, '--out-dir', outDir]); + assert.equal(stdout.includes(TOKEN), false, 'a signed artifact URL must never be printed'); + + const missing = runCli([ + '--ipa', + ipaPath, + '--aab', + aabPath, + '--podfile-lock', + podfileLockPath, + '--out-dir', + outDir, + ]); assert.notEqual(missing.status, 0); assert.match(missing.stderr, /Usage/); }); }); -test('the CLI prints the Podfile.lock coverage gap when --podfile-lock is given', () => { +test('a missing or unreadable Podfile.lock fails without writing an SBOM', () => { withFixture({}, ({ work, ipaPath, aabPath, buildJsonPath, outDir }) => { - const podfileLockPath = join(work, 'Podfile.lock'); - writeFileSync(podfileLockPath, PODFILE_LOCK); - const stdout = execFileSync( - 'node', - [ - 'scripts/mobile-sbom.mjs', - '--ipa', - ipaPath, - '--aab', - aabPath, - '--build-json', - buildJsonPath, - '--out-dir', - outDir, - '--podfile-lock', - podfileLockPath, - ], - { cwd: REPO_ROOT, encoding: 'utf8' } - ); - assert.match(stdout, /ios podfile-lock: declared=2 visible=1 missing=\[MissingPod\]/); + const omitted = runCli([ + '--ipa', + ipaPath, + '--aab', + aabPath, + '--build-json', + buildJsonPath, + '--out-dir', + outDir, + ]); + assert.notEqual(omitted.status, 0); + assert.match(omitted.stderr, /--podfile-lock is required/); + assert.deepEqual(cyclonedxFiles(outDir), []); + + const unreadable = runCli([ + '--ipa', + ipaPath, + '--aab', + aabPath, + '--build-json', + buildJsonPath, + '--podfile-lock', + join(work, 'missing', 'Podfile.lock'), + '--out-dir', + outDir, + ]); + assert.notEqual(unreadable.status, 0); + assert.match(unreadable.stderr, /cannot read Podfile\.lock/); + assert.equal(unreadable.stderr.includes(TOKEN), false); + assert.deepEqual(cyclonedxFiles(outDir), []); + assert.equal(existsSync(join(outDir, 'mobile-sbom-summary.json')), false); }); }); From 7361c6522239d77363795919f5575d4916390faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 05:04:22 +0200 Subject: [PATCH 09/11] fix(kilo-app): label the podspec checksum instead of claiming a pod hash SPEC CHECKSUMS hashes each pod's podspec, not the code in the IPA. A component hash would claim a provenance it does not have, so the value is now the kilo:sbom:podspec-checksum property. SHA-256 checksums are accepted so a CocoaPods upgrade cannot block a release. Files formatted with oxfmt. --- docs/sbom.md | 5 +-- scripts/inspect-mobile-artifacts.test.mjs | 6 +++- scripts/mobile-sbom-ipa.mjs | 22 ++++++++----- scripts/mobile-sbom-ipa.test.mjs | 38 +++++++++++++++++------ scripts/mobile-sbom.mjs | 2 +- scripts/mobile-sbom.test.mjs | 16 ++++++++-- 6 files changed, 66 insertions(+), 23 deletions(-) diff --git a/docs/sbom.md b/docs/sbom.md index 995ec1657a..06ab0eee4d 100644 --- a/docs/sbom.md +++ b/docs/sbom.md @@ -80,8 +80,9 @@ artifact you hold to confirm the SBOM describes those bytes. in `apps/mobile/eas.json` uploads `ios/Podfile.lock` through `buildArtifactPaths`, and the release workflow downloads it from the build's `buildArtifactsUrl` next to the IPA. Every root pod under `PODS:` becomes one component (subspecs such as `React-Core/Default` collapse into `React-Core`) - with its locked version, a `pkg:cocoapods/@` purl and, when listed, its - `SPEC CHECKSUMS` podspec SHA-1; `kilo:sbom:ios-kind` is `podfile-lock`. This is the only source + with its locked version and a `pkg:cocoapods/@` purl; `kilo:sbom:ios-kind` is + `podfile-lock`. The `SPEC CHECKSUMS` value, when listed, is the `kilo:sbom:podspec-checksum` + property, not a component hash: it hashes the podspec, not the shipped code. This is the only source for pods statically linked into the executable. The IPA scan adds the Mach-O `LC_LOAD_DYLIB`/weak/reexport load commands plus `Payload/*.app/Frameworks/*` (`kilo:sbom:ios-kind` `dylib-load-command` or `dynamic-framework`). Load commands naming an OS-provided library diff --git a/scripts/inspect-mobile-artifacts.test.mjs b/scripts/inspect-mobile-artifacts.test.mjs index a000d9f535..a94fbb49ba 100644 --- a/scripts/inspect-mobile-artifacts.test.mjs +++ b/scripts/inspect-mobile-artifacts.test.mjs @@ -174,7 +174,11 @@ function runSelect(builds) { writeFileSync(buildJsonPath, JSON.stringify(builds)); return spawnSync( 'node', - [fileURLToPath(new URL('./inspect-mobile-artifacts.mjs', import.meta.url)), '--select', buildJsonPath], + [ + fileURLToPath(new URL('./inspect-mobile-artifacts.mjs', import.meta.url)), + '--select', + buildJsonPath, + ], { encoding: 'utf8' } ); } finally { diff --git a/scripts/mobile-sbom-ipa.mjs b/scripts/mobile-sbom-ipa.mjs index 0cf3c53e79..2e243be986 100644 --- a/scripts/mobile-sbom-ipa.mjs +++ b/scripts/mobile-sbom-ipa.mjs @@ -61,7 +61,10 @@ const KILO_IOS_KIND = 'kilo:sbom:ios-kind'; const KIND_LOAD_COMMAND = 'dylib-load-command'; const KIND_DYNAMIC_FRAMEWORK = 'dynamic-framework'; const KIND_PODFILE_LOCK = 'podfile-lock'; -const SHA1_RE = /^[0-9a-f]{40}$/; +// SPEC CHECKSUMS is SHA-1 in current CocoaPods; accept SHA-256 so a CocoaPods +// upgrade cannot block a release. +const PODSPEC_CHECKSUM_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const KILO_PODSPEC_CHECKSUM = 'kilo:sbom:podspec-checksum'; // A top-level PODS: entry, ` - Name (1.2.3)`, with a trailing `:` when it lists // dependencies. Its dependency lines are indented further and never match. const POD_ENTRY_RE = /^ {2}- (.+?):?$/; @@ -441,7 +444,7 @@ function parsePodfileLock(text, podfileLockPath) { let section = null; for (const line of text.split(/\r?\n/)) { // Section headers are unindented; PODS: holds the resolved pods and - // SPEC CHECKSUMS: the podspec SHA-1 of each root pod. + // SPEC CHECKSUMS: the checksum of each root pod's podspec. if (/^\S/.test(line)) { section = line; continue; @@ -478,8 +481,9 @@ function parsePodfileLock(text, podfileLockPath) { /** * Read one CocoaPods component per root pod the Podfile.lock resolved, in - * lockfile order: subspecs collapse into their root pod, the version is the - * locked one, and the hash is the pod's `SPEC CHECKSUMS` SHA-1 when listed. + * lockfile order: subspecs collapse into their root pod and the version is the + * locked one. `SPEC CHECKSUMS` hashes the podspec, not the shipped code, so it + * is a labelled property, never a component hash. * An unreadable lockfile, or one that declares no pods, throws. */ export function readPodfileLockComponents({ podfileLockPath } = {}) { @@ -498,18 +502,22 @@ export function readPodfileLockComponents({ podfileLockPath } = {}) { } const components = [...versions].map(([pod, version]) => { const checksum = checksums.get(pod); - if (checksum !== undefined && !SHA1_RE.test(checksum)) { + if (checksum !== undefined && !PODSPEC_CHECKSUM_RE.test(checksum)) { throw new Error( `Podfile.lock ${podfileLockPath} has a malformed SPEC CHECKSUMS entry for ${pod}: ${JSON.stringify(checksum)}` ); } + const extraProperties = [{ name: KILO_IOS_KIND, value: KIND_PODFILE_LOCK }]; + if (checksum !== undefined) { + extraProperties.push({ name: KILO_PODSPEC_CHECKSUM, value: checksum }); + } return { ecosystem: 'cocoapods', name: pod, version, purl: `pkg:cocoapods/${encodeURIComponent(pod)}@${encodeURIComponent(version)}`, - hashes: checksum === undefined ? [] : [{ alg: 'SHA-1', content: checksum }], - extraProperties: [{ name: KILO_IOS_KIND, value: KIND_PODFILE_LOCK }], + hashes: [], + extraProperties, }; }); return { components }; diff --git a/scripts/mobile-sbom-ipa.test.mjs b/scripts/mobile-sbom-ipa.test.mjs index 97c5b0345c..0df4feb5bb 100644 --- a/scripts/mobile-sbom-ipa.test.mjs +++ b/scripts/mobile-sbom-ipa.test.mjs @@ -413,7 +413,7 @@ function withPodfileLock(text, run) { } } -test('readPodfileLockComponents lists one versioned, hashed component per root pod', () => { +test('readPodfileLockComponents lists one versioned component per root pod', () => { const podfileLock = `PODS: - EXConstants (17.0.8): - ExpoModulesCore @@ -443,32 +443,51 @@ COCOAPODS: 1.15.2 readPodfileLockComponents({ podfileLockPath }) ); + // SPEC CHECKSUMS hashes the podspec, so it is a labelled property, never a + // component hash that would claim to identify the shipped code. + const podspec = checksum => + checksum === undefined + ? [{ name: 'kilo:sbom:ios-kind', value: 'podfile-lock' }] + : [ + { name: 'kilo:sbom:ios-kind', value: 'podfile-lock' }, + { name: 'kilo:sbom:podspec-checksum', value: checksum }, + ]; assert.deepEqual( - components.map(({ name, version, purl, hashes }) => ({ name, version, purl, hashes })), + components.map(({ name, version, purl, hashes, extraProperties }) => ({ + name, + version, + purl, + hashes, + extraProperties, + })), [ { name: 'EXConstants', version: '17.0.8', purl: 'pkg:cocoapods/EXConstants@17.0.8', - hashes: [{ alg: 'SHA-1', content: 'fcfc75800824ac2d5c592b5bc74130bad17b146b' }], + hashes: [], + extraProperties: podspec('fcfc75800824ac2d5c592b5bc74130bad17b146b'), }, { name: 'hermes-engine', version: '0.76.9', purl: 'pkg:cocoapods/hermes-engine@0.76.9', - hashes: [{ alg: 'SHA-1', content: '06a9c6900587420b90accc394199527c64259db4' }], + hashes: [], + extraProperties: podspec('06a9c6900587420b90accc394199527c64259db4'), }, { name: 'React-Core', version: '0.76.9', purl: 'pkg:cocoapods/React-Core@0.76.9', - hashes: [{ alg: 'SHA-1', content: '4f1ba1b2a3b94ba77d4b0c9d5ebcd2c9fd9d2d8e' }], + hashes: [], + extraProperties: podspec('4f1ba1b2a3b94ba77d4b0c9d5ebcd2c9fd9d2d8e'), }, { name: 'RCT-Folly', version: '2024.10.14.00', purl: 'pkg:cocoapods/RCT-Folly@2024.10.14.00', - hashes: [{ alg: 'SHA-1', content: '84578c8756030547307e4572ab1947de1685c599' }], + hashes: [], + extraProperties: podspec('84578c8756030547307e4572ab1947de1685c599'), }, // Listed only as a subspec and absent from SPEC CHECKSUMS. { @@ -476,20 +495,19 @@ COCOAPODS: 1.15.2 version: '8.48.0', purl: 'pkg:cocoapods/Sentry@8.48.0', hashes: [], + extraProperties: podspec(undefined), }, ] ); for (const component of components) { assert.equal(component.ecosystem, 'cocoapods'); - assert.deepEqual(component.extraProperties, [ - { name: 'kilo:sbom:ios-kind', value: 'podfile-lock' }, - ]); } }); test('readPodfileLockComponents rejects a missing, empty or malformed Podfile.lock', () => { assert.throws( - () => readPodfileLockComponents({ podfileLockPath: join(tmpdir(), 'kilo-missing-Podfile.lock') }), + () => + readPodfileLockComponents({ podfileLockPath: join(tmpdir(), 'kilo-missing-Podfile.lock') }), /cannot read Podfile\.lock/ ); assert.throws( diff --git a/scripts/mobile-sbom.mjs b/scripts/mobile-sbom.mjs index 1bd50e854a..999cce2f8c 100644 --- a/scripts/mobile-sbom.mjs +++ b/scripts/mobile-sbom.mjs @@ -46,7 +46,7 @@ const ECOSYSTEM_ORDER = ['npm', 'cocoapods', 'maven', 'native-library']; const ECOSYSTEM_SOURCES = { npm: 'pnpm-lock.yaml production dependency closure of apps/mobile (the minified shipped JS bundle carries no package metadata)', cocoapods: - 'Podfile.lock of the EAS build (one component per root pod in PODS:, subspecs collapsed, hash = SPEC CHECKSUMS podspec SHA-1) plus an IPA scan: Mach-O LC_LOAD_DYLIB/weak/reexport install names and Payload/*.app/Frameworks/ (OS-provided /usr/lib and /System/Library libraries excluded)', + 'Podfile.lock of the EAS build (one component per root pod in PODS:, subspecs collapsed; the SPEC CHECKSUMS podspec checksum is the kilo:sbom:podspec-checksum property, not a component hash) plus an IPA scan: Mach-O LC_LOAD_DYLIB/weak/reexport install names and Payload/*.app/Frameworks/ (OS-provided /usr/lib and /System/Library libraries excluded)', maven: 'AAB BUNDLE-METADATA/com.android.tools.build.libraries/dependencies.pb (Android Gradle Plugin resolved Maven artifacts)', 'native-library': 'AAB base/lib/**/*.so', diff --git a/scripts/mobile-sbom.test.mjs b/scripts/mobile-sbom.test.mjs index e33daccae2..f718cf3e1b 100644 --- a/scripts/mobile-sbom.test.mjs +++ b/scripts/mobile-sbom.test.mjs @@ -387,26 +387,38 @@ test('writes one CycloneDX document per platform with the ecosystems that platfo property => property.name === 'kilo:sbom:ios-kind' && property.value === 'podfile-lock' ) ); + const podspecChecksum = component => + component.properties.find(property => property.name === 'kilo:sbom:podspec-checksum') + ?.value ?? null; assert.deepEqual( - pods.map(({ name, version, purl, hashes }) => ({ name, version, purl, hashes })), + pods.map(component => ({ + name: component.name, + version: component.version, + purl: component.purl, + hashes: component.hashes, + podspecChecksum: podspecChecksum(component), + })), [ { name: 'React', version: '0.72.0', purl: 'pkg:cocoapods/React@0.72.0', - hashes: [{ alg: 'SHA-1', content: REACT_CHECKSUM }], + hashes: [], + podspecChecksum: REACT_CHECKSUM, }, { name: 'React-Core', version: '0.72.0', purl: 'pkg:cocoapods/React-Core@0.72.0', hashes: [], + podspecChecksum: null, }, { name: 'SDWebImage', version: '5.19.0', purl: 'pkg:cocoapods/SDWebImage@5.19.0', hashes: [], + podspecChecksum: null, }, ] ); From 382fd62cfaec5d4ece989e086ee5572af8e0bd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 05:04:23 +0200 Subject: [PATCH 10/11] ci: temporary EAS proof of the Podfile.lock build artifact (revert before merge) --- .github/workflows/tmp-podfile-lock-proof.yml | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/tmp-podfile-lock-proof.yml diff --git a/.github/workflows/tmp-podfile-lock-proof.yml b/.github/workflows/tmp-podfile-lock-proof.yml new file mode 100644 index 0000000000..da51c16af1 --- /dev/null +++ b/.github/workflows/tmp-podfile-lock-proof.yml @@ -0,0 +1,70 @@ +name: tmp podfile lock proof + +on: + push: + branches: [fix/kilo-app-upload-marker-race] + paths: ['.github/workflows/tmp-podfile-lock-proof.yml'] + +permissions: + contents: read + +jobs: + proof: + runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }} + timeout-minutes: 60 + steps: + - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + with: + lfs: true + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - name: EAS iOS production build (no submission) + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + pnpx eas-cli@21.8.0 build --profile production --platform ios --non-interactive --json --wait > build.json + echo "artifacts keys: $(jq -c '[.[] | {platform, keys: (.artifacts | keys)}]' build.json)" + - name: Download and read the Podfile.lock the way the release job does + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + mkdir -p artifacts + download() { + url="$1"; out="$2"; label="$3" + if curl -fL -H "Authorization: Bearer $EXPO_TOKEN" -o "$out" "$url"; then + echo "$label: downloaded with Authorization header" + elif curl -fL -o "$out" "$url"; then + echo "$label: downloaded without Authorization header" + else + echo "::error::$label download failed"; return 1 + fi + } + download "$(jq -r '.[] | select(.platform=="IOS") | .artifacts.applicationArchiveUrl' build.json)" artifacts/app.ipa "iOS" + download "$(jq -r '.[] | select(.platform=="IOS") | .artifacts.buildArtifactsUrl' build.json)" artifacts/ios-build-artifacts "iOS build artifacts" + file artifacts/ios-build-artifacts + if gzip -t artifacts/ios-build-artifacts 2>/dev/null; then + echo "archive members:"; tar -tzf artifacts/ios-build-artifacts + MEMBER=$(tar -tzf artifacts/ios-build-artifacts | grep -E '(^|/)Podfile\.lock$' || true) + if [ "$(printf '%s' "$MEMBER" | grep -c '')" -ne 1 ]; then + echo "::error::the iOS build artifacts archive must hold exactly one Podfile.lock, found: ${MEMBER:-none}" + exit 1 + fi + tar -xzf artifacts/ios-build-artifacts -O "$MEMBER" > artifacts/Podfile.lock + else + mv artifacts/ios-build-artifacts artifacts/Podfile.lock + fi + grep -q '^PODS:' artifacts/Podfile.lock + node --input-type=module -e " + import { readPodfileLockComponents, readIpaComponents } from '../../scripts/mobile-sbom-ipa.mjs'; + const pods = readPodfileLockComponents({ podfileLockPath: 'artifacts/Podfile.lock' }).components; + const ipa = readIpaComponents({ ipaPath: 'artifacts/app.ipa' }); + const scan = Array.isArray(ipa) ? ipa : ipa.components; + console.log('lockfile pods:', pods.length, 'ipa scan components:', scan.length); + console.log('sample:', pods.filter(p => /^(React-Core|hermes-engine|RNSentry|EXConstants)$/.test(p.name)).map(p => p.purl).join(' ')); + " From 80ae363e1b7fb81755f460bb9d5bcad34b032742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 26 Sep 2026 05:20:04 +0200 Subject: [PATCH 11/11] ci: remove the temporary EAS Podfile.lock proof --- .github/workflows/tmp-podfile-lock-proof.yml | 70 -------------------- 1 file changed, 70 deletions(-) delete mode 100644 .github/workflows/tmp-podfile-lock-proof.yml diff --git a/.github/workflows/tmp-podfile-lock-proof.yml b/.github/workflows/tmp-podfile-lock-proof.yml deleted file mode 100644 index da51c16af1..0000000000 --- a/.github/workflows/tmp-podfile-lock-proof.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: tmp podfile lock proof - -on: - push: - branches: [fix/kilo-app-upload-marker-race] - paths: ['.github/workflows/tmp-podfile-lock-proof.yml'] - -permissions: - contents: read - -jobs: - proof: - runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }} - timeout-minutes: 60 - steps: - - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 - with: - lfs: true - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version-file: '.nvmrc' - cache: 'pnpm' - - run: pnpm install --frozen-lockfile - - name: EAS iOS production build (no submission) - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - pnpx eas-cli@21.8.0 build --profile production --platform ios --non-interactive --json --wait > build.json - echo "artifacts keys: $(jq -c '[.[] | {platform, keys: (.artifacts | keys)}]' build.json)" - - name: Download and read the Podfile.lock the way the release job does - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - mkdir -p artifacts - download() { - url="$1"; out="$2"; label="$3" - if curl -fL -H "Authorization: Bearer $EXPO_TOKEN" -o "$out" "$url"; then - echo "$label: downloaded with Authorization header" - elif curl -fL -o "$out" "$url"; then - echo "$label: downloaded without Authorization header" - else - echo "::error::$label download failed"; return 1 - fi - } - download "$(jq -r '.[] | select(.platform=="IOS") | .artifacts.applicationArchiveUrl' build.json)" artifacts/app.ipa "iOS" - download "$(jq -r '.[] | select(.platform=="IOS") | .artifacts.buildArtifactsUrl' build.json)" artifacts/ios-build-artifacts "iOS build artifacts" - file artifacts/ios-build-artifacts - if gzip -t artifacts/ios-build-artifacts 2>/dev/null; then - echo "archive members:"; tar -tzf artifacts/ios-build-artifacts - MEMBER=$(tar -tzf artifacts/ios-build-artifacts | grep -E '(^|/)Podfile\.lock$' || true) - if [ "$(printf '%s' "$MEMBER" | grep -c '')" -ne 1 ]; then - echo "::error::the iOS build artifacts archive must hold exactly one Podfile.lock, found: ${MEMBER:-none}" - exit 1 - fi - tar -xzf artifacts/ios-build-artifacts -O "$MEMBER" > artifacts/Podfile.lock - else - mv artifacts/ios-build-artifacts artifacts/Podfile.lock - fi - grep -q '^PODS:' artifacts/Podfile.lock - node --input-type=module -e " - import { readPodfileLockComponents, readIpaComponents } from '../../scripts/mobile-sbom-ipa.mjs'; - const pods = readPodfileLockComponents({ podfileLockPath: 'artifacts/Podfile.lock' }).components; - const ipa = readIpaComponents({ ipaPath: 'artifacts/app.ipa' }); - const scan = Array.isArray(ipa) ? ipa : ipa.components; - console.log('lockfile pods:', pods.length, 'ipa scan components:', scan.length); - console.log('sample:', pods.filter(p => /^(React-Core|hermes-engine|RNSentry|EXConstants)$/.test(p.name)).map(p => p.purl).join(' ')); - "