PHP信创=信创等保2.0下PHP项目代码安全审计与漏洞加固
·
我来给你一套完整的、可直接落地的方案,大白话解释每一步。
---
一、先搞清楚等保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的审计员主要查日志完整性、认证强度、传输加密这三块,把上面的代码落地就能过大部分检查
项。
更多推荐

所有评论(0)