Files
prop-ai-hr/mobile-uni/scripts/verify-android-apk.ps1
T

293 lines
14 KiB
PowerShell

[CmdletBinding()]
param(
[string]$ApkPath,
[ValidateSet('custom-base', 'dcloud-test', 'release')]
[string]$BuildKind = 'custom-base',
[string]$HBuilderHome,
[string]$ExpectedSignerSha256,
[string]$ExpectedTermsUrl,
[string]$ExpectedPrivacyUrl
)
$ErrorActionPreference = 'Stop'
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectRoot = Split-Path -Parent $scriptDirectory
if ([string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) {
$ExpectedTermsUrl = $env:VITE_APP_TERMS_URL
}
if ([string]::IsNullOrWhiteSpace($ExpectedPrivacyUrl)) {
$ExpectedPrivacyUrl = $env:VITE_APP_PRIVACY_URL
}
if (
[string]::IsNullOrWhiteSpace($ExpectedTermsUrl) -xor
[string]::IsNullOrWhiteSpace($ExpectedPrivacyUrl)
) {
throw 'ExpectedTermsUrl and ExpectedPrivacyUrl must be provided together.'
}
if ([string]::IsNullOrWhiteSpace($ApkPath)) {
$ApkPath = Join-Path $projectRoot 'dist\debug\android_debug.apk'
}
$resolvedApkPath = (Resolve-Path -LiteralPath $ApkPath).Path
if ([IO.Path]::GetExtension($resolvedApkPath) -ne '.apk') {
throw "Expected an .apk file: $resolvedApkPath"
}
if ((Get-Item -LiteralPath $resolvedApkPath).Length -lt 1MB) {
throw "APK is unexpectedly small: $resolvedApkPath"
}
$sourceManifestPath = Join-Path $projectRoot 'src\manifest.json'
$sourceManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $sourceManifestPath | ConvertFrom-Json
$sourceAndroid = $sourceManifest.'app-plus'.distribute.android
$candidateHomes = @()
if (-not [string]::IsNullOrWhiteSpace($HBuilderHome)) {
$candidateHomes += $HBuilderHome
}
if (-not [string]::IsNullOrWhiteSpace($env:HBUILDERX_HOME)) {
$candidateHomes += $env:HBUILDERX_HOME
}
$candidateHomes += 'D:\HBuilderX'
$resolvedHBuilderHome = $candidateHomes |
Where-Object { Test-Path -LiteralPath (Join-Path $_ 'plugins\app-safe-pack\apktool.jar') } |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($resolvedHBuilderHome)) {
throw 'HBuilderX app-safe-pack tools were not found. Pass -HBuilderHome or set HBUILDERX_HOME.'
}
$java = Get-Command java -ErrorAction Stop
$apktoolJar = Join-Path $resolvedHBuilderHome 'plugins\app-safe-pack\apktool.jar'
$apksignerJar = Join-Path $resolvedHBuilderHome 'plugins\app-safe-pack\apksigner.jar'
if (-not (Test-Path -LiteralPath $apksignerJar)) {
throw "Missing APK signer verifier: $apksignerJar"
}
$temporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$decodePath = [IO.Path]::GetFullPath(
(Join-Path $temporaryRoot ('bangdao-apk-verify-' + [Guid]::NewGuid().ToString('N')))
)
if (-not $decodePath.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to use a temporary path outside the system temp directory: $decodePath"
}
try {
& $java.Source -jar $apktoolJar d -s -o $decodePath $resolvedApkPath 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "apktool failed to decode $resolvedApkPath"
}
$decodedManifestPath = Join-Path $decodePath 'AndroidManifest.xml'
$apktoolMetadataPath = Join-Path $decodePath 'apktool.yml'
[xml]$decodedManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $decodedManifestPath
$decodedManifestText = Get-Content -Raw -Encoding UTF8 -LiteralPath $decodedManifestPath
$apktoolMetadata = Get-Content -Raw -Encoding UTF8 -LiteralPath $apktoolMetadataPath
$packageName = $decodedManifest.manifest.package
$targetSdkMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*targetSdkVersion:\s*(\d+)\s*$')
$versionCodeMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*versionCode:\s*(\d+)\s*$')
$versionNameMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*versionName:\s*(\S+)\s*$')
if (-not $targetSdkMatch.Success -or -not $versionCodeMatch.Success -or -not $versionNameMatch.Success) {
throw 'APK metadata is missing targetSdkVersion, versionCode, or versionName.'
}
$targetSdkVersion = [int]$targetSdkMatch.Groups[1].Value
$versionCode = $versionCodeMatch.Groups[1].Value
$versionName = $versionNameMatch.Groups[1].Value.Trim("'`"")
if ($packageName -ne $sourceAndroid.packagename) {
throw "APK package mismatch: got $packageName, expected $($sourceAndroid.packagename)"
}
if ($targetSdkVersion -ne [int]$sourceAndroid.targetSdkVersion) {
throw "APK targetSdkVersion mismatch: got $targetSdkVersion, expected $($sourceAndroid.targetSdkVersion)"
}
if ($versionCode -ne [string]$sourceManifest.versionCode) {
throw "APK versionCode mismatch: got $versionCode, expected $($sourceManifest.versionCode)"
}
if ($versionName -ne [string]$sourceManifest.versionName) {
throw "APK versionName mismatch: got $versionName, expected $($sourceManifest.versionName)"
}
if (-not [string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) {
if ($BuildKind -eq 'custom-base') {
# A DCloud custom base APK contains only the native debug runtime. HBuilderX
# syncs the compiled www resources separately when it runs the app on a
# device, so the legal links cannot truthfully be asserted from the APK.
$compiledPrivacyPath = Join-Path $projectRoot 'dist\build\app\androidPrivacy.json'
if (-not (Test-Path -LiteralPath $compiledPrivacyPath)) {
throw 'Compiled App resources are missing androidPrivacy.json; run build:app first.'
}
$privacyJson = Get-Content -Raw -Encoding UTF8 -LiteralPath $compiledPrivacyPath
}
else {
$bundledPrivacyFiles = @(
Get-ChildItem -LiteralPath (Join-Path $decodePath 'assets\apps') `
-Recurse -File -Filter 'androidPrivacy.json'
)
if ($bundledPrivacyFiles.Count -ne 1) {
throw "Expected exactly one bundled androidPrivacy.json, found $($bundledPrivacyFiles.Count)."
}
$privacyJson = Get-Content -Raw -Encoding UTF8 -LiteralPath $bundledPrivacyFiles[0].FullName
}
if (-not $privacyJson.Contains($ExpectedTermsUrl)) {
throw 'Packaged privacy configuration is missing the expected service agreement URL.'
}
if (-not $privacyJson.Contains($ExpectedPrivacyUrl)) {
throw 'Packaged privacy configuration is missing the expected privacy policy URL.'
}
$privacyConfig = $privacyJson | ConvertFrom-Json
if ($privacyConfig.prompt -ne 'template') {
throw 'Packaged privacy configuration must enable the DCloud native template prompt.'
}
}
if ($BuildKind -ne 'custom-base') {
$bundledAppServiceFiles = @(
Get-ChildItem -LiteralPath (Join-Path $decodePath 'assets\apps') `
-Recurse -File -Filter 'app-service.js'
)
if ($bundledAppServiceFiles.Count -ne 1) {
throw "Expected exactly one bundled app-service.js, found $($bundledAppServiceFiles.Count)."
}
$bundledAppServiceSha256 = (
Get-FileHash -Algorithm SHA256 -LiteralPath $bundledAppServiceFiles[0].FullName
).Hash
$compiledAppServiceCandidates = @(
(Join-Path $projectRoot 'dist\build\app\app-service.js'),
(Join-Path $projectRoot 'dist\build\app-plus\app-service.js')
) | Where-Object { Test-Path -LiteralPath $_ }
if ($compiledAppServiceCandidates.Count -eq 0) {
throw 'Compiled App resources are missing app-service.js; run build:app or HBuilderX pack first.'
}
$matchingCompiledAppService = $compiledAppServiceCandidates |
Where-Object {
(Get-FileHash -Algorithm SHA256 -LiteralPath $_).Hash -eq $bundledAppServiceSha256
} |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($matchingCompiledAppService)) {
throw 'APK app-service.js does not match the current compiled App resource.'
}
}
$sensitiveFiles = @(
Get-ChildItem -LiteralPath $decodePath -Recurse -File |
Where-Object {
$_.Name -match '^(?:\.env(?:\..*)?|id_rsa)$' -or
$_.Extension -match '^\.(?:jks|keystore|p12|pfx|pem)$'
}
)
if ($sensitiveFiles.Count -gt 0) {
throw "APK contains a sensitive file: $($sensitiveFiles[0].Name)"
}
$sensitiveRules = [ordered]@{
'private key' = 'BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY'
'cloud access key' = '\b(?:AKIA|ASIA)[A-Z0-9]{16}\b'
'GitHub token' = '\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b'
'model API key' = '\bsk-[A-Za-z0-9_-]{20,}\b'
'mobile number' = '(?<!\d)1[3-9]\d{9}(?!\d)'
'fixed test SMS code' = '\u6D4B\u8BD5\u9A8C\u8BC1\u7801.{0,24}\b\d{4,8}\b'
}
$textExtensions = @('.css', '.html', '.js', '.json', '.txt', '.xml')
foreach ($file in Get-ChildItem -LiteralPath $decodePath -Recurse -File) {
if ($textExtensions -notcontains $file.Extension.ToLowerInvariant()) {
continue
}
$content = [IO.File]::ReadAllText($file.FullName)
foreach ($rule in $sensitiveRules.GetEnumerator()) {
if ([regex]::IsMatch($content, $rule.Value)) {
$relativeFile = $file.FullName.Substring($decodePath.Length).TrimStart('\', '/')
throw "APK contains a possible $($rule.Key): $relativeFile"
}
}
}
$androidNamespace = 'http://schemas.android.com/apk/res/android'
$application = $decodedManifest.manifest.application
$debuggable = $application.GetAttribute('debuggable', $androidNamespace) -eq 'true'
$usesCleartextTraffic = $application.GetAttribute('usesCleartextTraffic', $androidNamespace) -eq 'true'
$networkSecurityConfig = $application.GetAttribute('networkSecurityConfig', $androidNamespace)
if ($BuildKind -eq 'custom-base' -and -not $debuggable) {
throw 'Custom base APK must remain debuggable.'
}
if ($BuildKind -eq 'dcloud-test' -and $debuggable) {
throw 'DCloud internal test APK must not be debuggable.'
}
if ($BuildKind -in @('dcloud-test', 'release') -and $usesCleartextTraffic) {
throw "$BuildKind APK must not allow cleartext HTTP traffic."
}
if ($BuildKind -in @('dcloud-test', 'release')) {
if ($networkSecurityConfig -ne '@xml/network_security_config') {
throw "$BuildKind APK must reference @xml/network_security_config."
}
$decodedNetworkSecurityPath = Join-Path $decodePath 'res\xml\network_security_config.xml'
if (-not (Test-Path -LiteralPath $decodedNetworkSecurityPath)) {
throw "$BuildKind APK is missing res/xml/network_security_config.xml."
}
$decodedNetworkSecurity = Get-Content -Raw -Encoding UTF8 -LiteralPath $decodedNetworkSecurityPath
if ($decodedNetworkSecurity -notmatch 'cleartextTrafficPermitted=\"false\"') {
throw "$BuildKind APK network security config must reject cleartext traffic."
}
if ($decodedNetworkSecurity -match 'cleartextTrafficPermitted=\"true\"') {
throw "$BuildKind APK network security config must not contain a cleartext exception."
}
if ($decodedNetworkSecurity -notmatch '<certificates src=\"system\"\s*/>') {
throw "$BuildKind APK network security config must trust system certificates only."
}
if ($decodedNetworkSecurity -match '<certificates src=\"(?!system\")[^\"]+\"') {
throw "$BuildKind APK network security config must not trust user or bundled certificates."
}
if ($decodedNetworkSecurity -match '<debug-overrides\b') {
throw "$BuildKind APK network security config must not contain debug trust overrides."
}
}
if ($BuildKind -eq 'release') {
if ($debuggable) {
throw 'Release APK must not be debuggable.'
}
if ([string]::IsNullOrWhiteSpace($ExpectedSignerSha256)) {
throw 'Release verification requires -ExpectedSignerSha256 for the enterprise signing certificate.'
}
}
$signatureOutput = & $java.Source -jar $apksignerJar verify --verbose --print-certs $resolvedApkPath 2>&1 | Out-String
if ($LASTEXITCODE -ne 0 -or $signatureOutput -notmatch 'Verified using v2 scheme \(APK Signature Scheme v2\): true') {
throw 'APK signature verification failed or APK Signature Scheme v2 is missing.'
}
if ($signatureOutput -notmatch 'Number of signers:\s*1') {
throw 'APK must have exactly one signer.'
}
$signerMatch = [regex]::Match($signatureOutput, 'Signer #1 certificate SHA-256 digest:\s*([0-9a-fA-F]+)')
if (-not $signerMatch.Success) {
throw 'APK signer certificate SHA-256 digest could not be read.'
}
$signerSha256 = $signerMatch.Groups[1].Value.ToUpperInvariant()
if (
$BuildKind -eq 'release' -and
$signerSha256 -ne $ExpectedSignerSha256.Replace(':', '').ToUpperInvariant()
) {
throw "APK signer mismatch: got $signerSha256"
}
$apkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedApkPath).Hash
Write-Output 'Android APK verification passed.'
Write-Output " kind: $BuildKind"
Write-Output " package: $packageName"
Write-Output " version: $versionName ($versionCode)"
Write-Output " targetSdkVersion: $targetSdkVersion"
Write-Output " usesCleartextTraffic: $usesCleartextTraffic"
Write-Output " signer SHA-256: $signerSha256"
Write-Output " APK SHA-256: $apkSha256"
Write-Output " path: $resolvedApkPath"
}
finally {
if (Test-Path -LiteralPath $decodePath) {
$resolvedDecodePath = [IO.Path]::GetFullPath($decodePath)
if (
$resolvedDecodePath.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase) -and
$resolvedDecodePath -ne $temporaryRoot
) {
Remove-Item -LiteralPath $resolvedDecodePath -Recurse -Force
}
}
}