# Linux Host Hardening Checks (Phase 7e) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extend `agentsec server-audit` from "OpenClaw application layer only" to "OS host layer too" by adding 4 CIS-style Linux hardening Checks: kernel sysctl baseline, sshd_config baseline, sudoers risky-pattern scan, and SUID/SGID whitelist drift. Each Check is profile-gated to `linux` / `linux_user_mode` (macOS marks `not_applicable`), reads a shared `linux_hardening_baseline.yaml`, and emits one Finding per individual violation.

**Architecture:** Same Check pattern as the existing 10 OpenClaw Checks — subclass `Check`, declare `id`, set `_REQUIRED_PATHS = ()` since these are OS-level (no OpenClaw paths), implement `async run(ctx) -> list[Finding]`. Each Check invokes a small set of SSH commands routed through `CommandPolicy.validate(argv)`; new SSH policy entries are added in lockstep with each Check. The shared baseline YAML lives at `src/agentsec/audit/checks/linux_hardening_baseline.yaml` and contains four top-level sections (`sysctl` / `sshd` / `sudoers` / `suid`) — each Check loads only its own section at module-init time, mirroring `config_audit.py`'s precedent.

**Tech Stack:** Python 3.12, pytest, Pydantic v2, pyyaml, ruff, pyright (existing repo conventions). No new runtime dependencies.

---

## Out of scope for 7e

- World-writable file scan (`find / -xdev -perm /002`) — defers to 7e.1 because `find /` from root needs a different `allowed_paths` strategy.
- auditd rule audit — defers to 7e.1; auditd parsing is its own subproject.
- Firewall (iptables / nftables / ufw) — defers; multiple backends, each with different argv and parser.
- systemd unit enumeration — defers; covered partially by `process_forensics`.

These are listed as `[ ]` follow-ups in ROADMAP after 7e lands.

---

## File structure

| File | Status | Responsibility |
|------|--------|----------------|
| `docs/adr/0008-linux-host-hardening-checks.md` | Create | Decision record: check decomposition, baseline shape, profile-gating, SSH policy approach. |
| `src/agentsec/audit/checks/linux_hardening_baseline.yaml` | Create | Source-of-truth for "secure" kernel/sshd/sudoers/SUID values. Each section is consumed by exactly one Check. |
| `src/agentsec/audit/checks/sysctl_audit.py` | Create | `SysctlAuditCheck` — runs `sysctl -n <key1> <key2> ...` once, compares each against baseline. |
| `src/agentsec/audit/checks/sshd_config_audit.py` | Create | `SshdConfigAuditCheck` — runs `sshd -T` (effective config including defaults), parses `key value` lines, compares against baseline. |
| `src/agentsec/audit/checks/sudoers_audit.py` | Create | `SudoersAuditCheck` — `cat /etc/sudoers` + each file in `/etc/sudoers.d/`, scans for forbidden regex patterns (NOPASSWD ALL etc.). |
| `src/agentsec/audit/checks/suid_audit.py` | Create | `SuidAuditCheck` — `find /usr/bin -maxdepth 1 -type f -perm /4000` and same for `/usr/sbin`, compares against expected-whitelist baseline. |
| `src/agentsec/audit/ssh_policy.yaml` | Modify | Add: `sysctl` (with `-n <key>` form), `sshd` (`-T` only), expand `cat` `allowed_paths` to include `/etc/sudoers`, `/etc/sudoers.d` and add `/etc/sudoers.d/*` listing via `find`. Add `/usr/bin` and `/usr/sbin` to `allowed_paths`. |
| `src/agentsec/cli.py` | Modify | `server_audit` and `_evaluate_server` register the 4 new Checks alongside the existing 10. |
| `tests/test_sysctl_audit.py` | Create | Unit tests — stubbed executor, baseline-violation cases. |
| `tests/test_sshd_config_audit.py` | Create | Unit tests — sshd -T output fixtures, missing-binary fallback. |
| `tests/test_sudoers_audit.py` | Create | Unit tests — sudoers/sudoers.d fixtures, regex pattern catches. |
| `tests/test_suid_audit.py` | Create | Unit tests — find output fixtures, whitelist drift. |
| `tests/integration/test_openclaw_mock.py` | (no change) | Mock fixture's stubbed SSH only covers OpenClaw commands; new checks return `not_applicable` (uname is "Linux" but stub returns no sysctl data) — confirm in Task 9. |
| `docs/samples/report-2026-04-28/server-audit-report.md` | Modify (drift refresh) | New `<!-- check:sysctl_audit ... -->` etc. machine-anchors land. |
| `ROADMAP.md` | Modify | Mark 7e complete. |
| `CHANGELOG.md` | Modify | `[Unreleased]` entry. |
| `README.md` | Modify | "服务端审计（10 类 Check）" line bumps to 14. |

**Total new files: 9. Total modified existing: 5.**

---

## Self-review

1. **Spec coverage**: Each of the 4 Checks is implemented in its own task (Tasks 3–6). Baseline data + SSH policy + ADR + CLI registration + sample refresh + docs are dedicated tasks. No requirement is unmapped.
2. **Placeholder scan**: No TBD / TODO / "fill in later" strings in the plan body.
3. **Type consistency**: The four new Check class names (`SysctlAuditCheck`, `SshdConfigAuditCheck`, `SudoersAuditCheck`, `SuidAuditCheck`) and their `id` attributes (`sysctl_audit`, `sshd_config_audit`, `sudoers_audit`, `suid_audit`) match across plan tasks and the CLI registration step.

---

### Task 1: ADR-0008 — Linux host hardening Check architecture

**Files:**
- Create: `docs/adr/0008-linux-host-hardening-checks.md`

- [ ] **Step 1: Write the ADR**

