267 lines
12 KiB
PowerShell
267 lines
12 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'
|
|
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 -eq 'release') {
|
|
if ($debuggable) {
|
|
throw 'Release APK must not be debuggable.'
|
|
}
|
|
if ($usesCleartextTraffic) {
|
|
throw 'Release APK must not allow cleartext HTTP traffic.'
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|