From aad47208b542bc77c308c3b6bd330f8be38ff1ea Mon Sep 17 00:00:00 2001 From: dev-belly Date: Sat, 24 Oct 2026 13:22:24 +0800 Subject: [PATCH] fix: version spreadsheet export policy while retaining legacy bundle replay --- README.md | 8 +-- docs/CSV_EXPORTS.md | 31 ++++++++++ docs/REVIEW_INTEGRITY.md | 4 +- pyproject.toml | 2 +- src/controltrace/__init__.py | 2 +- src/controltrace/exports.py | 28 ++++++--- src/controltrace/verify.py | 11 ++-- tests/fixtures/README.md | 11 ++++ tests/fixtures/controltrace-0.2.8-v3.zip | Bin 0 -> 21298 bytes tests/test_csv_policy.py | 75 +++++++++++++++++++++++ tests/test_exports.py | 2 +- uv.lock | 2 +- 12 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 docs/CSV_EXPORTS.md create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/controltrace-0.2.8-v3.zip create mode 100644 tests/test_csv_policy.py diff --git a/README.md b/README.md index f073d67..c90e5b8 100644 --- a/README.md +++ b/README.md @@ -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)。 ## 数据字典与案例 @@ -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 保持其历史重放策略。 ## 已知局限 diff --git a/docs/CSV_EXPORTS.md b/docs/CSV_EXPORTS.md new file mode 100644 index 0000000..2418791 --- /dev/null +++ b/docs/CSV_EXPORTS.md @@ -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)保留并重放旧字节, +同时检查新备注原值、全角及控制字符、前缀碰撞、数值与多行记录边界。 diff --git a/docs/REVIEW_INTEGRITY.md b/docs/REVIEW_INTEGRITY.md index 00a7d4f..ad6b2d2 100644 --- a/docs/REVIEW_INTEGRITY.md +++ b/docs/REVIEW_INTEGRITY.md @@ -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 策略及证据关联重放。更早版本仍需其原版本校验器。这种兼容不会把旧底稿升级成拥有新保证的证据。 指纹和包内清单不能证明复核者身份、源系统真实性或防御同时重写所有内容的攻击者。新包只带当前源快照,保留的历史指纹并不等于保存了旧源快照。认证、签名和历史源版本归档仍是实际业务接入前需要单独解决的工作。 diff --git a/pyproject.toml b/pyproject.toml index 4003e8a..c071b9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/controltrace/__init__.py b/src/controltrace/__init__.py index 7a1537f..f209780 100644 --- a/src/controltrace/__init__.py +++ b/src/controltrace/__init__.py @@ -1,3 +1,3 @@ """ControlTrace synthetic IT audit demonstration.""" -__version__ = "0.2.8" +__version__ = "0.2.9" diff --git a/src/controltrace/exports.py b/src/controltrace/exports.py index eee70de..1077593 100644 --- a/src/controltrace/exports.py +++ b/src/controltrace/exports.py @@ -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", @@ -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( @@ -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__, diff --git a/src/controltrace/verify.py b/src/controltrace/verify.py index a142e3c..bef0b02 100644 --- a/src/controltrace/verify.py +++ b/src/controltrace/verify.py @@ -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"}, @@ -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") @@ -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"} @@ -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 { diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..a3f7135 --- /dev/null +++ b/tests/fixtures/README.md @@ -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. diff --git a/tests/fixtures/controltrace-0.2.8-v3.zip b/tests/fixtures/controltrace-0.2.8-v3.zip new file mode 100644 index 0000000000000000000000000000000000000000..71dc406bd4dc0b0c0db4a8654e7cd660f983698a GIT binary patch literal 21298 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 bnt@#;uB`~F4OuZf?jzPrl~4Zxy4SH{ literal 0 HcmV?d00001 diff --git a/tests/test_csv_policy.py b/tests/test_csv_policy.py new file mode 100644 index 0000000..7ccb0ad --- /dev/null +++ b/tests/test_csv_policy.py @@ -0,0 +1,75 @@ +"""Spreadsheet copies preserve source evidence and retain old replay policies.""" + +from __future__ import annotations + +import csv +import io +import json +import zipfile +from pathlib import Path + +import pytest + +from controltrace.exports import _csv_bytes, exception_csv, workpaper_zip +from controltrace.rules import run_tests +from controltrace.store import get_finding, initialize_db, save_review +from controltrace.verify import verify_bundle + + +def _rows(content): + return list(csv.reader(io.StringIO(content.decode("utf-8-sig"), newline=""))) + + +@pytest.mark.parametrize( + "value", + ["=1+1", "+1+1", "-1+1", "@SUM(1)", "=1+1", "+1+1", "-1+1", "@SUM(1)", + " \t=1+1", "\ufeff=1+1", "\x00=1+1", "\x7f=1+1", "\r=1+1", "\n=1+1"], +) +def test_untrusted_string_prefixes_are_visible_text_in_values_and_headers(value): + assert _rows(_csv_bytes([{value: value}])) == [["text: " + value], ["text: " + value]] + + +def test_numeric_values_and_multiline_record_boundaries_are_preserved(): + text = 'ordinary, "quoted"\rCR\nLF\r\nCRLF' + assert _rows(_csv_bytes([{"note": text, "amount": -12.5}], ["note", "amount"])) == [ + ["note", "amount"], [text, "-12.5"], + ] + + +def test_visible_prefix_does_not_merge_distinct_strings(): + values = ["=1", "text: =1", "text: text: =1"] + assert _rows(_csv_bytes([{"note": value} for value in values]))[1:] == [ + ["text: " + value] for value in values + ] + + +def test_v4_review_export_keeps_original_json_and_replays(tmp_path): + db = tmp_path / "case.duckdb" + initialize_db(db) + finding_id = run_tests(db)[0]["finding_id"] + notes = "=1+1\rsecond line\nthird line" + save_review(db, finding_id, "Synthetic reviewer", "in_review", "needs_more_evidence", notes) + finding = get_finding(db, finding_id) + rows = list(csv.DictReader(io.StringIO(exception_csv([finding]).decode("utf-8-sig"), newline=""))) + assert rows[0]["notes"] == "text: " + notes + bundle = workpaper_zip(db, {finding_id}) + path = tmp_path / "new.zip" + path.write_bytes(bundle) + assert verify_bundle(path)["review_evidence_bound"] is True + with zipfile.ZipFile(io.BytesIO(bundle)) as archive: + manifest = json.loads(archive.read("manifest.json")) + assert manifest["bundle_format_version"] == 4 + assert manifest["controltrace_version"] == "0.2.9" + assert json.loads(archive.read("reviews.json"))[finding_id][0]["notes"] == notes + + +def test_actual_v028_fixture_retains_legacy_csv_and_evidence_binding(): + path = Path(__file__).parent / "fixtures" / "controltrace-0.2.8-v3.zip" + result = verify_bundle(path) + assert result["review_evidence_bound"] is True + assert result["replayed_findings"] == 12 + assert result["exported_findings"] == 1 + with zipfile.ZipFile(path) as archive: + rows = list(csv.DictReader(io.StringIO(archive.read("findings.csv").decode("utf-8-sig"), newline=""))) + assert rows[0]["notes"].startswith("'=SUM(1,2)") + assert "\r=1+1\nFullwidth: =1+1" in rows[0]["notes"] diff --git a/tests/test_exports.py b/tests/test_exports.py index 0c68ac3..7a1e1e6 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -93,7 +93,7 @@ def test_exception_csv_includes_cutoff_and_escapes_formula_notes(tmp_path): csv_text = exception_csv([reviewed]).decode("utf-8-sig") assert "audit_cutoff" in csv_text assert "Reviewer B" in csv_text - assert "'=HYPERLINK" in csv_text + assert "text: =HYPERLINK" in csv_text def test_workpaper_encodes_multiline_review_notes_as_data(tmp_path): diff --git a/uv.lock b/uv.lock index e763dd3..7bb6070 100644 --- a/uv.lock +++ b/uv.lock @@ -220,7 +220,7 @@ wheels = [ [[package]] name = "controltrace" -version = "0.2.8" +version = "0.2.9" source = { editable = "." } dependencies = [ { name = "duckdb" },