我来给你一套完整的、可直接落地的方案,大白话解释每一步。
                                                                                                                          ---
  一、先搞清楚等保2.0对PHP项目要求啥                                                                                    
  等保2.0(GB/T 22239-2019)对应用层主要关注这几块:

  ┌────────────┬────────────────────────────┐
  │   控制点   │           白话说           │
  ├────────────┼────────────────────────────┤
  │ 身份鉴别   │ 登录要验证,密码要加密存   │
  ├────────────┼────────────────────────────┤
  │ 访问控制   │ 谁能看啥、能干啥要管住     │
  ├────────────┼────────────────────────────┤
  │ 安全审计   │ 操作要留日志,日志不能被删 │
  ├────────────┼────────────────────────────┤
  │ 输入验证   │ 用户输入的东西不能直接用   │
  ├────────────┼────────────────────────────┤
  │ 通信安全   │ 传输要加密(HTTPS)        │
  ├────────────┼────────────────────────────┤
  │ 数据完整性 │ 数据不能被篡改             │
  └────────────┴────────────────────────────┘

  ---
  二、完整审计流程

  代码扫描(自动)→ 人工复核 → 漏洞分级 → 加固修复 → 回归验证 → 出报告

  ---
  三、自动化扫描工具选型(最好的库)

  3.1 安装工具

  # PHP静态分析 - Psalm(最强PHP静态分析)
  composer require --dev vimeo/psalm

  # PHP安全专项扫描 - PHPCS Security Audit
  composer require --dev pheromone/phpcs-security-audit
  composer require --dev squizlabs/php_codesniffer

  # 依赖漏洞扫描
  composer require --dev enlightn/security-checker

  # 或者用 local-php-security-checker(不需要网络)
  # 下载地址见官方 https://github.com/fabpot/local-php-security-checker

  3.2 配置 Psalm

  <!-- psalm.xml -->
  <?xml version="1.0"?>
  <psalm
      errorLevel="3"
      resolveFromConfigFile="true"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns="https://getpsalm.org/schema/config"
      xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
  >
      <projectFiles>
          <directory name="src" />
          <directory name="app" />
          <ignoreFiles>
              <directory name="vendor" />
          </ignoreFiles>
      </projectFiles>

      <!-- 开启污点分析,专门找注入漏洞 -->
      <taintAnalysis />
  </psalm>

  # 运行污点分析(找SQL注入、XSS、命令注入)
  ./vendor/bin/psalm --taint-analysis

  ---
  四、核心漏洞加固代码(完整可用)

  4.1 SQL注入防护

  <?php
  // ❌ 危险写法 - 直接拼SQL
  $sql = "SELECT * FROM users WHERE id = " . $_GET['id'];

  // ✅ 正确写法 - PDO预处理
  class SecureDB
  {
      private PDO $pdo;

      public function __construct(string $dsn, string $user, string $pass)
      {
          $this->pdo = new PDO($dsn, $user, $pass, [
              PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
              PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
              PDO::ATTR_EMULATE_PREPARES   => false, // 关键!关掉模拟预处理
          ]);
      }

      public function getUserById(int $id): array|false
      {
          // 参数绑定,用户输入永远不拼进SQL
          $stmt = $this->pdo->prepare('SELECT id, username, email FROM users WHERE id = :id');
          $stmt->bindValue(':id', $id, PDO::PARAM_INT);
          $stmt->execute();
          return $stmt->fetch();
      }

      public function getUserByUsername(string $username): array|false
      {
          $stmt = $this->pdo->prepare('SELECT id, username, email FROM users WHERE username = :username');
          $stmt->bindValue(':username', $username, PDO::PARAM_STR);
          $stmt->execute();
          return $stmt->fetch();
      }

      // 动态条件查询的安全写法
      public function searchUsers(array $filters): array
      {
          $where  = [];
          $params = [];

          // 白名单控制允许过滤的字段,防止字段名注入
          $allowedFields = ['username', 'email', 'status'];

          foreach ($filters as $field => $value) {
              if (!in_array($field, $allowedFields, true)) {
                  continue; // 不在白名单的字段直接忽略
              }
              $where[]          = "`{$field}` = :{$field}";
              $params[":{$field}"] = $value;
          }

          $sql  = 'SELECT id, username, email FROM users';
          $sql .= $where ? ' WHERE ' . implode(' AND ', $where) : '';

          $stmt = $this->pdo->prepare($sql);
          $stmt->execute($params);
          return $stmt->fetchAll();
      }
  }

  ---
  4.2 XSS防护

  <?php
  // ❌ 危险写法
  echo $_GET['name'];
  echo "<div>" . $userInput . "</div>";

  // ✅ 输出时转义 - 这是最重要的原则:在哪里输出就在哪里转义
  class OutputEscaper
  {
      // HTML上下文转义(最常用)
      public static function html(mixed $value): string
      {
          return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
      }

      // HTML属性上下文(用在 <input value="这里">)
      public static function attr(mixed $value): string
      {
          return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
      }

      // JavaScript上下文(用在 <script>var x = '这里'</script>)
      public static function js(mixed $value): string
      {
          // json_encode 会自动处理特殊字符
          return json_encode((string)$value, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
      }

      // URL参数上下文
      public static function url(mixed $value): string
      {
          return urlencode((string)$value);
      }
  }

  // 使用示例
  $name = $_GET['name'] ?? '';
  echo '<div>' . OutputEscaper::html($name) . '</div>';
  echo '<input value="' . OutputEscaper::attr($name) . '">';
  echo '<script>var name = ' . OutputEscaper::js($name) . ';</script>';

  <?php
  // 富文本场景用 HTMLPurifier(最好的HTML净化库)
  // composer require ezyang/htmlpurifier

  use HTMLPurifier;
  use HTMLPurifier_Config;

  class RichTextSanitizer
  {
      private HTMLPurifier $purifier;

      public function __construct()
      {
          $config = HTMLPurifier_Config::createDefault();

          // 只允许安全的HTML标签
          $config->set('HTML.Allowed', 'p,br,strong,em,ul,ol,li,a[href],img[src|alt]');

          // 强制链接只允许http/https
          $config->set('URI.AllowedSchemes', ['http' => true, 'https' => true]);

          // 缓存目录
          $config->set('Cache.SerializerPath', sys_get_temp_dir());

          $this->purifier = new HTMLPurifier($config);
      }

      public function clean(string $html): string
      {
          return $this->purifier->purify($html);
      }
  }

  ---
  4.3 CSRF防护

  <?php
  // composer require paragonie/anti-csrf  (最好的CSRF库)
  // 或者自己实现,下面是完整实现

  class CsrfProtection
  {
      private const TOKEN_LENGTH = 32;
      private const SESSION_KEY  = '_csrf_tokens';

      public static function init(): void
      {
          if (session_status() !== PHP_SESSION_ACTIVE) {
              // 安全的session配置
              ini_set('session.cookie_httponly', '1');
              ini_set('session.cookie_secure', '1');   // 仅HTTPS
              ini_set('session.cookie_samesite', 'Strict');
              ini_set('session.use_strict_mode', '1');
              session_start();
          }
      }

      // 生成token,绑定到表单action
      public static function generateToken(string $formName): string
      {
          self::init();
          $token = bin2hex(random_bytes(self::TOKEN_LENGTH));

          if (!isset($_SESSION[self::SESSION_KEY])) {
              $_SESSION[self::SESSION_KEY] = [];
          }

          // 每个表单存一个token,带过期时间
          $_SESSION[self::SESSION_KEY][$formName] = [
              'token'   => $token,
              'expires' => time() + 3600, // 1小时有效
          ];

          return $token;
      }

      // 验证token
      public static function validateToken(string $formName, string $submittedToken): bool
      {
          self::init();

          $stored = $_SESSION[self::SESSION_KEY][$formName] ?? null;

          if (!$stored) {
              return false;
          }

          // 验证后立即删除(一次性token)
          unset($_SESSION[self::SESSION_KEY][$formName]);

          // 检查过期
          if (time() > $stored['expires']) {
              return false;
          }

          // 用 hash_equals 防时序攻击
          return hash_equals($stored['token'], $submittedToken);
      }

      // 在表单里输出hidden字段
      public static function tokenField(string $formName): string
      {
          $token = self::generateToken($formName);
          return sprintf(
              '<input type="hidden" name="_csrf_token" value="%s">',
              htmlspecialchars($token, ENT_QUOTES, 'UTF-8')
          );
      }
  }

  // 表单页面使用
  // <form method="POST">
  //     <?= CsrfProtection::tokenField('login_form') ?>
  //     ...
  // </form>

  // 处理页面使用
  $token = $_POST['_csrf_token'] ?? '';
  if (!CsrfProtection::validateToken('login_form', $token)) {
      http_response_code(403);
      exit('CSRF验证失败');
  }

  ---
  4.4 文件上传安全

  <?php
  // composer require league/mime-type-detection (MIME检测)

  class SecureFileUpload
  {
      // 白名单:允许上传的MIME类型 → 对应扩展名
      private const ALLOWED_TYPES = [
          'image/jpeg' => 'jpg',
          'image/png'  => 'png',
          'image/gif'  => 'gif',
          'image/webp' => 'webp',
          'application/pdf' => 'pdf',
      ];

      private string $uploadDir;

      public function __construct(string $uploadDir)
      {
          // 上传目录必须在web根目录之外,或者禁止执行
          $this->uploadDir = rtrim($uploadDir, '/');
      }

      public function upload(array $file): array
      {
          // 1. 检查上传错误
          if ($file['error'] !== UPLOAD_ERR_OK) {
              throw new RuntimeException('文件上传失败,错误码:' . $file['error']);
          }

          // 2. 检查文件大小(10MB限制)
          $maxSize = 10 * 1024 * 1024;
          if ($file['size'] > $maxSize) {
              throw new RuntimeException('文件超过大小限制');
          }

          // 3. 用finfo检测真实MIME(不信任$_FILES['type'],那个可以伪造)
          $finfo    = new finfo(FILEINFO_MIME_TYPE);
          $mimeType = $finfo->file($file['tmp_name']);

          if (!array_key_exists($mimeType, self::ALLOWED_TYPES)) {
              throw new RuntimeException('不允许的文件类型:' . $mimeType);
          }

          // 4. 图片额外验证:用getimagesize确认是真图片
          if (str_starts_with($mimeType, 'image/')) {
              if (getimagesize($file['tmp_name']) === false) {
                  throw new RuntimeException('文件不是有效的图片');
              }
          }

          // 5. 生成随机文件名,绝对不用原始文件名(防路径穿越)
          $ext      = self::ALLOWED_TYPES[$mimeType];
          $newName  = bin2hex(random_bytes(16)) . '.' . $ext;
          $destPath = $this->uploadDir . '/' . $newName;

          // 6. 移动文件
          if (!move_uploaded_file($file['tmp_name'], $destPath)) {
              throw new RuntimeException('文件保存失败');
          }

          // 7. 设置严格权限(不可执行)
          chmod($destPath, 0644);

          return [
              'filename'  => $newName,
              'mime_type' => $mimeType,
              'size'      => $file['size'],
          ];
      }
  }

  ---
  4.5 身份认证与密码安全

  <?php
  // composer require firebase/php-jwt  (JWT最好的库)

  class AuthService
  {
      // 密码哈希 - 用PHP内置的password_hash,算法用bcrypt或argon2
      public function hashPassword(string $plainPassword): string
      {
          return password_hash($plainPassword, PASSWORD_ARGON2ID, [
              'memory_cost' => 65536, // 64MB
              'time_cost'   => 4,
              'threads'     => 3,
          ]);
      }

      // 验证密码
      public function verifyPassword(string $plainPassword, string $hash): bool
      {
          return password_verify($plainPassword, $hash);
      }

      // 登录限速(防暴力破解)
      public function checkLoginRateLimit(string $identifier): bool
      {
          // 用Redis实现,composer require predis/predis
          // 这里用文件模拟,生产环境换Redis
          $key     = 'login_attempts_' . md5($identifier);
          $cacheFile = sys_get_temp_dir() . '/' . $key;

          $data = [];
          if (file_exists($cacheFile)) {
              $data = json_decode(file_get_contents($cacheFile), true) ?? [];
          }

          // 清理5分钟前的记录
          $now  = time();
          $data = array_filter($data, fn($t) => $now - $t < 300);

          if (count($data) >= 5) {
              return false; // 5分钟内超过5次,拒绝
          }

          $data[] = $now;
          file_put_contents($cacheFile, json_encode(array_values($data)));
          return true;
      }
  }

  ---
  4.6 安全审计日志(等保2.0强制要求)

  <?php
  // composer require monolog/monolog  (PHP最好的日志库)

  use Monolog\Logger;
  use Monolog\Handler\RotatingFileHandler;
  use Monolog\Handler\StreamHandler;
  use Monolog\Formatter\JsonFormatter;

  class SecurityAuditLogger
  {
      private Logger $logger;

      public function __construct(string $logPath)
      {
          $this->logger = new Logger('security_audit');

          // 按天切割日志,保留180天(等保要求6个月)
          $handler = new RotatingFileHandler(
              $logPath . '/security_audit.log',
              180,
              Logger::INFO
          );

          // 用JSON格式,方便后续用ELK分析
          $handler->setFormatter(new JsonFormatter());
          $this->logger->pushHandler($handler);
      }

      // 记录登录事件
      public function logLogin(string $username, bool $success, string $ip): void
      {
          $level   = $success ? Logger::INFO : Logger::WARNING;
          $message = $success ? '用户登录成功' : '用户登录失败';

          $this->logger->log($level, $message, [
              'event'     => $success ? 'LOGIN_SUCCESS' : 'LOGIN_FAILURE',
              'username'  => $username,
              'ip'        => $ip,
              'user_agent'=> $_SERVER['HTTP_USER_AGENT'] ?? '',
              'timestamp' => date('c'),
          ]);
      }

      // 记录敏感操作
      public function logOperation(int $userId, string $action, array $detail = []): void
      {
          $this->logger->info('用户操作', array_merge([
              'event'     => 'USER_OPERATION',
              'user_id'   => $userId,
              'action'    => $action,
              'ip'        => $_SERVER['REMOTE_ADDR'] ?? '',
              'timestamp' => date('c'),
          ], $detail));
      }

      // 记录安全告警
      public function logAlert(string $type, string $detail, string $ip): void
      {
          $this->logger->critical('安全告警', [
              'event'     => 'SECURITY_ALERT',
              'type'      => $type,
              'detail'    => $detail,
              'ip'        => $ip,
              'timestamp' => date('c'),
          ]);
      }
  }

  ---
  4.7 HTTP安全响应头(等保必查项)

  <?php
  // 在入口文件或中间件里统一设置

  class SecurityHeaders
  {
      public static function send(): void
      {
          // 防点击劫持
          header('X-Frame-Options: DENY');

          // 防MIME嗅探
          header('X-Content-Type-Options: nosniff');

          // XSS过滤(老浏览器兼容)
          header('X-XSS-Protection: 1; mode=block');

          // 强制HTTPS(HSTS),等保要求
          header('Strict-Transport-Security: max-age=31536000; includeSubDomains');

          // 内容安全策略(CSP)- 最强XSS防御
          // 根据实际情况调整,这是严格版本
          header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
  img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'");

          // 控制Referer信息泄露
          header('Referrer-Policy: strict-origin-when-cross-origin');

          // 禁止浏览器猜测MIME类型
          header('Permissions-Policy: geolocation=(), microphone=(), camera=()');

          // 隐藏PHP版本(也要在php.ini里设 expose_php = Off)
          header_remove('X-Powered-By');
      }
  }

  // 在 index.php 最顶部调用
  SecurityHeaders::send();

  ---
  4.8 输入验证统一入口

  <?php
  // composer require respect/validation  (PHP最好的验证库)

  use Respect\Validation\Validator as v;

  class InputValidator
  {
      // 验证并清理整数
      public static function int(mixed $value, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX): int
      {
          $filtered = filter_var($value, FILTER_VALIDATE_INT, [
              'options' => ['min_range' => $min, 'max_range' => $max],
          ]);

          if ($filtered === false) {
              throw new InvalidArgumentException("无效的整数值");
          }

          return (int)$filtered;
      }

      // 验证邮箱
      public static function email(mixed $value): string
      {
          $filtered = filter_var(trim((string)$value), FILTER_VALIDATE_EMAIL);
          if ($filtered === false) {
              throw new InvalidArgumentException("无效的邮箱格式");
          }
          return $filtered;
      }

      // 验证手机号(中国大陆)
      public static function phone(mixed $value): string
      {
          $phone = preg_replace('/\D/', '', (string)$value);
          if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
              throw new InvalidArgumentException("无效的手机号");
          }
          return $phone;
      }

      // 验证字符串长度和内容
      public static function string(mixed $value, int $minLen = 0, int $maxLen = 255, string $pattern = ''): string
      {
          $str = trim((string)$value);
          $len = mb_strlen($str, 'UTF-8');

          if ($len < $minLen || $len > $maxLen) {
              throw new InvalidArgumentException("字符串长度不符合要求({$minLen}-{$maxLen})");
          }

          if ($pattern && !preg_match($pattern, $str)) {
              throw new InvalidArgumentException("字符串格式不符合要求");
          }

          return $str;
      }

      // 白名单枚举验证
      public static function enum(mixed $value, array $allowed): mixed
      {
          if (!in_array($value, $allowed, true)) {
              throw new InvalidArgumentException("值不在允许范围内");
          }
          return $value;
      }
  }

  ---
  五、PHP配置加固(php.ini)

  ; ===== 等保2.0 PHP配置加固清单 =====

  ; 隐藏PHP版本信息
  expose_php = Off

  ; 禁用危险函数
  disable_functions =
  exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source,phpinfo

  ; 关闭错误显示(生产环境)
  display_errors = Off
  display_startup_errors = Off
  log_errors = On
  error_log = /var/log/php/error.log

  ; 文件上传限制
  file_uploads = On
  upload_max_filesize = 10M
  max_file_uploads = 5

  ; Session安全
  session.cookie_httponly = 1
  session.cookie_secure = 1
  session.cookie_samesite = Strict
  session.use_strict_mode = 1
  session.use_only_cookies = 1
  session.gc_maxlifetime = 1800

  ; 禁止远程文件包含
  allow_url_fopen = Off
  allow_url_include = Off

  ; 限制PHP可访问的目录
  open_basedir = /var/www/html:/tmp

  ; 关闭注册全局变量(PHP老版本)
  register_globals = Off

  ---
  六、自动化扫描脚本(一键跑)

  #!/bin/bash
  # security_audit.sh - 一键安全扫描

  PROJECT_DIR="${1:-.}"
  REPORT_DIR="./security_reports/$(date +%Y%m%d_%H%M%S)"

  mkdir -p "$REPORT_DIR"

  echo "===== 开始安全审计 ====="

  # 1. Psalm污点分析(找注入漏洞)
  echo "[1/4] 运行Psalm污点分析..."
  ./vendor/bin/psalm --taint-analysis --report="$REPORT_DIR/psalm_taint.xml" 2>&1 | tee "$REPORT_DIR/psalm_output.txt"

  # 2. PHPCS安全规则扫描
  echo "[2/4] 运行PHPCS安全扫描..."
  ./vendor/bin/phpcs \
      --standard=vendor/pheromone/phpcs-security-audit/example_base_ruleset.xml \
      --report=json \
      --report-file="$REPORT_DIR/phpcs_security.json" \
      "$PROJECT_DIR/src" 2>&1

  # 3. 依赖漏洞扫描
  echo "[3/4] 扫描依赖漏洞..."
  ./vendor/bin/local-php-security-checker 2>&1 | tee "$REPORT_DIR/dependency_vulns.txt"

  # 4. 敏感信息泄露扫描(硬编码密码、密钥)
  echo "[4/4] 扫描硬编码敏感信息..."
  grep -rn \
      -e "password\s*=\s*['\"][^'\"]\+['\"]" \
      -e "secret\s*=\s*['\"][^'\"]\+['\"]" \
      -e "api_key\s*=\s*['\"][^'\"]\+['\"]" \
      -e "ACCESS_KEY\s*=\s*['\"][^'\"]\+['\"]" \
      --include="*.php" \
      "$PROJECT_DIR/src" > "$REPORT_DIR/hardcoded_secrets.txt" 2>&1

  echo "===== 审计完成,报告在 $REPORT_DIR ====="

  ---
  七、等保2.0检查清单

  应用安全检查项                          状态
  ─────────────────────────────────────────
  □ 所有SQL查询使用预处理语句
  □ 所有输出做了上下文相关的转义
  □ 表单有CSRF Token保护
  □ 文件上传验证真实MIME类型
  □ 密码用bcrypt/argon2存储
  □ 登录有失败次数限制
  □ Session配置了httponly/secure/samesite
  □ 设置了完整的安全响应头(含CSP)
  □ 关闭了PHP错误显示
  □ 禁用了危险函数
  □ 操作日志保留180天以上
  □ 日志记录了用户、时间、IP、操作
  □ 传输层使用TLS 1.2+
  □ 依赖包无已知高危漏洞
  □ 没有硬编码的密码/密钥

  ---
  八、推荐的完整依赖清单

  {
      "require": {
          "monolog/monolog": "^3.0",
          "firebase/php-jwt": "^6.0",
          "ezyang/htmlpurifier": "^4.17",
          "respect/validation": "^2.3",
          "paragonie/random_compat": "^9.99"
      },
      "require-dev": {
          "vimeo/psalm": "^5.0",
          "pheromone/phpcs-security-audit": "^2.0",
          "squizlabs/php_codesniffer": "^3.0",
          "enlightn/security-checker": "^1.10"
      }
  }

  ---
  核心原则就三条:输入验证(进来的数据都是脏的)、输出转义(输出到哪里就按那里的规则转义)、最小权限(数据库账号、文件权
  限、函数权限都给最小的)。等保2.0的审计员主要查日志完整性、认证强度、传输加密这三块,把上面的代码落地就能过大部分检查
  项。

更多推荐