一、 iOS 端常见被拒原因汇总

  1. App 内包含分发下载分发功能(引导用户下载 App 等功能)。
  2. 提供的测试账号无法查看实际功能
  3. 通过接口返回布尔值判断 App 是否升级,但审核期间该接口不请求
  4. 审核账号,任何时候在任何 ip 登录看到的都是审核版。
  5. 提供的登陆账号和密码不对,登陆不上
  6. 运营填写的营销关键字有问题
  7. 元数据问题,iPhoneX 截图中 iPhone 壳子是 iPhone7 的,应该是 iPhoneX
  8. 说明隐私权限的作用。
  9. 营销文字,某些能力需要资质。此类功能在审核期间都关闭
  10. 修改隐私权限相关的文案,做到让审核人员看得懂,做到「信达雅」
  11. App 无法登陆进去,属于 bug 级别
  12. App 没有适配 ipad。
  13. Privacy - Data Collection and Storage,说明 App 没有做隐私权限的收集。
  14. 访问 h5 页面出现问题。 属于 bug 级别
  15. App 集成了设备指纹 SDK, 会上传用户设备安装应用列表。 解决:移除设备指纹SDK, 成功上架

二、 App 被拒原因汇总

从 Android 和 iOS 2端 App 被驳回的一些信息来看,驳回原因一般划分为下面几类:

  1. 审核期间,资源和配置都应该调节为审核模式
  2. App 包含某些关键字
  3. 审核相关的元数据问题(截图与实际内容不匹配、机型和截图不匹配、提供给审核的账号和密码登陆不上)
  4. 使用的隐私权限必须说明,文案描述必须清晰
  5. App 存在 bug (账号无法登陆、没有适配 ipad、访问 h5 打不开 )
  6. 诱导用户打开查看更多 App
  7. Android 应用未加固
  8. 应用缺乏相关的资质和证书

三、 方案

常见审核失败的原因很多,很大比重一个就是代码或者文本里面存在一些敏感词,所以本文的侧重点在于关键词扫描。像上架设置的截图和当前设备不匹配、提供的账号无法使用功能 😂 这种情况打一顿就好了,非主流行为不在本文范围内

3.1 词云谁去收集?

每个公司一般来说都不止一条业务线,所以每个业务线的 App 情况和内容也不一样,所以敏感词也是千差万别。敏感词收集这个事情,应该由业务线主要负责 App 的开发者来收集,根据平时的上架情况,苹果的驳回的邮件来整理。

3.2 方案设计

公司自研工具 cli(iOS SDK、iOS App、Android SDK、Android App、RN、Node、React 依赖分析、构建、打包、测试、热修复、埋点、构建),各个端都是通过「模版」来提供能力。包含若干子项目,每个子项目就是所谓的 “模版”,每个模版其实就是一个 Node 工程,一个 npm 模块,主要负责以下功能:特定项目类型的目录结构、自定义命令供开发、构建等使用、模版持续更新及 patch 等。

所以可以在打包构建(各个端将项目提交到打包系统,打包系统根据项目语言、平台调度打包机)的时候,拿到源代码进行扫描。基于这个现状,所以方案是「扫描是基于源代码出发的扫描的」。

按照 iOS 端 pod install 这个过程,cocoapods 为我们预留了钩子:PreInstallHook.rbPostInstallHook.rb,允许我们在不同的阶段为工程做一些自定义的操作,所以我们的 iOS 模版设计也参考了这个思想,在打包构建前、构建中、构建后提供了钩子:prebuildbuildpostbuild。定位好了问题,要做的就是在 prebuild 里面进行关键词扫描的编码工作。

确定了什么时候做什么事情,接下来就要讨论怎么做才合适。

3.3 技术方案选择

字符串匹配算法 KMP 是一开始想到的内容,针对某个 App 进行时机测试,发现50多个敏感词的情况下,代码扫描耗时60秒钟,觉得非常不理想,看 KMP 算法没有啥问题,所以换个思路走下去。

因为模版本质上 Node 项目,所以 Node 下的 glob 模块正好提供根据正则匹配到合适的文件,也可以匹配文件里面的字符串。然后继续做实验,数据如下:9个铭感词语、代码文件5967个,耗时3.5秒

3.4 完整方案

  1. 业务线需要自定义敏感词云(因为每条业务线的关键词云都不一样)
  2. 敏感词需要划分等级:error、warning。扫描到 error 需要马上停止构建,并提示「已扫描到你的源码中存在敏感词***,可能存在提交审核失败的可能,请修改后再次构建」。warning 的情况不需要马上停止构建,等任务全部结束后汇总给出提示「已扫描到你的源码中存在敏感词***、***...,可能存在提交审核失败的可能,请开发者自己确认」
  3. 铭感词云的格式 scaner.yml 文件。
  • error: 数组的格式。后面写需要扫描的关键词,且等级为 error,表示扫描到 error 则马上停止构建
  • warning:数组的格式。后面写需要扫描的关键词,且等级为 warning,扫描结果不影响构建,最终只是展示出来
  • searchPath:字符串格式。可以让业务线自定义需要进行扫描的路径。
  • fileType:数组格式。可以让业务线自定义需要扫描的文件类型。默认为 sh|pch|json|xcconfig|mm|cpp|h|m
  • warningkeywordsScan:布尔值。业务线可以设置是否需要扫描 warning 级别的关键词。
  • errorKeywordsScan:布尔值。业务线可以设置是否需要扫描 error 级别的关键词。
