This commit is contained in:
parent
ddc96525b4
commit
374607756d
31
backend/hooks/plugin_identity_linux_test.go
Normal file
31
backend/hooks/plugin_identity_linux_test.go
Normal file
@ -0,0 +1,31 @@
|
||||
//go:build linux
|
||||
|
||||
package hooks
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPluginIdentityBoundsBeforeCredentialConversion(t *testing.T) {
|
||||
for _, name := range []string{"SMAIL_PLUGIN_UID", "SMAIL_PLUGIN_GID"} {
|
||||
for _, item := range []struct {
|
||||
value string
|
||||
want int
|
||||
}{
|
||||
{"1", 1}, {"65534", 65534}, {"1000", 1000},
|
||||
{"", 0}, {"0", 0}, {"-1", 0}, {"65535", 0},
|
||||
{"4294967296", 0}, {"9223372036854775808", 0},
|
||||
{"abc", 0}, {"1.5", 0}, {" 1000", 0},
|
||||
} {
|
||||
t.Run(name+"/"+item.value, func(t *testing.T) {
|
||||
t.Setenv(name, item.value)
|
||||
got, err := pluginIdentity(name)
|
||||
if item.want == 0 {
|
||||
if err == nil || got != 0 {
|
||||
t.Fatal("无效插件身份未在整数转换前被拒绝")
|
||||
}
|
||||
} else if err != nil || got != item.want {
|
||||
t.Fatal("合法插件身份被拒绝或发生截断")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
140
backend/services/dbmigration/identifier_security_test.go
Normal file
140
backend/services/dbmigration/identifier_security_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
package dbmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
)
|
||||
|
||||
func TestIdentifierQuotationContainsUntrustedSyntax(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"ordinary", "with space", "mail`prod", `mail"prod`,
|
||||
"quote`; DROP TABLE sentinel; --", `quote"; DROP TABLE sentinel; --`,
|
||||
"comma,other", "comment--suffix", "中文表名", "back\\slash",
|
||||
} {
|
||||
for _, kind := range []config.DatabaseType{config.DatabaseTypeMySQL, config.DatabaseTypePostgreSQL} {
|
||||
t.Run(string(kind)+"/"+value, func(t *testing.T) {
|
||||
quoted := quoteDatabaseIdentifier(kind, value)
|
||||
delimiter := byte('"')
|
||||
if kind == config.DatabaseTypeMySQL {
|
||||
delimiter = '`'
|
||||
}
|
||||
if len(quoted) < 2 || quoted[0] != delimiter || quoted[len(quoted)-1] != delimiter {
|
||||
t.Fatal("标识符缺少完整引用边界")
|
||||
}
|
||||
var decoded strings.Builder
|
||||
for index := 1; index < len(quoted)-1; index++ {
|
||||
if quoted[index] == delimiter {
|
||||
index++
|
||||
if index >= len(quoted)-1 || quoted[index] != delimiter {
|
||||
t.Fatal("标识符内部存在未成对的结束引用")
|
||||
}
|
||||
}
|
||||
decoded.WriteByte(quoted[index])
|
||||
}
|
||||
if decoded.String() != value {
|
||||
t.Fatal("标识符引用改变了原始名称")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLReferenceRulesRejectInjectedSuffixes(t *testing.T) {
|
||||
for _, rule := range []string{"CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"} {
|
||||
for _, suffix := range []string{"; SELECT 1", " --", "/*comment*/", "\x00", ", CASCADE"} {
|
||||
if validMySQLReferenceRule(rule + suffix) {
|
||||
t.Fatal("外键动作接受了白名单以外的语法")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只接受新建的隔离数据库;保留表和测试数据,不连接正式数据库或删除文件。
|
||||
func TestSQLIdentifierLiveIntegration(t *testing.T) {
|
||||
for _, kind := range []config.DatabaseType{config.DatabaseTypeMySQL, config.DatabaseTypePostgreSQL} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
raw := os.Getenv("SMAIL_TEST_IDENTIFIER_" + strings.ToUpper(string(kind)))
|
||||
if raw == "" {
|
||||
t.Skip("未配置专用实机标识符测试数据库")
|
||||
}
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&input) != nil ||
|
||||
!regexp.MustCompile(`^smail_runtime_test_[0-9a-f]{12}$`).MatchString(input.Name) ||
|
||||
!regexp.MustCompile(`^smail_rt_[0-9a-f]{12}$`).MatchString(input.User) || input.Password == "" {
|
||||
t.Fatal("实机测试连接必须使用专用隔离数据库和账号")
|
||||
}
|
||||
port := uint16(3306)
|
||||
if kind == config.DatabaseTypePostgreSQL {
|
||||
port = 5432
|
||||
}
|
||||
settings := config.DatabaseConfig{Type: kind, Host: "127.0.0.1", Port: port, Username: input.User}
|
||||
driver, dsn, err := runtimeSQLDSN(settings, input.Password, input.Name)
|
||||
if err != nil {
|
||||
t.Fatal("无法构建隔离连接")
|
||||
}
|
||||
database, err := sql.Open(driver, dsn)
|
||||
if err != nil {
|
||||
t.Fatal("无法打开隔离连接")
|
||||
}
|
||||
defer database.Close()
|
||||
database.SetMaxOpenConns(1)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
var count int
|
||||
query := "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()"
|
||||
if kind == config.DatabaseTypePostgreSQL {
|
||||
query = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public'"
|
||||
}
|
||||
if err := database.QueryRowContext(ctx, query).Scan(&count); err != nil {
|
||||
var pathError *os.PathError
|
||||
if errors.As(err, &pathError) {
|
||||
t.Fatalf("测试连接的文件访问失败:操作=%s 文件=%s", pathError.Op, filepath.Base(pathError.Path))
|
||||
}
|
||||
t.Fatalf("测试库查询失败,错误类型:%T", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatal("测试数据库必须为空,拒绝复用现有业务数据")
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, "CREATE TABLE sentinel (id INTEGER PRIMARY KEY)"); err != nil {
|
||||
t.Fatal("隔离哨兵表创建失败")
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, "INSERT INTO sentinel(id) VALUES (731)"); err != nil {
|
||||
t.Fatal("隔离哨兵数据写入失败")
|
||||
}
|
||||
names := []string{"ordinary", "with space", "mail`prod", `mail"prod`,
|
||||
"quote`; DROP TABLE sentinel; --", `quote"; DROP TABLE sentinel; --`, "中文表名"}
|
||||
for _, name := range names {
|
||||
quoted := quoteDatabaseIdentifier(kind, name)
|
||||
if _, err := database.ExecContext(ctx, "CREATE TABLE "+quoted+" (id INTEGER PRIMARY KEY)"); err != nil {
|
||||
t.Fatal("真实数据库未能按单个标识符创建测试表")
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, "INSERT INTO "+quoted+" (id) VALUES (17)"); err != nil {
|
||||
t.Fatal("真实数据库未能按单个标识符写入测试表")
|
||||
}
|
||||
var actual int
|
||||
if database.QueryRowContext(ctx, "SELECT id FROM "+quoted).Scan(&actual) != nil || actual != 17 {
|
||||
t.Fatal("真实数据库标识符引用未保留数据")
|
||||
}
|
||||
if database.QueryRowContext(ctx, "SELECT id FROM sentinel").Scan(&actual) != nil || actual != 731 {
|
||||
t.Fatal("恶意标识符影响了隔离哨兵表")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -24,7 +24,7 @@
|
||||
- [✓] 前端生产依赖审计、静态检查、全量自动化和全量竞态回归。
|
||||
- [✓] 四个 DNS 工具移除明文云密钥,新增脱敏扫描器及持续集成门禁。
|
||||
- [!] 历史云密钥、服务器凭据的轮换、访问审查和泄露处置没有完成证明。
|
||||
- [!] 静态安全扫描尚未逐条完成修复或可审计排除;Windows 初轮 61 条、Linux 目标平台前轮 44 条,不能生产放行。
|
||||
- [!] 静态安全扫描尚未逐条完成修复或可审计排除;Windows 初轮 61 条、最新 Linux 目标平台扫描 44 条,其中 7 条已有专项复核证据,不能生产放行。
|
||||
- [✓] SQLite、MySQL、PostgreSQL 各自从空库完成首次安装、真实配置历史写入及邮件业务闭环。
|
||||
- [✓] 新候选在三种数据库下验证协议码和客户端码撤销:三协议旧连接操作及新登录均被拒绝。
|
||||
- [✓] HTTP 退出后原 Cookie 重放、账号禁用及修改密码后的旧会话拒绝;浏览器操作另行验收。
|
||||
@ -181,6 +181,23 @@ IMAP 相关转换优先复核位置:`services/list/imap_search.go:107`,`list
|
||||
|
||||
**61 条告警不是 61 个已确认可利用漏洞,但当前扫描门禁确实未通过。** 本轮没有关闭规则、添加批量忽略或修改扫描退出码;不能用单元测试通过覆盖这个阻塞。
|
||||
|
||||
### 最新候选的 Linux 目标扫描与专项复核
|
||||
|
||||
北京时间 2026 年 9 月 21 日 02:35,最新候选应用代码的扫描日志结束;进程退出码为 1。此次是在 Windows 主机设置 `GOOS=linux`、`GOARCH=amd64`、`CGO_ENABLED=0` 后执行,不是云端原生 Linux 扫描。
|
||||
|
||||
- 扫描 345 个文件、56,780 行;44 条告警,高 5、中 37、低 2;Go 包加载错误为零,现有 `nosec` 数量为 5。
|
||||
- 分布:G115 5、G202 5、G204 1、G304 20、G302 8、G306 2、G505 1、G104 2。
|
||||
- 原始输出为 `test-log/production-remediation-20260921/gosec-revocation-linux.log`;工具将 JSON 写入日志末尾,随后完整提取为同目录的 `gosec-revocation-linux.json`,不是另一次扫描。
|
||||
- Windows 初轮与 Linux 目标报告的平台和代码基线不同,不能将 61 与 44 的差值解释为修复了 17 个漏洞。
|
||||
|
||||
新增两份回归测试,只扩展验收,不修改应用运行逻辑。云端 `023702` 专项在全新 MySQL、PostgreSQL 隔离库验证每库 7 个特殊表名的真实建表、写入、读取和哨兵表保留;另验证 20 组引用边界、25 组非法外键动作后缀,以及插件 UID/GID 的 24 组边界输入。最终两项测试程序均退出零,没有跳过,服务峰值分别为 9.6 MiB、20.7 MiB。
|
||||
|
||||
该专项为五条 G202 和两条插件 G115 提供源码与反例证据;没有新增规则忽略,原始扫描仍为 44 条、退出 1。其余 37 条尚未关闭,完整范围、失败记录和证据边界见 `静态安全告警复核记录.md`。
|
||||
|
||||
测试执行中遇到私有临时目录隐藏二进制、PostgreSQL 环境变量名称不一致、驱动尝试访问被隔离的默认客户端证书三个问题,失败和跳过日志全部保留,不计为通过。最终使用测试目录只读映射和私有临时目录内明确不存在的客户端证书路径,保留 `sslmode=require`,没有开放 `/opt/smail` 或 `/run/smail`。
|
||||
|
||||
本地证据位于 `test-log/production-remediation-20260921/security-evidence/`,十份下载文件的摘要在 `index.json`。最终数据库账号全部锁定,测试单元无运行进程,正式服务及其他五个保护服务没有变化。
|
||||
|
||||
## 发现的问题及未关闭门禁
|
||||
|
||||
### 9 月 21 日数据库与连接撤销整改
|
||||
|
||||
98
docs/静态安全告警复核记录.md
Normal file
98
docs/静态安全告警复核记录.md
Normal file
@ -0,0 +1,98 @@
|
||||
# 静态安全告警复核记录
|
||||
|
||||
## 结论与范围
|
||||
|
||||
本记录对应应用候选 `8899e74`,验收工具基线为 `ddc9652`。本次只增加测试,不改变应用运行逻辑,不替换正式程序。
|
||||
|
||||
**原始安全扫描仍未通过,不代表已发现 44 个可利用漏洞,也不代表整个项目审计完成。**
|
||||
|
||||
- [✓] 最新 Linux 目标平台扫描完成并保留原始输出。
|
||||
- [✓] 五条 SQL 拼接告警和两条插件身份转换告警已有本轮源码复核及专项实机证据。
|
||||
- [ ] 其余 37 条逐项整改或形成足以复审的排除依据。
|
||||
- [!] 未增加扫描抑制、降低等级或更改退出码;本轮证据不自动等同于生产放行。
|
||||
|
||||
## 扫描基线
|
||||
|
||||
北京时间 2026 年 9 月 21 日,Windows 主机执行 Linux/amd64、禁用 CGO 的目标平台扫描。本轮再次从可执行文件的 Go 模块构建信息核对工具版本为 `gosec v2.22.10`,报告展示版本仍为 `dev`;这不是云端原生执行的扫描。
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| 扫描文件、行数 | 345、56,780 |
|
||||
| 告警 | 44;高 5、中 37、低 2 |
|
||||
| Go 包加载错误 | 0 |
|
||||
| 原有 `nosec` | 5;本次没有增加 |
|
||||
| 退出码 | 1 |
|
||||
| 规则分布 | G115 5、G202 5、G204 1、G304 20、G302 8、G306 2、G505 1、G104 2 |
|
||||
|
||||
原始文件:`test-log/production-remediation-20260921/gosec-revocation-linux.log`。
|
||||
JSON 从上述日志末尾完整提取至同目录 `gosec-revocation-linux.json`;不能把提取过程描述为重新运行或扫描通过。
|
||||
|
||||
## 已形成专项证据的七条告警
|
||||
|
||||
下表行号相对当前候选的 `backend/`。结论限于所列风险和代码路径,不涵盖整个迁移事务、所有输入来源、并发一致性或插件隔离能力。
|
||||
|
||||
| 规则与位置 | 源码复核依据 | 反例证据与边界 |
|
||||
| --- | --- | --- |
|
||||
| G202 `services/dbmigration/sql_executor.go:239` | `SHOW FULL TABLES FROM` 的数据库名称经过 `quoteMySQLIdentifier`;引用内的反引号成对转义 | 引用函数边界测试、真实 MySQL 特殊表名读写;不是所有数据库命名规则的完整验收 |
|
||||
| G202 `services/dbmigration/sql_executor.go:278` | 源和目标数据库、表名分别引用,再拼接固定的 `INSERT INTO` 和 `SELECT` 语法 | 带结束引号、分号和删除语句的名称仍作为单一标识符,哨兵表保留 |
|
||||
| G202 `services/dbmigration/sql_executor.go:365` | 数据库、表、约束、列逐项引用;外键动作由 `listMySQLForeignKeys` 调用白名单校验后返回 | 25 组带分号、注释、空字符或额外动作的后缀全部拒绝;未执行非法外键 DDL |
|
||||
| G202 `services/dbmigration/portable_data.go:192` | 只有经过引用的表名参与拼接;序列值通过 `$1::regclass` 绑定,不拼接序列内容 | PostgreSQL 真实标识符测试通过;这不是对所有序列数值边界的验证 |
|
||||
| G202 `services/dbmigration/portable_data.go:237` | 表名经过 PostgreSQL 标识符引用;序列名称和计数分别通过 `$1`、`$2` 绑定 | PostgreSQL 引号、分号及中文表名实机通过;不替代完整迁移与故障注入验收 |
|
||||
| G115 `hooks/plugin_isolation_linux.go:97`,UID 转换 | 转换值仅来自 `pluginIdentity`,其返回前要求解析成功且介于 1 与 65534 | UID 的 12 组输入覆盖合法边界、零、负数、上界外、溢出及非数字 |
|
||||
| G115 `hooks/plugin_isolation_linux.go:97`,GID 转换 | 与 UID 使用相同校验函数,错误时在构建进程凭据之前返回 | GID 独立执行同样 12 组输入;不是整个插件沙箱的安全证明 |
|
||||
|
||||
这些证据支持“当前列出的路径已有约束,不能直接认定可利用注入或截断”。原始扫描没有消除这七条;正式排除仍需按清单复审,禁止添加整文件或整规则忽略。
|
||||
|
||||
## 尚未关闭的三十七条
|
||||
|
||||
| 规则 | 数量 | 位置及待办 |
|
||||
| --- | --- | --- |
|
||||
| G115 | 3 | 管理员恢复的 `transport_unix.go:89`、`invocation_linux.go:50`、`terminal_linux.go:72`;分别复核有效 UID、终端设备号和文件描述符转换。不能直接把有符号终端设备号改为拒绝负数 |
|
||||
| G204 | 1 | `services/adminreset/password_linux.go:86`;复核外部进程名称、参数和执行环境的来源及调用边界 |
|
||||
| G304 | 20 | 运行目录 2、手动 SSL 2、迁移 1、管理员恢复 2、发布 4、插件隔离 4、配置与证书存储 5;逐项核对可信目录、符号链接、权限和竞态,不以路径来自配置为安全依据 |
|
||||
| G302 | 8 | 运行目录 2、管理员控制目录 1、插件目录及可执行文件 3、配置和 SSL 存储 2;区分目录执行位、二进制执行位与普通敏感文件,不能统一改成 `0600` |
|
||||
| G306 | 2 | 发布清单与内嵌插件写入权限;需要复核实际文件种类、父目录以及现有文件权限 |
|
||||
| G505 | 1 | DNS 签名代码的 SHA-1 导入;需核对具体协议用途与官方兼容要求,不擅自替换签名算法,也不将其视为密码哈希使用 |
|
||||
| G104 | 2 | `services/adminreset/invocation_linux.go:54,81`;复核终端文件关闭失败的处理与恢复边界 |
|
||||
|
||||
完整位置、告警代码片段、置信度以原始 JSON 为准。上述分组不是批量排除结论。
|
||||
|
||||
## 实机执行与证据
|
||||
|
||||
新增测试文件:
|
||||
|
||||
- `backend/services/dbmigration/identifier_security_test.go`
|
||||
- `backend/hooks/plugin_identity_linux_test.go`
|
||||
|
||||
本地数据库迁移包执行 `node test/run.mjs backend -race -count=1 -p 1 ./services/dbmigration` 两轮通过。初轮日志为 `test-log/run-2026-09-20T18-29-14-906Z-25388/`;补充脱敏错误分类后最终轮为 `test-log/run-2026-09-20T18-41-49-136Z-34592/`,耗时 9.604 秒。本地没有专用实库连接的用例会跳过,不计为实机通过。
|
||||
|
||||
交叉编译后的 Linux 测试程序上传云服务器,逐一校验 SHA-256,再以非特权 `smail` 身份、192 MiB 内存上限、禁用交换空间、50% CPU 配额和 120 秒运行上限执行。使用全新空数据库和随机专用账号,不改写正式数据库;凭据只通过管道与子进程环境传递,不写入证据文件或命令行。
|
||||
|
||||
最终实机证据:`/var/tmp/smail-review-security-20260921/20260921-023702/`。
|
||||
|
||||
| 检查 | 实际结果 |
|
||||
| --- | --- |
|
||||
| SQL 引用边界 | 两种引用方式共 20 组输入通过 |
|
||||
| 非法外键动作后缀 | 五种合法动作分别追加五种非法后缀,25 组拒绝断言通过 |
|
||||
| MySQL | 7 个真实表名分别建表、写入、读取,哨兵数据始终为预设值 |
|
||||
| PostgreSQL | 独立空库执行相同 7 组真实读写与哨兵检查 |
|
||||
| 插件身份 | UID/GID 合计 24 组通过 |
|
||||
| 跳过、退出码 | 两项正式专项均无跳过、退出零;日志分别含 25 条 PASS 记录,含父测试,不能称为各 25 个顶层测试 |
|
||||
| 资源峰值 | 数据库专项 9.6 MiB,插件专项 20.7 MiB;不用于推导生产容量 |
|
||||
| 收尾 | 专用 SQL 账号锁定,无运行中的专项单元;正式服务及其他五个保护服务进程标识保持不变 |
|
||||
|
||||
最终测试二进制:
|
||||
|
||||
- 数据库 `identifier-db-v2.test`:`e57e771b236e6941cef292a219a3f09a47e89fda65d3083b7de7856b40aeb20e`。
|
||||
- 插件 `identifier-hooks.test`:`23fa5b14524c02ec49bde341d7f6886b6b58cf56387db03281968c19aa9e83ea`。
|
||||
|
||||
十份有效下载文件位于 `test-log/production-remediation-20260921/security-evidence/`,摘要在 `index.json`,包括失败记录,不能全部计作通过证据。
|
||||
|
||||
## 保留的失败与环境修正
|
||||
|
||||
1. `023145`:`PrivateTmp=yes` 隐藏了 `/var/tmp` 下测试程序,业务测试未启动。增加仅测试目录的只读映射后重跑,没有放宽正式目录隔离。
|
||||
2. `023255`:数据库辅助工具使用 `postgres`,配置类型为 `postgresql`,环境变量未对应,PostgreSQL 用例跳过。外层验收因存在跳过而失败,随后修正工具侧映射;没有把退出零当成全部通过。
|
||||
3. `023355`:PostgreSQL 驱动尝试探测服务用户默认客户端证书,但其主目录 `/opt/smail` 按设计不可访问。初始测试错误把查询失败与非空数据库合并显示;现区分文件访问失败、查询失败与非空库,错误信息不输出凭据。
|
||||
4. `023702/homeguard.log`:同一隔离条件下单独重现 `stat postgresql.crt` 失败。正式专项将可选客户端证书路径设置为私有临时目录中明确不存在的文件,保留 TLS `sslmode=require`,不访问正式证书、不禁用 TLS;随后两种数据库均通过。
|
||||
|
||||
`homeguard` 是预期失败的环境反例,不是数据库正向通过。仍保留失败单元状态供复核,没有运行中的测试进程,也没有重置或隐藏系统失败记录。
|
||||
Loading…
Reference in New Issue
Block a user