Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ uv run controltrace export # 重新测试并导出 CSV、可核验底稿 ZIP
uv run controltrace verify # 校验 ZIP 文件哈希,并从包内源数据重放自动测试
```

界面可以按系统、期间、控制类型和风险等级筛选线索;点开一条线索可查看事件时间线、关联记录、规则依据和证据 ID。复核者填写姓名、处理状态、结论与备注后,导出的 CSV 和 ZIP 会包含当前复核记录。ZIP 同时包含合成源表的原值 JSON、适合表格查看的 CSV、机器可读的复核历史 `reviews.json`、规则元数据、全部文件的 SHA-256 清单和逐条 Markdown 底稿。`verify` 会核对文件哈希、CSV 与 JSON 的一致性,并用包内原值重放规则,逐字节比较所选发现清单与底稿。旧格式底稿须用当前版本重新导出。
界面可以按系统、期间、控制类型和风险等级筛选线索;点开一条线索可查看事件时间线、关联记录、规则依据和证据 ID。复核者填写姓名、处理状态、结论与备注后,导出的 CSV 和 ZIP 会包含当前复核记录。ZIP 同时包含合成源表的原值 JSON、适合表格查看的 CSV、机器可读的复核历史 `reviews.json`、规则元数据、全部文件的 SHA-256 清单和逐条 Markdown 底稿。`verify` 会核对文件哈希、CSV 与 JSON 的一致性,并用包内原值重放规则,逐字节比较所选发现清单与底稿。格式 2/3 继续按其历史规则核验,更早格式请使用原版本。

底稿 JSON 会先经过结构校验:清单必须是对象、源表必须是对象行数组,重复键与 `NaN`/`Infinity` 等非有限数值会被拒绝。非法输入由 `verify` 返回错误说明和退出码 1,避免把解析歧义带入后续重放。

复核历史按解析后的 UTC 时间排列,保留小数秒精度;时间相同则按稳定复核 ID 排序,因此补录较早的记录不会替换较晚的结论。`verify` 同时检查每条复核的字段、复核人、时间、状态与结论:`pending` 不能带结论,`in_review` 可不填结论或标记待补证,`closed` 必须有最终结论和说明。即使文件哈希及 CSV、底稿内容相互一致,非法复核或逆序历史也会被拒绝。此检查仍不能证明复核人的身份或原始材料真实性。升级到新版本后,请重新导出旧版本底稿。
复核历史按解析后的 UTC 时间排列,保留小数秒精度;时间相同则按稳定复核 ID 排序,因此补录较早的记录不会替换较晚的结论。`verify` 同时检查每条复核的字段、复核人、时间、状态与结论:`pending` 不能带结论,`in_review` 可不填结论或标记待补证,`closed` 必须有最终结论和说明。即使文件哈希及 CSV、底稿内容相互一致,非法复核或逆序历史也会被拒绝。此检查仍不能证明复核人的身份或原始材料真实性。

**复核不能只跟着发现 ID 走。** 0.2.8 将每次新复核绑定到自动发现的完整证据指纹。相同证据重跑保留当前结论;同一 ID 的关联来源记录或判断口径变化后,原结论保留在历史中,当前状态返回待复核。界面保存时核对首次展示的编辑版本;其他人追加复核或证据发生变化后,须显式重新加载,防止旧表单覆盖新证据。版本检查、复核及指纹关联在一个事务内提交,并对同一发现的并发写入进行冲突检查。[复核完整性设计](docs/REVIEW_INTEGRITY.md)解释业务场景、Python API、迁移和可复现实验。

新 ZIP 使用格式 3,增加 `review_evidence.json`,`verify` 根据重放的当前证据选择可适用的复核。旧数据库的复核历史继续保留,但因原证据版本未知,升级后需要重新评估当前证据。格式 2 / 0.2.7 底稿仍可核验,结果明确说明它没有证据版本关联;更早底稿请使用原版本。新包没有归档历史源快照,也不提供身份认证或签名。
新 ZIP 使用格式 4,延续格式 3 的 `review_evidence.json`,`verify` 根据重放的当前证据选择可适用的复核。0.2.9 加强 CSV 字符串和表头的公式防护:全角触发符、前置空白与控制字符使用可见 `text: ` 前缀,JSON 原值不变。格式 3 / 0.2.8 保留旧 CSV 规则,格式 2 / 0.2.7 也仍可核验,后者明确说明没有证据版本关联。旧数据库的未知历史仍须重新评估,不补造证据关联;新包没有归档历史源快照,也不提供身份认证或签名。[CSV 策略与版本兼容表](docs/CSV_EXPORTS.md)。

## 数据字典与案例

Expand All @@ -91,7 +91,7 @@ python -m ruff check src tests
python -m pytest -q
```

