Skip to content
2 changes: 2 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,6 @@ ALS = "ALS" # AsyncLocalStorage abbreviation (used in CLI perf-tracking spec)
UDID = "UDID" # Apple's "Unique Device IDentifier" — distinct from UUID, used in Developer Portal walkthroughs.
unparseable = "unparseable" # Valid English synonym of "unparsable".
CIPS = "CIPS" # real App Store Connect session role value (ASC key helper)
ITMS = "ITMS" # App Store Connect upload error-code prefix (ITMS-90704, ITMS-90474, ...) cited in iOS prescan findings
loca = "loca" # localtunnel host suffix *.loca.lt detected by the iOS capacitor-server-url check
# Add more project-specific terms as needed
64 changes: 64 additions & 0 deletions cli/src/build/mobileprovision-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,22 @@ export interface MobileprovisionDetail extends MobileprovisionInfo {
profileType: 'app_store' | 'ad_hoc' | 'development' | 'enterprise' | 'unknown'
/** SHA1 (40-char lowercase hex) of each DeveloperCertificate embedded in the profile */
certificateSha1s: string[]
/**
* The capability-bearing keys parsed from the profile's `<key>Entitlements</key>`
* dict, keyed by entitlement name. String/bool entitlements map to their value;
* array entitlements (application-groups, associated-domains, keychain-access-groups,
* iCloud container ids, etc.) map to the list of `<string>` members. Only keys
* actually present in the profile are included, so a caller can both test
* presence (`key in profileEntitlements`) and read the granted value/members.
* No credential material is included — these are capability KEY names + their
* declared identifiers, the same data the entitlement-coverage checks surface.
*/
profileEntitlements: ProfileEntitlements
}

export type ProfileEntitlementValue = string | string[] | boolean
export type ProfileEntitlements = Record<string, ProfileEntitlementValue>

