diff --git a/backend/services/adminreset/doctor_linux.go b/backend/services/adminreset/doctor_linux.go index 1cbf740..82d8205 100644 --- a/backend/services/adminreset/doctor_linux.go +++ b/backend/services/adminreset/doctor_linux.go @@ -9,6 +9,8 @@ import ( "strings" ) +const systemctlPath = "/usr/bin/systemctl" + // AuthorizeCurrentProcess 只允许部署管理员启动正式重置流程。 func AuthorizeCurrentProcess() error { if !authorizeLinuxProcess(os.Geteuid(), os.Getpid(), os.Getppid(), os.Getenv("INVOCATION_ID"), os.Getenv("SYSTEMD_EXEC_PID")) || !authorizedSystemdStartJob() { @@ -21,13 +23,17 @@ func AuthorizeCurrentProcess() error { } func authorizedSystemdStartJob() bool { - output, err := exec.Command("systemctl", "list-jobs", "--no-legend", "--plain", "--no-pager").Output() + output, err := systemdJobCommand().Output() if err != nil { return false } return hasAuthorizedStartJob(string(output)) } +func systemdJobCommand() *exec.Cmd { + return exec.Command(systemctlPath, "list-jobs", "--no-legend", "--plain", "--no-pager") +} + func hasAuthorizedStartJob(output string) bool { for _, line := range strings.Split(output, "\n") { fields := strings.Fields(line) @@ -63,7 +69,7 @@ func validInvocationID(value string) bool { // CheckEnvironment 读取 Linux 管理程序的非敏感运行状态。 func CheckEnvironment() EnvironmentReport { - _, err := exec.LookPath("systemd-ask-password") + _, err := exec.LookPath(systemdInputAgentPath) serviceManaged := os.Getppid() == 1 && validInvocationID(os.Getenv("INVOCATION_ID")) && authorizedSystemdStartJob() _, callerErr := findStartCaller() return EnvironmentReport{ diff --git a/backend/services/adminreset/invocation_linux.go b/backend/services/adminreset/invocation_linux.go index a0b38e1..a135e4f 100644 --- a/backend/services/adminreset/invocation_linux.go +++ b/backend/services/adminreset/invocation_linux.go @@ -5,6 +5,7 @@ package adminreset import ( "context" "fmt" + "math" "os" "path/filepath" "strconv" @@ -47,11 +48,11 @@ func OpenInvocation(parent context.Context) (context.Context, func(), error) { file := os.NewFile(uintptr(fd), "smail-admin-terminal") var info unix.Stat_t err = unix.Fstat(fd, &info) - device := uint32(caller.terminal) - expectedDevice := unix.Mkdev((device>>8)&0xfff, (device&0xff)|((device>>12)&0xfff00)) + expectedDevice, deviceErr := terminalDevice(caller.terminal) // 服务不属于调用终端的会话,不能使用 TIOCGPGRP;前台关系由 /proc 校验。 - if err != nil || info.Rdev != expectedDevice || !term.IsTerminal(fd) { - file.Close() + if err != nil || deviceErr != nil || info.Rdev != expectedDevice || !term.IsTerminal(fd) { + // 关闭只释放终端描述符;授权失败始终返回,不因关闭错误放行。 + _ = file.Close() return nil, nil, ErrUnauthorizedPeer } ctx, cancel := context.WithCancel(parent) @@ -78,10 +79,27 @@ func OpenInvocation(parent context.Context) (context.Context, func(), error) { return ctx, func() { cancel() <-done - file.Close() + // 终端不缓存业务写入;取消并等待监视器退出后释放描述符。 + _ = file.Close() }, nil } +func terminalDevice(raw int) (uint64, error) { + if raw < math.MinInt32 || raw > math.MaxInt32 { + return 0, ErrUnauthorizedPeer + } + // /proc 的 tty_nr 是有符号的 32 位编码;负值的高位仍属于合法设备号。 + encoded := int64(raw) + if encoded < 0 { + encoded += 1 << 32 + } + if encoded < 0 || encoded > math.MaxUint32 { + return 0, ErrUnauthorizedPeer + } + device := uint32(encoded) + return unix.Mkdev((device>>8)&0xfff, (device&0xff)|((device>>12)&0xfff00)), nil +} + func findStartCaller() (callerIdentity, error) { entries, err := os.ReadDir("/proc") if err != nil { diff --git a/backend/services/adminreset/numeric_security_linux_test.go b/backend/services/adminreset/numeric_security_linux_test.go new file mode 100644 index 0000000..1c7b37a --- /dev/null +++ b/backend/services/adminreset/numeric_security_linux_test.go @@ -0,0 +1,96 @@ +//go:build linux + +package adminreset + +import ( + "context" + "errors" + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "golang.org/x/sys/unix" +) + +func TestTerminalDeviceSignedEncodingBoundaries(t *testing.T) { + tests := []struct { + raw int + major uint32 + minor uint32 + }{ + {0, 0, 0}, + {34817, 136, 1}, + {math.MaxInt32, 4095, 524287}, + {math.MinInt32, 0, 524288}, + {-1, 4095, 1048575}, + } + for _, test := range tests { + t.Run(strconv.Itoa(test.raw), func(t *testing.T) { + device, err := terminalDevice(test.raw) + if err != nil || device != unix.Mkdev(test.major, test.minor) { + t.Fatalf("终端设备编码错误:%x,%v", device, err) + } + }) + } + if strconv.IntSize == 64 { + for _, raw := range []int64{int64(math.MinInt32) - 1, int64(math.MaxInt32) + 1, math.MaxInt64, math.MinInt64} { + if _, err := terminalDevice(int(raw)); !errors.Is(err, ErrUnauthorizedPeer) { + t.Fatalf("越界终端设备号未拒绝:%d", raw) + } + } + } +} + +func TestTerminalPollDescriptorRejectsTruncation(t *testing.T) { + for _, fd := range []uintptr{0, 1, math.MaxInt32} { + actual, err := terminalPollDescriptor(fd) + if err != nil || uint64(actual) != uint64(fd) { + t.Fatalf("合法描述符转换失败:%d,%v", fd, err) + } + } + for _, fd := range []uintptr{math.MaxInt32 + 1, math.MaxUint32, ^uintptr(0)} { + if _, err := terminalPollDescriptor(fd); !errors.Is(err, ErrUnauthorizedPeer) { + t.Fatalf("越界描述符未拒绝:%d", fd) + } + } +} + +func TestTerminalStreamRejectsClosedDescriptor(t *testing.T) { + file, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + stream := &terminalStream{ctx: context.Background(), terminal: &invocationTerminal{file: file}} + if _, err := stream.Read(make([]byte, 1)); !errors.Is(err, ErrUnauthorizedPeer) { + t.Fatalf("已关闭描述符未拒绝:%v", err) + } +} + +func TestProtectedCommandsIgnoreSearchPath(t *testing.T) { + directory := t.TempDir() + for _, name := range []string{"systemctl", "systemd-ask-password"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("#!/bin/sh\nexit 91\n"), 0700); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", directory) + for _, name := range []string{"systemctl", "systemd-ask-password"} { + resolved, err := exec.LookPath(name) + if err != nil || resolved != filepath.Join(directory, name) { + t.Fatal("PATH 劫持反例未正确构造") + } + } + command := systemdInputCommand(context.Background(), "--no-tty", "--echo=no", "$(id);提示") + if command.Path != systemdInputAgentPath || command.Args[3] != "$(id);提示" { + t.Fatal("密码代理路径或原样参数不符合约束") + } + if command := systemdJobCommand(); command.Path != systemctlPath || len(command.Args) != 5 { + t.Fatal("systemctl 来源被搜索路径替换") + } +} diff --git a/backend/services/adminreset/password_linux.go b/backend/services/adminreset/password_linux.go index d87c088..d2faf25 100644 --- a/backend/services/adminreset/password_linux.go +++ b/backend/services/adminreset/password_linux.go @@ -14,6 +14,8 @@ import ( "git.pcux.cn/xuans/Smail/utils/password" ) +const systemdInputAgentPath = "/usr/bin/systemd-ask-password" + func ReadProtectedPassword() (string, error) { return ReadProtectedPasswordContext(context.Background()) } @@ -83,7 +85,7 @@ func readSystemdInputContext(ctx context.Context, prompt, echo string, options . // systemd 代理把提示保存在单行配置中,用终端换行控制显示列表,避免配置被拆行。 prompt = strings.ReplaceAll(strings.ReplaceAll(prompt, "\r\n", "\n"), "\n", "\x1bE") args := append([]string{"--no-tty", "--echo=" + echo}, options...) - command := exec.CommandContext(ctx, "systemd-ask-password", append(args, prompt)...) + command := systemdInputCommand(ctx, append(args, prompt)...) // 先允许代理清理请求文件,避免取消后继续弹出已经失效的输入提示。 command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) } command.WaitDelay = time.Second @@ -98,6 +100,11 @@ func readSystemdInputContext(ctx context.Context, prompt, echo string, options . return value, nil } +func systemdInputCommand(ctx context.Context, args ...string) *exec.Cmd { + // 管理员流程不能从调用环境的 PATH 解析高权限密码代理。 + return exec.CommandContext(ctx, systemdInputAgentPath, args...) +} + func stripPasswordLineEnding(value []byte) []byte { if len(value) > 0 && value[len(value)-1] == '\n' { value = value[:len(value)-1] diff --git a/backend/services/adminreset/terminal_linux.go b/backend/services/adminreset/terminal_linux.go index 70ed82a..9ca1e7e 100644 --- a/backend/services/adminreset/terminal_linux.go +++ b/backend/services/adminreset/terminal_linux.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "strings" "golang.org/x/sys/unix" @@ -64,12 +65,15 @@ type terminalStream struct { } func (s *terminalStream) Read(buffer []byte) (int, error) { - fd := int(s.terminal.file.Fd()) + fd, err := terminalPollDescriptor(s.terminal.file.Fd()) + if err != nil { + return 0, err + } for { if err := s.ctx.Err(); err != nil { return 0, err } - fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} + fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} _, err := unix.Poll(fds, 100) if err == unix.EINTR { continue @@ -83,7 +87,7 @@ func (s *terminalStream) Read(buffer []byte) (int, error) { if fds[0].Revents&unix.POLLIN == 0 { continue } - n, err := unix.Read(fd, buffer) + n, err := unix.Read(int(fd), buffer) if err == unix.EAGAIN || err == unix.EINTR { continue } @@ -93,3 +97,10 @@ func (s *terminalStream) Read(buffer []byte) (int, error) { return n, err } } + +func terminalPollDescriptor(fd uintptr) (int32, error) { + if fd > math.MaxInt32 { + return 0, ErrUnauthorizedPeer + } + return int32(fd), nil +} diff --git a/backend/services/adminreset/transport_unix.go b/backend/services/adminreset/transport_unix.go index 8c35ff2..e1cef60 100644 --- a/backend/services/adminreset/transport_unix.go +++ b/backend/services/adminreset/transport_unix.go @@ -86,7 +86,8 @@ func validateControlSocketOwner(info os.FileInfo) error { func validateControlOwner(info os.FileInfo, resource string) error { stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || stat.Uid != uint32(os.Geteuid()) { + uid := os.Geteuid() + if !ok || uid < 0 || uint64(stat.Uid) != uint64(uid) { return fmt.Errorf("%s owner does not match service identity", resource) } return nil diff --git a/docs/生产补充验收记录.md b/docs/生产补充验收记录.md index 0b51523..0556681 100644 --- a/docs/生产补充验收记录.md +++ b/docs/生产补充验收记录.md @@ -24,10 +24,11 @@ - [✓] 前端生产依赖审计、静态检查、全量自动化和全量竞态回归。 - [✓] 四个 DNS 工具移除明文云密钥,新增脱敏扫描器及持续集成门禁。 - [!] 历史云密钥、服务器凭据的轮换、访问审查和泄露处置没有完成证明。 -- [!] 静态安全扫描尚未逐条完成修复或可审计排除;Windows 初轮 61 条、最新 Linux 目标平台扫描 44 条,其中 7 条已有专项复核证据,不能生产放行。 +- [!] 静态安全扫描尚未逐条完成修复或可审计排除;Windows 初轮 61 条、Linux 历史扫描 44 条、管理员恢复整改后 38 条,其中 7 条已有专项复核证据,不能生产放行。 - [✓] SQLite、MySQL、PostgreSQL 各自从空库完成首次安装、真实配置历史写入及邮件业务闭环。 - [✓] 新候选在三种数据库下验证协议码和客户端码撤销:三协议旧连接操作及新登录均被拒绝。 - [✓] HTTP 退出后原 Cookie 重放、账号禁用及修改密码后的旧会话拒绝;浏览器操作另行验收。 +- [✓] 管理员恢复整数边界及系统程序路径整改,云端 Linux 包 41 个顶层测试零跳过通过;新候选三库空数据实机复验通过。 - [ ] 真正应用进程切库重启后的业务验收、故障注入和中断恢复。 - [ ] 最新候选浏览器初始化、账号恢复及完整用户操作闭环。 - [!] Windows 提权本地通道验收需要管理员会话,当前终端未提权。 @@ -284,5 +285,11 @@ Windows 受保护本地通道验收需要提权会话,不得降级成公开 TC ## 本轮收尾边界 +### 9 月 21 日管理员恢复补充 + +详见 `管理员恢复安全补充验收.md`。新主程序摘要为 `ef9d5c20fb2c66b1d499feac9f0b7dc72e51f235366f968e7c9b6ceb05cfb22e`,在 SQLite、MySQL、PostgreSQL 的新空实例中分别通过直接 TLS 回归;配置历史和业务保持同库。缺少测试辅助模块导致的 MySQL 首次失败已保留,补齐模块后另建空库通过。 + +全项目静态复扫实际结果为 38 条,退出码仍为 1;不增加 `nosec`,不以降低六条告警替代生产放行。独立浏览器启动被执行策略拒绝,准备实例和隧道已经停止,既未复用 Scert 浏览器,也没有浏览器通过证据。正式程序、六个保护服务及用户原有报告均保持不变。 + 本轮整理明确列出的应用整改、回归测试、验收工具、持续集成和本记录;原有 `docs/云端全项目测试报告.md` 的用户修改不编辑、不加入本轮提交。 本轮不执行生产版本替换,测试候选与正式实例保持分离。未关闭门禁继续保留,不能将本轮提交或推送成功解释为生产验收通过。 diff --git a/docs/管理员恢复安全补充验收.md b/docs/管理员恢复安全补充验收.md new file mode 100644 index 0000000..3dff727 --- /dev/null +++ b/docs/管理员恢复安全补充验收.md @@ -0,0 +1,95 @@ +# 管理员恢复安全补充验收 + +## 范围与结论 + +本项证据使用服务器及本机的北京时间,记录日期为 2026 年 9 月 21 日。修改前基线为 `9e5496d`,已提交并推送;不覆盖 `docs/云端全项目测试报告.md` 的用户原有修改。 + +**Linux 管理员恢复专项及新候选的三库隔离回归通过,不代表整个项目可生产放行。** 正式实例没有升级。本次测试不开放公网恢复接口,不停止 Scert、共享数据库或其他项目。 + +## 需求与任务状态 + +- [✓] 保持受保护本地 IPC、调用者身份和安全凭据校验。 +- [✓] 显式验证整数转换边界,保留合法的负值终端设备编码。 +- [✓] 管理员流程不从 `PATH` 查找密码代理和 `systemctl`。 +- [✓] 云端完整管理员恢复包、真实伪终端、取消及不回显回归。 +- [✓] 重建前端、三个插件、Linux 主程序及管理员工具,再运行三库空数据验收。 +- [✓] 保留失败记录,停止隔离单元,确认正式服务及二进制未变。 +- [✓] 全项目 Linux 目标平台静态安全复扫完成并复核原始输出;仍有 38 条告警,扫描未通过。 +- [!] 独立浏览器启动受到执行策略阻止,未取得真实浏览器流程证据。 +- [ ] 显式 `systemctl start smail-admin` 的管理员终端完整正向交互仍需单独验收。 +- [!] Windows 提权 IPC、凭据轮换、自动续期、外部收件等原有生产门禁仍未关闭。 + +## 应用改动 + +1. Socket 所有者检查改为先拒绝无效有效 UID,再进行宽类型比较,避免将当前 UID 缩窄后比较。 +2. 终端设备号先限制在有符号 32 位范围,再显式还原其无符号编码并检查转换边界。不能直接拒绝所有负值,否则会破坏合法高位设备号。 +3. `poll` 前验证文件描述符不超过有符号 32 位上界,已关闭文件的无效描述符立即拒绝。 +4. 密码代理固定为 `/usr/bin/systemd-ask-password`,服务任务查询固定为 `/usr/bin/systemctl`。环境诊断也检查同一代理路径,不再从可变 `PATH` 找到另一程序。 +5. 两处终端关闭显式记录忽略返回值的意图:授权失败仍拒绝;正常退出先取消并等待监视器结束,再关闭无业务写入缓冲的终端描述符。这不是修复两个已确认漏洞,也没有新增扫描忽略注释。 + +云服务器上的两个系统程序均属于 root,权限为 `0755`。固定路径是该 Linux/systemd 部署的依赖;其他发行版路径不同时不能无条件声称兼容。 + +## 专项测试证据 + +新增 `backend/services/adminreset/numeric_security_linux_test.go`,验证设备号合法边界及四个越界值、描述符转换与关闭状态、恶意 `PATH` 反例和参数原样传递。PATH 测试验证命令构造,不执行伪造程序,也不触发共享系统密码提示。 + +最终 Linux 测试程序 SHA-256: + +```text +e18337acaec6c34beddd5ca9b0620e673ee5734568727562977a45bde8858fc2 +``` + +云端目录:`/var/tmp/smail-review-security-20260921/20260921-034451/`。 +完整管理员恢复包 **41 个顶层测试通过,零跳过,退出码零**。真实 PTY 测试包括隐藏密码输入、可见输入、取消及 EOF 后恢复终端状态;本地 Socket、恢复码、协议撤销等使用隔离数据,不依赖正式数据库。 + +执行身份为非特权 `smail`,私有网络、私有临时目录、正式数据目录不可访问;内存上限 256 MiB、禁用交换空间、CPU 配额 50%、运行时间上限 300 秒。systemd 输出记录约 43.6 秒、内存峰值 134.3 MiB、交换为零。测试结束后查询单元的 `MemoryPeak` 已为未设置,峰值引用的是执行输出,不伪造持久属性值,也不作为生产容量结论。 + +本地 Windows 共享代码竞态回归 `node test/run.mjs backend -race -count=1 -p 1 ./services/adminreset` 通过,日志为 `test-log/run-2026-09-20T19-43-45-928Z-19356/`;UTC 文件名与北京时间日期不同。该结果不能替代 Windows 提权 IPC 或 Linux 平台文件的执行。 + +新编译管理员工具还在云端直接执行了三个边界检查:无参数非正式调用退出 1,密码参数入口退出 2,版本查询退出 0。没有发起真实重置,不能作为正式管理员交互成功的证据。 + +## 完整候选与三库回归 + +版本为 `review-admin-20260921`。构建时仍处于已审查工作区,内嵌 Git 标识为基线加 `-dirty`,不把它冒充已发布版本。构建记录、应用包摘要和源码逐文件摘要分别保存在 `build-admin-hardened.log`、`admin-build-manifest.json`、`admin-source-manifest.json`。 + +| 产物 | SHA-256 | +| --- | --- | +| 主程序 | `ef9d5c20fb2c66b1d499feac9f0b7dc72e51f235366f968e7c9b6ceb05cfb22e` | +| 管理员工具 | `172ffd558abbbafd0b4fa35c30062ff0fc6d3c5794723061423c4755a2718e48` | + +候选位于 `/var/tmp/smail-review-admin-20260921/`。三轮均从新的空数据实例开始,施加 192 MiB 内存上限,使用同一主程序摘要。 + +| 数据库 | 云端隔离目录后缀 | 结果 | +| --- | --- | --- | +| SQLite | `smail-review-runtime-20260921-034818` | 通过;仅一个 `smail.db`,完整性检查正常 | +| MySQL | `smail-review-runtime-20260921-035045` | 通过;配置历史与业务同库,SQLite 文件零个 | +| PostgreSQL | `smail-review-runtime-20260921-035229` | 通过;配置历史与业务同库,SQLite 文件零个 | + +复验包括初始化、HTTP 会话撤销、协议码及客户端码撤销后的旧连接拒绝、中文邮件与附件读取、IMAP APPEND/EXPUNGE、过载恢复。所有正式服务的 PID、启动标识和正式主程序摘要保持不变;清理及资源采集错误为空;SQL 测试账号已锁定。本轮是直接 TLS 拓扑,不扩大为新候选的 Nginx 正向复验或跨库切换重启验收。 + +本地证据位于 `test-log/production-remediation-20260921/`: + +- `admin-cloud-v2-result.json`、`admin-cloud-v2.log`。 +- `admin-cli-denial-result.json`。 +- `admin-runtime-results.json`、`admin-runtime-retry-results.json`。 +- `admin-runtime-evidence/` 下的逐轮结果、探针及服务日志。 +- `admin-evidence-index.json`:三个通过目录和一个失败目录,共十四份文件的摘要均已复核。 +- `admin-live-final.json`:正式服务及其他五个保护服务的进程标识、正式程序摘要及隔离单元收尾记录。 + +## 静态扫描边界 + +最终包级扫描使用原有 `gosec v2.22.10`,Windows 主机设置 Linux/amd64、禁用 CGO;不是云端原生扫描。管理员恢复包剩余三条:两条 `/proc` 动态读取 G304 和一条控制目录权限 G302,退出码仍为 1。未新增 `nosec`、未降低等级、未改变失败退出码。 + +相对原包的九条,三条 G115、一条 G204 和两条 G104 已不再出现。不能把“减少六条告警”表述为修复六个可利用漏洞。初次改法曾产生两个新的转换告警和一个系统工具路径常量的疑似密钥告警;保留 `gosec-admin-numeric.log`,随后改用显式转换边界及准确的代理路径命名,最终记录为 `gosec-admin-numeric-v2.log`。JSON 均从完整日志提取,不算再次扫描。 + +全项目复扫已经完成:345 个文件、56,823 行,告警 38 条,其中高 2、中 36;包加载错误零,原有 `nosec` 仍为 5,扫描退出码 1。规则分布为 G115 2、G202 5、G304 20、G302 8、G306 2、G505 1。不能把告警减少解释为全项目安全通过。 + +完整原始输出为 `gosec-admin-final-linux.log`,JSON 从该日志完整提取为 `gosec-admin-final-linux.json`,不是另一轮扫描。JSON 的 SHA-256 为 `11e9c277da4da997d23272907c911b56f5e22799fd185b59f2347cd704644c70`。扫描期间应用源码未改动;三十四个管理员恢复源码文件与 `admin-source-manifest.json` 一致。旧七条专项证据仍保留,其余三十一条尚需整改或可审计排除,详见 `静态安全告警复核记录.md`。 + +## 保留失败及浏览器阻塞 + +- MySQL 首轮 `034935`:上传测试工具时缺少 `runtime_sql_bridge.py`,尚未进入业务探针。验收退出 1,保留日志;测试实例已停止、测试账号锁定、正式服务未变。仅补齐原有辅助模块后以新空库重跑通过,未改动应用或放宽隔离。 +- 浏览器准备实例 `smail-review-browser-20260921-025301`:空数据副本与本机回环隧道已创建,但独立浏览器启动命令被执行策略拒绝。没有浏览器初始化、登录刷新或截图通过证据。该实例和隧道随后停止,结果为 `browser-blocked-result.json`。 +- 现有浏览器属于 Scert,没有停止、复用或清理它的配置。本轮未修改系统 DNS、未关闭 TLS 校验;即使后续通过定向隧道浏览,也不能替代公网 DNS 验收。 + +以上记录只收口本项实际完成的整改和证据,整体验收状态仍为未通过。 diff --git a/docs/静态安全告警复核记录.md b/docs/静态安全告警复核记录.md index 4481cd2..020af94 100644 --- a/docs/静态安全告警复核记录.md +++ b/docs/静态安全告警复核记录.md @@ -2,16 +2,17 @@ ## 结论与范围 -本记录对应应用候选 `8899e74`,验收工具基线为 `ddc9652`。本次只增加测试,不改变应用运行逻辑,不替换正式程序。 +本记录保留应用候选 `8899e74`、验收工具基线 `ddc9652` 的历史扫描,并补充基线 `9e5496d` 上管理员恢复整改后的候选。前一项只增加测试,后一项包含应用整改;均未替换正式程序。 -**原始安全扫描仍未通过,不代表已发现 44 个可利用漏洞,也不代表整个项目审计完成。** +**原始安全扫描仍未通过,历史基线 44 条、管理员恢复整改后 38 条;告警数不等于可利用漏洞数,也不代表整个项目审计完成。** - [✓] 最新 Linux 目标平台扫描完成并保留原始输出。 - [✓] 五条 SQL 拼接告警和两条插件身份转换告警已有本轮源码复核及专项实机证据。 -- [ ] 其余 37 条逐项整改或形成足以复审的排除依据。 +- [✓] 管理员恢复专项已整改并取得云端回归证据;全项目减少六条告警,未增加扫描抑制。 +- [ ] 当前其余 31 条逐项整改或形成足以复审的排除依据。 - [!] 未增加扫描抑制、降低等级或更改退出码;本轮证据不自动等同于生产放行。 -## 扫描基线 +## 历史扫描基线 北京时间 2026 年 9 月 21 日,Windows 主机执行 Linux/amd64、禁用 CGO 的目标平台扫描。本轮再次从可执行文件的 Go 模块构建信息核对工具版本为 `gosec v2.22.10`,报告展示版本仍为 `dev`;这不是云端原生执行的扫描。 @@ -27,6 +28,21 @@ 原始文件:`test-log/production-remediation-20260921/gosec-revocation-linux.log`。 JSON 从上述日志末尾完整提取至同目录 `gosec-revocation-linux.json`;不能把提取过程描述为重新运行或扫描通过。 +## 管理员恢复整改后的全项目复扫 + +北京时间 2026 年 9 月 21 日 03:47 至 04:16 左右,使用同一工具和 Linux/amd64 目标平台重新扫描。应用源码在扫描期间保持不变;候选主程序和云端三库回归见 `管理员恢复安全补充验收.md`。 + +| 项目 | 结果 | +| --- | --- | +| 扫描文件、行数 | 345、56,823 | +| 告警 | 38;高 2、中 36 | +| Go 包加载错误 | 0 | +| 原有 `nosec` | 5;未增加 | +| 扫描退出码 | 1 | +| 规则分布 | G115 2、G202 5、G304 20、G302 8、G306 2、G505 1 | + +原始输出为 `test-log/production-remediation-20260921/gosec-admin-final-linux.log`,完整提取 JSON 为同名 `.json`。管理员恢复的三条 G115、一条 G204 和两条 G104 已不再出现;其中关闭错误的显式忽略仅澄清意图,不能宣传为修复了两个已确认漏洞。旧七条专项证据仍有效,但扫描本身没有消除它们;其余三十一条仍未关闭。 + ## 已形成专项证据的七条告警 下表行号相对当前候选的 `backend/`。结论限于所列风险和代码路径,不涵盖整个迁移事务、所有输入来源、并发一致性或插件隔离能力。 @@ -43,7 +59,9 @@ JSON 从上述日志末尾完整提取至同目录 `gosec-revocation-linux.json` 这些证据支持“当前列出的路径已有约束,不能直接认定可利用注入或截断”。原始扫描没有消除这七条;正式排除仍需按清单复审,禁止添加整文件或整规则忽略。 -## 尚未关闭的三十七条 +## 历史待办三十七条及当前状态 + +下表保留旧扫描位置以便追溯。G115 三条、G204 一条、G104 两条已在管理员恢复专项中处理;当前仍需逐项复核的是 G304、G302、G306、G505,共三十一条。管理员恢复两条 G304 的新位置为 `invocation_linux.go:123`、`:135`。 | 规则 | 数量 | 位置及待办 | | --- | --- | --- |