error:
  - checkSwitch
warning:
  - loan
  - online
  - ischeck
searchPath:
  ../fixtures
fileType:
  - h
  - m
  - cpp
  - mm
  - js
warningkeywordsScan: true
errorKeywordsScan: true
  1. iOS 端存在私有 api 的情况,Android 端不存在该问题私有 api 70111个文件,每个文件假设10个方法,则共70万个 api。所以计划找出 top 100.去扫描匹配,支持业务线是否开启的选项

其实这些问题都是业界标准的做法,肯定需要预留这样的能力,所以自定义规则的格式可以查看上面 yml 文件的各个字段所确定。明确了做什么事,以及做事情的标准,那就可以很快的开展并落地实现。

'use strict'

const { Error, logger } = require('@company/BFF-utils')
const fs = require('fs-extra')
const glob = require('glob')
const YAML = require('yamljs')

module.exports = class PreBuildCommand {
  constructor(ctx) {
    this.ctx = ctx
    this.projectPath = ''
    this.fileNum = 0
    this.isExist = false
    this.errorFiles = []
    this.warningFiles = []
    this.keywordsObject = {}
    this.errorReg = null
    this.warningReg = null
    this.warningkeywordsScan = false
    this.errorKeywordsScan = false
    this.scanFileTypes = ''
  }

  async fetchCodeFiles(dirPath, fileType = 'sh|pch|json|xcconfig|mm|cpp|h|m') {
    return new Promise((resolve, reject) => {
      glob(`**/*.?(${fileType})`, { root: dirPath, cwd: dirPath, realpath: true }, (err, files) => {
        if (err) reject(err)
        resolve(files)
      })
    })
  }

  async scanConfigurationReader(keywordsPath) {
    return new Promise((resolve, reject) => {
      fs.readFile(keywordsPath, 'UTF-8', (err, data) => {
        if (!err) {
          let keywords = YAML.parse(data)
          resolve(keywords)
        } else {
          reject(err)
        }
      })
    })
  }

  async run() {
    const { argv } = this.ctx
    const buildParam = {
      scheme: argv.opts.scheme,
      cert: argv.opts.cert,
      env: argv.opts.env
    }

    // 处理包关键词扫描(敏感词汇 + 私有 api)
    this.keywordsObject = (await this.scanConfigurationReader(this.ctx.cwd + '/.scaner.yml')) || {}
    this.warningkeywordsScan = this.keywordsObject.warningkeywordsScan || false
    this.errorKeywordsScan = this.keywordsObject.errorKeywordsScan || false
    if (Array.isArray(this.keywordsObject.fileType)) {
      this.scanFileTypes = this.keywordsObject.fileType.join('|')
    }
    if (Array.isArray(this.keywordsObject.error)) {
      this.errorReg = this.keywordsObject.error.join('|')
    }
    if (Array.isArray(this.keywordsObject.warning)) {
      this.warningReg = this.keywordsObject.warning.join('|')
    }

    // 从指定目录下获取所有文件
    this.projectPath = this.keywordsObject ? this.keywordsObject.searchPath : this.ctx.cwd
    const files = await this.fetchCodeFiles(this.projectPath, this.scanFileTypes)

    if (this.errorReg && this.errorKeywordsScan) {
      await Promise.all(
        files.map(async file => {
          try {
            const content = await fs.readFile(file, 'utf-8')
            const result = await content.match(new RegExp(`(${this.errorReg})`, 'g'))
            if (result) {
              if (result.length > 0) {
                this.isExist = true
                this.fileNum++
                this.errorFiles.push(
                  `编号: ${this.fileNum}, 所在文件: ${file},  出现次数: ${result &&
                    (result.length || 0)}`
                )
              }
            }
          } catch (error) {
            throw error
          }
        })
      )
    }

    if (this.errorFiles.length > 0) {
      throw new Error(
        `从你的项目中扫描到了 error 级别的敏感词,建议你修改方法名称、属性名、方法注释、文档描述。\n敏感词有 「${
          this.errorReg
        }」\n存在问题的文件有 ${JSON.stringify(this.errorFiles, null, 2)}`
      )
    }

    // warning
    if (this.warningReg && !this.isExist && this.fileNum === 0 && this.warningkeywordsScan) {
      await Promise.all(
        files.map(async file => {
          try {
            const content = await fs.readFile(file, 'utf-8')
            const result = await content.match(new RegExp(`(${this.warningReg})`, 'g'))
            if (result) {
              if (result.length > 0) {
                this.isExist = true
                this.fileNum++
                this.warningFiles.push(
                  `编号: ${this.fileNum}, 所在文件: ${file},  出现次数: ${result &&
                    (result.length || 0)}`
                )
              }
            }
          } catch (error) {
            throw error
          }
        })
      )

      if (this.warningFiles.length > 0) {
        logger.info(
          `从你的项目中扫描到了 warning 级别的敏感词,建议你修改方法名称、属性名、方法注释、文档描述。\n敏感词有 「${
            this.warningReg
          }」。有问题的文件有${JSON.stringify(this.warningFiles, null, 2)}`
        )
      }
    }

    for (const key in buildParam) {
      if (!buildParam[key]) {
        throw new Error(`build: ${key} 参数缺失`)
      }
    }
  }
}
09-01 23:05