```markdown
# ADR-0008: Linux 主机加固 Check 架构

**状态**: 已采纳
**日期**: 2026-05-08
**参与者**: roger

## 背景

`agentsec server-audit` 现有 10 类 Check 全部聚焦 OpenClaw 应用层
（`native_audit` / `config_audit` / `version_patch` / `active_test` /
`exposure_scan` / `filesystem` / `process_forensics` /
`credential_audit` / `plugin_static` / `log_review`）。但「目标机器
是否安全」远不止 OpenClaw 进程本身 —— OS 层面是否打了补丁、
sysctl 是否合规、sshd 是否禁了 root 登录、sudoers 是否过宽、SUID
binary 是否被植入了非白名单条目，这些 CIS-级别的主机加固问题对
真实部署的安全姿态同样关键。

Phase 7e 把 server-audit 的 Check 生态从「应用层」扩到「主机
层」，覆盖 4 类 Linux 主机加固检查。

## 决策

新增 4 个 Check 类，全部遵循现有 `Check` ABC 模式：

1. **`SysctlAuditCheck` (`sysctl_audit`)** —
   `sysctl -n <key1> <key2> ...` 单次拉取多个 key，按
   baseline 期望值比对。每条违规一个 Finding。
2. **`SshdConfigAuditCheck` (`sshd_config_audit`)** —
   `sshd -T` 拿到有效配置（含默认值），按 baseline 比对。
3. **`SudoersAuditCheck` (`sudoers_audit`)** —
   `cat /etc/sudoers` + 列出 `/etc/sudoers.d/` 下每个文件并
   `cat`，按 baseline 的禁止 regex 模式扫描。
4. **`SuidAuditCheck` (`suid_audit`)** —
   `find /usr/bin -maxdepth 1 -type f -perm /4000` 与 `/usr/sbin`
   同款，比对 baseline 白名单（不在白名单的视为异常）。

四个 Check 共享一个 baseline 文件
`src/agentsec/audit/checks/linux_hardening_baseline.yaml`，按 section
切片（每个 Check 只读自己那一段，等同于
`config_audit.py:config_baseline.yaml` 的关系）。

**平台门槛**：每个 Check 在 `run()` 顶部检查
`ctx.profile.name in ("linux", "linux_user_mode")`；macOS / 未来其它
平台直接 `ctx.status.not_applicable(...)`。host hardening 是 Linux
独有概念，强行在 macOS 上跑只会出现 N/A 噪音。

## 理由

- **粒度选择**：每个违规一个 Finding，与 `config_audit` 一致，让
  报告 §3 详细发现按严重度分组时颗粒度可控。如果做成「整体一个
  finding 列出全部违规」会丢掉 per-key 的 fingerprint 稳定性。
- **baseline 静态化**：第一版用静态 YAML（CIS-aligned 默认值），
  不引入运维侧 override。后续 7e.1 可加 `linux_hardening.yaml`
  式的 ServerAuditConfig 字段，但当前不需要。
- **SSH policy 严格化**：每个新命令都补完整 argv schema 而非
  `allowed_argv` 通配，避免「allowed_argv 给了 sysctl 一个万用
  口子」。
- **Out of scope**：world-writable 扫描、auditd、firewall、
  systemd 服务枚举留作 7e.1 跟进；这一波先稳稳出 4 个高价值
  CIS 项。

## 备选方案

1. **复用 `config_audit` 写一个超大 baseline 把 sysctl/sshd/...
   全塞进去**：*放弃*：4 个 Check 的命令面、错误处理、参数构造
   差异大，硬合在一起会让 `config_audit` 变成 2000 行的怪物。
2. **跑 `lynis` / `oscap` 等现成工具，再 parse 输出**：*放弃*：
   外部工具版本差异大、SSH policy 难收敛（很多 lynis 子命令
   会 fork 子进程做事），跨真机部署成本反而更高；自写 4 个
   Check 反倒可控。
3. **每个 Check 一个独立 baseline 文件**：*放弃*：
   `linux_hardening_baseline.yaml` 单文件好检索，CIS 项之间
   的关联关系（比如「sysctl + sshd + sudoers 的 root 加固
   一致」）也方便 reviewer 一眼看穿。

## 后果

- 真机 server-audit 的 finding 总数会显著上升 —— Lightsail prod
  实跑预计 +20–40 finding（取决于 sysctl 默认配置）。这是预期
  的「Check 生态扩展」效应，不是回归。
- `ssh_policy.yaml` 新增 4 类命令（`sysctl` / `sshd` / `find` 在
  /usr/bin、/usr/sbin / `cat` 在 /etc/sudoers + /etc/sudoers.d）。
  每条都按 v1 严格 argv schema 落地，不放 `allowed_argv` 通配。
- macOS profile 的 server-audit 会看到 4 条新的
  `not_applicable`，sample drift guard 会随之刷新。
- 后续 7e.1 / 7f / 7g 的 Check 可以直接借这条 baseline-YAML +
  CLI-register-list 模式扩展，结构成熟。
```

- [ ] **Step 2: Commit**

```bash
git add docs/adr/0008-linux-host-hardening-checks.md
git commit -m "docs(adr): 0008 — Linux host hardening Check architecture"
```

---

### Task 2: Linux hardening baseline YAML

**Files:**
- Create: `src/agentsec/audit/checks/linux_hardening_baseline.yaml`

- [ ] **Step 1: Write the baseline**

