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 0000000..71dc406 Binary files /dev/null and b/tests/fixtures/controltrace-0.2.8-v3.zip differ 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" },