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

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

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

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

新 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)。
新 ZIP 使用格式 5,延续 `review_evidence.json` 和可见 `text: ` CSV 策略,按解析后的 UTC 时刻展示时间线,保留原始时间字符串。0.2.10 修复小数秒、混合时区被字符串顺序颠倒的问题。旧格式 2–4 继续按其原顺序重放,校验器明确提示不保证 UTC 时间顺序;格式 2 还明确说明没有证据版本关联。排序修正会改变发现的完整指纹,旧复核保留为历史,当前结论须按新指纹重新评估。新包不补造未知历史关联,不归档历史源快照,也不提供身份认证或签名。[时间线与复核迁移](docs/TIMELINE_ORDER.md) · [CSV 兼容表](docs/CSV_EXPORTS.md)。

## 数据字典与案例

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

**本机验证(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 保持其历史重放策略。
**本机验证(2026-10-24,Linux / Python 3.12.14):** 完整 pytest 为 **113 passed**,Ruff、git diff --check 与锁文件检查通过。18 个 CSV/兼容回归覆盖全角和控制字符、表头、前缀碰撞、数值、多行记录边界及原值保留;另有 5 个时间线/历史绑定回归,覆盖小数秒、混合时区、无效展示时间、旧顺序提示和旧结论不能继承。保留未修改的 0.2.8、0.2.9 代码实际生成的合成底稿,不以当前序列化器伪造旧包。既有用例覆盖并发复核、事务回滚、迁移、非法 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.10,新导出采用格式 5;格式 2–4 保持其原有 CSV 与时间线重放策略。

## 已知局限

Expand Down
4 changes: 3 additions & 1 deletion docs/CSV_EXPORTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ CSV 是便于表格查看的副本。`source_tables/*.json` 与 `reviews.json`
| 2 / 0.2.7 | 原单引号策略 | 无,明确返回 `review_evidence_bound=false` |
| 3 / 0.2.8 | 原单引号策略 | 有 |
| 4 / 0.2.9 | 可见 `text: ` 策略,包含表头 | 有 |
| 5 / 0.2.10 | 同格式 4,时间线改用实际 UTC 顺序 | 有 |

格式号与应用版本必须匹配;校验器不能把旧 CSV 当成新策略处理。格式 2/3
只在校验旧底稿时使用历史序列化规则,新导出始终使用格式 4。
只在校验旧底稿时使用历史序列化规则,新导出始终使用格式 5。
格式 2–4 仍按原字符串时间线重放,并明确报告旧排序;见[时间线与复核迁移](TIMELINE_ORDER.md)。
保留旧包原文件,再用当前源快照重新导出,可得到采用新 CSV 策略的副本;
不要手改旧包的清单、版本号或哈希。新导出不能补造未知的历史证据版本。

Expand Down
2 changes: 1 addition & 1 deletion docs/REVIEW_INTEGRITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ save_review(

从格式 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`,命令行也说明旧格式没有证据版本关联。格式 3 / 0.2.8 保留原 CSV 策略及证据关联重放。更早版本仍需其原版本校验器。这种兼容不会把旧底稿升级成拥有新保证的证据。
格式 2、版本 0.2.7 的旧底稿继续支持原始重放;返回 `review_evidence_bound=false`,命令行也说明旧格式没有证据版本关联。格式 3 / 0.2.8 保留原 CSV 策略及证据关联重放,格式 4 / 0.2.9 保留其可见文本 CSV。0.2.10 新导出使用格式 5,按实际 UTC 时刻排列时间线;格式 2–4 继续按原字符串顺序重放,并返回 `legacy_timeline_order=true`。更早版本仍需其原版本校验器。这种兼容不会把旧底稿升级成拥有新保证的证据。[时间线迁移](TIMELINE_ORDER.md)说明为什么排序修正也可能要求重新复核。

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

Expand Down
31 changes: 31 additions & 0 deletions docs/TIMELINE_ORDER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 审计时间线按实际 UTC 时刻排序

ISO 时间字符串的字典顺序不一定是事件顺序:`11:00:00+01:00` 是 UTC
`10:00:00`,而 `06:00:02-04:00` 是 UTC `10:00:02`。旧展示会把后者排在
前面。整秒的 `...00Z` 与小数秒的 `...00.050000Z` 也不能直接按字符排序。

0.2.10 根据解析后的 UTC 时刻排序,以证据 ID 和字段名打破同时刻的并列。
时间线上仍显示原始来源字符串;不能解析或为空的时间不加入时间线,但原记录
继续保留在关联证据中。控制谓词未改变,未知或非法业务输入仍须单独核实。

## 为什么需要格式 5

发现指纹包含完整时间线。若修正顺序,旧指纹就不再表示当前自动发现,之前的
复核保留为历史,当前状态返回 `pending`;不能因为来源 ID 没变就继承旧结论。
这里可能只改变了证据呈现的顺序,并不意味着来源时间被改写。

| 底稿格式 / 版本 | 时间线重放 | CSV 策略 | 证据关联 |
| --- | --- | --- | --- |
| 2 / 0.2.7 | 原字符串顺序 | 原单引号方式 | 无 |
| 3 / 0.2.8 | 原字符串顺序 | 原单引号方式 | 有 |
| 4 / 0.2.9 | 原字符串顺序 | 可见 `text: ` | 有 |
| 5 / 0.2.10 | 实际 UTC 时刻 | 可见 `text: ` | 有 |

格式 2–4 校验时重建原顺序,以保留原底稿与复核指纹的一致性,返回
`legacy_timeline_order=true`,命令行明确提醒它不保证 UTC 时间顺序。
新导出始终使用格式 5,返回 `legacy_timeline_order=false`。不要直接修改旧包
的格式号或清单来冒充升级;保留原包,再用当前源快照重跑并按新指纹重新复核。

[0.2.9 实际导出包](../tests/fixtures/README.md)保留混合时区的旧顺序和一条
程序生成的合成复核。回归同时验证旧包可核验,以及当前排序不会继承旧指纹
的关闭结论。这些是合成工程测试,不是实际审计意见或来源真实性证明。
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.9"
version = "0.2.10"
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.9"
__version__ = "0.2.10"
2 changes: 2 additions & 0 deletions src/controltrace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def main(argv: list[str] | None = None) -> int:
)
if not result["review_evidence_bound"]:
print("Legacy bundle: review history is valid but was not bound to an evidence snapshot.")
if result["legacy_timeline_order"]:
print("Legacy timeline: original string order is replayed; UTC chronology is not guaranteed.")
return 0
from controltrace.rules import run_tests
from controltrace.store import initialize_db
Expand Down
2 changes: 1 addition & 1 deletion src/controltrace/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,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": 4,
"bundle_format_version": 5,
"audit_cutoff": DEMO_CUTOFF,
"generator_seed": int(meta.get("seed", DEFAULT_SEED)),
"controltrace_version": __version__,
Expand Down
38 changes: 27 additions & 11 deletions src/controltrace/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,32 @@ def _index(rows: list[dict], field: str) -> dict[str, dict]:
return {row[field]: row for row in rows}


def evidence_timeline(related: list[dict], *, legacy: bool = False) -> list[dict]:
"""Preserve source timestamps while ordering the current view by UTC."""
timeline = []
for item in related:
record = item["record"]
for field, label, kind in _TIMELINE_FIELDS.get(item["table"], []):
value = record.get(field)
if legacy:
include = bool(value)
else:
try:
include = parse_utc(value) is not None
except (ValueError, TypeError, AttributeError, OverflowError):
include = False
if include:
timeline.append({
"at": value, "event": label, "kind": kind,
"field": field, "evidence_id": item["id"],
"source_system": record["source_system"],
})
timeline.sort(key=lambda item: (
item["at"] if legacy else parse_utc(item["at"]), item["evidence_id"], item["field"],
))
return timeline


def _finding(
control_id: str,
issue_code: str,
Expand All @@ -132,17 +158,7 @@ def _finding(
{"table": table, "id": str(record[PRIMARY_KEYS[table]]), "record": record.copy()}
for table, record in unique.values()
]
timeline = []
for item in related:
record = item["record"]
for field, label, kind in _TIMELINE_FIELDS.get(item["table"], []):
if record.get(field):
timeline.append({
"at": record[field], "event": label, "kind": kind,
"field": field, "evidence_id": item["id"],
"source_system": record["source_system"],
})
timeline.sort(key=lambda item: (item["at"], item["evidence_id"], item["field"]))
timeline = evidence_timeline(related)
digest = hashlib.sha256(f"{control_id}|{issue_code}|{entity_id}".encode()).hexdigest()[:12]
return {
"finding_id": f"F-{digest.upper()}",
Expand Down
13 changes: 10 additions & 3 deletions src/controltrace/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from controltrace.data import DEMO_CUTOFF, SOURCE_TABLES
from controltrace.exports import _csv_bytes, _json, exception_csv, finding_workpaper
from controltrace.reviews import REVIEW_FIELDS, bind_review_history, validate_review_decision
from controltrace.rules import RULES, evaluate
from controltrace.rules import RULES, evaluate, evidence_timeline


class BundleVerificationError(ValueError):
Expand Down Expand Up @@ -61,10 +61,11 @@ 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, 4),
_require(bundle_format in (2, 3, 4, 5),
"Unsupported bundle format; export again with this ControlTrace version")
evidence_bound = bundle_format >= 3
legacy_csv = bundle_format < 4
legacy_timeline = bundle_format < 5
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 @@ -88,7 +89,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 == {2: "0.2.7", 3: "0.2.8", 4: __version__}[bundle_format],
_require(bundle_version == {2: "0.2.7", 3: "0.2.8", 4: "0.2.9", 5: __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 @@ -105,6 +106,11 @@ def verify_bundle(path: str | Path) -> dict[str, Any]:
source_rows[table] = rows
data = {table: rows for table, rows in source_rows.items() if table != "expected_results"}
replayed = evaluate(data)
if legacy_timeline:
# Old evidence fingerprints include the original timeline order.
# Replay those retained bytes without granting UTC-order guarantees.
for finding in replayed:
finding["timeline"] = evidence_timeline(finding["related_records"], legacy=True)
all_ids = [finding["finding_id"] for finding in replayed]
_require(len(all_ids) == len(set(all_ids)), "Replay produced duplicate finding IDs")
_require(manifest.get("all_finding_ids") == all_ids,
Expand Down Expand Up @@ -191,4 +197,5 @@ def verify_bundle(path: str | Path) -> dict[str, Any]:
"exported_findings": len(selected_ids),
"scope": scope,
"review_evidence_bound": evidence_bound,
"legacy_timeline_order": legacy_timeline,
}
9 changes: 9 additions & 0 deletions tests/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ created test review, not a real person's audit decision. The review note contain
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.

`controltrace-0.2.9-v4-timeline.zip` was exported by the unmodified 0.2.9 code at
`20c0bcfe33e23a95a3c0f95975d86474800fcd26` before changing timeline ordering.
SHA-256: `85401d33aee4c20b81469d58b505d07658a1f3c8c0b57c27a184b6906ea069c9`.
Three synthetic CH002/D002 timestamps use mixed UTC offsets, and one test review
is programmatically recorded. Original lexical order places deployment before
approval. Format-4 replay must preserve its historical bytes and review binding,
with an explicit legacy-order notice. A current UTC-order replay must require
a new review for the corrected finding representation.
Binary file added tests/fixtures/controltrace-0.2.9-v4-timeline.zip
Binary file not shown.
6 changes: 3 additions & 3 deletions tests/test_csv_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def test_visible_prefix_does_not_merge_distinct_strings():
]


def test_v4_review_export_keeps_original_json_and_replays(tmp_path):
def test_current_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"]
Expand All @@ -58,8 +58,8 @@ def test_v4_review_export_keeps_original_json_and_replays(tmp_path):
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 manifest["bundle_format_version"] == 5
assert manifest["controltrace_version"] == "0.2.10"
assert json.loads(archive.read("reviews.json"))[finding_id][0]["notes"] == notes


Expand Down
Loading
Loading