```yaml
# Linux 主机加固基线 —— 4 个 Check 共享。
# CIS-aligned 默认值；任一条违规即一条 Finding。
# 注意：为何 must_equal 总是字符串 —— sysctl/sshd 输出本身就是字符串
# （`0` / `yes` / `2` 等），用字符串比较避免 YAML 把 `2` 解析为 int 后
# 与 sysctl 文本输出 `"2"` 不等的边界问题。

sysctl:
  # 内核信息泄露面收敛 —— dmesg / kallsyms 不该对非特权用户开放。
  - key: kernel.dmesg_restrict
    must_equal: "1"
    severity: medium
    rationale: dmesg 默认对非特权用户可读，泄露内核地址 / 模块加载信息
    remediation: |
      在 /etc/sysctl.d/99-hardening.conf 加 `kernel.dmesg_restrict=1`，
      然后 `sudo sysctl --system`。
  - key: kernel.kptr_restrict
    must_equal: "2"
    severity: medium
    rationale: kallsyms 暴露内核符号地址，绕 KASLR 的踏板
    remediation: |
      在 sysctl.d 配置 `kernel.kptr_restrict=2`。
  - key: fs.suid_dumpable
    must_equal: "0"
    severity: high
    rationale: SUID 可生成 core dump 会带出特权进程的内存
    remediation: 设为 0，禁止 SUID 程序生成 core dump。
  # 网络层基础硬化。
  - key: net.ipv4.conf.all.rp_filter
    must_equal: "1"
    severity: medium
    rationale: 关闭后允许源地址欺骗，影响入站流量校验
    remediation: 设为 1（strict mode）。
  - key: net.ipv4.conf.all.send_redirects
    must_equal: "0"
    severity: medium
    rationale: 主机不该主动发 ICMP redirect，仅路由器需要
    remediation: 设为 0。
  - key: net.ipv4.conf.all.accept_redirects
    must_equal: "0"
    severity: medium
    rationale: 接受 ICMP redirect 可被劫持中间路由
    remediation: 设为 0。
  - key: net.ipv4.conf.all.accept_source_route
    must_equal: "0"
    severity: medium
    rationale: source-routed 包是经典伪造源 IP 工具
    remediation: 设为 0。
  - key: net.ipv4.tcp_syncookies
    must_equal: "1"
    severity: low
    rationale: SYN flood 防御兜底
    remediation: 设为 1。

sshd:
  # 关键 sshd 安全配置；覆盖 sshd -T 输出的小写 key。
  - key: permitrootlogin
    must_equal: "no"
    severity: critical
    rationale: 直接允许 root 登录是常被打的入口
    remediation: 改为 `PermitRootLogin no`，重启 sshd。
  - key: passwordauthentication
    must_equal: "no"
    severity: high
    rationale: 密码登录易被暴力撞库
    remediation: 改为 `PasswordAuthentication no`，强制密钥登录。
  - key: permitemptypasswords
    must_equal: "no"
    severity: critical
    rationale: 允许空密码登录是配置错误典型表现
    remediation: 改为 `PermitEmptyPasswords no`。
  - key: x11forwarding
    must_equal: "no"
    severity: low
    rationale: 默认开启 X11 转发增加攻击面，无 GUI 服务不需要
    remediation: 改为 `X11Forwarding no`。
  - key: protocol
    must_equal: "2"
    severity: high
    rationale: SSH 协议 1 已废弃，存在多个已知缺陷
    remediation: 强制 `Protocol 2`。
  - key: maxauthtries
    must_equal: "4"
    severity: low
    rationale: 默认 6 次尝试容许的撞库次数偏宽
    remediation: 设为 4 或更小。

sudoers:
  # 禁止的 regex 模式 —— 命中即一条 Finding。
  - id: nopasswd_all
    pattern: '(?m)^[^#]*\bNOPASSWD:?\s*ALL\b'
    severity: critical
    rationale: NOPASSWD ALL 等于把整机 root 权限白送给该用户
    remediation: |
      移除 NOPASSWD ALL 条目；如确需免密，限定到具体命令路径
      （Cmnd_Alias）。
  - id: world_writable_runas
    pattern: '(?m)^[^#]*\(ALL:ALL\)\s+ALL\s*$'
    severity: high
    rationale: `(ALL:ALL) ALL` 给了用户切换到任意用户运行任意命令的权限
    remediation: 限定 RunasUser 列表到具体身份。
  - id: explicit_user_root_no_password
    pattern: '(?m)^\s*[a-z_][a-z0-9_-]*\s+ALL=\(ALL\)\s+NOPASSWD'
    severity: critical
    rationale: 单个用户被显式赋予 NOPASSWD 全机权限
    remediation: 评估该用户是否仍需此权限；如需保留，限定到具体命令。

suid:
  # 期望的 SUID/SGID 二进制白名单（按 distro 默认安装）。在
  # `/usr/bin` 和 `/usr/sbin` 这两个路径下出现的非白名单条目即一条
  # Finding。
  expected:
    - /usr/bin/su
    - /usr/bin/sudo
    - /usr/bin/passwd
    - /usr/bin/chsh
    - /usr/bin/chfn
    - /usr/bin/gpasswd
    - /usr/bin/newgrp
    - /usr/bin/mount
    - /usr/bin/umount
    - /usr/bin/ping
    - /usr/bin/ping6
    - /usr/bin/at
    - /usr/bin/crontab
    - /usr/bin/expiry
    - /usr/bin/wall
    - /usr/bin/write
    - /usr/sbin/pam_timestamp_check
    - /usr/sbin/unix_chkpwd
    - /usr/sbin/grub2-set-bootflag
    - /usr/sbin/sendmail.sendmail
    - /usr/sbin/usernetctl
    - /usr/sbin/mount.nfs
    - /usr/sbin/mount.cifs
  unexpected_severity: high
  rationale: 不在 distro 白名单中的 SUID/SGID 二进制是常见后门 / 提权植入点
  remediation: |
    审查每个非白名单条目：是合法第三方安装（如 vmware-* / docker-*）
    还是植入。合法的可加进 expected 列表；可疑的立即 `chmod -s` 移除
    SUID/SGID 位并审计。
```

- [ ] **Step 2: Loader smoke**

```bash
.venv/bin/python -c "
import yaml
from pathlib import Path
data = yaml.safe_load(Path('src/agentsec/audit/checks/linux_hardening_baseline.yaml').read_text())
print('sections:', list(data.keys()))
print(f'sysctl rules: {len(data[\"sysctl\"])}')
print(f'sshd rules: {len(data[\"sshd\"])}')
print(f'sudoers patterns: {len(data[\"sudoers\"])}')
print(f'suid expected: {len(data[\"suid\"][\"expected\"])}')
"
```

Expected:

```
sections: ['sysctl', 'sshd', 'sudoers', 'suid']
sysctl rules: 8
sshd rules: 6
sudoers patterns: 3
suid expected: 23
```

- [ ] **Step 3: Commit**

```bash
git add src/agentsec/audit/checks/linux_hardening_baseline.yaml
git commit -m "feat(audit): add linux hardening baseline (sysctl / sshd / sudoers / suid)"
```

---

### Task 3: SysctlAuditCheck

**Files:**
- Create: `src/agentsec/audit/checks/sysctl_audit.py`
- Create: `tests/test_sysctl_audit.py`
- Modify: `src/agentsec/audit/ssh_policy.yaml`

- [ ] **Step 1: SSH policy entry for `sysctl`**

In `src/agentsec/audit/ssh_policy.yaml`, find the `commands:` block. Add this entry near other read-only commands (alphabetical placement near `ss:` is fine):

```yaml
  sysctl:
    args_schema:
      - role: flags
        allowed: ["-n"]
      - role: positional
        kind: opaque
        # sysctl key is dotted lowercase ascii — no shell metachars,
        # no path traversal. Multiple keys allowed in one call.
        regex: '^[a-z][a-z0-9._]*$'
```

If `kind: opaque` isn't already a registered positional kind in `path_matcher.py` / `command_policy.py`, look at how existing positional non-path values are handled (e.g. `journalctl --since <since>` from log_review). Mirror that pattern. If you encounter the kind isn't pluggable, fall back to:

```yaml
  sysctl:
    args_schema:
      - role: flags
        allowed: ["-n"]
      - role: positional
        kind: free_token
        regex: '^[a-z][a-z0-9._]*$'
```

The exact name of the regex-only positional kind needs to be looked up in `src/agentsec/audit/command_policy.py` — look for how `journalctl --since` constrains its `<since>` value. Use the same `kind:` name.

- [ ] **Step 2: Write failing tests**

