产物与变更的可追溯性

让操作系统为每一次文件修改留下可验证、可审计、可回放的证据链

摘要

在软件交付、合规审计、安全取证和故障复盘中,"谁在什么时候改了什么、为什么改、改成了什么"是最核心的问题。本文从操作系统与文件系统的视角出发,系统梳理文件变更可追溯性的技术体系:从传统的 mtime/ctime/inode 元数据,到 inotify/fanotify 实时事件,再到 auditd 审计、eBPF 内核级追踪,以及 Git、内容寻址存储(CAS)、不可变基础设施等应用层方案。文章给出多个可直接运行的代码示例,帮助你构建一条从内核事件到产物哈希的完整证据链。

一、引言:为什么"可追溯"如此困难

在单机时代,文件的"最后修改时间"似乎就足够了。但在今天:

  • 一个构建产物可能经过 CI/CD 流水线、容器镜像层、制品仓库多次转手;
  • 一次生产事故的根因,可能藏在某个被覆盖的配置文件里;
  • 一次合规审计要求你证明"过去 180 天内没有任何人篡改过日志"。

问题的本质在于:文件系统默认只保存"当前状态",而不保存"变更历史"。 mtime 只是最后一次修改的时间戳,它无法回答:

  • 修改前的内容是什么?
  • 是谁修改的?
  • 通过什么进程、什么系统调用修改的?
  • 这个产物是否与源码、构建环境一一对应?

因此,可追溯性需要跨越多个层次协同实现。下面我们逐层拆解。


二、第一层:文件系统元数据——最基础的痕迹

2.1 三个时间戳的区别

Linux 中每个 inode 都带有三个时间戳,理解它们是可追溯性的起点:

时间戳含义触发时机
mtimeModification Time文件内容被修改
ctimeChange Timeinode 元数据变化(权限、属主、链接数、内容)
atimeAccess Time文件被读取

注意一个常见误区:ctime 不是"创建时间"。真正的创建时间(birth time)需要 statx 系统调用配合支持的文件系统(如 ext4、XFS、Btrfs)。

2.2 用 Python 读取完整时间戳

下面的示例同时使用 os.stat 与 statx,展示如何读取包括创建时间在内的全部信息:

#!/usr/bin/env python3
"""
读取文件的完整时间戳信息,包括 birth time(创建时间)。
需要 Python 3.7+ 且底层文件系统支持 statx。
"""
import os
import ctypes
import ctypes.util
from datetime import datetime

# statx 相关的常量定义(来自 <linux/stat.h>)
AT_FDCWD = -100
AT_SYMLINK_NOFOLLOW = 0x100
STATX_BTIME = 0x800  # 请求 birth time

class StatxTimestamp(ctypes.Structure):
    _fields_ = [
        ("tv_sec", ctypes.c_int64),
        ("tv_nsec", ctypes.c_uint32),
        ("__reserved", ctypes.c_int32),
    ]

class Statx(ctypes.Structure):
    _fields_ = [
        ("stx_mask", ctypes.c_uint32),
        ("stx_blksize", ctypes.c_uint32),
        ("stx_attributes", ctypes.c_uint64),
        ("stx_nlink", ctypes.c_uint32),
        ("stx_uid", ctypes.c_uint32),
        ("stx_gid", ctypes.c_uint32),
        ("stx_mode", ctypes.c_uint16),
        ("__spare0", ctypes.c_uint16),
        ("stx_ino", ctypes.c_uint64),
        ("stx_size", ctypes.c_uint64),
        ("stx_blocks", ctypes.c_uint64),
        ("stx_attributes_mask", ctypes.c_uint64),
        ("stx_atime", StatxTimestamp),
        ("stx_btime", StatxTimestamp),
        ("stx_ctime", StatxTimestamp),
        ("stx_mtime", StatxTimestamp),
        ("stx_rdev_major", ctypes.c_uint32),
        ("stx_rdev_minor", ctypes.c_uint32),
        ("stx_dev_major", ctypes.c_uint32),
        ("stx_dev_minor", ctypes.c_uint32),
        ("__spare2", ctypes.c_uint64 * 14),
    ]

def ts_to_str(ts: StatxTimestamp) -> str:
    if ts.tv_sec == 0:
        return "N/A"
    return datetime.fromtimestamp(
        ts.tv_sec + ts.tv_nsec / 1e9
    ).strftime("%Y-%m-%d %H:%M:%S.%f")

def show_timestamps(path: str):
    # 使用 statx 获取包含 btime 的完整信息
    libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
    stx = Statx()
    ret = libc.statx(
        AT_FDCWD, path.encode(), AT_SYMLINK_NOFOLLOW,
        STATX_BTIME, ctypes.byref(stx)
    )
    if ret != 0:
        err = ctypes.get_errno()
        raise OSError(err, os.strerror(err), path)

    print(f"文件: {path}")
    print(f"  inode   : {stx.stx_ino}")
    print(f"  大小    : {stx.stx_size} bytes")
    print(f"  属主    : uid={stx.stx_uid} gid={stx.stx_gid}")
    print(f"  权限    : {oct(stx.stx_mode)}")
    print(f"  atime   : {ts_to_str(stx.stx_atime)}")
    print(f"  mtime   : {ts_to_str(stx.stx_mtime)}")
    print(f"  ctime   : {ts_to_str(stx.stx_ctime)}")
    print(f"  btime   : {ts_to_str(stx.stx_btime)}  <- 创建时间")

