release: harden Android network security

This commit is contained in:
key
2026-07-29 01:13:18 +08:00
parent 9ea5d79a62
commit ba68fc8326
16 changed files with 198 additions and 29 deletions
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.yincheng.wygj">
<application
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false"
tools:replace="android:networkSecurityConfig,android:usesCleartextTraffic" />
</manifest>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aihr-mobile-uni",
"version": "0.1.6",
"version": "0.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aihr-mobile-uni",
"version": "0.1.6",
"version": "0.1.8",
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5020220260725001",
"@dcloudio/uni-app-plus": "3.0.0-alpha-5020220260725001",
+2 -2
View File
@@ -1,14 +1,14 @@
{
"$schema": "https://json.schemastore.org/package",
"name": "aihr-mobile-uni",
"version": "0.1.6",
"version": "0.1.8",
"private": true,
"type": "module",
"scripts": {
"dev": "uni -p h5",
"dev:h5": "uni -p h5",
"build:h5": "uni build -p h5",
"build:app": "uni build -p app",
"build:app": "uni build -p app && node scripts/sync-app-native-resources.mjs",
"generate:app-assets": "sh scripts/generate-app-assets.sh",
"configure:app-privacy": "node scripts/configure-android-privacy.mjs",
"verify:app-assets": "node scripts/verify-app-assets.mjs",
@@ -0,0 +1,24 @@
import { cpSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const projectRoot = resolve(import.meta.dirname, '..');
const mappings = [
[
resolve(projectRoot, 'AndroidManifest.xml'),
resolve(projectRoot, 'dist/build/app/AndroidManifest.xml')
],
[
resolve(projectRoot, 'nativeResources/android/res/xml/network_security_config.xml'),
resolve(projectRoot, 'dist/build/app/nativeResources/android/res/xml/network_security_config.xml')
]
];
for (const [source, destination] of mappings) {
if (!existsSync(source)) {
throw new Error(`Missing required native App resource: ${source}`);
}
mkdirSync(dirname(destination), { recursive: true });
cpSync(source, destination);
}
console.log('Android native manifest and network security resources synchronized.');
+29 -3
View File
@@ -205,19 +205,45 @@ try {
$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 ($usesCleartextTraffic) {
throw 'Release APK must not allow cleartext HTTP traffic.'
}
if ([string]::IsNullOrWhiteSpace($ExpectedSignerSha256)) {
throw 'Release verification requires -ExpectedSignerSha256 for the enterprise signing certificate.'
}
+59 -1
View File
@@ -15,6 +15,16 @@ let releaseLegalUrls;
const fail = (message) => failures.push(message);
const compiledAppRoot = resolve(projectRoot, 'dist/build/app');
const sourceAndroidManifestPath = resolve(projectRoot, 'AndroidManifest.xml');
const compiledAndroidManifestPath = resolve(compiledAppRoot, 'AndroidManifest.xml');
const sourceNetworkSecurityPath = resolve(
projectRoot,
'nativeResources/android/res/xml/network_security_config.xml'
);
const compiledNetworkSecurityPath = resolve(
compiledAppRoot,
'nativeResources/android/res/xml/network_security_config.xml'
);
const releaseTextExtensions = new Set(['.css', '.html', '.js', '.json', '.txt', '.xml']);
const releaseSensitiveFilePattern = /(^|[\\/])(?:\.env(?:\.|$)|id_rsa$)|\.(?:jks|keystore|p12|pfx|pem)$/i;
const releaseSensitiveContentPatterns = [
@@ -126,7 +136,7 @@ if (app?.ssl?.untrustedca !== 'refuse') {
fail('app-plus.ssl.untrustedca must remain refuse');
}
if (!/^\d+\.\d+\.\d+$/.test(String(manifest.versionName || ''))) {
fail('versionName must use semantic numeric form such as 0.1.6');
fail('versionName must use semantic numeric form such as 0.1.8');
}
if (!/^[1-9]\d*$/.test(String(manifest.versionCode || ''))) {
fail('versionCode must be a positive integer');
@@ -137,6 +147,9 @@ if (android?.packagename !== 'com.yincheng.wygj') {
if (android?.targetSdkVersion !== 35) {
fail('Android targetSdkVersion must be 35 for the August 1 test package');
}
if (android?.usesCleartextTraffic !== false) {
fail('Android manifest configuration must explicitly disable cleartext traffic');
}
const expectedAndroidAbis = ['arm64-v8a', 'armeabi-v7a'];
const actualAndroidAbis = Array.isArray(android?.abiFilters)
? [...android.abiFilters].sort()
@@ -162,6 +175,46 @@ for (const permission of ['android.permission.RECORD_AUDIO', 'android.permission
}
}
const verifyAndroidNetworkSecurity = (manifestPath, networkSecurityPath, label) => {
if (!existsSync(manifestPath)) {
fail(`${label} is missing AndroidManifest.xml`);
return;
}
if (!existsSync(networkSecurityPath)) {
fail(`${label} is missing network_security_config.xml`);
return;
}
const androidManifest = readFileSync(manifestPath, 'utf8');
const networkSecurity = readFileSync(networkSecurityPath, 'utf8');
if (!/android:usesCleartextTraffic\s*=\s*["']false["']/.test(androidManifest)) {
fail(`${label} AndroidManifest.xml must disable cleartext traffic`);
}
if (!/android:networkSecurityConfig\s*=\s*["']@xml\/network_security_config["']/.test(androidManifest)) {
fail(`${label} AndroidManifest.xml must reference network_security_config`);
}
if (!/cleartextTrafficPermitted\s*=\s*["']false["']/.test(networkSecurity)) {
fail(`${label} network security config must reject cleartext traffic`);
}
if (/cleartextTrafficPermitted\s*=\s*["']true["']/.test(networkSecurity)) {
fail(`${label} network security config must not contain a cleartext exception`);
}
if (!/<certificates\s+src\s*=\s*["']system["']\s*\/>/.test(networkSecurity)) {
fail(`${label} network security config must trust system certificates only`);
}
if (/<certificates\s+src\s*=\s*["'](?!system["'])[^"']+["']/.test(networkSecurity)) {
fail(`${label} network security config must not trust user or bundled certificates`);
}
if (/<debug-overrides\b/.test(networkSecurity)) {
fail(`${label} network security config must not contain debug trust overrides`);
}
};
verifyAndroidNetworkSecurity(
sourceAndroidManifestPath,
sourceNetworkSecurityPath,
'source App'
);
const androidIcons = [
['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192]
];
@@ -251,6 +304,11 @@ if (requirePrivacy) {
fail(`compiled App androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
}
}
verifyAndroidNetworkSecurity(
compiledAndroidManifestPath,
compiledNetworkSecurityPath,
'compiled App'
);
scanCompiledAppForSensitiveValues();
}
+3 -2
View File
@@ -3,8 +3,8 @@
"appid" : "__UNI__B36D8BE",
"description" : "帮道员工端",
"vueVersion" : "3",
"versionName" : "0.1.6",
"versionCode" : "106",
"versionName" : "0.1.8",
"versionCode" : "108",
"uniStatistics" : {
"enable" : false
},
@@ -89,6 +89,7 @@
"android" : {
"packagename" : "com.yincheng.wygj",
"targetSdkVersion" : 35,
"usesCleartextTraffic" : false,
"abiFilters" : [ "armeabi-v7a", "arm64-v8a" ],
"permissionExternalStorage" : {
"request" : "none"
+38
View File
@@ -42,6 +42,38 @@ test('Android 自定义基座显式面向 Android 15 权限模型', async () =>
assert.equal(android?.packagename, 'com.yincheng.wygj');
assert.equal(android?.targetSdkVersion, 35, '不得回退到 DCloud 默认的 targetSdkVersion 28 兼容模式');
assert.equal(android?.usesCleartextTraffic, false, '发布配置不得允许明文 HTTP');
});
test('Android 原生网络安全配置拒绝明文流量且只信任系统证书', async () => {
const androidManifest = await readFile(
new URL('../AndroidManifest.xml', import.meta.url),
'utf8'
);
const networkSecurity = await readFile(
new URL('../nativeResources/android/res/xml/network_security_config.xml', import.meta.url),
'utf8'
);
assert.match(androidManifest, /android:usesCleartextTraffic="false"/);
assert.match(androidManifest, /android:networkSecurityConfig="@xml\/network_security_config"/);
assert.match(networkSecurity, /cleartextTrafficPermitted="false"/);
assert.match(networkSecurity, /<certificates src="system" \/>/);
assert.doesNotMatch(networkSecurity, /cleartextTrafficPermitted="true"/);
assert.doesNotMatch(networkSecurity, /<certificates src="user"/);
assert.doesNotMatch(networkSecurity, /<debug-overrides\b/);
});
test('App 构建链将 Android 原生网络安全资源同步到云打包输入目录', async () => {
const pkg = await readJson('../package.json');
const syncScript = await readFile(
new URL('../scripts/sync-app-native-resources.mjs', import.meta.url),
'utf8'
);
assert.match(pkg.scripts?.['build:app'] || '', /sync-app-native-resources\.mjs/);
assert.match(syncScript, /dist\/build\/app\/AndroidManifest\.xml/);
assert.match(syncScript, /dist\/build\/app\/nativeResources\/android\/res\/xml\/network_security_config\.xml/);
});
test('Android 启动阶段不主动索取设备信息或外部存储权限', async () => {
@@ -146,6 +178,12 @@ test('APK 门禁区分自定义调试基座与内置业务资源的测试包', a
assert.match(verifier, /dist\\build\\app-plus\\app-service\.js/);
assert.match(verifier, /fixed test SMS code/);
assert.match(verifier, /'mobile number'\s*=/);
assert.match(verifier, /dcloud-test.*release.*usesCleartextTraffic/s);
assert.match(verifier, /res\\xml\\network_security_config\.xml/);
assert.match(verifier, /network security config must trust system certificates only/);
assert.match(verifier, /must not contain a cleartext exception/);
assert.match(verifier, /must not trust user or bundled certificates/);
assert.match(verifier, /must not contain debug trust overrides/);
});
test('8 月 1 日 RC 脚本默认拒绝脏工作区且记录完整构建证据', async () => {