```python
# tests/test_sysctl_audit.py
"""Unit tests for SysctlAuditCheck (Phase 7e)."""

from __future__ import annotations

from pathlib import Path

import pytest

from agentsec.audit.checks.base import CheckContext, CheckStatus
from agentsec.audit.checks.sysctl_audit import SysctlAuditCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


class _StubExecutor:
    def __init__(self, response: RunResult):
        self._response = response
        self.last_argv: list[str] | None = None

    def run(self, argv, *, role=None):
        self.last_argv = list(argv)
        return self._response


def _runresult(stdout: str = "", exit_code: int = 0) -> RunResult:
    return RunResult(
        stdout=stdout, stderr="", exit_code=exit_code,
        rejected=False, reject_reason=None, argv=[],
        command_sha256="0" * 64, duration_ms=0.0,
        bytes_out=len(stdout.encode()),
    )


def _ctx(executor, *, profile_name="linux"):
    profile = materialize_paths(LINUX, "/home/u")
    if profile_name != "linux":
        profile = profile.model_copy(update={"name": profile_name})
    return CheckContext(
        executor=executor, redactor=None,
        ioc_dir=Path("/tmp"), profile=profile,
        log_review_config=None,
    )


def test_compliant_system_emits_no_findings():
    # Baseline expects 8 sysctl values; provide all-compliant defaults.
    sysctl_output = "\n".join(["1", "2", "0", "1", "0", "0", "0", "1"]) + "\n"
    executor = _StubExecutor(_runresult(stdout=sysctl_output))
    check = SysctlAuditCheck()
    ctx = _ctx(executor)
    findings = ctx_run(check, ctx)
    assert findings == []
    # Ensure exactly one sysctl call, with -n + all 8 keys.
    assert executor.last_argv is not None
    assert executor.last_argv[0] == "sysctl"
    assert "-n" in executor.last_argv
    # 8 baseline keys; argv tail length == 8 dotted keys.
    keys_in_argv = [a for a in executor.last_argv if "." in a]
    assert len(keys_in_argv) == 8


def test_violation_emits_one_finding_per_key():
    # All 8 keys violate (all return 0 instead of expected).
    sysctl_output = "\n".join(["0"] * 8) + "\n"
    executor = _StubExecutor(_runresult(stdout=sysctl_output))
    check = SysctlAuditCheck()
    ctx = _ctx(executor)
    findings = ctx_run(check, ctx)
    # `fs.suid_dumpable` has must_equal=0 — that one is compliant.
    # The other 7 violate.
    assert len(findings) == 7
    titles = [f.title for f in findings]
    assert any("kernel.dmesg_restrict" in t for t in titles)
    assert any("net.ipv4.tcp_syncookies" in t for t in titles)


def test_skip_on_macos_profile():
    executor = _StubExecutor(_runresult())
    check = SysctlAuditCheck()
    ctx = _ctx(executor, profile_name="macos")
    findings = ctx_run(check, ctx)
    assert findings == []
    assert ctx.status.is_not_applicable
    assert "Linux" in (ctx.status.not_applicable_reason or "")


def test_skip_on_executor_rejection():
    executor = _StubExecutor(RunResult(
        stdout="", stderr="", exit_code=0,
        rejected=True, reject_reason="policy",
        argv=[], command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
    ))
    check = SysctlAuditCheck()
    ctx = _ctx(executor)
    findings = ctx_run(check, ctx)
    assert findings == []
    assert ctx.status.is_skipped


# Helper: drive the async run() synchronously for terse test bodies.
def ctx_run(check, ctx):
    import asyncio
    return asyncio.get_event_loop().run_until_complete(check.run(ctx))
```

- [ ] **Step 3: Run, verify failure**

Run: `.venv/bin/pytest tests/test_sysctl_audit.py -x`
Expected: ImportError on `agentsec.audit.checks.sysctl_audit`.

- [ ] **Step 4: Implement the check**

```python
# src/agentsec/audit/checks/sysctl_audit.py
"""Linux kernel sysctl baseline audit (Phase 7e).

Pulls 8 baseline keys via a single `sysctl -n key1 key2 ...` call and
emits one Finding per key whose runtime value differs from baseline.
"""

from __future__ import annotations

from pathlib import Path

import yaml

from ..findings import Finding
from .base import Check, CheckContext

_BASELINE_PATH = (
    Path(__file__).parent / "linux_hardening_baseline.yaml"
)


class SysctlAuditCheck(Check):
    id = "sysctl_audit"

    def __init__(self):
        super().__init__()
        self._rules = yaml.safe_load(_BASELINE_PATH.read_text())["sysctl"]

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"sysctl_audit：当前 profile {ctx.profile.name!r} "
                "不是 Linux，主机加固检查跳过"
            )
            return []
        keys = [r["key"] for r in self._rules]
        argv = ["sysctl", "-n", *keys]
        result = ctx.executor.run(argv)
        if result.rejected:
            ctx.status.skipped(
                f"sysctl_audit 被策略拒绝：{result.reject_reason}"
            )
            return []
        if result.exit_code != 0:
            ctx.status.skipped(
                f"sysctl_audit 退出码 {result.exit_code}"
            )
            return []
        actual_values = result.stdout.strip().splitlines()
        if len(actual_values) != len(keys):
            ctx.status.skipped(
                f"sysctl_audit 输出行数 {len(actual_values)} 与 "
                f"key 数 {len(keys)} 不匹配"
            )
            return []
        findings: list[Finding] = []
        for rule, actual in zip(self._rules, actual_values, strict=False):
            if actual.strip() != rule["must_equal"]:
                findings.append(Finding(
                    check=self.id,
                    severity=rule["severity"],
                    title=(
                        f"sysctl {rule['key']} = {actual.strip()!r}，"
                        f"基线要求 {rule['must_equal']!r}"
                    ),
                    description=rule["rationale"],
                    evidence={
                        "key": rule["key"],
                        "actual": actual.strip(),
                        "expected": rule["must_equal"],
                    },
                    remediation=rule["remediation"],
                    threat_refs=["TI-LINUX-CIS-SYSCTL"],
                ))
        return findings
```

- [ ] **Step 5: Run tests, verify pass**

```bash
.venv/bin/pytest tests/test_sysctl_audit.py -v
```

Expected: 4 passing.

- [ ] **Step 6: Lint + types**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/checks/sysctl_audit.py tests/test_sysctl_audit.py src/agentsec/audit/ssh_policy.yaml
git commit -m "feat(audit): add SysctlAuditCheck — Linux kernel sysctl baseline (8 keys)"
```

---

### Task 4: SshdConfigAuditCheck

**Files:**
- Create: `src/agentsec/audit/checks/sshd_config_audit.py`
- Create: `tests/test_sshd_config_audit.py`
- Modify: `src/agentsec/audit/ssh_policy.yaml`

- [ ] **Step 1: SSH policy entry for `sshd`**

In `src/agentsec/audit/ssh_policy.yaml`, in the `commands:` block:

```yaml
  sshd:
    # Only `sshd -T` is permitted — outputs effective config including
    # default values. No other invocations.
    allowed_argv:
      - ["sshd", "-T"]