if __name__ == "__main__":
    import sys
    target = sys.argv[1] if len(sys.argv) > 1 else __file__
    show_timestamps(target)

局限性:元数据只保留"最后一次"变更。任何中间历史都被覆盖,且用户态程序可以通过 touch、utimensat 伪造时间戳。因此元数据只能作为线索,不能作为证据。


三、第二层:实时事件——inotify 与 fanotify

如果要在变更发生的当下捕获它,Linux 提供了两套内核事件接口:

  • inotify:轻量、面向单个进程,适合监控少量文件;
  • fanotify:支持全局文件系统级监控、可拦截访问决策,适合安全审计。

3.1 inotify 实战:监控目录变更

#!/usr/bin/env python3
"""
使用 inotify 实时监控目录,记录每一次文件变更事件。
依赖: pip install inotify_simple
"""
import time
import json
import hashlib
from datetime import datetime
from pathlib import Path
from inotify_simple import INotify, flags

WATCH_DIR = Path("./watched").resolve()
WATCH_DIR.mkdir(exist_ok=True)

# 需要关注的事件类型
MASK = (
    flags.CREATE | flags.MODIFY | flags.DELETE |
    flags.MOVED_FROM | flags.MOVED_TO | flags.ATTRIB |
    flags.CLOSE_WRITE
)

def file_digest(path: Path) -> str:
    """计算文件 SHA-256,用于内容指纹。"""
    if not path.is_file():
        return ""
    h = hashlib.sha256()
    try:
        with path.open("rb") as f:
            for chunk in iter(lambda: f.read(65536), b""):
                h.update(chunk)
        return h.hexdigest()
    except (FileNotFoundError, PermissionError):
        return ""

def main():
    inotify = INotify()
    wd = inotify.add_watch(str(WATCH_DIR), MASK)
    print(f"[*] 开始监控 {WATCH_DIR},Ctrl+C 退出\n")

    try:
        while True:
            for event in inotify.read():
                name = event.name or ""
                full = WATCH_DIR / name
                record = {
                    "time": datetime.now().isoformat(timespec="milliseconds"),
                    "event": str(event.mask),
                    "name": name,
                    "sha256": file_digest(full),
                }
                # 输出为 JSON Lines,方便后续入库或审计
                print(json.dumps(record, ensure_ascii=False))
    except KeyboardInterrupt:
        print("\n[*] 已停止监控")

if __name__ == "__main__":
    main()

运行后,任何对 ./watched 目录的写入都会输出一条带哈希的记录:

$ python3 watch_dir.py &
$ echo "hello" > watched/a.txt
{"time": "2024-06-01T10:00:00.123", "event": "flags.CREATE", "name": "a.txt", "sha256": ""}
{"time": "2024-06-01T10:00:00.124", "event": "flags.CLOSE_WRITE", "name": "a.txt", "sha256": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"}

3.2 inotify 的局限

  • 只能监控已存在的目录,不递归(需自己遍历);
  • 事件可能合并、丢失(队列溢出);
  • 无法获取触发变更的进程 PID。

要拿到 PID,就必须升级到 fanotify 或 audit。


四、第三层:审计子系统——auditd 与 fanotify 的 PID 追踪

4.1 auditd:内核级审计

auditd 是 Linux 内核审计框架的用户态守护进程,可以记录文件访问、系统调用、进程执行等。配置一条针对 /etc/passwd 写入的规则:

# 添加审计规则:监控 /etc/passwd 的写入与属性变更
auditctl -w /etc/passwd -p wa -k passwd_changes

# 查看规则
auditctl -l

# 触发一次修改
echo "# test" >> /etc/passwd

# 查询审计日志
ausearch -k passwd_changes -i

ausearch 的输出会包含关键字段:

type=SYSCALL msg=audit(1717200000.123:456): arch=x86_64 syscall=openat
    success=yes exit=3 a0=ffffff9c a1=7ffd... 
    ppid=1234 pid=5678 auid=1000 uid=0 gid=0
    comm="bash" exe="/usr/bin/bash"
type=PATH msg=audit(...): item=0 name="/etc/passwd" inode=123456

其中 pid、ppid、comm、exe、auid 就是进程级溯源信息——这正是 inotify 缺失的部分。

4.2 fanotify:可拦截的文件系统事件

fanotify 更强大,它不仅能通知,还能在内核态阻塞事件等待用户态决策(用于防病毒、DLP 场景)。下面用 C 演示捕获文件写入事件并读取 PID:

/* fanotify_demo.c
 * 编译: gcc -o fanotify_demo fanotify_demo.c
 * 运行: sudo ./fanotify_demo /path/to/watch
 *
 * 监控指定路径下的文件创建/写入,打印触发进程的 PID 与命令名。
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <limits.h>
#include <errno.h>
#include <sys/fanotify.h>
#include <sys/stat.h>

#define BUF_SIZE 4096

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s <监控路径>\n", argv[0]);
        return 1;
    }

    /* 初始化 fanotify,使用 FAN_CLASS_NOTIF 表示只通知不拦截 */
    int fd = fanotify_init(FAN_CLASS_NOTIF | FAN_CLOEXEC,
                           O_RDONLY | O_LARGEFILE);
    if (fd < 0) {
        perror("fanotify_init");
        return 1;
    }

    /* 监控文件创建、删除、移动、写入关闭 */
    uint64_t mask = FAN_CREATE | FAN_DELETE | FAN_MOVED_FROM |
                    FAN_MOVED_TO | FAN_CLOSE_WRITE;

    if (fanotify_mark(fd, FAN_MARK_ADD | FAN_MARK_MOUNT,
                      mask, AT_FDCWD, argv[1]) < 0) {
        perror("fanotify_mark");
        close(fd);
        return 1;
    }

    printf("[*] 正在监控 %s (fanotify)\n", argv[1]);

    char buf[BUF_SIZE];
    while (1) {
        ssize_t len = read(fd, buf, sizeof(buf));
        if (len <= 0) {
            if (errno == EINTR) continue;
            perror("read");
            break;
        }

        struct fanotify_event_metadata *meta;
        for (meta = (struct fanotify_event_metadata *)buf;
             FAN_EVENT_OK(meta, len);
             meta = FAN_EVENT_NEXT(meta, len)) {

            if (meta->fd == FAN_NOFD) continue;

            /* 解析事件对应的文件路径 */
            char path[PATH_MAX];
            char proc_path[64];
            snprintf(proc_path, sizeof(proc_path),
                     "/proc/self/fd/%d", meta->fd);
            ssize_t plen = readlink(proc_path, path, sizeof(path) - 1);
            if (plen > 0) path[plen] = '\0';
            else strcpy(path, "<unknown>");

            /* 获取触发进程信息 */
            char comm[64] = {0};
            char cmdline_path[64];
            snprintf(cmdline_path, sizeof(cmdline_path),
                     "/proc/%d/comm", meta->pid);
            FILE *f = fopen(cmdline_path, "r");
            if (f) {
                if (fgets(comm, sizeof(comm), f)) {
                    comm[strcspn(comm, "\n")] = '\0';
                }
                fclose(f);
            }

            printf("event=0x%llx pid=%d comm=%s path=%s\n",
                   (unsigned long long)meta->mask,
                   meta->pid, comm, path);

            close(meta->fd);
        }
    }

    close(fd);
    return 0;
}

运行效果:

$ sudo ./fanotify_demo /data
[*] 正在监控 /data (fanotify)
event=0x20 pid=12345 comm=bash path=/data/report.csv
event=0x8  pid=12345 comm=bash path=/data/report.csv

至此,我们拿到了时间 + 路径 + 进程 + 事件类型四要素。但还缺一样东西:变更前后的内容。


五、第四层:内容寻址与版本化——让产物自带指纹

5.1 内容寻址存储(CAS)的核心思想

元数据和事件告诉你"何时何地被谁改",但要证明"改了成什么",必须对内容做指纹。内容寻址存储(Content-Addressable Storage)用哈希值作为键来索引对象:

对象ID = SHA-256(对象内容)

这意味着:

  • 相同内容永远得到相同 ID(天然去重);
  • 任何一比特的改动都会导致 ID 变化(天然防篡改);
  • 通过 ID 就能精确回放任意历史版本。

Git 就是最经典的 CAS 实现。我们可以用几十行 Python 复刻一个迷你 CAS:

#!/usr/bin/env python3
"""
mini_cas.py —— 一个最小化的内容寻址存储,支持 put/get/history。
对象按 SHA-256 前缀分目录存储,避免单目录文件过多。
"""
import hashlib
import os
import shutil
import sys
import time
from pathlib import Path

class MiniCAS:
    def __init__(self, root: str = ".cas"):
        self.root = Path(root)
        self.objects = self.root / "objects"
        self.refs = self.root / "refs"
        self.objects.mkdir(parents=True, exist_ok=True)
        self.refs.mkdir(parents=True, exist_ok=True)

    def _object_path(self, digest: str) -> Path:
        # 用前两位做子目录,模拟 Git 的对象布局
        return self.objects / digest[:2] / digest[2:]

    def put(self, data: bytes) -> str:
        """存入内容,返回其 SHA-256 摘要。"""
        digest = hashlib.sha256(data).hexdigest()
        path = self._object_path(digest)
        if not path.exists():
            path.parent.mkdir(parents=True, exist_ok=True)
            # 先写临时文件再原子重命名,避免读到半成品
            tmp = path.with_suffix(".tmp")
            tmp.write_bytes(data)
            os.replace(tmp, path)
        return digest

    def get(self, digest: str) -> bytes:
        path = self._object_path(digest)
        if not path.exists():
            raise KeyError(f
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