**本机验证(2026-10-12,Windows / Python 3.12.13):** 完整 pytest 为 **90 passed**,Ruff 与 git diff --check 通过;实际 CSV/ZIP 导出及包内重放有 12 条预置线索(9 条演示异常、3 条人工判断),验证 28 个源文件。新增回归覆盖 CH002 审批时间修正后同一发现重新待复核、无关线索保留结论、两个复核者同版本并发、事务失败回滚、界面显式重新加载、旧数据库迁移、旧格式兼容及重哈希后的伪造当前结论拒绝。既有测试覆盖日期边界、缺失关联、复核 UTC 时间、合法状态和非法 JSON。[GitHub Actions CI](https://github.com/dev-belly/ControlTrace/actions/workflows/ci.yml) 配置在 Ubuntu / Python 3.11、3.12 和 Windows / Python 3.12 运行 Ruff 与完整 pytest。当前包版本为 0.2.8;格式 2 / 0.2.7 底稿可继续核验,新导出采用格式 3。
**本机验证(2026-10-24,Linux / Python 3.12.14):** 完整 pytest 为 **108 passed**,Ruff 与 git diff --check 通过。新增 18 个 CSV/兼容回归,覆盖全角及控制字符、表头、前缀碰撞、数值、多行记录边界、新格式原值保留,以及由未修改的 0.2.8 实际导出的旧包重放。该旧包含 12 条预置线索中的一条导出、28 个源文件和一条程序生成的合成复核;不是实际人员的审计意见。既有用例覆盖证据变更后重新待复核、并发复核、事务回滚、迁移、非法 JSON 和重哈希后的伪造结论。[GitHub Actions CI](https://github.com/dev-belly/ControlTrace/actions/workflows/ci.yml) 在 Ubuntu / Python 3.11、3.12 和 Windows / Python 3.12 运行 Ruff 与完整 pytest。当前包版本为 0.2.9,新导出采用格式 4;格式 2/3 保持其历史重放策略。

## 已知局限

Expand Down
31 changes: 31 additions & 0 deletions docs/CSV_EXPORTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# CSV 展示副本与原值证据

从 0.2.9 / 底稿格式 4 起,异常清单和源表 CSV 的字符串字段及表头若以
ASCII 或全角 `= + - @` 开始(包括前置空白),或以控制字符、BOM 开始,
会加上可见的 `text: ` 前缀。原本就以 `text: ` 开始的字符串再次加前缀,
防止它与处理后的另一个原值混淆。数值类型保持原数值,字符串中的 CR、LF、
逗号和引号按 CSV 规则引用,不能变成额外记录。

CSV 是便于表格查看的副本。`source_tables/*.json` 与 `reviews.json` 保留
原始字符串;证据关联和规则计算使用 JSON 原值。比如复核备注 `=1+1`
在 CSV 中显示为 `text: =1+1`,在复核历史 JSON 中仍是 `=1+1`。

旧的单引号方式没有覆盖全角触发字符,且部分电子表格软件重新保存后可能
移除转义。可见文本前缀提高了审阅时的可辨认性,但不保证所有表格软件、
导入设置或后续手工编辑都安全。参见 [OWASP CSV Injection](https://owasp.org/www-community/attacks/CSV_Injection)。

## 重放兼容

| 底稿格式 / 应用版本 | CSV 重放规则 | 复核证据关联 |
| --- | --- | --- |
| 2 / 0.2.7 | 原单引号策略 | 无,明确返回 `review_evidence_bound=false` |
| 3 / 0.2.8 | 原单引号策略 | 有 |
| 4 / 0.2.9 | 可见 `text: ` 策略,包含表头 | 有 |

格式号与应用版本必须匹配;校验器不能把旧 CSV 当成新策略处理。格式 2/3
只在校验旧底稿时使用历史序列化规则,新导出始终使用格式 4。
保留旧包原文件,再用当前源快照重新导出,可得到采用新 CSV 策略的副本;
不要手改旧包的清单、版本号或哈希。新导出不能补造未知的历史证据版本。

[实际 0.2.8 导出包的回归](../tests/test_csv_policy.py)保留并重放旧字节,
同时检查新备注原值、全角及控制字符、前缀碰撞、数值与多行记录边界。
4 changes: 2 additions & 2 deletions docs/REVIEW_INTEGRITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ save_review(

旧数据库没有保存复核时的证据指纹。迁移保留所有原记录,但把它们视为证据版本未知,不从今天的源数据补造历史关联;使用者要重新评估当前证据后记录新结论。

新底稿采用格式 3,保留原 `reviews.json` 字段,同时增加 `review_evidence.json`:按发现和复核 ID 保存关联指纹,未知历史为 `null`。CSV 标明当前证据指纹和不适用历史数量,Markdown 明确显示当前结论与全部历史。校验器从包内源记录重放自动发现,重算指纹并独立选择当前结论;即使 CSV、底稿和文件哈希被一致修改,旧证据结论冒充当前结论仍会被拒绝。
从格式 3 起,底稿保留原 `reviews.json` 字段,同时增加 `review_evidence.json`:按发现和复核 ID 保存关联指纹,未知历史为 `null`。CSV 标明当前证据指纹和不适用历史数量,Markdown 明确显示当前结论与全部历史。校验器从包内源记录重放自动发现,重算指纹并独立选择当前结论;即使 CSV、底稿和文件哈希被一致修改,旧证据结论冒充当前结论仍会被拒绝。0.2.9 新导出采用格式 4,加强 CSV 字符串和表头的展示策略,JSON 原值与证据关联规则不变;[CSV 版本表](CSV_EXPORTS.md)列出细节。

格式 2、版本 0.2.7 的旧底稿继续支持原始重放;返回 `review_evidence_bound=false`,命令行也说明旧格式没有证据版本关联。更早版本仍需其原版本校验器。这种兼容不会把旧底稿升级成拥有新保证的证据。
格式 2、版本 0.2.7 的旧底稿继续支持原始重放;返回 `review_evidence_bound=false`,命令行也说明旧格式没有证据版本关联。格式 3 / 0.2.8 保留原 CSV 策略及证据关联重放。更早版本仍需其原版本校验器。这种兼容不会把旧底稿升级成拥有新保证的证据。

指纹和包内清单不能证明复核者身份、源系统真实性或防御同时重写所有内容的攻击者。新包只带当前源快照,保留的历史指纹并不等于保存了旧源快照。认证、签名和历史源版本归档仍是实际业务接入前需要单独解决的工作。

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "controltrace"
version = "0.2.8"
version = "0.2.9"
description = "Synthetic IT audit evidence and internal control testing workbench"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/controltrace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ControlTrace synthetic IT audit demonstration."""

__version__ = "0.2.8"
__version__ = "0.2.9"
28 changes: 19 additions & 9 deletions src/controltrace/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,40 @@ def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)


def _csv_bytes(rows: list[dict[str, Any]], fields: list[str] | None = None) -> bytes:
def _csv_bytes(
rows: list[dict[str, Any]], fields: list[str] | None = None, *, legacy: bool = False,
) -> bytes:
fields = fields or sorted({key for row in rows for key in row})
buffer = io.StringIO(newline="")
writer = csv.DictWriter(buffer, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
# Keep original mapping keys; the displayed header is also untrusted text.
writer.writerow({key: key if legacy else _csv_cell(key) for key in fields})
for row in rows:
writer.writerow(
{
key: _csv_cell(value)
key: _csv_cell(value, legacy=legacy)
for key, value in row.items()
}
)
return buffer.getvalue().encode("utf-8-sig")


def _csv_cell(value: Any) -> Any:
def _csv_cell(value: Any, *, legacy: bool = False) -> Any:
if isinstance(value, (list, dict)):
value = _json(value)
if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@")):
return "'" + value
if isinstance(value, str) and value:
if legacy:
# Formats 2/3 retain their exact historical bytes for replay only.
if value.lstrip().startswith(("=", "+", "-", "@")):
return "'" + value
elif (value.startswith("text: ") or ord(value[0]) < 32 or ord(value[0]) == 127
or value[0] == "\ufeff"
or value.lstrip().startswith(("=", "+", "-", "@", "=", "+", "-", "@"))):
return "text: " + value
return value


def exception_csv(findings: list[dict[str, Any]]) -> bytes:
def exception_csv(findings: list[dict[str, Any]], *, legacy: bool = False) -> bytes:
"""A flat list, including decisions, for review outside the app."""
fields = [
"finding_id", "control_id", "issue_code", "classification", "risk", "system",
Expand All @@ -67,7 +77,7 @@ def exception_csv(findings: list[dict[str, Any]]) -> bytes:
"notes": review.get("notes", ""),
}
)
return _csv_bytes(rows, fields)
return _csv_bytes(rows, fields, legacy=legacy)


def finding_workpaper(
Expand Down Expand Up @@ -207,7 +217,7 @@ def workpaper_zip(db_path: str | Path, finding_ids: set[str] | None = None) -> b
})
manifest = {
"notice": "Synthetic case study; no real enterprise audit data",
"bundle_format_version": 3,
"bundle_format_version": 4,
"audit_cutoff": DEMO_CUTOFF,
"generator_seed": int(meta.get("seed", DEFAULT_SEED)),
"controltrace_version": __version__,
Expand Down
11 changes: 6 additions & 5 deletions src/controltrace/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ def verify_bundle(path: str | Path) -> dict[str, Any]:
manifest = _read_json(archive, "manifest.json")
_require(isinstance(manifest, dict), "Manifest must be a JSON object")
bundle_format = manifest.get("bundle_format_version")
_require(bundle_format in (2, 3),
_require(bundle_format in (2, 3, 4),
"Unsupported bundle format; export again with this ControlTrace version")
evidence_bound = bundle_format == 3
evidence_bound = bundle_format >= 3
legacy_csv = bundle_format < 4
expected_files = manifest.get("files_sha256")
_require(isinstance(expected_files, dict), "Manifest has no complete file hash list")
_require(set(expected_files) == set(names) - {"manifest.json"},
Expand All @@ -87,7 +88,7 @@ def verify_bundle(path: str | Path) -> dict[str, Any]:
_require(manifest.get("audit_cutoff") == DEMO_CUTOFF,
"Bundle cutoff does not match the current control rules")
bundle_version = manifest.get("controltrace_version")
_require(bundle_version == (__version__ if evidence_bound else "0.2.7"),
_require(bundle_version == {2: "0.2.7", 3: "0.2.8", 4: __version__}[bundle_format],
"Bundle was created by a different ControlTrace version")
_require(_read_json(archive, "rules.json") == RULES,
"Rule catalog differs from the installed ControlTrace version")
Expand All @@ -99,7 +100,7 @@ def verify_bundle(path: str | Path) -> dict[str, Any]:
_require(isinstance(rows, list), f"Invalid source JSON: {table}")
_require(all(isinstance(row, dict) for row in rows),
f"Source JSON rows must be objects: {table}")
_require(archive.read(f"source_tables/{table}.csv") == _csv_bytes(rows),
_require(archive.read(f"source_tables/{table}.csv") == _csv_bytes(rows, legacy=legacy_csv),
f"Source CSV differs from JSON: {table}")
source_rows[table] = rows
data = {table: rows for table, rows in source_rows.items() if table != "expected_results"}
Expand Down Expand Up @@ -180,7 +181,7 @@ def verify_bundle(path: str | Path) -> dict[str, Any]:
finding, dataset_hash, version=bundle_version,
).encode("utf-8"),
f"Workpaper differs from replayed evidence or review: {finding_id}")
_require(archive.read("findings.csv") == exception_csv(selected_findings),
_require(archive.read("findings.csv") == exception_csv(selected_findings, legacy=legacy_csv),
"Findings CSV differs from replayed evidence or review")

return {
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Legacy replay fixture

`controltrace-0.2.8-v3.zip` was exported with the unmodified 0.2.8 implementation
at commit `58b03c1aa0d23778753fd1917fb121a8c98fe77c` before changing CSV policy.
SHA-256: `43e1ab5af2bf6992ce49186ae55bd4d6ae409ccd5c570299f2c1e1901b2bf082`.

It contains the default synthetic source snapshot and one programmatically
created test review, not a real person's audit decision. The review note contains
an ASCII formula prefix, embedded CR/LF and fullwidth text. Its CSV uses the
historical apostrophe policy. The regression must replay these retained bytes
under the format-3 policy, while new format-4 exports use visible `text: ` prefixes.
Binary file added tests/fixtures/controltrace-0.2.8-v3.zip
Binary file not shown.
Loading
Loading