```

- [ ] **Step 2: Write failing tests**

```python
# tests/test_sshd_config_audit.py
"""Unit tests for SshdConfigAuditCheck (Phase 7e)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.sshd_config_audit import SshdConfigAuditCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


class _StubExecutor:
    def __init__(self, response: RunResult):
        self._response = response
        self.last_argv: list[str] | None = None

    def run(self, argv, *, role=None):
        self.last_argv = list(argv)
        return self._response


def _runresult(stdout="", stderr="", exit_code=0, rejected=False) -> RunResult:
    return RunResult(
        stdout=stdout, stderr=stderr, exit_code=exit_code,
        rejected=rejected, reject_reason="policy" if rejected else None,
        argv=[], command_sha256="0" * 64, duration_ms=0.0,
        bytes_out=len(stdout.encode()),
    )


def _ctx(executor, profile_name="linux"):
    profile = materialize_paths(LINUX, "/home/u")
    if profile_name != "linux":
        profile = profile.model_copy(update={"name": profile_name})
    return CheckContext(
        executor=executor, redactor=None,
        ioc_dir=Path("/tmp"), profile=profile,
        log_review_config=None,
    )


def _run(check, ctx):
    return asyncio.get_event_loop().run_until_complete(check.run(ctx))


# Realistic `sshd -T` excerpt — keys are space-separated `key value`,
# all lowercase, one per line.
COMPLIANT_SSHD_OUTPUT = """\
permitrootlogin no
passwordauthentication no
permitemptypasswords no
x11forwarding no
protocol 2
maxauthtries 4
"""

VIOLATION_SSHD_OUTPUT = """\
permitrootlogin yes
passwordauthentication yes
permitemptypasswords no
x11forwarding yes
protocol 2
maxauthtries 6
"""


def test_compliant_sshd_emits_no_findings():
    executor = _StubExecutor(_runresult(stdout=COMPLIANT_SSHD_OUTPUT))
    findings = _run(SshdConfigAuditCheck(), _ctx(executor))
    assert findings == []


def test_violations_emit_findings_per_key():
    executor = _StubExecutor(_runresult(stdout=VIOLATION_SSHD_OUTPUT))
    findings = _run(SshdConfigAuditCheck(), _ctx(executor))
    titles = [f.title for f in findings]
    # 4 violations: permitrootlogin yes, passwordauthentication yes,
    # x11forwarding yes, maxauthtries 6.
    assert len(findings) == 4
    assert any("permitrootlogin" in t for t in titles)
    assert any("passwordauthentication" in t for t in titles)
    # Severity escalation: permitrootlogin yes is critical
    crit = [f for f in findings if "permitrootlogin" in f.title]
    assert crit[0].severity == "critical"


def test_macos_profile_skips():
    executor = _StubExecutor(_runresult())
    ctx = _ctx(executor, profile_name="macos")
    findings = _run(SshdConfigAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable


def test_sshd_t_unavailable_falls_back_to_skipped():
    # Some old SSH builds don't support -T (very rare in practice).
    executor = _StubExecutor(_runresult(
        stderr="sshd: unknown option -- T",
        exit_code=1,
    ))
    ctx = _ctx(executor)
    findings = _run(SshdConfigAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped
```

- [ ] **Step 3: Run, verify failure**

Run: `.venv/bin/pytest tests/test_sshd_config_audit.py -x`
Expected: ImportError.

- [ ] **Step 4: Implement the check**

```python
# src/agentsec/audit/checks/sshd_config_audit.py
"""sshd_config baseline audit (Phase 7e).

Runs `sshd -T` to capture effective config (with defaults applied), parses
`key value` lines, compares against baseline, emits one Finding per
non-compliant key.
"""

from __future__ import annotations

from pathlib import Path

import yaml

from ..findings import Finding
from .base import Check, CheckContext

_BASELINE_PATH = (
    Path(__file__).parent / "linux_hardening_baseline.yaml"
)


class SshdConfigAuditCheck(Check):
    id = "sshd_config_audit"

    def __init__(self):
        super().__init__()
        self._rules = yaml.safe_load(_BASELINE_PATH.read_text())["sshd"]

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"sshd_config_audit：当前 profile {ctx.profile.name!r} "
                "不是 Linux，主机加固检查跳过"
            )
            return []
        result = ctx.executor.run(["sshd", "-T"])
        if result.rejected:
            ctx.status.skipped(
                f"sshd_config_audit 被策略拒绝：{result.reject_reason}"
            )
            return []
        if result.exit_code != 0:
            ctx.status.skipped(
                f"sshd_config_audit `sshd -T` 退出码 {result.exit_code}"
            )
            return []
        # Parse `key value` lines (whitespace-split, take first whitespace
        # boundary as separator; values may contain spaces e.g. for
        # `ciphers aes256-gcm@openssh.com,...` but our 6 baseline keys
        # all have single-token values so split-once is safe).
        actual: dict[str, str] = {}
        for line in result.stdout.splitlines():
            line = line.strip()
            if not line:
                continue
            parts = line.split(None, 1)
            if len(parts) != 2:
                continue
            actual[parts[0].lower()] = parts[1].strip()
        findings: list[Finding] = []
        for rule in self._rules:
            key = rule["key"]
            current = actual.get(key)
            if current is None:
                continue  # absent from `sshd -T`; can't judge
            if current != rule["must_equal"]:
                findings.append(Finding(
                    check=self.id,
                    severity=rule["severity"],
                    title=(
                        f"sshd_config {key} = {current!r}，"
                        f"基线要求 {rule['must_equal']!r}"
                    ),
                    description=rule["rationale"],
                    evidence={
                        "key": key,
                        "actual": current,
                        "expected": rule["must_equal"],
                    },
                    remediation=rule["remediation"],
                    threat_refs=["TI-LINUX-CIS-SSHD"],
                ))
        return findings
```

- [ ] **Step 5: Run tests, verify pass**

```bash
.venv/bin/pytest tests/test_sshd_config_audit.py -v
```

Expected: 4 passing.

- [ ] **Step 6: Lint + types + full suite**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/checks/sshd_config_audit.py tests/test_sshd_config_audit.py src/agentsec/audit/ssh_policy.yaml
git commit -m "feat(audit): add SshdConfigAuditCheck — sshd_config baseline (6 keys)"
```

---

### Task 5: SudoersAuditCheck

**Files:**
- Create: `src/agentsec/audit/checks/sudoers_audit.py`
- Create: `tests/test_sudoers_audit.py`
- Modify: `src/agentsec/audit/ssh_policy.yaml`

- [ ] **Step 1: SSH policy update — allow `cat` on /etc/sudoers + /etc/sudoers.d, find on /etc/sudoers.d**

In `src/agentsec/audit/ssh_policy.yaml`, find the `allowed_paths:` block at the top. Add new entries:

```yaml
  - { path: "/etc/sudoers", match: exact }
  - { path: "/etc/sudoers.d", match: prefix }
```

(Match existing entry style — look at how `/etc/openclaw` etc. are declared.)

This automatically gates `cat /etc/sudoers` + `cat /etc/sudoers.d/<name>` + `find /etc/sudoers.d -type f` through the existing `cat` and `find` command schemas (no new commands needed).

- [ ] **Step 2: Write failing tests**

```python
# tests/test_sudoers_audit.py
"""Unit tests for SudoersAuditCheck (Phase 7e)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.sudoers_audit import SudoersAuditCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


class _RecordingExecutor:
    """Returns a per-argv canned response. argv keyed via tuple(argv)."""
    def __init__(self, responses: dict[tuple, RunResult]):
        self._responses = responses
        self.calls: list[list[str]] = []

    def run(self, argv, *, role=None):
        argv = list(argv)
        self.calls.append(argv)
        key = tuple(argv)
        if key in self._responses:
            return self._responses[key]
        # default empty/noop
        return RunResult(
            stdout="", stderr="", exit_code=0,
            rejected=False, reject_reason=None, argv=argv,
            command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
        )


