Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 (typeof range !== 'string')
return null
const m = range.match(/(\d+)/)
return m ? Number(m[1]) : null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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 ${reason} — this ships a live-reload/remote endpoint into the build`
: 'server.url 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: HIGH

[Privacy Guard] The new detail strings include the full server.url value (server.url=${url}), and detail is explicitly emitted to terminal output and JSON reports that are commonly captured in CI logs. If the URL contains userinfo, signed query params, or token-like values, this creates credential/privacy data exposure in logging artifacts.

Impact: Sensitive URL material can leak into build logs and downstream log retention systems, violating the prescan contract that output fields must not include credential material.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit b31a380. Configure here.

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 http:// server.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