diff --git a/.github/workflows/kilo-app-release.yml b/.github/workflows/kilo-app-release.yml index af26d533a2..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 @@ -331,8 +350,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}" @@ -354,7 +387,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 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..06ab0eee4d 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,34 @@ 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 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 + (`/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..a94fbb49ba 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,53 @@ 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..2e243be986 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,17 @@ 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'; +// 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}- (.+?):?$/; +// `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 +431,64 @@ 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:'; + // Section headers are unindented; PODS: holds the resolved pods and + // SPEC CHECKSUMS: the checksum of each root pod's podspec. + if (/^\S/.test(line)) { + section = line; continue; } - if (!inPodsSection) { - 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 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 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 +496,29 @@ 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 && !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: [], + extraProperties, + }; + }); + return { components }; } diff --git a/scripts/mobile-sbom-ipa.test.mjs b/scripts/mobile-sbom-ipa.test.mjs index 2f94424fe3..0df4feb5bb 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,134 @@ 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 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 }) + ); + + // 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, extraProperties }) => ({ + name, + version, + purl, + hashes, + extraProperties, + })), + [ + { + name: 'EXConstants', + version: '17.0.8', + purl: 'pkg:cocoapods/EXConstants@17.0.8', + hashes: [], + extraProperties: podspec('fcfc75800824ac2d5c592b5bc74130bad17b146b'), + }, + { + name: 'hermes-engine', + version: '0.76.9', + purl: 'pkg:cocoapods/hermes-engine@0.76.9', + hashes: [], + extraProperties: podspec('06a9c6900587420b90accc394199527c64259db4'), + }, + { + name: 'React-Core', + version: '0.76.9', + purl: 'pkg:cocoapods/React-Core@0.76.9', + hashes: [], + extraProperties: podspec('4f1ba1b2a3b94ba77d4b0c9d5ebcd2c9fd9d2d8e'), + }, + { + name: 'RCT-Folly', + version: '2024.10.14.00', + purl: 'pkg:cocoapods/RCT-Folly@2024.10.14.00', + hashes: [], + extraProperties: podspec('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: [], + extraProperties: podspec(undefined), + }, + ] + ); + for (const component of components) { + assert.equal(component.ecosystem, 'cocoapods'); } }); + +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..999cce2f8c 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; 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', @@ -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..f718cf3e1b 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,53 @@ 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' + ) + ); + const podspecChecksum = component => + component.properties.find(property => property.name === 'kilo:sbom:podspec-checksum') + ?.value ?? null; + assert.deepEqual( + 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: [], + 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, + }, + ] + ); + 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 +441,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 +503,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 +511,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 +528,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 +546,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 +564,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 +576,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 +611,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 +625,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); }); });