def _runresult(stdout="", exit_code=0) -> RunResult:
    return RunResult(
        stdout=stdout, stderr="", exit_code=exit_code,
        rejected=False, reject_reason=None, argv=[],
        command_sha256="0" * 64, duration_ms=0.0,
        bytes_out=len(stdout.encode()),
    )


def _ctx(executor, profile_name="linux"):
    profile = materialize_paths(LINUX, "/home/u")
    if profile_name != "linux":
        profile = profile.model_copy(update={"name": profile_name})
    return CheckContext(
        executor=executor, redactor=None,
        ioc_dir=Path("/tmp"), profile=profile,
        log_review_config=None,
    )


def _run(check, ctx):
    return asyncio.get_event_loop().run_until_complete(check.run(ctx))


CLEAN_SUDOERS = """\
# Cleanup sudoers
root  ALL=(ALL:ALL) ALL
%admin ALL=(ALL) ALL
"""

DIRTY_SUDOERS = """\
# Risky entries
root  ALL=(ALL:ALL) ALL
deploy ALL=(ALL) NOPASSWD: ALL
%wheel ALL=(ALL:ALL) ALL
"""


def test_clean_sudoers_no_findings():
    responses = {
        ("cat", "/etc/sudoers"): _runresult(stdout=CLEAN_SUDOERS),
        ("find", "/etc/sudoers.d", "-maxdepth", "1", "-type", "f"):
            _runresult(stdout=""),  # no drop-in files
    }
    executor = _RecordingExecutor(responses)
    findings = _run(SudoersAuditCheck(), _ctx(executor))
    # CLEAN_SUDOERS contains `(ALL:ALL) ALL` — that fires
    # `world_writable_runas` (high). And `(ALL) ALL`. Adjust expectations:
    # both rules fire on this fixture.
    titles = [f.title for f in findings]
    # Should match the high-severity (ALL:ALL) ALL pattern at least once.
    assert any("ALL:ALL" in t or "(ALL:ALL)" in t for t in titles)


def test_dirty_sudoers_emits_critical_findings():
    responses = {
        ("cat", "/etc/sudoers"): _runresult(stdout=DIRTY_SUDOERS),
        ("find", "/etc/sudoers.d", "-maxdepth", "1", "-type", "f"):
            _runresult(stdout=""),
    }
    executor = _RecordingExecutor(responses)
    findings = _run(SudoersAuditCheck(), _ctx(executor))
    severities = [f.severity for f in findings]
    assert "critical" in severities  # nopasswd_all rule fires
    titles = [f.title for f in findings]
    assert any("NOPASSWD" in t for t in titles)


def test_sudoers_d_drop_in_files_scanned():
    """When find returns drop-in files, each is cat'd + scanned."""
    responses = {
        ("cat", "/etc/sudoers"): _runresult(stdout="# main empty\n"),
        ("find", "/etc/sudoers.d", "-maxdepth", "1", "-type", "f"):
            _runresult(stdout="/etc/sudoers.d/deploy\n"),
        ("cat", "/etc/sudoers.d/deploy"): _runresult(
            stdout="deploy ALL=(ALL) NOPASSWD: ALL\n",
        ),
    }
    executor = _RecordingExecutor(responses)
    findings = _run(SudoersAuditCheck(), _ctx(executor))
    titles = [f.title for f in findings]
    # The drop-in file's NOPASSWD ALL must surface as a finding.
    assert any("NOPASSWD" in t for t in titles)


def test_macos_skips():
    executor = _RecordingExecutor({})
    ctx = _ctx(executor, profile_name="macos")
    findings = _run(SudoersAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable
```

- [ ] **Step 3: Run, verify failure**

Run: `.venv/bin/pytest tests/test_sudoers_audit.py -x`
Expected: ImportError.

- [ ] **Step 4: Implement the check**

```python
# src/agentsec/audit/checks/sudoers_audit.py
"""sudoers risky-pattern audit (Phase 7e).

Reads /etc/sudoers and every file under /etc/sudoers.d/, scans each line
against baseline forbidden regex patterns, emits one Finding per match.
"""

from __future__ import annotations

import re
from pathlib import Path

import yaml

from ..findings import Finding
from .base import Check, CheckContext

_BASELINE_PATH = (
    Path(__file__).parent / "linux_hardening_baseline.yaml"
)


class SudoersAuditCheck(Check):
    id = "sudoers_audit"

    def __init__(self):
        super().__init__()
        rules = yaml.safe_load(_BASELINE_PATH.read_text())["sudoers"]
        self._rules = [
            {**r, "compiled": re.compile(r["pattern"])} for r in rules
        ]

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"sudoers_audit：当前 profile {ctx.profile.name!r} "
                "不是 Linux，主机加固检查跳过"
            )
            return []
        files: list[str] = ["/etc/sudoers"]
        # Enumerate /etc/sudoers.d/*
        find_result = ctx.executor.run([
            "find", "/etc/sudoers.d", "-maxdepth", "1", "-type", "f",
        ])
        if not find_result.rejected and find_result.exit_code == 0:
            for line in find_result.stdout.splitlines():
                line = line.strip()
                if line and line != "/etc/sudoers.d":
                    files.append(line)
        findings: list[Finding] = []
        for path in files:
            cat_result = ctx.executor.run(["cat", path])
            if cat_result.rejected or cat_result.exit_code != 0:
                continue
            content = cat_result.stdout
            for rule in self._rules:
                for match in rule["compiled"].finditer(content):
                    snippet = match.group(0).strip()
                    if len(snippet) > 200:
                        snippet = snippet[:200] + "…"
                    findings.append(Finding(
                        check=self.id,
                        severity=rule["severity"],
                        title=(
                            f"sudoers 风险模式 {rule['id']!r} "
                            f"匹配 {path}"
                        ),
                        description=rule["rationale"],
                        evidence={
                            "file": path,
                            "rule": rule["id"],
                            "snippet": snippet,
                        },
                        remediation=rule["remediation"],
                        threat_refs=["TI-LINUX-CIS-SUDOERS"],
                    ))
        return findings
```

- [ ] **Step 5: Run tests, verify pass**

```bash
.venv/bin/pytest tests/test_sudoers_audit.py -v
```

Expected: 4 passing.

- [ ] **Step 6: Lint + types + full suite**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/checks/sudoers_audit.py tests/test_sudoers_audit.py src/agentsec/audit/ssh_policy.yaml
git commit -m "feat(audit): add SudoersAuditCheck — risky pattern scan over /etc/sudoers + drop-ins"
```

---

### Task 6: SuidAuditCheck

**Files:**
- Create: `src/agentsec/audit/checks/suid_audit.py`
- Create: `tests/test_suid_audit.py`
- Modify: `src/agentsec/audit/ssh_policy.yaml`