export function parseMobileprovision(filePath: string): MobileprovisionInfo {
const data = readFileSync(filePath)
return parseMobileprovisionBuffer(data, filePath)
Expand Down Expand Up @@ -103,13 +117,15 @@ export function parseMobileprovisionBufferDetailed(data: Buffer, source = '<buff
const expirationDate = extractPlistValue(plistXml, 'ExpirationDate', 'date') || ''
const profileType = deriveProfileType(plistXml)
const certificateSha1s = extractCertificateSha1s(plistXml)
const profileEntitlements = extractProfileEntitlements(plistXml)

return {
...base,
teamId,
expirationDate,
profileType,
certificateSha1s,
profileEntitlements,
}
}

Expand Down Expand Up @@ -187,6 +203,54 @@ function extractCertificateSha1s(xml: string): string[] {
return sha1s
}

// Auto-managed entitlement keys the profile always carries; never surfaced as
// capabilities (the coverage checks exclude the same set on the app side).
function isAutoManagedEntitlementKey(key: string): boolean {
return key === 'application-identifier' || key.endsWith('.team-identifier')
}

/** `<string>` children of `<array>...</array>` block text. */
function arrayStringMembers(arrayXml: string): string[] {
return Array.from(arrayXml.matchAll(/<string>([\s\S]*?)<\/string>/g), m => m[1].trim())
}

/**
* Parse the capability keys from the profile's first `<key>Entitlements</key>`
* dict (one-level capture, mirroring extractNestedPlistValue). EVERY key present
* in the dict is read generically by its sibling value tag (string / bool / array),
* so the profile side is symmetric with the app side — a granted capability outside
* any fixed allowlist is recorded, not silently treated as "missing" (which would
* false-positive in the entitlements-vs-profile coverage check). Auto-managed keys
* (application-identifier, *.team-identifier) are skipped. Only keys actually present
* are added, so a missing capability is absent (not a false value). Never throws —
* returns {} when there is no Entitlements dict.
*/
function extractProfileEntitlements(xml: string): ProfileEntitlements {
const dict = xml.match(/<key>Entitlements<\/key>\s*<dict>([\s\S]*?)<\/dict>/)?.[1]
if (dict === undefined)
return {}
const out: ProfileEntitlements = {}
// Each <key>K</key> followed by its value: a self-closing <true/>/<false/>, an
// <array>...</array> (non-greedy, first close wins), or a scalar <tag>V</tag>.
const re = /<key>([\s\S]*?)<\/key>\s*(?:<(true|false)\s*\/>|<array>([\s\S]*?)<\/array>|<([a-z]+)>([\s\S]*?)<\/\4>)/g
for (const m of dict.matchAll(re)) {
const key = m[1].trim()
if (key in out || isAutoManagedEntitlementKey(key))
continue
if (m[2] !== undefined)
out[key] = m[2] === 'true'
else if (m[3] !== undefined)
out[key] = arrayStringMembers(m[3])
else if (m[4] === 'string')
out[key] = m[5].trim()
// Other scalar kinds (integer/real/date/data) are not capability-bearing and
// are intentionally not surfaced; their presence is still implied by the key.
else
out[key] = m[5].trim()
}
return out
}

function extractPlistValue(xml: string, key: string, valueTag: string = 'string'): string | null {
const tag = escapeRegex(valueTag)
const regex = new RegExp(`<key>${escapeRegex(key)}</key>\\s*<${tag}>([^<]*)</${tag}>`)
Expand Down
4 changes: 4 additions & 0 deletions cli/src/build/onboarding/ios/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,9 @@ function synthesizeProfileFromAscSummary(
? 'ad_hoc'
: 'unknown') as DiscoveredProfile['profileType'],
certificateSha1s: [identity.sha1],
// No entitlements dict in an ASC profile summary — the field is required on
// MobileprovisionDetail, so populate it with an empty map.
profileEntitlements: {},
profileBase64: summary.profileContent,
} as DiscoveredProfile & { profileBase64: string }
}
Expand Down Expand Up @@ -3086,6 +3089,7 @@ export async function runIosEffect(
expirationDate: detail.expirationDate,
profileType: detail.profileType as DiscoveredProfile['profileType'],
certificateSha1s: detail.certificateSha1s,
profileEntitlements: detail.profileEntitlements,
}
const matches = deps.carried?.importMatches ?? []
const injectedMatches = matches.map(m => m.identity.sha1 === chosenIdentity.sha1
Expand Down
31 changes: 31 additions & 0 deletions cli/src/build/prescan/capacitor-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// src/build/prescan/capacitor-version.ts
import { join } from 'node:path'
import { readTextIfExists } from './gradle'

/**
* Major version of the Capacitor runtime declared in package.json, or null.
*
* Checks `@capacitor/core` first (the platform-agnostic runtime), then the
* platform packages `@capacitor/ios` and `@capacitor/android`, so both the iOS
* and Android prescan checks resolve the same major from one shared place.
* Reads dev+prod dependencies, returns null on a missing/malformed package.json
* (never throws).
*/
export function capacitorMajor(projectDir: string): number | null {
const raw = readTextIfExists(join(projectDir, 'package.json'))
if (raw === null)
return null
let deps: Record<string, string>
try {
const pkg = JSON.parse(raw) as { dependencies?: Record<string, string>, devDependencies?: Record<string, string> }
deps = { ...pkg.devDependencies, ...pkg.dependencies }
}
catch {
return null
}
const range = deps['@capacitor/core'] ?? deps['@capacitor/ios'] ?? deps['@capacitor/android']
if (!range)
return null
const m = range.match(/(\d+)/)
return m ? Number(m[1]) : null
Comment on lines +26 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard non-string dependency ranges before calling .match().

On Line 29, range can be a non-string when package.json is syntactically valid but malformed (for example, numeric/object dependency values), which throws at runtime and breaks the documented "never throws" contract.

Proposed fix
-  const range = deps['`@capacitor/core`'] ?? deps['`@capacitor/ios`'] ?? deps['`@capacitor/android`']
-  if (!range)
+  const range = deps['`@capacitor/core`'] ?? deps['`@capacitor/ios`'] ?? deps['`@capacitor/android`']
+  if (typeof range !== 'string' || range.length === 0)
     return null
   const m = range.match(/(\d+)/)
   return m ? Number(m[1]) : null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const range = deps['@capacitor/core'] ?? deps['@capacitor/ios'] ?? deps['@capacitor/android']
if (!range)
return null
const m = range.match(/(\d+)/)
return m ? Number(m[1]) : null
const range = deps['`@capacitor/core`'] ?? deps['`@capacitor/ios`'] ?? deps['`@capacitor/android`']
if (typeof range !== 'string' || range.length === 0)
return null
const m = range.match(/(\d+)/)
return m ? Number(m[1]) : null
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/build/prescan/capacitor-version.ts` around lines 26 - 30, The code
assumes `range` is always a string before calling `.match()` on line 29, but
package.json can contain non-string dependency values (numbers, objects, etc.)
which will cause a runtime error. Add a type guard to check if `range` is a
string before calling the `.match()` method. If `range` is not a string, return
null to maintain the contract that this function never throws. This guard should
be added after the initial null check on line 27 but before the `.match()` call
on line 29.

}
21 changes: 1 addition & 20 deletions cli/src/build/prescan/checks/android-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
stripGradleComments,
} from '../gradle'
import { readAndroidManifest, stripXmlComments } from '../manifest'
import { capacitorMajor } from '../capacitor-version'
import { willUploadToPlay } from '../upload-intent'

function hasCordovaPlugins(ctx: ScanContext): boolean {
Expand Down Expand Up @@ -535,26 +536,6 @@ export const targetSdkPlay: PrescanCheck = {
const CAP_MINSDK_FLOORS: Record<number, number> = { 6: 22, 7: 23, 8: 24 }
const CAP_MINSDK_DEFAULT = 24

/** Major version of @capacitor/core or @capacitor/android from package.json, or null. */
function capacitorMajor(projectDir: string): number | null {
const raw = readTextIfExists(join(projectDir, 'package.json'))
if (raw === null)
return null
let deps: Record<string, string>
try {
const pkg = JSON.parse(raw) as { dependencies?: Record<string, string>, devDependencies?: Record<string, string> }
deps = { ...pkg.devDependencies, ...pkg.dependencies }
}
catch {
return null
}
const range = deps['@capacitor/core'] ?? deps['@capacitor/android']
if (!range)
return null
const m = range.match(/(\d+)/)
return m ? Number(m[1]) : null
}

function capacitorMinSdkFloor(major: number): number {
return CAP_MINSDK_FLOORS[major] ?? CAP_MINSDK_DEFAULT
}
Expand Down
118 changes: 118 additions & 0 deletions cli/src/build/prescan/checks/ios-capacitor-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// src/build/prescan/checks/ios-capacitor-config.ts
//
// §2.D — Capacitor config checks. These read only ctx.config (a passthrough
// zod object, so server.* is reached via safe optional access — no parser
// needed). Upload intent escalates severity for the server.url check. All
// findings name only config values, never credential material.
import type { Finding, PrescanCheck, ScanContext } from '../types'
import { willUploadToAppStore } from '../upload-intent'

// ctx.config is `.passthrough()`, so the server block is not in the static type.
// Read it through a narrow shape rather than re-parsing the config.
interface CapServer {
url?: unknown
cleartext?: unknown
allowNavigation?: unknown
}
function serverOf(ctx: ScanContext): CapServer | undefined {
return (ctx.config as { server?: CapServer } | undefined)?.server
}
function serverUrlOf(ctx: ScanContext): string | null {
const url = serverOf(ctx)?.url
return typeof url === 'string' && url !== '' ? url : null
}

// Dev-only markers that make a shipped server.url unambiguously a live-reload
// leftover rather than a deliberate production host.
const RFC1918_RE = /^https?:\/\/(?:10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.)/i
const LOCALHOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?:[:/]|$)/i
const TUNNEL_RE = /^https?:\/\/[^/]*\.(?:ngrok\.io|ngrok-free\.app|trycloudflare\.com|loca\.lt)(?:[:/]|$)/i

/** A human-readable reason this url looks like a dev/live-reload target, or null. */
function devTargetReason(url: string): string | null {
if (LOCALHOST_RE.test(url))
return 'points at localhost / 127.0.0.1'
if (RFC1918_RE.test(url))
return 'points at a private LAN IP (live-reload)'
if (TUNNEL_RE.test(url))
return 'points at a dev tunnel host (ngrok / cloudflare / localtunnel)'
if (/^http:\/\//i.test(url))
return 'uses cleartext http://'
return null
}

export const serverUrlShipped: PrescanCheck = {
id: 'ios/capacitor-server-url-shipped',
platforms: ['ios'],
appliesTo: ctx => serverUrlOf(ctx) !== null,
async run(ctx): Promise<Finding[]> {
const url = serverUrlOf(ctx)
if (url === null)
return []
const uploading = willUploadToAppStore(ctx)
const reason = devTargetReason(url)
const detail = reason
? `server.url=${url} (${reason}) — this ships a live-reload/remote endpoint into the build`
: `server.url=${url} — this ships a remote endpoint into the build instead of the bundled web assets`
return [{
id: 'ios/capacitor-server-url-shipped',
severity: uploading ? 'error' : 'warning',
title: 'capacitor.config server.url is set — the build will load a remote URL instead of bundled assets',
detail,
fix: 'Remove the server.url live-reload block before a production build; build web assets and run `npx cap sync`',
}]
},
}

export const serverCleartext: PrescanCheck = {
id: 'ios/capacitor-server-cleartext',
platforms: ['ios'],
appliesTo: ctx => serverOf(ctx)?.cleartext === true,
async run(ctx): Promise<Finding[]> {
if (serverOf(ctx)?.cleartext !== true)
return []
const url = serverUrlOf(ctx)
const httpUrl = url !== null && /^http:\/\//i.test(url)
return [{
id: 'ios/capacitor-server-cleartext',
severity: httpUrl ? 'error' : 'warning',
title: 'capacitor.config server.cleartext is enabled — arbitrary cleartext HTTP traffic is allowed',
detail: httpUrl ? `paired with a cleartext server.url (${url})` : undefined,
fix: 'Remove server.cleartext (or set it false) for production; use https or a scoped ATS exception',
}]
},
}

/** A blanket `*` or public-suffix wildcard (`*.com`, `*.io`) with no specific host. */
function isPublicWildcard(entry: string): boolean {
if (entry === '*')
return true
// `*.<single-label>` such as *.com / *.io — a wildcard with no concrete host.
// `*.example.com` (two+ labels after the dot) is a specific subdomain wildcard
// and is NOT flagged.
return /^\*\.[a-z0-9-]+$/i.test(entry)
}
function navWildcards(ctx: ScanContext): string[] {
const list = serverOf(ctx)?.allowNavigation
if (!Array.isArray(list))
return []
return list.filter((e): e is string => typeof e === 'string' && isPublicWildcard(e))
}

export const allowNavigationWildcard: PrescanCheck = {
id: 'ios/capacitor-allow-navigation-wildcard',
platforms: ['ios'],
appliesTo: ctx => navWildcards(ctx).length > 0,
async run(ctx): Promise<Finding[]> {
const offenders = navWildcards(ctx)
if (offenders.length === 0)
return []
return [{
id: 'ios/capacitor-allow-navigation-wildcard',
severity: 'warning',
title: 'capacitor.config server.allowNavigation contains a blanket wildcard',
detail: `wildcard entr(y/ies): ${offenders.join(', ')}`,
fix: 'Restrict allowNavigation to specific hosts; remove blanket "*" / "*.<tld>" entries',
}]
},
}
Loading
Loading