- [ ] **Step 1: SSH policy — add /usr/bin and /usr/sbin to allowed_paths**

In `src/agentsec/audit/ssh_policy.yaml`, in the `allowed_paths:` block, add:

```yaml
  - { path: "/usr/bin", match: prefix }
  - { path: "/usr/sbin", match: prefix }
```

This lets `find /usr/bin -maxdepth 1 -type f -perm /4000` and same for `/usr/sbin` pass policy validation (the existing `find` command schema gates the `-perm /4000` predicate via the existing `allowed_predicates` list which already includes `-perm`).

- [ ] **Step 2: Write failing tests**

```python
# tests/test_suid_audit.py
"""Unit tests for SuidAuditCheck (Phase 7e)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.suid_audit import SuidAuditCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


class _StubExecutor:
    def __init__(self, responses):
        self._responses = responses
        self.calls = []

    def run(self, argv, *, role=None):
        argv = list(argv)
        self.calls.append(argv)
        key = tuple(argv)
        return self._responses.get(
            key,
            RunResult(
                stdout="", stderr="", exit_code=0,
                rejected=False, reject_reason=None, argv=argv,
                command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            ),
        )


def _runresult(stdout="") -> RunResult:
    return RunResult(
        stdout=stdout, stderr="", exit_code=0,
        rejected=False, reject_reason=None, argv=[],
        command_sha256="0" * 64, duration_ms=0.0,
        bytes_out=len(stdout.encode()),
    )


def _ctx(executor, profile_name="linux"):
    profile = materialize_paths(LINUX, "/home/u")
    if profile_name != "linux":
        profile = profile.model_copy(update={"name": profile_name})
    return CheckContext(
        executor=executor, redactor=None,
        ioc_dir=Path("/tmp"), profile=profile,
        log_review_config=None,
    )


def _run(check, ctx):
    return asyncio.get_event_loop().run_until_complete(check.run(ctx))


def test_only_whitelisted_suid_no_findings():
    responses = {
        ("find", "/usr/bin", "-maxdepth", "1", "-type", "f", "-perm", "/4000"):
            _runresult(stdout="/usr/bin/su\n/usr/bin/sudo\n/usr/bin/passwd\n"),
        ("find", "/usr/sbin", "-maxdepth", "1", "-type", "f", "-perm", "/4000"):
            _runresult(stdout="/usr/sbin/unix_chkpwd\n"),
    }
    findings = _run(SuidAuditCheck(), _ctx(_StubExecutor(responses)))
    assert findings == []


def test_unknown_suid_emits_finding_per_binary():
    responses = {
        ("find", "/usr/bin", "-maxdepth", "1", "-type", "f", "-perm", "/4000"):
            _runresult(stdout="/usr/bin/su\n/usr/bin/evil_backdoor\n"),
        ("find", "/usr/sbin", "-maxdepth", "1", "-type", "f", "-perm", "/4000"):
            _runresult(stdout="/usr/sbin/another_unknown\n"),
    }
    findings = _run(SuidAuditCheck(), _ctx(_StubExecutor(responses)))
    titles = [f.title for f in findings]
    assert len(findings) == 2
    assert any("/usr/bin/evil_backdoor" in t for t in titles)
    assert any("/usr/sbin/another_unknown" in t for t in titles)
    # Severity comes from baseline (high)
    assert all(f.severity == "high" for f in findings)


def test_macos_skips():
    ctx = _ctx(_StubExecutor({}), profile_name="macos")
    findings = _run(SuidAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable


def test_find_failure_skips_check():
    """If both find calls fail, the check skips with a clear reason."""
    responses = {
        ("find", "/usr/bin", "-maxdepth", "1", "-type", "f", "-perm", "/4000"):
            RunResult(
                stdout="", stderr="permission denied", exit_code=1,
                rejected=False, reject_reason=None, argv=[],
                command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            ),
        ("find", "/usr/sbin", "-maxdepth", "1", "-type", "f", "-perm", "/4000"):
            RunResult(
                stdout="", stderr="permission denied", exit_code=1,
                rejected=False, reject_reason=None, argv=[],
                command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            ),
    }
    ctx = _ctx(_StubExecutor(responses))
    findings = _run(SuidAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped
```

- [ ] **Step 3: Run, verify failure**

Run: `.venv/bin/pytest tests/test_suid_audit.py -x`
Expected: ImportError.

- [ ] **Step 4: Implement the check**

```python
# src/agentsec/audit/checks/suid_audit.py
"""SUID/SGID whitelist drift audit (Phase 7e).

Enumerates SUID files under /usr/bin and /usr/sbin via find, compares each
against a baseline whitelist, emits one Finding per non-whitelist entry.
"""

from __future__ import annotations

from pathlib import Path

import yaml

from ..findings import Finding
from .base import Check, CheckContext

_BASELINE_PATH = (
    Path(__file__).parent / "linux_hardening_baseline.yaml"
)
_SCAN_DIRS: tuple[str, ...] = ("/usr/bin", "/usr/sbin")


class SuidAuditCheck(Check):
    id = "suid_audit"

    def __init__(self):
        super().__init__()
        section = yaml.safe_load(_BASELINE_PATH.read_text())["suid"]
        self._whitelist = set(section["expected"])
        self._severity = section["unexpected_severity"]
        self._rationale = section["rationale"]
        self._remediation = section["remediation"]

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"suid_audit：当前 profile {ctx.profile.name!r} "
                "不是 Linux，主机加固检查跳过"
            )
            return []
        all_failed = True
        suid_paths: list[str] = []
        for d in _SCAN_DIRS:
            result = ctx.executor.run([
                "find", d, "-maxdepth", "1", "-type", "f",
                "-perm", "/4000",
            ])
            if result.rejected:
                continue
            if result.exit_code != 0:
                continue
            all_failed = False
            for line in result.stdout.splitlines():
                line = line.strip()
                if line:
                    suid_paths.append(line)
        if all_failed:
            ctx.status.skipped(
                "suid_audit 全部 find 调用失败（可能权限不足）"
            )
            return []
        findings: list[Finding] = []
        for path in suid_paths:
            if path in self._whitelist:
                continue
            findings.append(Finding(
                check=self.id,
                severity=self._severity,
                title=f"非白名单 SUID/SGID 二进制：{path}",
                description=self._rationale,
                evidence={"path": path},
                remediation=self._remediation,
                threat_refs=["TI-LINUX-CIS-SUID"],
            ))
        return findings
```

- [ ] **Step 5: Run tests, verify pass**

```bash
.venv/bin/pytest tests/test_suid_audit.py -v
```

Expected: 4 passing.

- [ ] **Step 6: Lint + types + full suite**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/checks/suid_audit.py tests/test_suid_audit.py src/agentsec/audit/ssh_policy.yaml
git commit -m "feat(audit): add SuidAuditCheck — SUID/SGID whitelist drift in /usr/bin + /usr/sbin"
```

---

### Task 7: CLI integration — register 4 new Checks

**Files:**
- Modify: `src/agentsec/cli.py`

- [ ] **Step 1: Add imports**

In `src/agentsec/cli.py`, near the existing Check imports (around lines 17–28), add:

```python
from .audit.checks.sshd_config_audit import SshdConfigAuditCheck
from .audit.checks.sudoers_audit import SudoersAuditCheck
from .audit.checks.suid_audit import SuidAuditCheck
from .audit.checks.sysctl_audit import SysctlAuditCheck
```

- [ ] **Step 2: Register in `server_audit` and `_evaluate_server`**

There are two places that build `checks = [...]`. In each, append four new entries to the list (after `LogReviewCheck()`):

```python
        SysctlAuditCheck(),
        SshdConfigAuditCheck(),
        SudoersAuditCheck(),
        SuidAuditCheck(),
```

`grep -n "LogReviewCheck" src/agentsec/cli.py` to find both call sites.

- [ ] **Step 3: Verify CLI works (no live SSH — just construction)**

```bash
.venv/bin/python -c "
from agentsec.audit.checks.sysctl_audit import SysctlAuditCheck
from agentsec.audit.checks.sshd_config_audit import SshdConfigAuditCheck
from agentsec.audit.checks.sudoers_audit import SudoersAuditCheck
from agentsec.audit.checks.suid_audit import SuidAuditCheck
checks = [SysctlAuditCheck(), SshdConfigAuditCheck(), SudoersAuditCheck(), SuidAuditCheck()]
print('OK', [c.id for c in checks])
"
```

Expected:

```
OK ['sysctl_audit', 'sshd_config_audit', 'sudoers_audit', 'suid_audit']
```

- [ ] **Step 4: Run full suite to confirm CLI integration didn't regress**

```bash
.venv/bin/pytest tests/ -q
```

Expected: all green.

- [ ] **Step 5: Lint + types**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
```

All green.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/cli.py
git commit -m "cli(server-audit): register 4 Linux hardening Checks (Phase 7e)"
```

---

### Task 8: Sample report drift refresh

**Files:**
- (drift refresh only): `docs/samples/report-2026-04-28/server-audit-report.md`

The integration mock-fixture stubs SSH so the new Checks will get
either `not_applicable` (mock platform isn't recognised as proper
Linux) or `skipped` (stub returns nothing for sysctl/sshd argv). The
sample's machine-anchor list (`<!-- check:sysctl_audit ... -->`)
needs to update.

- [ ] **Step 1: Refresh sample**

```bash
AGENTSEC_REFRESH_SAMPLE=1 .venv/bin/pytest \
  tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v
```

- [ ] **Step 2: Drift guard pass**

```bash
.venv/bin/pytest tests/integration/test_openclaw_mock.py -v
```

Both tests must pass.

- [ ] **Step 3: Spot-check the refreshed sample**

```bash
grep -E "sysctl_audit|sshd_config_audit|sudoers_audit|suid_audit" docs/samples/report-2026-04-28/server-audit-report.md | head -10
```

Should show the four new check ids appear at least once (in the §4 未执行 / 不适用 table or the appendix machine anchors).

- [ ] **Step 4: Commit (only if diff non-empty)**

```bash
git diff --stat docs/samples/report-2026-04-28/
```

If shown, commit:

```bash
git add docs/samples/report-2026-04-28/
git commit -m "test(integration): refresh sample for Phase 7e (4 new check anchors)"
```

If no diff, skip the commit — the drift guard already passes.

---

### Task 9: ROADMAP / CHANGELOG / README

**Files:**
- Modify: `ROADMAP.md`
- Modify: `CHANGELOG.md`
- Modify: `README.md`

- [ ] **Step 1: ROADMAP — mark 7e complete**

In `ROADMAP.md`, find the Phase 7 section. Change the `7e` line from `- [ ]` to:

```markdown
- [x] Phase 7e — Linux 主机加固 Check（sysctl / sshd_config / sudoers / SUID 4 类，CIS 对齐）— 2026-05-08
```

Leave 7c / 7d / 7f as `[ ]`.

- [ ] **Step 2: CHANGELOG — `[Unreleased]` entry**

In `CHANGELOG.md`, add to the top of `[Unreleased]` → `### Added`:

```markdown
- **Linux 主机加固 Check（4 类）** —— `server-audit` 从应用层扩到
  OS 主机层。新增 `sysctl_audit`（kernel.dmesg_restrict / kptr_restrict /
  fs.suid_dumpable / 5 项 net.ipv4 共 8 个 baseline key），
  `sshd_config_audit`（PermitRootLogin / PasswordAuthentication /
  PermitEmptyPasswords / X11Forwarding / Protocol / MaxAuthTries 共
  6 项），`sudoers_audit`（NOPASSWD ALL / 风险 RunasUser / 显式
  全机免密 3 类 regex），`suid_audit`（/usr/bin + /usr/sbin
  下非白名单 SUID/SGID 漂移）。共享 baseline
  `linux_hardening_baseline.yaml`，profile 不是 Linux 时统一标
  `not_applicable`。CIS-aligned 默认值。SSH policy 同步扩展：新增
  `sysctl` / `sshd` 命令、`/etc/sudoers` / `/etc/sudoers.d` /
  `/usr/bin` / `/usr/sbin` allowed_paths 条目。详见 ADR-0008。
```

- [ ] **Step 3: README — bump check count**

In `README.md`, find the line that currently mentions 10 类 Check.
Replace "10 类" with "14 类（10 OpenClaw 应用层 + 4 Linux 主机加固）"
or similar. Look near the project pitch / 服务端审计段。

If the README mentions `10 类` in multiple places, update each.

- [ ] **Step 4: Final verification**

```bash
.venv/bin/pytest tests/ -q
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/python scripts/check_owasp_coverage.py
```

All four green.

- [ ] **Step 5: Commit**

```bash
git add ROADMAP.md CHANGELOG.md README.md
git commit -m "docs: ROADMAP/CHANGELOG/README — Phase 7e Linux host hardening Checks"
```

- [ ] **Step 6: Final sanity log**

```bash
git log --reverse --oneline feat/owasp-phase-7e ^main
```

Expected: 9 commits (or 10 if Task 8 produced a sample diff).

---

## Acceptance criteria

1. `.venv/bin/pytest tests/ -q` — green; new test counts: ~16 added (4 per Check × 4 Checks).
2. `.venv/bin/ruff check src/ tests/ scripts/` — clean.
3. `.venv/bin/pyright` — 0 errors.
4. `.venv/bin/python scripts/check_owasp_coverage.py` — still OK.
5. Local CLI smoke (Task 7 Step 3) prints the 4 new check ids.
6. Mock integration test passes; sample drift guard passes (with or without sample refresh commit).
7. `docs/adr/0008-linux-host-hardening-checks.md` exists.
8. ROADMAP shows 7e checked, CHANGELOG `[Unreleased]` includes the Linux hardening entry.
