From 95764217f94be49d9d9bebe3e98f228dabdb5afa Mon Sep 17 00:00:00 2001 From: Yahoo <119646314+y4ho0@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:28:43 +0800 Subject: [PATCH 1/3] Harden workspace and reporting security boundaries --- CHANGELOG.md | 9 + CHANGELOG.zh-CN.md | 9 + README.md | 10 +- README.zh-CN.md | 10 +- docs/JSON_SCHEMA.md | 4 +- docs/LIMITATIONS.md | 6 +- docs/PRODUCT_SPEC.md | 2 +- docs/SCENARIOS.md | 4 +- docs/SECURITY_MODEL.md | 11 +- docs/demo/report-v1.example.json | 2 +- docs/zh-CN/JSON_SCHEMA.md | 4 +- docs/zh-CN/LIMITATIONS.md | 6 +- docs/zh-CN/PRODUCT_SPEC.md | 2 +- docs/zh-CN/SCENARIOS.md | 4 +- docs/zh-CN/SECURITY_MODEL.md | 11 +- examples/assumezero.toml | 2 + schemas/config-v1.schema.json | 9 +- schemas/report-v1.schema.json | 7 +- scripts/validate-schemas.sh | 20 + src/cli.rs | 110 ++-- src/config.rs | 98 ++- src/engine.rs | 145 +++-- src/fingerprint.rs | 155 ++++- src/model.rs | 4 +- src/oracle.rs | 103 +++- src/platform.rs | 31 + src/redaction.rs | 355 ++++++++++- src/report.rs | 347 +++++++++-- src/runner.rs | 19 +- src/scenarios.rs | 9 +- src/workspace.rs | 982 ++++++++++++++++++++++++++++--- tests/e2e.rs | 571 ++++++++++++++++++ tests/helpers/fixture.rs | 29 + 33 files changed, 2744 insertions(+), 346 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bae03a..94a2e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ All notable changes are documented here. The format follows Keep a Changelog, an - Complete Simplified Chinese documentation with bidirectional navigation and automated coverage/link checks. +### Security + +- Reject workspace paths that escape through intermediate symlinks, unsafe workspace names, destination ancestors, or file-Oracle symlinks. +- Reject report run-ID traversal and report-root/output symlink escapes; stage report files before publication and apply the same 64 MiB limit to generated and loaded JSON reports. +- Preflight workspace byte and entry budgets before destination mutation, preserve Git path bytes on Unix, and stream source fingerprinting. +- Frame raw platform path bytes in source fingerprints and persist Git status as a SHA-256 digest; integrity hashes are version-specific rather than cross-version identifiers. +- Redact sensitive CLI option values consistently in command displays, verbose output, JSON, Markdown, Oracle details, and persisted evidence. +- Bound `deep_path_length` to 240 bytes so generated workspace names remain one portable component; configurations above that limit now fail validation. + ## [0.1.0] - 2026-07-27 ### Added diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 50cb334..b49cd00 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -10,6 +10,15 @@ - 完整的简体中文文档、双向导航,以及自动化覆盖范围/链接检查。 +### 安全 + +- 拒绝经中间符号链接、不安全工作区名称、目标祖先或文件 Oracle 符号链接逃逸的工作区路径。 +- 拒绝报告运行 ID 穿越及报告根目录/输出文件符号链接逃逸,在发布前暂存报告文件,并对生成和读取的 JSON 报告应用相同的 64 MiB 上限。 +- 在修改目标前预检工作区字节和条目预算,在 Unix 上保真处理 Git 路径字节,并以流式方式计算源码指纹。 +- 在源码指纹中对平台原始路径字节进行分帧,并以 SHA-256 摘要持久化 Git 状态;完整性哈希与版本相关,不是跨版本标识符。 +- 在命令显示、详细输出、JSON、Markdown、Oracle 详情和持久化证据中一致脱敏敏感 CLI 选项值。 +- 将 `deep_path_length` 限制为 240 字节,使生成的工作区名称保持为单一可移植组件;超过该限制的既有配置现在会校验失败。 + ## [0.1.0] - 2026-07-27 ### 新增 diff --git a/README.md b/README.md index f6a374a..963f658 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Scenarios: 0 skipped 0 inconclusive/infrastructure -Secret values persisted: no environment values are report fields +Secret values persisted: no recognized environment or CLI secret values are report fields Source workspace unchanged: yes ``` @@ -90,7 +90,7 @@ AssumeZero always prints the final command it selected. Arguments after `--` ove | AZ-S004 | `MINIMAL_PATH` | deep | Keeps the top-level command directory, system essentials, and explicit entries | | AZ-S005 | `SPACE_WORKDIR` | ✓ | Uses a copied path containing multiple spaces | | AZ-S006 | `UNICODE_WORKDIR` | ✓ | Uses a copied path containing Unicode | -| AZ-S007 | `DEEP_WORKDIR` | ✓ | Uses a safely bounded deep path | +| AZ-S007 | `DEEP_WORKDIR` | ✓ | Uses a safely bounded long path component | | AZ-S008 | `REDIRECTED_TEMP` | ✓ | Redirects `TMP`, `TEMP`, and `TMPDIR` | | AZ-S009 | `TIMEZONE_UTC` | deep | Sets process-level `TZ=UTC` on supported platforms; best effort | | AZ-S010 | `LOCALE_C` | deep | Sets `LANG=C` and `LC_ALL=C` when the locale exists | @@ -130,6 +130,7 @@ confirm_failures = 2 [workspace] mode = "working-tree" max_size_mib = 2048 +max_entries = 100000 exclude = [".git", ".assumezero"] include_untracked = [] @@ -146,6 +147,7 @@ max_total_seconds = 1800 [report] formats = ["terminal", "json", "markdown"] +sensitive_options = [] # For ambiguous short secret flags, for example ["-p"] ``` Unknown fields fail validation. Output-text, regular-expression, required-file, and forbidden-file oracle conditions are documented in [PRODUCT_SPEC.md](docs/PRODUCT_SPEC.md). The machine-readable format is [config-v1.schema.json](schemas/config-v1.schema.json). @@ -173,7 +175,7 @@ Verified fixture transcripts for hidden environment variables, hidden child tool ## Privacy -AssumeZero itself does not upload files, call external APIs, send telemetry, inspect the contents of the real home directory, or persist environment-variable values. Sensitive environment values are used only in memory to redact command output before it is written. Reports contain names, presence/classification metadata, and redacted output summaries. +AssumeZero itself does not upload files, call external APIs, send telemetry, inspect the contents of the real home directory, or persist recognized secret values. Sensitive environment values and values of recognized long CLI options such as `--token`, `--password`, and `--api-key` are used only in memory to redact command/output evidence before it is written. Configure ambiguous short or custom options in `report.sensitive_options`. Reports contain names, presence/classification metadata, and redacted output summaries. User-provided preparation and tested commands can still access the network and other resources available to the current user. Redaction is defense in depth; pattern matching can have both false positives and false negatives. @@ -183,7 +185,7 @@ User-provided preparation and tested commands can still access the network and o The tested command never has the source project as its working directory, but it retains the current user's operating-system privileges. It can deliberately reach outside its copied workspace. Do not use AssumeZero to run untrusted code. Shell parsing is disabled unless `--shell` is explicitly selected, in which case a warning is shown. -External symlinks are refused by default without reading their targets. Process-tree termination on timeout or interruption is best effort and cannot be guaranteed on every platform. +External symlinks are refused by default after resolving their targets for containment, without reading target file contents. Process-tree termination on timeout or interruption is best effort and cannot be guaranteed on every platform. ## Platform support diff --git a/README.zh-CN.md b/README.zh-CN.md index 8d379c9..30feda9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,7 +25,7 @@ Scenarios: 0 skipped 0 inconclusive/infrastructure -Secret values persisted: no environment values are report fields +Secret values persisted: no recognized environment or CLI secret values are report fields Source workspace unchanged: yes ``` @@ -90,7 +90,7 @@ AssumeZero 总会打印最终选中的命令。`--` 后的参数优先于配置 | AZ-S004 | `MINIMAL_PATH` | deep | 保留顶层命令目录、系统必需目录和显式保留条目 | | AZ-S005 | `SPACE_WORKDIR` | ✓ | 使用包含多个空格的副本路径 | | AZ-S006 | `UNICODE_WORKDIR` | ✓ | 使用包含 Unicode 的副本路径 | -| AZ-S007 | `DEEP_WORKDIR` | ✓ | 使用安全、有界的深层路径 | +| AZ-S007 | `DEEP_WORKDIR` | ✓ | 使用安全、有界的长路径组件 | | AZ-S008 | `REDIRECTED_TEMP` | ✓ | 重定向 `TMP`、`TEMP` 和 `TMPDIR` | | AZ-S009 | `TIMEZONE_UTC` | deep | 在支持的平台上设置进程级 `TZ=UTC`;best effort | | AZ-S010 | `LOCALE_C` | deep | 在 C/POSIX Locale 可用时设置 `LANG=C` 和 `LC_ALL=C` | @@ -130,6 +130,7 @@ confirm_failures = 2 [workspace] mode = "working-tree" max_size_mib = 2048 +max_entries = 100000 exclude = [".git", ".assumezero"] include_untracked = [] @@ -146,6 +147,7 @@ max_total_seconds = 1800 [report] formats = ["terminal", "json", "markdown"] +sensitive_options = [] # 含秘密值的歧义短选项,例如 ["-p"] ``` 未知字段会导致校验失败。输出文本、正则表达式、必需文件和禁止文件等 Oracle 条件见[产品规格](docs/zh-CN/PRODUCT_SPEC.md)。机器可读配置格式见 [config-v1.schema.json](schemas/config-v1.schema.json);字段名保持英文,作为稳定接口的一部分。 @@ -173,7 +175,7 @@ assumezero explain ## 隐私 -AssumeZero 本身不会上传文件、调用外部 API、发送遥测、检查真实主目录的内容或持久化环境变量值。敏感环境值只在内存中用于输出脱敏,随后才写入报告。报告只包含名称、存在性/分类元数据和脱敏后的输出摘要。 +AssumeZero 本身不会上传文件、调用外部 API、发送遥测、检查真实主目录的内容或持久化已识别的秘密值。敏感环境值以及 `--token`、`--password`、`--api-key` 等已识别长 CLI 选项的值只在内存中用于命令/输出证据脱敏,随后才写入报告。存在歧义的短选项或自定义选项需在 `report.sensitive_options` 中声明。报告只包含名称、存在性/分类元数据和脱敏后的输出摘要。 用户提供的准备命令和被测命令仍可访问当前用户可访问的网络及其他资源。脱敏是纵深防御,模式匹配可能出现误报或漏报。 @@ -183,7 +185,7 @@ AssumeZero 本身不会上传文件、调用外部 API、发送遥测、检查 被测命令不会以源项目作为工作目录,但仍保留当前用户的操作系统权限,因此可以故意访问副本之外的资源。不要用 AssumeZero 运行不受信任代码。除非显式选择 `--shell`,否则禁用 Shell 解析;启用时会显示警告。 -默认拒绝指向外部的符号链接,并且不会读取目标。超时或中断后的进程树终止是 best effort,无法在所有平台上保证。 +默认拒绝指向外部的符号链接;工具会解析目标以判断边界,但不会读取目标文件内容。超时或中断后的进程树终止是 best effort,无法在所有平台上保证。 ## 平台支持 diff --git a/docs/JSON_SCHEMA.md b/docs/JSON_SCHEMA.md index f850b4e..7139c77 100644 --- a/docs/JSON_SCHEMA.md +++ b/docs/JSON_SCHEMA.md @@ -29,7 +29,9 @@ workspace_integrity `started_at` and `finished_at` are Unix epoch-second strings in schema v1. Durations are integer milliseconds. -Environment-variable values are not fields anywhere in the schema. `restored_names` contains variable names or redacted normalized `PATH` entries. Captured output is a bounded, redacted summary and carries an `output_truncated` flag. +`run_id` is a canonical 26-character ULID and a portable single path component. Repository and workspace fingerprints are opaque, version-dependent integrity evidence intended for before/after comparison within the same run; do not compare values across AssumeZero versions. `git_status_before` and `git_status_after` may contain legacy porcelain text from older v1 reports or a `sha256:` digest from hardened builds; new reports persist only the digest so repository path names do not re-enter evidence. + +Environment-variable values are not fields anywhere in the schema. Recognized or configured sensitive CLI option values are replaced before the `command` field is constructed. `restored_names` contains variable names or redacted normalized `PATH` entries. Captured output and Oracle details are bounded where applicable and redacted before Report construction; captured output carries an `output_truncated` flag. Consumers must: diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 741bcee..2410ead 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -5,6 +5,8 @@ Version 0.1.0 intentionally supports only finite, non-interactive commands. - Workspace copies are isolation from direct relative source writes, not a security sandbox. +- Canonicalization, preflight, and source rechecks narrow filesystem race windows but do not provide capability-relative, atomic protection against a repository being modified concurrently during copy. +- `workspace.max_entries` also bounds full-source fingerprint collection; only `.git` and `.assumezero` are excluded from that count, and stored fingerprint path names have a 64 MiB aggregate limit. - Absolute-path and deliberate out-of-workspace writes cannot be prevented. - Timeout/interruption termination targets the direct child; complete descendant-tree termination is best effort. - Only Unicode environment variables available through Rust's portable string API participate in v0.1.0 minimization. @@ -12,12 +14,14 @@ Version 0.1.0 intentionally supports only finite, non-interactive commands. - Cache redirection covers a conservative known-variable list and cannot prove a cache was actually read. - `TZ=UTC` is a process-level best-effort setting, not an operating-system timezone change. - `LOCALE_C` is skipped if the locale cannot be discovered reliably. -- Deep paths are bounded and do not probe beyond operating-system limits. +- The `DEEP_WORKDIR` path-length probe uses one bounded component (maximum 240 ASCII bytes); it does not measure arbitrary directory nesting or probe beyond common component limits. - A 1-minimal result is not a globally minimum or unique causal explanation. - Minimization assumes sufficiently stable behavior and can stop with a `SUSPECTED` current-best result when budget is exhausted. - Pairwise scenario reduction is documented for a future release and is not enabled in v0.1.0. - Shell mode is explicitly trusted-input only. - Log redaction can miss encoded, transformed, fragmented, or unfamiliar secrets. +- Raw secrets passed as command arguments remain visible to the operating system while the process runs. A single opaque shell script is hidden from command metadata, but transformed/echoed values and undeclared short-option meanings cannot be redacted reliably. +- Regenerating an older report reapplies current built-in long-option redaction, but historical custom/short-option semantics cannot be reconstructed. Inspect historical `.assumezero/runs` content before regenerating or sharing it. - The tool does not trace arbitrary filesystem access, syscalls, network faults, databases, services, or containers. - No crates.io package, telemetry, account system, cloud backend, or Marketplace Action is provided. diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index 5eded4c..a983bf9 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -57,7 +57,7 @@ Absolute and parent-traversing oracle file paths are rejected. Each run records `git-clean` uses Git's tracked-file list plus relative paths explicitly named in `workspace.include_untracked`. It does not copy `.git`, ignored dependencies, or build output by default. It generally needs preparation commands. -The default maximum copied size is 2 GiB. External symlinks are refused unless the user explicitly accepts their risk. +The default copy limits are 2 GiB and 100,000 filesystem entries. Eligible sources, sizes, and entry counts are preflighted before the destination project directory is created; oversized copies fail before mutation. The same entry count also bounds source-fingerprint collection across the source tree (excluding `.git` and `.assumezero`, but not other workspace exclusions), and fingerprint path storage has a 64 MiB hard limit. Workspace names are exactly one normal path component. External symlinks and paths crossing symlink ancestors are refused unless the user explicitly accepts the limited final-link exception; nested traversal through symlinks is always refused. ## Minimization diff --git a/docs/SCENARIOS.md b/docs/SCENARIOS.md index 71501c9..e3eceaa 100644 --- a/docs/SCENARIOS.md +++ b/docs/SCENARIOS.md @@ -9,9 +9,9 @@ Every scenario has a stable ID, description, quick/deep profile membership, plat - `AZ-S001 EMPTY_HOME`: redirects the applicable home, profile, config, and data variables to an empty directory. It never lists or copies the real home. v0.1.0 does not trace a specific missing file. - `AZ-S002 EMPTY_CACHE`: redirects `XDG_CACHE_HOME`, `npm_config_cache`, `PIP_CACHE_DIR`, `UV_CACHE_DIR`, and `GRADLE_USER_HOME`. It never deletes real caches and does not guess Maven cache options. - `AZ-S003 CLEAN_ENV`: preserves platform essentials plus `[environment].preserve`. Stable failure, full recovery, and a completed `ddmin` can produce a `PROVEN` variable-name set. -- `AZ-S005 SPACE_WORKDIR`: copies into `AssumeZero Test Workspace/project copy`. +- `AZ-S005 SPACE_WORKDIR`: copies into the single-component workspace name `AssumeZero Test Workspace project copy`. - `AZ-S006 UNICODE_WORKDIR`: copies into `项目-测试-Δ`; inability to create or use the path is `SKIPPED_UNSUPPORTED`. -- `AZ-S007 DEEP_WORKDIR`: uses a configurable, safely bounded target length and does not deliberately exceed documented OS limits. +- `AZ-S007 DEEP_WORKDIR`: uses a configurable, safely bounded long workspace-name component. The value is limited to 240 ASCII bytes and does not deliberately exceed common per-component filesystem limits. - `AZ-S008 REDIRECTED_TEMP`: redirects `TMP`, `TEMP`, and `TMPDIR` to a scenario-owned directory. ## Deep diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index b7b792d..87c9a21 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -13,9 +13,13 @@ A tested or preparation command can intentionally access the network, home direc - The tested command's working directory is always a fresh copy. - Baseline, scenario, recovery, and minimization executions do not reuse modified copies. - Ordinary files are byte-copied, never writable hard-linked. +- Repository roots and eligible sources are resolved before copying; ordinary sources reached through an intermediate symlink are refused. +- Workspace names are one path component, destinations remain under a fresh temporary root, and symlinks are created only after ordinary entries. +- Byte and entry budgets are checked during a read-only preflight before the destination project directory is created. - `.git` and prior `.assumezero` evidence are excluded by default. -- External symlinks are refused without reading their targets unless explicitly allowed. +- Symlink targets are resolved for containment without reading target file contents; external targets are refused unless explicitly allowed. - Source fingerprint and Git-status evidence are compared before report persistence. +- Git-status evidence is persisted as a SHA-256 digest rather than raw path output. Report run IDs are one normal path component; `.assumezero`, `runs`, run directories, and report files are checked to reject symlink escapes, and generated files are staged before publication. Generated and loaded JSON reports share a 64 MiB limit. - `.assumezero` is deliberate tool metadata and is excluded from source-content integrity claims. Commands can still deliberately write outside their working directory. Do not test untrusted projects or commands. @@ -26,11 +30,12 @@ Arguments are passed directly to the process API. `--shell` is explicit and warn ## Environment values -Environment values are held only in process memory for execution, recovery, and exact-value redaction. Report structures contain names—not values. Sensitive names include token, secret, password, API/access/private-key, authentication, and credential patterns. +Environment values are held only in process memory for execution, recovery, and exact-value redaction. Report structures contain names—not values. Sensitive names include token, secret, password, API/access/private-key, authentication, and credential patterns. Semantic long CLI options such as `--token`, `--password`, and `--api-key` are also recognized. Ambiguous short options are redacted only when named in `report.sensitive_options`, for example `["-p"]`. Before any output summary is written, redaction replaces: - exact values of sensitive-named inherited variables; +- exact values supplied to recognized or configured sensitive CLI options; - Bearer tokens; - GitHub token shapes; - AWS access key IDs; @@ -41,6 +46,8 @@ Before any output summary is written, redaction replaces: Redaction has unavoidable false-positive and false-negative risk. A tested command that transforms or fragments a secret can evade exact matching. Use test credentials and inspect evidence before sharing it. +Verbose output is emitted only after bounded capture and redaction, rather than streamed before the redaction boundary. Raw CLI values still exist in process memory and the operating system's process argument view while the command runs. A single opaque `--shell` script is hidden in command displays and persisted command metadata because its option semantics cannot be parsed reliably; values echoed by that script may still evade output redaction. Do not place real credentials directly in command arguments. + ## Reporting a vulnerability Follow [SECURITY.md](../SECURITY.md). Never include real credentials in an issue or report artifact. diff --git a/docs/demo/report-v1.example.json b/docs/demo/report-v1.example.json index da72fab..bb611c2 100644 --- a/docs/demo/report-v1.example.json +++ b/docs/demo/report-v1.example.json @@ -1,7 +1,7 @@ { "schema_version": 1, "tool_version": "0.1.0", - "run_id": "01DEMO00000000000000000000", + "run_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "started_at": "1785081600", "finished_at": "1785081619", "platform": { diff --git a/docs/zh-CN/JSON_SCHEMA.md b/docs/zh-CN/JSON_SCHEMA.md index 9bf6ce4..99f0a98 100644 --- a/docs/zh-CN/JSON_SCHEMA.md +++ b/docs/zh-CN/JSON_SCHEMA.md @@ -29,7 +29,9 @@ workspace_integrity Schema v1 中,`started_at` 和 `finished_at` 是 Unix Epoch 秒数字符串,持续时间是整数毫秒。 -环境变量值不会出现在 Schema 的任何字段中。`restored_names` 只包含变量名或脱敏、规范化后的 `PATH` 条目。捕获的输出是有界、脱敏的摘要,并带有 `output_truncated` 标志。 +`run_id` 是规范的 26 字符 ULID,同时也是可移植的单一路径组件。仓库和工作区指纹是不透明、与版本相关的完整性证据,只用于同一次运行的前后比较;不要跨 AssumeZero 版本比较。`git_status_before` 和 `git_status_after` 可能包含旧版 v1 报告的 porcelain 文本,或加固构建生成的 `sha256:` 摘要;新报告只持久化摘要,避免仓库路径名称重新进入证据。 + +环境变量值不会出现在 Schema 的任何字段中。已识别或已配置的敏感 CLI 选项值会在构造 `command` 字段之前被替换。`restored_names` 只包含变量名或脱敏、规范化后的 `PATH` 条目。捕获输出及适用的 Oracle 详情会在构造 Report 前完成有界处理和脱敏;捕获输出带有 `output_truncated` 标志。 使用方必须: diff --git a/docs/zh-CN/LIMITATIONS.md b/docs/zh-CN/LIMITATIONS.md index 7031376..995f77f 100644 --- a/docs/zh-CN/LIMITATIONS.md +++ b/docs/zh-CN/LIMITATIONS.md @@ -5,6 +5,8 @@ v0.1.0 有意只支持有限、非交互式命令。 - 工作区副本用于隔离普通的相对路径源码写入,不是安全沙箱。 +- 路径解析、预检和源文件复核可以收窄文件系统竞争窗口,但无法在复制期间为被并发修改的仓库提供基于目录能力且原子的保护。 +- `workspace.max_entries` 同时限制全源指纹收集;该计数只排除 `.git` 和 `.assumezero`,保存的指纹路径名称另有 64 MiB 聚合上限。 - 无法阻止绝对路径写入或命令故意写入工作区之外。 - 超时/中断时会终止直接子进程;完整后代进程树终止是 best effort。 - 只有 Rust 可移植字符串 API 能表示的 Unicode 环境变量参与 v0.1.0 最小化。 @@ -12,12 +14,14 @@ v0.1.0 有意只支持有限、非交互式命令。 - 缓存重定向只覆盖保守的已知变量列表,无法证明命令实际读取了缓存。 - `TZ=UTC` 是进程级 best-effort 设置,不会改变操作系统时区。 - 如果无法可靠发现 C/POSIX Locale,则跳过 `LOCALE_C`。 -- 深层路径有明确边界,不会探测操作系统限制之外的路径。 +- `DEEP_WORKDIR` 路径长度探测使用一个有界组件(最多 240 个 ASCII 字节);它不测量任意目录嵌套深度,也不会探测常见单组件限制之外的路径。 - 1-minimal 结果不是全局最小或唯一因果解释。 - 最小化假设行为足够稳定;预算耗尽时可能以 `SUSPECTED` 返回当前最优结果。 - 两两场景约简计划用于未来版本,v0.1.0 未启用。 - Shell 模式只允许显式信任的输入。 - 日志脱敏可能漏掉编码、变换、拆分或未知形式的秘密。 +- 作为命令参数传入的原始秘密在进程运行期间仍对操作系统可见;单个不透明 Shell 脚本会从命令元数据中整体隐藏,但经过转换/回显的值以及未声明短选项的含义仍无法可靠脱敏。 +- 重新生成旧报告时会再次应用当前内置长选项脱敏,但无法恢复历史自定义/短选项的语义;重新生成或分享历史 `.assumezero/runs` 内容前请先人工检查。 - 工具不跟踪任意文件系统访问、系统调用、网络故障、数据库、服务或容器。 - 不提供 crates.io 包、遥测、账号系统、云后端或 Marketplace Action。 diff --git a/docs/zh-CN/PRODUCT_SPEC.md b/docs/zh-CN/PRODUCT_SPEC.md index b1a33a1..f1686c2 100644 --- a/docs/zh-CN/PRODUCT_SPEC.md +++ b/docs/zh-CN/PRODUCT_SPEC.md @@ -57,7 +57,7 @@ v0.1.0 的 Oracle 支持: `git-clean` 使用 Git 的已跟踪文件列表,再加上 `workspace.include_untracked` 中显式指定的相对路径。默认不复制 `.git`、被忽略的依赖或构建输出,通常需要配置准备命令。 -默认最大复制大小为 2 GiB。除非用户显式接受风险,否则拒绝外部符号链接。 +默认复制限制为 2 GiB 和 100,000 个文件系统条目。工具会在创建目标项目目录前预检候选源、大小和条目数;超限会在目标发生修改前失败。同一条目上限也用于整个源树的源码指纹收集(排除 `.git` 和 `.assumezero`,但不应用其他工作区排除项),指纹路径存储另有 64 MiB 硬上限。工作区名称必须正好是一个普通路径组件。除非用户显式接受只针对最终链接的有限例外,否则拒绝外部符号链接;任何经符号链接进行的嵌套穿越始终拒绝。 ## 最小化 diff --git a/docs/zh-CN/SCENARIOS.md b/docs/zh-CN/SCENARIOS.md index 2c9acc9..0b05ac0 100644 --- a/docs/zh-CN/SCENARIOS.md +++ b/docs/zh-CN/SCENARIOS.md @@ -9,9 +9,9 @@ - `AZ-S001 EMPTY_HOME`:把适用的主目录、Profile、配置和数据变量重定向到空目录。它不会列出或复制真实主目录。v0.1.0 不跟踪具体缺失文件。 - `AZ-S002 EMPTY_CACHE`:重定向 `XDG_CACHE_HOME`、`npm_config_cache`、`PIP_CACHE_DIR`、`UV_CACHE_DIR` 和 `GRADLE_USER_HOME`。它不会删除真实缓存,也不会猜测 Maven 缓存参数。 - `AZ-S003 CLEAN_ENV`:保留平台必需变量和 `[environment].preserve`。稳定失败、完整恢复和完成的 `ddmin` 可以产生 `PROVEN` 变量名集合。 -- `AZ-S005 SPACE_WORKDIR`:复制到 `AssumeZero Test Workspace/project copy`。 +- `AZ-S005 SPACE_WORKDIR`:复制到单组件工作区名称 `AssumeZero Test Workspace project copy`。 - `AZ-S006 UNICODE_WORKDIR`:复制到 `项目-测试-Δ`;如果无法创建或使用该路径,则返回 `SKIPPED_UNSUPPORTED`。 -- `AZ-S007 DEEP_WORKDIR`:使用可配置、安全有界的目标长度,不会故意超过已记录的操作系统限制。 +- `AZ-S007 DEEP_WORKDIR`:使用可配置、安全有界的长工作区名称组件;该值最多为 240 个 ASCII 字节,不会故意超过常见文件系统的单组件限制。 - `AZ-S008 REDIRECTED_TEMP`:把 `TMP`、`TEMP` 和 `TMPDIR` 重定向到场景专属目录。 ## Deep diff --git a/docs/zh-CN/SECURITY_MODEL.md b/docs/zh-CN/SECURITY_MODEL.md index 4ef4c5b..af60c08 100644 --- a/docs/zh-CN/SECURITY_MODEL.md +++ b/docs/zh-CN/SECURITY_MODEL.md @@ -13,9 +13,13 @@ AssumeZero 使用当前用户权限,在工作区副本中执行用户提供的 - 被测命令的工作目录始终是新副本。 - 基线、场景、恢复和最小化执行不会复用已修改副本。 - 普通文件按字节复制,绝不会使用可写硬链接。 +- 复制前解析仓库根和候选源;任何通过中间符号链接到达的普通源都会被拒绝。 +- 工作区名称只能是一个路径组件,目标必须位于新的临时根内,并且符号链接只在普通条目之后创建。 +- 在创建目标项目目录之前,通过只读预检核对字节和条目预算。 - 默认排除 `.git` 和以前的 `.assumezero` 证据。 -- 除非显式允许,否则拒绝外部符号链接且不读取其目标。 +- 符号链接目标只会为边界判断而解析,不会读取目标文件内容;除非显式允许,否则拒绝外部目标。 - 在报告持久化前比较源文件指纹和 Git 状态证据。 +- Git 状态证据以 SHA-256 摘要持久化,不写入原始路径输出。报告运行 ID 必须是一个普通路径组件;工具会检查 `.assumezero`、`runs`、运行目录和报告文件以拒绝符号链接逃逸,并在发布前暂存生成文件。生成和读取的 JSON 报告共用 64 MiB 上限。 - `.assumezero` 是有意生成的工具元数据,不计入源文件内容完整性声明。 命令仍然可以故意写入工作目录之外。不要测试不受信任的项目或命令。 @@ -26,11 +30,12 @@ AssumeZero 使用当前用户权限,在工作区副本中执行用户提供的 ## 环境值 -环境值只在进程内存中用于执行、恢复和精确值脱敏。报告结构包含名称,不包含值。敏感名称包括 token、secret、password、API/access/private-key、authentication 和 credential 等模式。 +环境值只在进程内存中用于执行、恢复和精确值脱敏。报告结构包含名称,不包含值。敏感名称包括 token、secret、password、API/access/private-key、authentication 和 credential 等模式。工具也会识别 `--token`、`--password`、`--api-key` 等语义明确的长 CLI 选项。存在歧义的短选项只有在 `report.sensitive_options` 中声明后才会脱敏,例如 `["-p"]`。 任何输出摘要写入之前,脱敏会替换: - 敏感名称的继承环境变量精确值; +- 已识别或已配置敏感 CLI 选项的精确值; - Bearer Token; - GitHub Token 形态; - AWS Access Key ID; @@ -41,6 +46,8 @@ AssumeZero 使用当前用户权限,在工作区副本中执行用户提供的 脱敏不可避免地存在误报和漏报风险。被测命令如果转换或拆分秘密,可能绕过精确匹配。请使用测试凭据,并在分享证据前自行检查。 +详细输出会在有界捕获并脱敏后再显示,不会在脱敏边界之前流式直出。命令运行期间,原始 CLI 值仍会存在于进程内存和操作系统的进程参数视图中。单个不透明 `--shell` 脚本的选项语义无法可靠解析,因此会在命令显示和持久化命令元数据中整体隐藏;该脚本回显的值仍可能绕过输出脱敏。不要把真实凭据直接放入命令参数。 + ## 报告漏洞 请遵循[安全政策](../../SECURITY.zh-CN.md)。Issue 或报告产物中绝不要包含真实凭据。 diff --git a/examples/assumezero.toml b/examples/assumezero.toml index 8909a8a..eeb90d8 100644 --- a/examples/assumezero.toml +++ b/examples/assumezero.toml @@ -10,6 +10,7 @@ confirm_failures = 2 [workspace] mode = "working-tree" max_size_mib = 2048 +max_entries = 100000 exclude = [".git", ".assumezero"] include_untracked = [] @@ -34,3 +35,4 @@ max_total_seconds = 1800 [report] formats = ["terminal", "json", "markdown", "junit"] redact_home = true +sensitive_options = [] diff --git a/schemas/config-v1.schema.json b/schemas/config-v1.schema.json index 74af2d5..b93f02e 100644 --- a/schemas/config-v1.schema.json +++ b/schemas/config-v1.schema.json @@ -41,6 +41,7 @@ "default": "working-tree" }, "max_size_mib": { "type": "integer", "minimum": 1, "default": 2048 }, + "max_entries": { "type": "integer", "minimum": 1, "default": 100000 }, "exclude": { "type": "array", "items": { "type": "string" }, @@ -53,7 +54,7 @@ "description": "Explicit relative untracked paths to copy in git-clean mode." }, "allow_external_symlinks": { "type": "boolean", "default": false }, - "deep_path_length": { "type": "integer", "minimum": 32, "default": 180 } + "deep_path_length": { "type": "integer", "minimum": 32, "maximum": 240, "default": 180 } } }, "oracle": { @@ -162,6 +163,12 @@ "type": "integer", "minimum": 1024, "default": 131072 + }, + "sensitive_options": { + "type": "array", + "items": { "type": "string", "pattern": "^-{1,2}[^\\s=-][^\\s=]*$" }, + "default": [], + "description": "Additional command options whose following or inline value is sensitive; intended especially for ambiguous short options." } } } diff --git a/schemas/report-v1.schema.json b/schemas/report-v1.schema.json index 7791d11..59fa63d 100644 --- a/schemas/report-v1.schema.json +++ b/schemas/report-v1.schema.json @@ -25,7 +25,11 @@ "properties": { "schema_version": { "const": 1 }, "tool_version": { "type": "string" }, - "run_id": { "type": "string", "minLength": 1 }, + "run_id": { + "type": "string", + "pattern": "^[0-7][0-9A-HJKMNP-TV-Z]{25}$", + "description": "A canonical 26-character ULID and one portable path component." + }, "started_at": { "type": "string", "pattern": "^[0-9]+$" }, "finished_at": { "type": "string", "pattern": "^[0-9]+$" }, "platform": { @@ -256,4 +260,3 @@ } } } - diff --git a/scripts/validate-schemas.sh b/scripts/validate-schemas.sh index a630c00..5b812ac 100755 --- a/scripts/validate-schemas.sh +++ b/scripts/validate-schemas.sh @@ -32,3 +32,23 @@ npx --yes ajv-cli@5.0.0 validate \ --spec=draft2020 \ -s "$repository_root/schemas/report-v1.schema.json" \ -d "$repository_root/docs/demo/report-v1.example.json" + +python3 - "$repository_root/docs/demo/report-v1.example.json" "$validation_root/invalid-report.json" <<'PY' +import json +import pathlib +import sys + +source = pathlib.Path(sys.argv[1]) +destination = pathlib.Path(sys.argv[2]) +report = json.loads(source.read_text(encoding="utf-8")) +report["run_id"] = "Z0000000000000000000000000" +destination.write_text(json.dumps(report), encoding="utf-8") +PY + +if npx --yes ajv-cli@5.0.0 validate \ + --spec=draft2020 \ + -s "$repository_root/schemas/report-v1.schema.json" \ + -d "$validation_root/invalid-report.json" >/dev/null 2>&1; then + echo "invalid overflow ULID unexpectedly passed report schema" >&2 + exit 1 +fi diff --git a/src/cli.rs b/src/cli.rs index f830d04..b8f6b46 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,7 @@ use crate::config::{Config, EXAMPLE_CONFIG}; use crate::engine; use crate::platform; +use crate::redaction::Redactor; use crate::report; use crate::scenarios; use anyhow::{Context, Result}; @@ -11,7 +12,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -#[derive(Debug, Parser)] +#[derive(Parser)] #[command( name = "assumezero", version, @@ -38,7 +39,7 @@ pub struct Cli { command: Commands, } -#[derive(Debug, Subcommand)] +#[derive(Subcommand)] enum Commands { /// Create a documented assumezero.toml configuration. Init { @@ -101,8 +102,14 @@ pub fn run_cli() -> Result { match execute(cli) { Ok(code) => Ok(code), Err(error) => { + let rendered = env::current_dir().ok().map_or_else( + || format!("{error:#}"), + |project| { + Redactor::new(&env::vars().collect(), &project).redact(&format!("{error:#}")) + }, + ); eprintln!("AssumeZero could not use the requested configuration or command."); - eprintln!("\nWhat happened:\n {error:#}"); + eprintln!("\nWhat happened:\n {rendered}"); eprintln!("\nOriginal project modified:\n No tested command was run in the source directory."); eprintln!( "\nNext:\n Correct the field or command shown above, or run `assumezero doctor`." @@ -122,7 +129,8 @@ fn execute(cli: Cli) -> Result { Ok(0) } Commands::Explain { run_id } => { - let saved = report::load(¤t, &run_id)?; + let mut saved = report::load(¤t, &run_id)?; + redact_loaded_report(¤t, &mut saved); if cli.json { println!("{}", serde_json::to_string_pretty(&saved.findings)?); } else { @@ -131,7 +139,8 @@ fn execute(cli: Cli) -> Result { Ok(0) } Commands::Report { run_id, format } => { - let saved = report::load(¤t, &run_id)?; + let mut saved = report::load(¤t, &run_id)?; + redact_loaded_report(¤t, &mut saved); let path = report::write_requested_format(¤t, &saved, format.as_str())?; println!( "Wrote /{}", @@ -147,7 +156,12 @@ fn execute(cli: Cli) -> Result { suspected_is_failure, command, } => { - let (mut config, source) = load_config(cli.config.as_deref(), ¤t)?; + let mut bootstrap_redactor = Redactor::new(&env::vars().collect(), ¤t); + bootstrap_redactor.add_commands([command.as_slice()], &[]); + let (mut config, source) = + load_config(cli.config.as_deref(), ¤t).map_err(|error| { + anyhow::anyhow!(bootstrap_redactor.redact(&format!("{error:#}"))) + })?; if let Some(profile) = profile { config.scenarios.profile = profile; } @@ -165,6 +179,8 @@ fn execute(cli: Cli) -> Result { or set `run.command` in assumezero.toml" ); } + let logical_command = final_command.clone(); + let opaque_shell_script = shell && logical_command.len() == 1; if shell { let script = if final_command.len() == 1 { final_command.remove(0) @@ -191,18 +207,32 @@ fn execute(cli: Cli) -> Result { } config.run.command.clone_from(&final_command); config.validate()?; + let command_redactor = + redactor_for_commands(&config, ¤t, &final_command, &logical_command); + let redacted_command = if opaque_shell_script { + redact_opaque_shell_command(&final_command) + } else { + command_redactor.redact_command(&final_command) + }; + let redacted_source = command_redactor.redact(&source); if dry_run { - dry_run_summary(&config, &source, &final_command, cli.json); + dry_run_summary(&config, &redacted_source, &redacted_command, cli.json); return Ok(0); } if !cli.quiet { - eprintln!( - "Final command: {}", - safe_command_for_display(&final_command, ¤t) - ); - eprintln!("Configuration source: {source}"); + eprintln!("Final command: {}", redacted_command.join(" ")); + eprintln!("Configuration source: {redacted_source}"); } - let output = engine::check(¤t, &config, &source, &final_command, cli.verbose)?; + let output = engine::check( + ¤t, + &config, + &redacted_source, + &final_command, + &logical_command, + opaque_shell_script, + cli.verbose, + ) + .map_err(|error| anyhow::anyhow!(command_redactor.redact(&format!("{error:#}"))))?; if cli.json { println!("{}", serde_json::to_string_pretty(&output.report)?); } else { @@ -244,31 +274,35 @@ fn load_config(explicit: Option<&Path>, current: &Path) -> Result<(Config, Strin } } -fn safe_command_for_display(command: &[String], project: &Path) -> String { - let home = env::var_os("HOME").map(PathBuf::from); - command - .iter() - .map(|part| { - let path = Path::new(part); - if !path.is_absolute() { - return part.clone(); - } - if let Ok(relative) = path.strip_prefix(project) { - return format!("/{}", relative.display()); - } - if let Some(home) = &home { - if let Ok(relative) = path.strip_prefix(home) { - return format!("/{}", relative.display()); - } - } - format!( - "/{}", - path.file_name() - .map_or_else(|| "item".into(), |name| name.to_string_lossy()) - ) - }) - .collect::>() - .join(" ") +fn redactor_for_commands( + config: &Config, + project: &Path, + command: &[String], + logical_command: &[String], +) -> Redactor { + let environment = env::vars().collect(); + let mut redactor = Redactor::new(&environment, project); + redactor.add_commands( + std::iter::once(command) + .chain(std::iter::once(logical_command)) + .chain(config.run.prepare.iter().map(Vec::as_slice)), + &config.report.sensitive_options, + ); + redactor +} + +fn redact_loaded_report(project: &Path, report: &mut crate::model::Report) { + let mut redactor = Redactor::new(&env::vars().collect(), project); + redactor.add_commands([report.command.as_slice()], &[]); + redactor.redact_report(report); +} + +fn redact_opaque_shell_command(command: &[String]) -> Vec { + let mut redacted = command.to_vec(); + if let Some(script) = redacted.last_mut() { + *script = "".into(); + } + redacted } fn init(path: &Path, force: bool, json_output: bool) -> Result { diff --git a/src/config.rs b/src/config.rs index 7e9bc2c..275cff4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,11 @@ +use crate::platform; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { #[serde(default = "version_one")] @@ -44,7 +45,7 @@ impl Default for Config { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RunConfig { #[serde(default)] @@ -107,6 +108,8 @@ pub struct WorkspaceConfig { pub mode: WorkspaceMode, #[serde(default = "default_max_size")] pub max_size_mib: u64, + #[serde(default = "default_max_entries")] + pub max_entries: usize, #[serde(default = "default_excludes")] pub exclude: Vec, #[serde(default)] @@ -122,6 +125,7 @@ impl Default for WorkspaceConfig { Self { mode: default_workspace_mode(), max_size_mib: default_max_size(), + max_entries: default_max_entries(), exclude: default_excludes(), include_untracked: Vec::new(), allow_external_symlinks: false, @@ -136,6 +140,9 @@ fn default_workspace_mode() -> WorkspaceMode { const fn default_max_size() -> u64 { 2_048 } +const fn default_max_entries() -> usize { + 100_000 +} fn default_excludes() -> Vec { vec![".git".into(), ".assumezero".into()] } @@ -143,7 +150,7 @@ const fn default_deep_path() -> usize { 180 } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct OracleConfig { #[serde(default = "default_oracle_kind")] @@ -252,6 +259,8 @@ pub struct ReportConfig { pub redact_home: bool, #[serde(default = "default_log_limit")] pub log_limit_bytes: usize, + #[serde(default)] + pub sensitive_options: Vec, } impl Default for ReportConfig { fn default() -> Self { @@ -259,6 +268,7 @@ impl Default for ReportConfig { formats: default_formats(), redact_home: true, log_limit_bytes: default_log_limit(), + sensitive_options: Vec::new(), } } } @@ -278,8 +288,12 @@ impl Config { format!("configuration file `{}` could not be read", path.display()) })?; let config: Self = toml::from_str(&text).map_err(|error| { + let location = error.span().map_or_else( + || "at an unknown location".into(), + |span| format!("near byte offset {}", span.start), + ); anyhow::anyhow!( - "configuration error in `{}`: {error}\n\nFix example:\n \ + "configuration error in `{}` {location}; parser source excerpts are suppressed because configuration values may be sensitive\n\nFix example:\n \ version = 1\n [run]\n command = [\"cargo\", \"test\"]", path.display() ) @@ -316,9 +330,15 @@ impl Config { if self.workspace.max_size_mib == 0 { bail!("field `workspace.max_size_mib` must be at least 1"); } + if self.workspace.max_entries == 0 { + bail!("field `workspace.max_entries` must be at least 1"); + } if self.workspace.deep_path_length < 32 { bail!("field `workspace.deep_path_length` must be at least 32"); } + if self.workspace.deep_path_length > 240 { + bail!("field `workspace.deep_path_length` must not exceed 240"); + } for path in &self.workspace.include_untracked { validate_relative_path("workspace.include_untracked", path)?; } @@ -329,8 +349,11 @@ impl Config { bail!("field `oracle.accepted_exit_codes` must not be empty"); } if let Some(pattern) = &self.oracle.stdout_regex { - regex::Regex::new(pattern) - .with_context(|| "field `oracle.stdout_regex` contains an invalid regex")?; + regex::Regex::new(pattern).map_err(|_| { + anyhow::anyhow!( + "field `oracle.stdout_regex` contains an invalid regex; pattern suppressed because configuration values may be sensitive" + ) + })?; } for path in self .oracle @@ -343,10 +366,16 @@ impl Config { if !matches!(self.scenarios.profile.as_str(), "quick" | "deep") { bail!("field `scenarios.profile` must be `quick` or `deep`"); } - for scenario in self.scenarios.include.iter().chain(&self.scenarios.exclude) { + for (index, scenario) in self + .scenarios + .include + .iter() + .chain(&self.scenarios.exclude) + .enumerate() + { if !valid_scenario_name(scenario) { bail!( - "scenario `{scenario}` is unknown; run `assumezero list-scenarios` for stable IDs and names" + "field `scenarios.include/exclude[{index}]` contains an unknown scenario; value suppressed because configuration values may be sensitive. Run `assumezero list-scenarios` for stable IDs and names" ); } } @@ -359,36 +388,39 @@ impl Config { let formats: BTreeSet<_> = ["terminal", "json", "markdown", "junit"] .into_iter() .collect(); - for format in &self.report.formats { + for (index, format) in self.report.formats.iter().enumerate() { if !formats.contains(format.as_str()) { bail!( - "field `report.formats` contains unsupported format `{format}`; \ - choose terminal, json, markdown, or junit" + "field `report.formats[{index}]` contains an unsupported format; value suppressed because configuration values may be sensitive. Choose terminal, json, markdown, or junit" ); } } if self.report.log_limit_bytes < 1_024 { bail!("field `report.log_limit_bytes` must be at least 1024"); } + for (index, option) in self.report.sensitive_options.iter().enumerate() { + let option_name = option + .strip_prefix("--") + .or_else(|| option.strip_prefix('-')) + .unwrap_or_default(); + if option_name.is_empty() + || option_name.starts_with('-') + || option_name.contains('=') + || option_name.chars().any(char::is_whitespace) + { + bail!( + "field `report.sensitive_options[{index}]` contains an invalid option token; value suppressed because it may contain sensitive data. Use a token such as `-p` or `--custom-key`" + ); + } + } Ok(()) } } fn validate_relative_path(field: &str, path: &Path) -> Result<()> { - if path.as_os_str().is_empty() - || path.is_absolute() - || path.components().any(|component| { - matches!( - component, - std::path::Component::ParentDir - | std::path::Component::RootDir - | std::path::Component::Prefix(_) - ) - }) - { + if !platform::is_portable_relative_path(path) { bail!( - "field `{field}` contains unsafe path `{}`; use a non-empty relative path without `..`", - path.display() + "field `{field}` contains an unsafe path; value suppressed because configuration values may be sensitive. Use a non-empty relative path without `..`" ); } Ok(()) @@ -432,6 +464,7 @@ confirm_failures = 2 [workspace] mode = "working-tree" max_size_mib = 2048 +max_entries = 100000 exclude = [".git", ".assumezero"] include_untracked = [] @@ -455,6 +488,7 @@ max_total_seconds = 1800 [report] formats = ["terminal", "json", "markdown"] redact_home = true +sensitive_options = [] "#; #[cfg(test)] @@ -466,12 +500,14 @@ mod tests { let config: Config = toml::from_str("version = 1").expect("valid config"); assert_eq!(config.run.baseline_runs, 2); assert_eq!(config.scenarios.profile, "quick"); + assert_eq!(config.workspace.max_entries, 100_000); } #[test] fn unknown_fields_are_rejected() { let error = toml::from_str::("version = 1\ntyop = true") - .expect_err("unknown field must fail"); + .err() + .expect("unknown field must fail"); assert!(error.to_string().contains("unknown field")); } @@ -490,6 +526,18 @@ mod tests { .include_untracked .push(PathBuf::from("../outside")); assert!(config.validate().is_err()); + + config.workspace.include_untracked = vec![PathBuf::from("C:outside")]; + assert!(config.validate().is_err()); + } + + #[test] + fn sensitive_short_options_require_explicit_option_tokens() { + let mut config = Config::default(); + config.report.sensitive_options.push("-p".into()); + assert!(config.validate().is_ok()); + config.report.sensitive_options = vec!["password".into()]; + assert!(config.validate().is_err()); } #[test] diff --git a/src/engine.rs b/src/engine.rs index 19a601c..90a4880 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -16,10 +16,10 @@ use anyhow::{bail, Context as _, Result}; use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; -#[derive(Debug)] struct ResolvedCommand { display: Vec, executable: PathBuf, @@ -86,6 +86,7 @@ struct Context<'a> { config: &'a Config, command: &'a ResolvedCommand, original_environment: &'a BTreeMap, + redactor: &'a Redactor, verbose: bool, budget: &'a mut Budget, } @@ -100,6 +101,8 @@ pub fn check( config: &Config, config_source: &str, command_tokens: &[String], + logical_command_tokens: &[String], + opaque_shell_script: bool, verbose: bool, ) -> Result { config.validate()?; @@ -108,11 +111,18 @@ pub fn check( .context("current project directory could not be resolved")?; let original_environment: BTreeMap = env::vars().collect(); let command = resolve_command(&source, command_tokens, &original_environment)?; - let before_fingerprint = fingerprint::source_fingerprint(&source)?; + let before_fingerprint = + fingerprint::source_fingerprint(&source, config.workspace.max_entries)?; let git_before = fingerprint::git_status(&source); let started_at = timestamp(); let mut budget = Budget::new(config); - let base_redactor = Redactor::new(&original_environment, &source); + let mut base_redactor = Redactor::new(&original_environment, &source); + base_redactor.add_commands( + std::iter::once(command.display.as_slice()) + .chain(std::iter::once(logical_command_tokens)) + .chain(config.run.prepare.iter().map(Vec::as_slice)), + &config.report.sensitive_options, + ); let mut baseline = Vec::new(); { @@ -121,6 +131,7 @@ pub fn check( config, command: &command, original_environment: &original_environment, + redactor: &base_redactor, verbose, budget: &mut budget, }; @@ -148,6 +159,7 @@ pub fn check( config, command: &command, original_environment: &original_environment, + redactor: &base_redactor, verbose, budget: &mut budget, }; @@ -164,7 +176,7 @@ pub fn check( } } - let after_fingerprint = fingerprint::source_fingerprint(&source)?; + let after_fingerprint = fingerprint::source_fingerprint(&source, config.workspace.max_entries)?; let git_after = fingerprint::git_status(&source); let source_unchanged = before_fingerprint == after_fingerprint && git_before == git_after; let integrity = IntegrityEvidence { @@ -175,7 +187,7 @@ pub fn check( git_status_after: git_after, note: "Fingerprint and Git-status comparison exclude .git and AssumeZero's own .assumezero report directory; tested commands ran only in disposable copies.".into(), }; - let report = Report { + let mut report = Report { schema_version: 1, tool_version: env!("CARGO_PKG_VERSION").into(), run_id: ulid::Ulid::new().to_string(), @@ -192,11 +204,15 @@ pub fn check( workspace_mode: config.workspace.mode.as_str().into(), report_formats: config.report.formats.clone(), }, - command: command - .display - .iter() - .map(|part| redact_command_part(part, &base_redactor, &source)) - .collect(), + command: if opaque_shell_script { + let mut display = command.display.clone(); + if let Some(script) = display.last_mut() { + *script = "".into(); + } + display + } else { + base_redactor.redact_command(&command.display) + }, baseline, baseline_status, scenarios: scenario_results, @@ -205,10 +221,13 @@ pub fn check( redaction_summary: BTreeMap::from([ ("in_memory_rules".into(), base_redactor.rule_count()), ("persisted_environment_values".into(), 0), + ("persisted_cli_sensitive_values".into(), 0), ]), workspace_integrity: integrity, }; - let directory = report::persist(&source, &report, &config.report.formats)?; + base_redactor.redact_report(&mut report); + let directory = report::persist(&source, &report, &config.report.formats) + .map_err(|error| redacted_error(error, &base_redactor))?; Ok(EngineOutput { report, directory }) } @@ -581,7 +600,12 @@ fn run_once( plan_spec: PlanSpec, workspace_name: &str, ) -> Result { - let isolated = workspace::create(context.source, &context.config.workspace, workspace_name)?; + let isolated = workspace::create(context.source, &context.config.workspace, workspace_name) + .map_err(|error| redacted_error(error, context.redactor))?; + let redactor = redactor_for(context.redactor, &isolated); + isolated + .validate_boundary() + .map_err(|error| redacted_error(error, &redactor))?; let project = isolated.project(); let executable = context.command.workspace_relative.as_ref().map_or_else( || context.command.executable.clone(), @@ -639,6 +663,9 @@ fn run_once( if !context.budget.take() { return Ok(Attempt::BudgetExhausted); } + isolated + .validate_boundary() + .map_err(|error| redacted_error(error, &redactor))?; let raw = run_tokens( context.source, project, @@ -646,22 +673,31 @@ fn run_once( &plan, context.config.run.timeout_seconds, context.config.report.log_limit_bytes, - context.verbose, - )?; - let redactor = redactor_for(context.original_environment, context.source, &isolated); + ) + .map_err(|error| redacted_error(error, &redactor))?; + isolated + .validate_boundary() + .map_err(|error| redacted_error(error, &redactor))?; let evidence = oracle::evaluate(raw, &OracleConfig::default(), project, |text| { redactor.redact(text) - })?; + }) + .map_err(|error| redacted_error(error, &redactor))?; + if context.verbose { + emit_verbose_evidence(&evidence); + } if !evidence.accepted { bail!( "prepare command `{}` failed in an isolated workspace", - prepare.join(" ") + redactor.redact_command(prepare).join(" ") ); } } if !context.budget.take() { return Ok(Attempt::BudgetExhausted); } + isolated + .validate_boundary() + .map_err(|error| redacted_error(error, &redactor))?; let raw = runner::execute(&ExecutionRequest { executable, args: context.command.args.clone(), @@ -670,15 +706,19 @@ fn run_once( clear_env: plan.clear, timeout_seconds: context.config.run.timeout_seconds, log_limit_bytes: context.config.report.log_limit_bytes, - verbose: context.verbose, - })?; - let redactor = redactor_for(context.original_environment, context.source, &isolated); - Ok(Attempt::Evidence(oracle::evaluate( - raw, - &context.config.oracle, - project, - |text| redactor.redact(text), - )?)) + }) + .map_err(|error| redacted_error(error, &redactor))?; + isolated + .validate_boundary() + .map_err(|error| redacted_error(error, &redactor))?; + let evidence = oracle::evaluate(raw, &context.config.oracle, project, |text| { + redactor.redact(text) + }) + .map_err(|error| redacted_error(error, &redactor))?; + if context.verbose { + emit_verbose_evidence(&evidence); + } + Ok(Attempt::Evidence(evidence)) } fn run_tokens( @@ -688,7 +728,6 @@ fn run_tokens( plan: &EnvironmentPlan, timeout_seconds: u64, log_limit_bytes: usize, - verbose: bool, ) -> Result { let command = resolve_command(source, tokens, &plan.values)?; let executable = command @@ -702,20 +741,37 @@ fn run_tokens( clear_env: plan.clear, timeout_seconds, log_limit_bytes, - verbose, }) } -fn redactor_for( - environment: &BTreeMap, - source: &Path, - isolated: &workspace::IsolatedWorkspace, -) -> Redactor { - let mut redactor = Redactor::new(environment, source); +fn redactor_for(base: &Redactor, isolated: &workspace::IsolatedWorkspace) -> Redactor { + let mut redactor = base.clone(); redactor.add_temporary_root(isolated.temporary_root()); redactor } +fn redacted_error(error: anyhow::Error, redactor: &Redactor) -> anyhow::Error { + anyhow::anyhow!(redactor.redact(&format!("{error:#}"))) +} + +fn emit_verbose_evidence(evidence: &RunEvidence) { + let mut output = io::stderr().lock(); + if !evidence.stdout_summary.is_empty() { + let _ = writeln!( + output, + "[tested command stdout]\n{}", + evidence.stdout_summary + ); + } + if !evidence.stderr_summary.is_empty() { + let _ = writeln!( + output, + "[tested command stderr]\n{}", + evidence.stderr_summary + ); + } +} + fn prepare_scenario_directories(kind: ScenarioKind, root: &Path) -> Result<()> { match kind { ScenarioKind::EmptyHome => fs::create_dir_all(root.join("empty-home"))?, @@ -828,27 +884,6 @@ fn timestamp() -> String { .map_or_else(|_| "0".into(), |value| value.as_secs().to_string()) } -fn redact_command_part(part: &str, redactor: &Redactor, project: &Path) -> String { - let path = Path::new(part); - if path.is_absolute() { - if let Ok(relative) = path.strip_prefix(project) { - return format!( - "/{}", - relative.to_string_lossy().replace('\\', "/") - ); - } - } - let redacted = redactor.redact(part); - if redacted != part || !path.is_absolute() { - return redacted; - } - format!( - "/{}", - path.file_name() - .map_or_else(|| "item".into(), |name| name.to_string_lossy()) - ) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/fingerprint.rs b/src/fingerprint.rs index 6cfff30..8cc0236 100644 --- a/src/fingerprint.rs +++ b/src/fingerprint.rs @@ -1,15 +1,24 @@ use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; -use std::fs; +use std::ffi::OsStr; +use std::fs::{self, File}; +use std::io::Read; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; use walkdir::WalkDir; -pub fn source_fingerprint(root: &Path) -> Result { +const MAX_FINGERPRINT_PATH_BYTES: usize = 64 * 1_048_576; +const MAX_GIT_STATUS_BYTES: u64 = 64 * 1_048_576; + +pub fn source_fingerprint(root: &Path, max_entries: usize) -> Result { + let root = root + .canonicalize() + .context("source root could not be resolved for fingerprinting")?; let mut entries = Vec::new(); - let walker = WalkDir::new(root).follow_links(false).into_iter(); + let mut path_bytes = 0_usize; + let walker = WalkDir::new(&root).follow_links(false).into_iter(); for entry in walker.filter_entry(|entry| { - let Ok(relative) = entry.path().strip_prefix(root) else { + let Ok(relative) = entry.path().strip_prefix(&root) else { return false; }; let first = relative.components().next().map(|value| value.as_os_str()); @@ -17,43 +26,137 @@ pub fn source_fingerprint(root: &Path) -> Result { && first != Some(std::ffi::OsStr::new(".assumezero")) }) { let entry = entry?; - let relative = entry.path().strip_prefix(root)?; + let relative = entry.path().strip_prefix(&root)?; if relative.as_os_str().is_empty() { continue; } + if entries.len() >= max_entries { + anyhow::bail!( + "source fingerprint would exceed the configured limit of {max_entries} entries" + ); + } + path_bytes = path_bytes + .checked_add(relative.as_os_str().as_encoded_bytes().len()) + .context("source fingerprint path storage overflowed")?; + if path_bytes > MAX_FINGERPRINT_PATH_BYTES { + anyhow::bail!( + "source fingerprint paths would exceed the {} MiB safety limit", + MAX_FINGERPRINT_PATH_BYTES / 1_048_576 + ); + } entries.push(relative.to_path_buf()); } entries.sort(); let mut hasher = Sha256::new(); for relative in entries { let path = root.join(&relative); - hasher.update(relative.to_string_lossy().as_bytes()); + update_os_str(&mut hasher, b"path", relative.as_os_str()); let metadata = fs::symlink_metadata(&path)?; if metadata.file_type().is_symlink() { - hasher.update(b"symlink:"); - hasher.update(fs::read_link(&path)?.to_string_lossy().as_bytes()); + update_frame(&mut hasher, b"type", b"symlink"); + let target = fs::read_link(&path)?; + update_os_str(&mut hasher, b"target", target.as_os_str()); } else if metadata.is_file() { - hasher.update(b"file:"); - let bytes = fs::read(&path) + update_frame(&mut hasher, b"type", b"file"); + hasher.update(metadata.len().to_le_bytes()); + let resolved = path.canonicalize().with_context(|| { + format!( + "could not resolve `{}` for fingerprinting", + relative.display() + ) + })?; + if resolved.strip_prefix(&root).is_err() { + anyhow::bail!( + "could not fingerprint `{}` because it resolved outside the source root", + relative.display() + ); + } + let mut file = File::open(&resolved) .with_context(|| format!("could not fingerprint `{}`", relative.display()))?; - hasher.update(&bytes); + let mut buffer = [0_u8; 65_536]; + let mut total = 0_u64; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + total = total + .checked_add(count as u64) + .context("fingerprinted file size overflowed")?; + hasher.update(&buffer[..count]); + } + if total != metadata.len() { + anyhow::bail!( + "could not fingerprint `{}` because it changed while being read", + relative.display() + ); + } } else if metadata.is_dir() { - hasher.update(b"dir:"); + update_frame(&mut hasher, b"type", b"dir"); + } else { + update_frame(&mut hasher, b"type", b"special"); } } Ok(format!("{:x}", hasher.finalize())) } +fn update_frame(hasher: &mut Sha256, label: &[u8], value: &[u8]) { + hasher.update((label.len() as u64).to_le_bytes()); + hasher.update(label); + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +#[cfg(unix)] +fn update_os_str(hasher: &mut Sha256, label: &[u8], value: &OsStr) { + use std::os::unix::ffi::OsStrExt; + update_frame(hasher, label, value.as_bytes()); +} + +#[cfg(windows)] +fn update_os_str(hasher: &mut Sha256, label: &[u8], value: &OsStr) { + use std::os::windows::ffi::OsStrExt; + let encoded: Vec = value.encode_wide().flat_map(u16::to_le_bytes).collect(); + update_frame(hasher, label, &encoded); +} + pub fn git_status(root: &Path) -> Option { - let output = Command::new("git") + let mut child = Command::new("git") .args(["status", "--porcelain=v1", "--untracked-files=all"]) .current_dir(root) - .output() + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() .ok()?; - output - .status + let mut stdout = child.stdout.take()?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 65_536]; + let mut total = 0_u64; + loop { + let count = match stdout.read(&mut buffer) { + Ok(count) => count, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + }; + if count == 0 { + break; + } + total = total.checked_add(count as u64)?; + if total > MAX_GIT_STATUS_BYTES { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + hasher.update(&buffer[..count]); + } + child + .wait() + .ok()? .success() - .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) + .then(|| format!("sha256:{:x}", hasher.finalize())) } #[cfg(test)] @@ -64,9 +167,9 @@ mod tests { fn content_change_changes_fingerprint() { let directory = tempfile::tempdir().expect("tempdir"); fs::write(directory.path().join("file"), "one").expect("write"); - let first = source_fingerprint(directory.path()).expect("fingerprint"); + let first = source_fingerprint(directory.path(), 100).expect("fingerprint"); fs::write(directory.path().join("file"), "two").expect("write"); - let second = source_fingerprint(directory.path()).expect("fingerprint"); + let second = source_fingerprint(directory.path(), 100).expect("fingerprint"); assert_ne!(first, second); } @@ -74,12 +177,20 @@ mod tests { fn report_directory_is_ignored() { let directory = tempfile::tempdir().expect("tempdir"); fs::write(directory.path().join("file"), "one").expect("write"); - let first = source_fingerprint(directory.path()).expect("fingerprint"); + let first = source_fingerprint(directory.path(), 100).expect("fingerprint"); fs::create_dir(directory.path().join(".assumezero")).expect("mkdir"); fs::write(directory.path().join(".assumezero/report"), "metadata").expect("write"); assert_eq!( first, - source_fingerprint(directory.path()).expect("fingerprint") + source_fingerprint(directory.path(), 100).expect("fingerprint") ); } + + #[test] + fn fingerprint_entry_collection_is_bounded() { + let directory = tempfile::tempdir().expect("tempdir"); + fs::write(directory.path().join("one"), "one").expect("one"); + fs::write(directory.path().join("two"), "two").expect("two"); + assert!(source_fingerprint(directory.path(), 1).is_err()); + } } diff --git a/src/model.rs b/src/model.rs index 56701bb..6e9432d 100644 --- a/src/model.rs +++ b/src/model.rs @@ -118,7 +118,7 @@ pub struct Report { pub workspace_integrity: IntegrityEvidence, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ExecutionRequest { pub executable: PathBuf, pub args: Vec, @@ -127,10 +127,8 @@ pub struct ExecutionRequest { pub clear_env: bool, pub timeout_seconds: u64, pub log_limit_bytes: usize, - pub verbose: bool, } -#[derive(Debug)] pub struct RawExecution { pub exit_code: Option, pub duration_ms: u128, diff --git a/src/oracle.rs b/src/oracle.rs index 7b81c68..ac85847 100644 --- a/src/oracle.rs +++ b/src/oracle.rs @@ -1,7 +1,10 @@ use crate::config::OracleConfig; use crate::model::{OracleCheck, RawExecution, RunEvidence}; +use crate::platform; use anyhow::{Context, Result}; use regex::Regex; +use std::fs; +use std::io::ErrorKind; use std::path::Path; pub fn evaluate( @@ -72,6 +75,9 @@ pub fn evaluate( detail: format!("forbidden `{}`", path.display()), }); } + for check in &mut checks { + check.detail = redact(&check.detail); + } Ok(RunEvidence { accepted: checks.iter().all(|check| check.accepted), @@ -87,22 +93,55 @@ pub fn evaluate( } fn safe_join(root: &Path, relative: &Path) -> Result { - if relative.is_absolute() - || relative.components().any(|component| { - matches!( - component, - std::path::Component::ParentDir - | std::path::Component::RootDir - | std::path::Component::Prefix(_) - ) - }) - { + if !platform::is_portable_relative_path(relative) { anyhow::bail!( "oracle file path `{}` must remain inside the copied workspace", relative.display() ); } - Ok(root.join(relative)) + let root_metadata = fs::symlink_metadata(root) + .context("copied workspace root could not be inspected for a file oracle")?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + anyhow::bail!("copied workspace root was replaced by a symlink or non-directory entry"); + } + let canonical_root = root + .canonicalize() + .context("copied workspace root could not be resolved for a file oracle")?; + if canonical_root != root { + anyhow::bail!("copied workspace root resolved differently before a file oracle"); + } + let candidate = canonical_root.join(relative); + let mut current = canonical_root.clone(); + for component in relative.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(_) => { + let resolved = current.canonicalize().with_context(|| { + format!( + "oracle file path `{}` contains a broken or unresolvable symlink", + relative.display() + ) + })?; + if resolved.strip_prefix(&canonical_root).is_err() { + anyhow::bail!( + "oracle file path `{}` resolves outside the copied workspace", + relative.display() + ); + } + current = resolved; + } + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(candidate), + Err(error) => { + return Err(error).with_context(|| { + format!( + "oracle file path `{}` could not be inspected", + relative.display() + ) + }); + } + } + } + Ok(current) } #[cfg(test)] @@ -140,4 +179,46 @@ mod tests { fn traversal_in_file_oracle_is_rejected() { assert!(safe_join(Path::new("/tmp/project"), Path::new("../secret")).is_err()); } + + #[test] + fn windows_style_traversal_is_rejected_portably() { + let directory = tempfile::tempdir().expect("tempdir"); + for path in ["C:secret", "C:\\secret"] { + assert!( + safe_join(directory.path(), Path::new(path)).is_err(), + "{path}" + ); + } + #[cfg(not(windows))] + assert!(safe_join(directory.path(), Path::new("nested\\secret")).is_err()); + } + + #[cfg(unix)] + #[test] + fn file_oracle_refuses_symlinks_outside_workspace() { + let workspace = tempfile::tempdir().expect("workspace"); + let outside = tempfile::tempdir().expect("outside"); + std::fs::write(outside.path().join("secret"), "outside").expect("outside file"); + std::os::unix::fs::symlink(outside.path(), workspace.path().join("link")).expect("symlink"); + assert!(safe_join( + &workspace.path().canonicalize().expect("workspace"), + Path::new("link/secret") + ) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn file_oracle_refuses_a_replaced_workspace_root() { + let parent = tempfile::tempdir().expect("parent"); + let workspace = parent.path().join("workspace"); + let moved = parent.path().join("moved"); + let outside = tempfile::tempdir().expect("outside"); + fs::create_dir(&workspace).expect("workspace"); + fs::write(outside.path().join("secret"), "outside").expect("outside file"); + let canonical_workspace = workspace.canonicalize().expect("canonical workspace"); + fs::rename(&workspace, moved).expect("move workspace"); + std::os::unix::fs::symlink(outside.path(), &workspace).expect("replace with symlink"); + assert!(safe_join(&canonical_workspace, Path::new("secret")).is_err()); + } } diff --git a/src/platform.rs b/src/platform.rs index 7b05d98..71d7d45 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -3,6 +3,37 @@ use std::env; use std::ffi::OsString; use std::path::{Path, PathBuf}; +pub fn is_portable_relative_path(path: &Path) -> bool { + if path.as_os_str().is_empty() + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return false; + } + + let encoded = path.as_os_str().as_encoded_bytes(); + let drive_prefixed = + encoded.len() >= 2 && encoded[0].is_ascii_alphabetic() && encoded[1] == b':'; + #[cfg(not(windows))] + let contains_foreign_separator = encoded.contains(&b'\\'); + #[cfg(windows)] + let contains_foreign_separator = false; + + !drive_prefixed && !contains_foreign_separator +} + +pub fn is_single_normal_component(value: &str) -> bool { + let path = Path::new(value); + let mut components = path.components(); + let exactly_one_normal = matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(); + let bytes = value.as_bytes(); + let drive_prefixed = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + exactly_one_normal && !value.is_empty() && !value.contains(['/', '\\']) && !drive_prefixed +} + pub fn facts() -> BTreeMap { BTreeMap::from([ ("os".into(), env::consts::OS.into()), diff --git a/src/redaction.rs b/src/redaction.rs index 120be43..8d564de 100644 --- a/src/redaction.rs +++ b/src/redaction.rs @@ -1,5 +1,7 @@ +use crate::model::{Report, RunEvidence}; use regex::Regex; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use std::path::{Path, PathBuf}; const SENSITIVE_NAME_PARTS: &[&str] = &[ @@ -14,22 +16,56 @@ const SENSITIVE_NAME_PARTS: &[&str] = &[ "CREDENTIAL", ]; -#[derive(Debug, Clone)] +const BUILTIN_SENSITIVE_LONG_OPTIONS: &[&str] = &[ + "token", + "secret", + "password", + "passwd", + "api-key", + "apikey", + "access-key", + "accesskey", + "private-key", + "authorization", + "auth", + "credential", + "credentials", + "client-secret", + "auth-token", + "access-token", + "refresh-token", +]; + +#[derive(Clone)] +struct ExactValue { + value: String, + replacement: &'static str, +} + +#[derive(Clone)] pub struct Redactor { - exact_values: Vec, + exact_values: Vec, + sensitive_options: BTreeSet, home: Option, project: PathBuf, temporary_roots: Vec, patterns: Vec, } +impl fmt::Debug for Redactor { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Redactor") + .field("exact_rule_count", &self.exact_values.len()) + .field("sensitive_option_count", &self.sensitive_options.len()) + .field("temporary_root_count", &self.temporary_roots.len()) + .field("pattern_rule_count", &self.patterns.len()) + .finish() + } +} + impl Redactor { pub fn new(environment: &BTreeMap, project: &Path) -> Self { - let exact_values = environment - .iter() - .filter(|(name, value)| is_sensitive_name(name) && value.len() >= 4) - .map(|(_, value)| value.clone()) - .collect(); let patterns = [ r"(?i)Bearer\s+[A-Za-z0-9._~+/=-]{8,}", r"\bgh[pousr]_[A-Za-z0-9]{20,}\b", @@ -41,12 +77,34 @@ impl Redactor { .into_iter() .filter_map(|pattern| Regex::new(pattern).ok()) .collect(); - Self { - exact_values, + let mut redactor = Self { + exact_values: Vec::new(), + sensitive_options: BTreeSet::new(), home: std::env::var_os("HOME").map(PathBuf::from), project: project.to_path_buf(), temporary_roots: Vec::new(), patterns, + }; + for (name, value) in environment { + if is_sensitive_name(name) && value.len() >= 4 { + redactor.add_exact(value, ""); + } + } + redactor + } + + pub fn add_commands<'a>( + &mut self, + commands: impl IntoIterator, + configured_sensitive_options: &[String], + ) { + self.sensitive_options.extend( + configured_sensitive_options + .iter() + .map(|option| option.to_ascii_lowercase()), + ); + for command in commands { + self.add_command_values(command); } } @@ -56,8 +114,8 @@ impl Redactor { pub fn redact(&self, input: &str) -> String { let mut result = input.to_owned(); - for value in &self.exact_values { - result = result.replace(value, ""); + for exact in &self.exact_values { + result = result.replace(&exact.value, exact.replacement); } for pattern in &self.patterns { result = pattern @@ -74,9 +132,176 @@ impl Redactor { result } + pub fn redact_command(&self, command: &[String]) -> Vec { + let mut result = Vec::with_capacity(command.len()); + let mut redact_next = false; + for part in command { + if redact_next { + result.push("".into()); + redact_next = false; + continue; + } + if let Some((option, _value)) = part.split_once('=') { + if self.is_sensitive_option(option) { + result.push(format!("{option}=")); + continue; + } + } + if self.is_sensitive_option(part) { + result.push(part.clone()); + redact_next = true; + continue; + } + result.push(self.redact_command_part(part)); + } + result + } + pub fn rule_count(&self) -> usize { self.exact_values.len() + self.patterns.len() } + + pub fn redact_report(&self, report: &mut Report) { + report.configuration.source = self.redact(&report.configuration.source); + report.command = self.redact_command(&report.command); + for run in &mut report.baseline { + self.redact_run(run); + } + for scenario in &mut report.scenarios { + scenario.name = self.redact(&scenario.name); + scenario.description = self.redact(&scenario.description); + for run in &mut scenario.runs { + self.redact_run(run); + } + for restored in &mut scenario.restored_names { + *restored = self.redact(restored); + } + scenario.note = self.redact(&scenario.note); + } + for finding in &mut report.findings { + finding.changed = self.redact(&finding.changed); + finding.observed = self.redact(&finding.observed); + finding.conclusion = self.redact(&finding.conclusion); + finding.next_step = self.redact(&finding.next_step); + finding.not_proven = self.redact(&finding.not_proven); + for restored in &mut finding.restored_names { + *restored = self.redact(restored); + } + } + if let Some(status) = &mut report.workspace_integrity.git_status_before { + *status = self.redact(status); + } + if let Some(status) = &mut report.workspace_integrity.git_status_after { + *status = self.redact(status); + } + report.workspace_integrity.note = self.redact(&report.workspace_integrity.note); + } + + fn add_command_values(&mut self, command: &[String]) { + let mut capture_next = false; + for part in command { + if capture_next { + self.add_exact(part, ""); + capture_next = false; + continue; + } + if let Some((option, value)) = part.split_once('=') { + if self.is_sensitive_option(option) { + self.add_exact(value, ""); + continue; + } + } + if self.is_sensitive_option(part) { + capture_next = true; + } + } + } + + fn add_exact(&mut self, value: &str, replacement: &'static str) { + if value.is_empty() { + return; + } + if let Some(existing) = self + .exact_values + .iter_mut() + .find(|candidate| candidate.value == value) + { + if replacement == "" { + existing.replacement = replacement; + } + return; + } + self.exact_values.push(ExactValue { + value: value.into(), + replacement, + }); + self.exact_values + .sort_by_key(|exact| std::cmp::Reverse(exact.value.len())); + } + + fn is_sensitive_option(&self, option: &str) -> bool { + let lowercase = option.to_ascii_lowercase(); + if self.sensitive_options.contains(&lowercase) { + return true; + } + let Some(long_name) = lowercase.strip_prefix("--") else { + return false; + }; + let normalized = long_name.replace(['_', '.'], "-"); + if BUILTIN_SENSITIVE_LONG_OPTIONS.contains(&normalized.as_str()) { + return true; + } + let components: Vec<_> = normalized + .split('-') + .filter(|component| !component.is_empty()) + .collect(); + matches!( + components.last().copied(), + Some( + "token" + | "secret" + | "password" + | "passwd" + | "authorization" + | "auth" + | "credential" + | "credentials" + ) + ) || normalized.ends_with("-api-key") + || normalized.ends_with("-access-key") + || normalized.ends_with("-private-key") + || normalized.ends_with("-secret-key") + } + + fn redact_run(&self, run: &mut RunEvidence) { + run.stdout_summary = self.redact(&run.stdout_summary); + run.stderr_summary = self.redact(&run.stderr_summary); + for check in &mut run.oracle_checks { + check.check = self.redact(&check.check); + check.detail = self.redact(&check.detail); + } + } + + fn redact_command_part(&self, part: &str) -> String { + let path = Path::new(part); + if path.is_absolute() { + if let Ok(relative) = path.strip_prefix(&self.project) { + return format!( + "/{}", + relative.to_string_lossy().replace('\\', "/") + ); + } + } + let redacted = self.redact(part); + if redacted != part || !path.is_absolute() { + return redacted; + } + format!( + "/{}", + path.file_name() + .map_or_else(|| "item".into(), |name| name.to_string_lossy()) + ) + } } pub fn is_sensitive_name(name: &str) -> bool { @@ -100,6 +325,14 @@ fn replace_path(input: &str, path: &Path, replacement: &str) -> String { mod tests { use super::*; + fn command_redactor(command: &[&str], configured: &[&str]) -> Redactor { + let command: Vec = command.iter().map(|value| (*value).into()).collect(); + let configured: Vec = configured.iter().map(|value| (*value).into()).collect(); + let mut redactor = Redactor::new(&BTreeMap::new(), Path::new("/project")); + redactor.add_commands([command.as_slice()], &configured); + redactor + } + #[test] fn exact_secret_values_are_removed() { let env = BTreeMap::from([("DEMO_TOKEN".into(), "obviously-invalid-secret".into())]); @@ -116,6 +349,104 @@ mod tests { assert!(!redactor.redact(fake).contains("abcdefghijklmnopqrstuvwxyz")); } + #[test] + fn command_options_redact_separate_inline_and_case_variant_values() { + let raw = [ + "tool", + "--token", + "fake-token", + "--password", + "fake-password", + "--api-key=fake-api-key", + "--API_KEY", + "fake-case-key", + ]; + let redactor = command_redactor(&raw, &[]); + let command: Vec = raw.iter().map(|part| (*part).into()).collect(); + let redacted = redactor.redact_command(&command).join(" "); + for secret in [ + "fake-token", + "fake-password", + "fake-api-key", + "fake-case-key", + ] { + assert!(!redacted.contains(secret)); + assert!(!redactor.redact(&format!("echo {secret}")).contains(secret)); + } + } + + #[test] + fn documented_sensitive_option_forms_redact_short_values() { + for raw in [ + vec!["tool", "--token", "abc"], + vec!["tool", "--password", "abc"], + vec!["tool", "--api-key", "abc"], + vec!["tool", "--api-key=abc"], + vec!["tool", "--API-KEY=abc"], + ] { + let redactor = command_redactor(&raw, &[]); + let command: Vec = raw.iter().map(|part| (*part).into()).collect(); + let rendered = redactor.redact_command(&command).join(" "); + assert!(!rendered.contains("abc")); + assert!(rendered.contains("REDACTED_CLI_VALUE")); + } + } + + #[test] + fn configured_short_option_is_redacted_without_guessing_other_short_options() { + let raw = ["tool", "-p", "fake-short", "-j", "4"]; + let redactor = command_redactor(&raw, &["-p"]); + let command: Vec = raw.iter().map(|part| (*part).into()).collect(); + assert_eq!( + redactor.redact_command(&command), + ["tool", "-p", "", "-j", "4"] + .map(String::from) + .to_vec() + ); + } + + #[test] + fn ordinary_and_similarly_named_options_are_not_redacted() { + let raw = [ + "tool", + "--output", + "artifact", + "--tokenizer", + "wordpiece", + "--secretary", + "name", + ]; + let redactor = command_redactor(&raw, &[]); + let command: Vec = raw.iter().map(|part| (*part).into()).collect(); + assert_eq!(redactor.redact_command(&command), command); + } + + #[test] + fn compound_sensitive_long_option_names_are_redacted_by_component() { + let raw = [ + "tool", + "--github-token", + "fake-github", + "--oauth_token=fake-oauth", + "--aws-secret-access-key", + "fake-aws", + ]; + let redactor = command_redactor(&raw, &[]); + let command: Vec = raw.iter().map(|part| (*part).into()).collect(); + let rendered = redactor.redact_command(&command).join(" "); + for secret in ["fake-github", "fake-oauth", "fake-aws"] { + assert!(!rendered.contains(secret)); + } + } + + #[test] + fn safe_debug_does_not_include_exact_values() { + let redactor = command_redactor(&["tool", "--token", "fake-debug-secret"], &[]); + let debug = format!("{redactor:?}"); + assert!(!debug.contains("fake-debug-secret")); + assert!(debug.contains("exact_rule_count")); + } + #[test] fn ordinary_names_are_not_sensitive() { assert!(!is_sensitive_name("PATH")); diff --git a/src/report.rs b/src/report.rs index e938aa0..18c595c 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,36 +1,80 @@ use crate::model::{EvidenceLevel, Report, ScenarioStatus}; -use anyhow::{Context, Result}; -use std::fs; +use crate::platform; +use anyhow::{bail, Context, Result}; +use std::fs::{self, File, OpenOptions}; +use std::io::{ErrorKind, Read, Write}; use std::path::{Path, PathBuf}; -pub fn run_directory(project: &Path, run_id: &str) -> PathBuf { - project.join(".assumezero").join("runs").join(run_id) -} +const MAX_SAVED_REPORT_BYTES: u64 = 64 * 1_048_576; pub fn persist(project: &Path, report: &Report, formats: &[String]) -> Result { - let directory = run_directory(project, &report.run_id); - fs::create_dir_all(&directory).with_context(|| { - format!( - "could not create report directory `{}`", - directory.display() - ) - })?; + validate_run_id(&report.run_id)?; let json = serde_json::to_vec_pretty(report)?; - fs::write(directory.join("report.json"), json)?; + if json.len() as u64 > MAX_SAVED_REPORT_BYTES { + bail!( + "generated report exceeds the {} MiB persisted-report limit; reduce run or log budgets", + MAX_SAVED_REPORT_BYTES / 1_048_576 + ); + } + let runs = report_root(project, true)?; + let directory = runs.join(&report.run_id); + if fs::symlink_metadata(&directory).is_ok() { + bail!("report run `{}` already exists", report.run_id); + } + + let staging = tempfile::Builder::new() + .prefix(".pending-") + .tempdir_in(&runs) + .context("could not create a staged report directory")?; + let staging_root = staging + .path() + .canonicalize() + .context("staged report directory could not be resolved")?; + ensure_contained(&runs, &staging_root, "staged report directory")?; + write_new_file(&staging_root.join("report.json"), &json)?; if formats.iter().any(|format| format == "markdown") { - fs::write(directory.join("report.md"), markdown(report))?; + write_new_file(&staging_root.join("report.md"), markdown(report).as_bytes())?; } if formats.iter().any(|format| format == "junit") { - fs::write(directory.join("report.junit.xml"), junit(report)?)?; + write_new_file(&staging_root.join("report.junit.xml"), &junit(report)?)?; } - Ok(directory) + fs::rename(staging.path(), &directory).with_context(|| { + format!( + "could not publish report run `{}` inside the project", + report.run_id + ) + })?; + checked_directory(&runs, &directory, "report run directory") } pub fn load(project: &Path, run_id: &str) -> Result { - let path = run_directory(project, run_id).join("report.json"); - let bytes = fs::read(&path) + let directory = existing_run_directory(project, run_id)?; + let path = checked_regular_file(&directory, &directory.join("report.json"), "saved report")?; + let mut file = File::open(&path) .with_context(|| format!("run `{run_id}` was not found at `{}`", path.display()))?; - serde_json::from_slice(&bytes).context("saved report is not valid report schema v1") + if !file.metadata()?.is_file() { + bail!("saved report for run `{run_id}` is not a regular file"); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(MAX_SAVED_REPORT_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_SAVED_REPORT_BYTES { + bail!( + "saved report for run `{run_id}` exceeds the {} MiB read limit", + MAX_SAVED_REPORT_BYTES / 1_048_576 + ); + } + let report: Report = + serde_json::from_slice(&bytes).context("saved report is not valid report schema v1")?; + validate_run_id(&report.run_id).context("saved report contains an unsafe run ID")?; + if report.run_id != run_id { + bail!( + "saved report run ID `{}` does not match requested run `{run_id}`", + report.run_id + ); + } + Ok(report) } pub fn print_terminal(report: &Report, quiet: bool) { @@ -87,7 +131,7 @@ pub fn print_terminal(report: &Report, quiet: bool) { ); } println!( - "Secret values persisted: no environment values are report fields\nSource workspace unchanged: {}\nRun ID: {}\n", + "Secret values persisted: no recognized environment or CLI secret values are report fields\nSource workspace unchanged: {}\nRun ID: {}\n", if report.workspace_integrity.source_unchanged { "yes" } else { @@ -105,6 +149,7 @@ pub fn markdown(report: &Report) -> String { - Platform: `{}` / `{}`\n\ - Source workspace unchanged: **{}**\n\ - Environment variable values persisted: **no**\n\n\ + - Recognized CLI secret values persisted: **no**\n\n\ ## Scenarios\n\n\ | ID | Scenario | Status | Runs | Note |\n\ |---|---|---:|---:|---|\n", @@ -201,29 +246,172 @@ pub fn junit(report: &Report) -> Result> { } pub fn write_requested_format(project: &Path, report: &Report, format: &str) -> Result { - let directory = run_directory(project, &report.run_id); - fs::create_dir_all(&directory)?; - let path = match format { - "json" => { - let path = directory.join("report.json"); - fs::write(&path, serde_json::to_vec_pretty(report)?)?; - path - } - "markdown" => { - let path = directory.join("report.md"); - fs::write(&path, markdown(report))?; - path - } - "junit" => { - let path = directory.join("report.junit.xml"); - fs::write(&path, junit(report)?)?; - path - } + validate_run_id(&report.run_id)?; + let directory = existing_run_directory(project, &report.run_id)?; + let (path, bytes) = match format { + "json" => ( + directory.join("report.json"), + serde_json::to_vec_pretty(report)?, + ), + "markdown" => (directory.join("report.md"), markdown(report).into_bytes()), + "junit" => (directory.join("report.junit.xml"), junit(report)?), other => anyhow::bail!("unsupported report format `{other}`"), }; + if format == "json" && bytes.len() as u64 > MAX_SAVED_REPORT_BYTES { + bail!( + "generated report exceeds the {} MiB persisted-report limit; reduce run or log budgets", + MAX_SAVED_REPORT_BYTES / 1_048_576 + ); + } + atomic_write_regular(&directory, &path, &bytes)?; Ok(path) } +fn validate_run_id(run_id: &str) -> Result<()> { + let valid_ulid = run_id + .parse::() + .is_ok_and(|parsed| parsed.to_string() == run_id); + if !valid_ulid || !platform::is_single_normal_component(run_id) { + bail!( + "run ID `{run_id}` must be a canonical 26-character ULID and one normal path component" + ); + } + Ok(()) +} + +fn report_root(project: &Path, create: bool) -> Result { + let project_root = project + .canonicalize() + .context("project root could not be resolved for report access")?; + let metadata_root = project_root.join(".assumezero"); + let metadata_root = ensure_plain_directory( + &project_root, + &metadata_root, + create, + "AssumeZero metadata directory", + )?; + let runs = metadata_root.join("runs"); + ensure_plain_directory(&project_root, &runs, create, "report root") +} + +fn existing_run_directory(project: &Path, run_id: &str) -> Result { + validate_run_id(run_id)?; + let runs = report_root(project, false)?; + let directory = runs.join(run_id); + checked_directory(&runs, &directory, "report run directory") + .with_context(|| format!("run `{run_id}` was not found")) +} + +fn ensure_plain_directory( + trusted_root: &Path, + path: &Path, + create: bool, + label: &str, +) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound && create => { + fs::create_dir(path) + .with_context(|| format!("could not create {label} `{}`", path.display()))?; + } + Err(error) => { + return Err(error) + .with_context(|| format!("{label} `{}` is unavailable", path.display())); + } + } + checked_directory(trusted_root, path, label) +} + +fn checked_directory(trusted_root: &Path, path: &Path, label: &str) -> Result { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("{label} `{}` could not be inspected", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "{label} `{}` must be a real directory, not a symlink or special file", + path.display() + ); + } + let resolved = path + .canonicalize() + .with_context(|| format!("{label} `{}` could not be resolved", path.display()))?; + ensure_contained(trusted_root, &resolved, label)?; + Ok(resolved) +} + +fn checked_regular_file(trusted_root: &Path, path: &Path, label: &str) -> Result { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("{label} `{}` could not be inspected", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!( + "{label} `{}` must be a regular file, not a symlink or special file", + path.display() + ); + } + let resolved = path + .canonicalize() + .with_context(|| format!("{label} `{}` could not be resolved", path.display()))?; + ensure_contained(trusted_root, &resolved, label)?; + Ok(resolved) +} + +fn ensure_contained(root: &Path, candidate: &Path, label: &str) -> Result<()> { + if candidate.strip_prefix(root).is_err() { + bail!( + "{label} `{}` resolves outside trusted root `{}`", + candidate.display(), + root.display() + ); + } + Ok(()) +} + +fn write_new_file(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("could not create report file `{}`", path.display()))?; + file.write_all(bytes)?; + file.flush()?; + Ok(()) +} + +fn atomic_write_regular(directory: &Path, path: &Path, bytes: &[u8]) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + bail!( + "report output `{}` must be absent or a regular file, not a symlink or special file", + path.display() + ); + } + Ok(_) => { + checked_regular_file(directory, path, "report output")?; + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("report output `{}` could not be inspected", path.display()) + }); + } + } + + let mut temporary = tempfile::NamedTempFile::new_in(directory) + .context("could not create a staged report file")?; + temporary.write_all(bytes)?; + temporary.flush()?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| { + format!( + "could not atomically write report output `{}`", + path.display() + ) + })?; + checked_regular_file(directory, path, "report output")?; + Ok(()) +} + pub fn explain(report: &Report) -> String { if report.findings.is_empty() { return format!( @@ -317,7 +505,7 @@ mod tests { Report { schema_version: 1, tool_version: "0.1.0".into(), - run_id: "demo".into(), + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV".into(), started_at: "0".into(), finished_at: "1".into(), platform: BTreeMap::new(), @@ -387,4 +575,85 @@ mod tests { let xml = String::from_utf8(junit(&report).expect("junit")).expect("UTF-8"); assert!(xml.contains("a & "b" < c")); } + + #[test] + fn run_ids_must_be_one_portable_normal_component() { + let project = tempfile::tempdir().expect("project"); + for invalid in [ + "", + ".", + "..", + "/absolute", + "nested/path", + "nested\\path", + "C:relative", + "C:\\absolute", + "\\\\server\\share", + "//server/share", + ] { + assert!(load(project.path(), invalid).is_err(), "{invalid}"); + let mut report = sample_report(ScenarioStatus::Pass); + report.run_id = invalid.into(); + assert!(persist(project.path(), &report, &["json".into()]).is_err()); + } + } + + #[test] + fn loaded_report_id_must_match_requested_directory() { + let project = tempfile::tempdir().expect("project"); + let report = sample_report(ScenarioStatus::Pass); + let directory = persist(project.path(), &report, &["json".into()]).expect("persist"); + let mut changed = report; + changed.run_id = "other".into(); + fs::write( + directory.join("report.json"), + serde_json::to_vec_pretty(&changed).expect("json"), + ) + .expect("replace fixture"); + assert!(load(project.path(), "01ARZ3NDEKTSV4RRFFQ69G5FAV").is_err()); + } + + #[cfg(unix)] + #[test] + fn report_roots_and_outputs_refuse_symlink_escape() { + let outside = tempfile::tempdir().expect("outside"); + let outside_file = outside.path().join("outside.md"); + fs::write(&outside_file, "unchanged").expect("outside file"); + + let metadata_link_project = tempfile::tempdir().expect("metadata link project"); + std::os::unix::fs::symlink( + outside.path(), + metadata_link_project.path().join(".assumezero"), + ) + .expect("metadata symlink"); + assert!(persist( + metadata_link_project.path(), + &sample_report(ScenarioStatus::Pass), + &["json".into()] + ) + .is_err()); + + let run_link_project = tempfile::tempdir().expect("run link project"); + fs::create_dir_all(run_link_project.path().join(".assumezero/runs")).expect("runs"); + std::os::unix::fs::symlink( + outside.path(), + run_link_project + .path() + .join(".assumezero/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV"), + ) + .expect("run symlink"); + assert!(load(run_link_project.path(), "01ARZ3NDEKTSV4RRFFQ69G5FAV").is_err()); + + let output_link_project = tempfile::tempdir().expect("output link project"); + let report = sample_report(ScenarioStatus::Pass); + let directory = persist(output_link_project.path(), &report, &["json".into()]) + .expect("persist safe report"); + std::os::unix::fs::symlink(&outside_file, directory.join("report.md")) + .expect("output symlink"); + assert!(write_requested_format(output_link_project.path(), &report, "markdown").is_err()); + assert_eq!( + fs::read_to_string(outside_file).expect("outside"), + "unchanged" + ); + } } diff --git a/src/runner.rs b/src/runner.rs index 13a41c2..e04cbb7 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,7 +1,7 @@ use crate::model::{ExecutionRequest, RawExecution}; use crate::platform; use anyhow::{Context, Result}; -use std::io::{self, Read, Write}; +use std::io::Read; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Once; @@ -20,7 +20,6 @@ fn install_interrupt_handler() { }); } -#[derive(Debug)] struct BoundedLog { head: Vec, tail: Vec, @@ -73,27 +72,15 @@ impl BoundedLog { fn capture( mut reader: R, limit: usize, - verbose: bool, - stderr: bool, ) -> thread::JoinHandle<(Vec, bool)> { thread::spawn(move || { let mut bounded = BoundedLog::new(limit); let mut buffer = [0_u8; 8_192]; - let mut streamed = 0_usize; loop { match reader.read(&mut buffer) { Ok(0) => break, Ok(count) => { bounded.push(&buffer[..count]); - if verbose && streamed < limit { - let allowed = (limit - streamed).min(count); - if stderr { - let _ = io::stderr().write_all(&buffer[..allowed]); - } else { - let _ = io::stdout().write_all(&buffer[..allowed]); - } - streamed += allowed; - } } Err(_) => break, } @@ -133,8 +120,8 @@ pub fn execute(request: &ExecutionRequest) -> Result { .stderr .take() .context("stderr capture was unavailable")?; - let stdout_thread = capture(stdout, request.log_limit_bytes, request.verbose, false); - let stderr_thread = capture(stderr, request.log_limit_bytes, request.verbose, true); + let stdout_thread = capture(stdout, request.log_limit_bytes); + let stderr_thread = capture(stderr, request.log_limit_bytes); let timeout = Duration::from_secs(request.timeout_seconds); let mut timed_out = false; diff --git a/src/scenarios.rs b/src/scenarios.rs index c93d85c..112190d 100644 --- a/src/scenarios.rs +++ b/src/scenarios.rs @@ -79,7 +79,7 @@ pub const ALL: &[ScenarioDefinition] = &[ ScenarioDefinition { id: "AZ-S007", name: "DEEP_WORKDIR", - description: "Run from a safely bounded deep copied workspace path.", + description: "Run from a copied workspace with a safely bounded long path component.", kind: ScenarioKind::DeepWorkdir, quick: true, best_effort: false, @@ -110,7 +110,7 @@ pub const ALL: &[ScenarioDefinition] = &[ }, ]; -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct EnvironmentPlan { pub values: BTreeMap, pub clear: bool, @@ -174,15 +174,16 @@ pub fn clean_environment(original: &BTreeMap, config: &Config) - pub fn workspace_name(kind: ScenarioKind, deep_target: usize) -> String { match kind { - ScenarioKind::SpaceWorkdir => "AssumeZero Test Workspace/project copy".into(), + ScenarioKind::SpaceWorkdir => "AssumeZero Test Workspace project copy".into(), ScenarioKind::UnicodeWorkdir => "项目-测试-Δ".into(), ScenarioKind::DeepWorkdir => { let mut result = String::from("deep"); let mut index = 0; while result.len() < deep_target { - result.push_str(&format!("/segment-{index:03}")); + result.push_str(&format!("-segment-{index:03}")); index += 1; } + result.truncate(deep_target); result } _ => "project-copy".into(), diff --git a/src/workspace.rs b/src/workspace.rs index bd2e83b..8b32b86 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,15 +1,20 @@ use crate::config::{WorkspaceConfig, WorkspaceMode}; use crate::platform; use anyhow::{bail, Context, Result}; -use std::fs; +use std::collections::BTreeSet; +#[cfg(unix)] +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::time::SystemTime; use tempfile::TempDir; use walkdir::WalkDir; -#[derive(Debug)] pub struct IsolatedWorkspace { - root: TempDir, + _root: TempDir, + temporary_root: PathBuf, project: PathBuf, } @@ -19,7 +24,129 @@ impl IsolatedWorkspace { } pub fn temporary_root(&self) -> &Path { - self.root.path() + &self.temporary_root + } + + pub fn validate_boundary(&self) -> Result<()> { + let temporary_metadata = fs::symlink_metadata(&self.temporary_root) + .context("isolated temporary root could not be inspected after command execution")?; + if temporary_metadata.file_type().is_symlink() || !temporary_metadata.is_dir() { + bail!("isolated temporary root was replaced by a symlink or non-directory entry"); + } + let resolved_temporary = self + .temporary_root + .canonicalize() + .context("isolated temporary root could not be resolved after command execution")?; + if resolved_temporary != self.temporary_root { + bail!("isolated temporary root resolved differently after command execution"); + } + + let project_metadata = fs::symlink_metadata(&self.project) + .context("isolated project root could not be inspected after command execution")?; + if project_metadata.file_type().is_symlink() || !project_metadata.is_dir() { + bail!("isolated project root was replaced by a symlink or non-directory entry"); + } + let resolved_project = self + .project + .canonicalize() + .context("isolated project root could not be resolved after command execution")?; + if resolved_project != self.project { + bail!("isolated project root resolved differently after command execution"); + } + ensure_contained( + &self.temporary_root, + &resolved_project, + "isolated project root", + ) + } +} + +#[derive(Clone)] +enum PlannedKind { + Directory, + File { + len: u64, + modified: Option, + }, + Symlink { + source_target: PathBuf, + destination_target: PathBuf, + target_is_dir: bool, + }, +} + +#[derive(Clone)] +struct PlannedEntry { + relative: PathBuf, + source: PathBuf, + resolved_source: Option, + kind: PlannedKind, +} + +struct CopyPlan { + entries: Vec, +} + +struct PlanBudget { + bytes: u64, + entries: usize, + byte_limit: u64, + entry_limit: usize, +} + +const MAX_PATH_STORAGE_BYTES: usize = 64 * 1_048_576; + +fn add_path_storage(total: &mut usize, path: &Path, label: &str) -> Result<()> { + *total = total + .checked_add(path.as_os_str().as_encoded_bytes().len()) + .context("workspace path storage count overflowed")?; + if *total > MAX_PATH_STORAGE_BYTES { + bail!( + "{label} would exceed the {} MiB path-storage safety limit", + MAX_PATH_STORAGE_BYTES / 1_048_576 + ); + } + Ok(()) +} + +impl PlanBudget { + fn new(config: &WorkspaceConfig) -> Result { + let byte_limit = config + .max_size_mib + .checked_mul(1_048_576) + .context("workspace.max_size_mib is too large to represent safely")?; + Ok(Self { + bytes: 0, + entries: 0, + byte_limit, + entry_limit: config.max_entries, + }) + } + + fn add(&mut self, kind: &PlannedKind, config: &WorkspaceConfig) -> Result<()> { + self.entries = self + .entries + .checked_add(1) + .context("workspace entry count overflowed")?; + if self.entries > self.entry_limit { + bail!( + "workspace copy would exceed the configured limit of {} entries; add exclusions or increase `workspace.max_entries`", + config.max_entries + ); + } + if let PlannedKind::File { len, .. } = kind { + self.bytes = self + .bytes + .checked_add(*len) + .context("workspace byte count overflowed")?; + if self.bytes > self.byte_limit { + bail!( + "workspace copy would exceed the configured limit of {} MiB; add exclusions or increase `workspace.max_size_mib`", + config.max_size_mib + ); + } + } + Ok(()) } } @@ -28,17 +155,47 @@ pub fn create( config: &WorkspaceConfig, requested_name: &str, ) -> Result { + validate_workspace_name(requested_name)?; + let source_root = source.canonicalize().with_context(|| { + format!( + "repository root `{}` could not be resolved", + source.display() + ) + })?; + let plan = match config.mode { + WorkspaceMode::WorkingTree => plan_working_tree(&source_root, config)?, + WorkspaceMode::GitClean => plan_git_clean(&source_root, config)?, + }; + let root = tempfile::Builder::new() .prefix("assumezero-") .tempdir() .context("could not create an isolated temporary directory")?; - let project = root.path().join(requested_name); - fs::create_dir_all(&project)?; - match config.mode { - WorkspaceMode::WorkingTree => copy_working_tree(source, &project, config)?, - WorkspaceMode::GitClean => copy_git_clean(source, &project, config)?, + let temporary_root = root + .path() + .canonicalize() + .context("isolated temporary directory could not be resolved")?; + let project_path = temporary_root.join(requested_name); + populate(&source_root, &temporary_root, &project_path, &plan)?; + let project = project_path + .canonicalize() + .context("isolated project directory could not be resolved after population")?; + let isolated = IsolatedWorkspace { + _root: root, + temporary_root, + project, + }; + isolated.validate_boundary()?; + Ok(isolated) +} + +fn validate_workspace_name(requested_name: &str) -> Result<()> { + if !platform::is_single_normal_component(requested_name) { + bail!( + "workspace name `{requested_name}` must be one normal path component without separators, roots, drive prefixes, `.` or `..`" + ); } - Ok(IsolatedWorkspace { root, project }) + Ok(()) } fn is_excluded(relative: &Path, excludes: &[String]) -> bool { @@ -49,119 +206,484 @@ fn is_excluded(relative: &Path, excludes: &[String]) -> bool { }) } -fn copy_working_tree(source: &Path, destination: &Path, config: &WorkspaceConfig) -> Result<()> { - let mut copied_bytes = 0_u64; - let limit = config.max_size_mib.saturating_mul(1_048_576); - let walker = WalkDir::new(source).follow_links(false).into_iter(); +fn plan_working_tree(source_root: &Path, config: &WorkspaceConfig) -> Result { + let mut budget = PlanBudget::new(config)?; + let mut entries = Vec::new(); + let walker = WalkDir::new(source_root).follow_links(false).into_iter(); for entry in walker.filter_entry(|entry| { entry .path() - .strip_prefix(source) + .strip_prefix(source_root) .map_or(true, |relative| !is_excluded(relative, &config.exclude)) }) { - let entry = entry?; - let relative = entry.path().strip_prefix(source)?; - if relative.as_os_str().is_empty() { + let entry = entry.context("workspace source could not be traversed")?; + let relative = entry.path().strip_prefix(source_root)?; + if relative.as_os_str().is_empty() || is_excluded(relative, &config.exclude) { continue; } - if is_excluded(relative, &config.exclude) { - continue; + let planned = plan_entry(source_root, relative, config)?; + budget.add(&planned.kind, config)?; + entries.push(planned); + } + Ok(CopyPlan { entries }) +} + +fn plan_git_clean(source_root: &Path, config: &WorkspaceConfig) -> Result { + let mut child = Command::new("git") + .args(["ls-files", "-z", "--cached", "--"]) + .current_dir(source_root) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .context("git-clean mode requires Git")?; + let mut with_parents = BTreeSet::new(); + let mut path_storage_bytes = 0_usize; + let mut git_stream_bytes = 0_usize; + let parse_result = (|| -> Result<()> { + let stdout = child + .stdout + .take() + .context("Git tracked-file output was unavailable")?; + let mut reader = BufReader::new(stdout); + let mut bytes = Vec::new(); + while read_nul_path(&mut reader, &mut bytes)? { + if bytes.is_empty() { + continue; + } + git_stream_bytes = git_stream_bytes + .checked_add(bytes.len()) + .context("Git path stream byte count overflowed")?; + if git_stream_bytes > MAX_PATH_STORAGE_BYTES { + bail!( + "Git path stream would exceed the {} MiB safety limit", + MAX_PATH_STORAGE_BYTES / 1_048_576 + ); + } + let relative = parse_git_path(&bytes)?; + insert_requested_path(&mut with_parents, &mut path_storage_bytes, relative, config)?; } - copy_entry( - source, - entry.path(), - &destination.join(relative), + Ok(()) + })(); + if let Err(error) = parse_result { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + if !child.wait()?.success() { + bail!("git-clean mode requires the source directory to be a Git repository"); + } + for relative in &config.include_untracked { + insert_requested_path( + &mut with_parents, + &mut path_storage_bytes, + relative.clone(), config, - &mut copied_bytes, - limit, )?; } + + let mut budget = PlanBudget::new(config)?; + let mut entries = Vec::new(); + for relative in with_parents { + let planned = plan_entry(source_root, &relative, config)?; + budget.add(&planned.kind, config)?; + entries.push(planned); + } + Ok(CopyPlan { entries }) +} + +const MAX_GIT_PATH_BYTES: usize = 1_048_576; + +fn read_nul_path(reader: &mut impl BufRead, output: &mut Vec) -> Result { + output.clear(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + if output.is_empty() { + return Ok(false); + } + bail!("Git tracked-file output ended without a NUL path terminator"); + } + let (chunk, consumed, complete) = match available.iter().position(|byte| *byte == 0) { + Some(position) => (&available[..position], position + 1, true), + None => (available, available.len(), false), + }; + if output.len().saturating_add(chunk.len()) > MAX_GIT_PATH_BYTES { + bail!( + "Git returned a tracked path longer than the {} byte safety limit", + MAX_GIT_PATH_BYTES + ); + } + output.extend_from_slice(chunk); + reader.consume(consumed); + if complete { + return Ok(true); + } + } +} + +#[cfg(unix)] +fn parse_git_path(bytes: &[u8]) -> Result { + use std::os::unix::ffi::OsStringExt; + Ok(PathBuf::from(OsString::from_vec(bytes.to_vec()))) +} + +#[cfg(windows)] +fn parse_git_path(bytes: &[u8]) -> Result { + let value = std::str::from_utf8(bytes) + .context("Git returned a path that is not valid UTF-8 on Windows")?; + Ok(PathBuf::from(value)) +} + +fn insert_requested_path( + with_parents: &mut BTreeSet, + path_storage_bytes: &mut usize, + relative: PathBuf, + config: &WorkspaceConfig, +) -> Result<()> { + validate_copy_relative(&relative)?; + if is_excluded(&relative, &config.exclude) { + return Ok(()); + } + let mut current = relative.as_path(); + while !current.as_os_str().is_empty() { + if !with_parents.contains(current) { + add_path_storage(path_storage_bytes, current, "Git path collection")?; + with_parents.insert(current.to_path_buf()); + if with_parents.len() > config.max_entries { + bail!( + "workspace copy would exceed the configured limit of {} entries while reading Git paths", + config.max_entries + ); + } + } + let Some(parent) = current.parent() else { + break; + }; + current = parent; + } Ok(()) } -fn copy_git_clean(source: &Path, destination: &Path, config: &WorkspaceConfig) -> Result<()> { - let output = Command::new("git") - .args(["ls-files", "-z"]) - .current_dir(source) - .output() - .context("git-clean mode requires Git")?; - if !output.status.success() { - bail!("git-clean mode requires the source directory to be a Git repository"); +fn validate_copy_relative(relative: &Path) -> Result<()> { + if !platform::is_portable_relative_path(relative) { + bail!( + "workspace source path `{}` is not a safe relative path", + relative.display() + ); } - let mut copied_bytes = 0_u64; - let limit = config.max_size_mib.saturating_mul(1_048_576); - let mut paths: Vec = output - .stdout - .split(|byte| *byte == 0) - .filter(|part| !part.is_empty()) - .map(|bytes| PathBuf::from(String::from_utf8_lossy(bytes).into_owned())) - .collect(); - paths.extend(config.include_untracked.iter().cloned()); - paths.sort(); - paths.dedup(); - for relative in paths { - if relative.is_absolute() - || relative - .components() - .any(|part| matches!(part, std::path::Component::ParentDir)) - || is_excluded(&relative, &config.exclude) - { - continue; + Ok(()) +} + +fn ensure_no_symlink_ancestor(source_root: &Path, relative: &Path) -> Result<()> { + let components: Vec<_> = relative.components().collect(); + let mut current = source_root.to_path_buf(); + for component in components.iter().take(components.len().saturating_sub(1)) { + current.push(component.as_os_str()); + let metadata = fs::symlink_metadata(¤t).with_context(|| { + format!( + "workspace source ancestor `{}` could not be inspected", + current.display() + ) + })?; + if metadata.file_type().is_symlink() { + bail!( + "workspace source path `{}` crosses symlink ancestor `{}`; nested paths through symlinks are refused", + relative.display(), + current + .strip_prefix(source_root) + .unwrap_or(¤t) + .display() + ); } - let from = source.join(&relative); - let to = destination.join(&relative); - if let Some(parent) = to.parent() { - fs::create_dir_all(parent)?; + if !metadata.is_dir() { + bail!( + "workspace source ancestor `{}` is not a directory", + current.display() + ); } - copy_entry(source, &from, &to, config, &mut copied_bytes, limit)?; + let resolved = current.canonicalize()?; + ensure_contained(source_root, &resolved, "workspace source ancestor")?; } Ok(()) } -fn copy_entry( +fn plan_entry( source_root: &Path, - from: &Path, - to: &Path, + relative: &Path, config: &WorkspaceConfig, - copied_bytes: &mut u64, - limit: u64, -) -> Result<()> { - let metadata = fs::symlink_metadata(from)?; +) -> Result { + validate_copy_relative(relative)?; + ensure_no_symlink_ancestor(source_root, relative)?; + let source = source_root.join(relative); + let metadata = fs::symlink_metadata(&source).with_context(|| { + format!( + "workspace source `{}` could not be inspected", + relative.display() + ) + })?; + if metadata.file_type().is_symlink() { - let target = fs::read_link(from)?; - let lexical_target = if target.is_absolute() { - platform::normalize_path(&target) + let source_target = fs::read_link(&source)?; + let target_candidate = if source_target.is_absolute() { + source_target.clone() } else { - platform::normalize_path(&from.parent().unwrap_or(source_root).join(&target)) + source.parent().unwrap_or(source_root).join(&source_target) }; - if !config.allow_external_symlinks && !lexical_target.starts_with(source_root) { + let resolved_target = target_candidate.canonicalize().with_context(|| { + format!( + "symlink `{}` has a broken or unresolvable target and was refused", + relative.display() + ) + })?; + let (destination_target, target_is_dir) = if let Ok(target_relative) = + resolved_target.strip_prefix(source_root) + { + let link_parent = relative.parent().unwrap_or_else(|| Path::new("")); + ( + relative_path(link_parent, target_relative), + resolved_target.is_dir(), + ) + } else if config.allow_external_symlinks { + (source_target.clone(), resolved_target.is_dir()) + } else { bail!( - "external symlink `{}` was not copied; its target was not read. \ - Remove it, exclude it, or explicitly set `workspace.allow_external_symlinks = true` after reviewing the risk", - from.strip_prefix(source_root).unwrap_or(from).display() - ); - } - create_symlink(&target, to, lexical_target.is_dir())?; - } else if metadata.is_dir() { - fs::create_dir_all(to)?; + "external symlink `{}` was not copied; its resolved target is outside the repository. Remove it, exclude it, or explicitly set `workspace.allow_external_symlinks = true` after reviewing the risk", + relative.display() + ); + }; + return Ok(PlannedEntry { + relative: relative.to_path_buf(), + source, + resolved_source: None, + kind: PlannedKind::Symlink { + source_target, + destination_target, + target_is_dir, + }, + }); + } + + let resolved_source = source.canonicalize().with_context(|| { + format!( + "workspace source `{}` could not be resolved", + relative.display() + ) + })?; + ensure_contained(source_root, &resolved_source, "workspace source")?; + let kind = if metadata.is_dir() { + PlannedKind::Directory } else if metadata.is_file() { - *copied_bytes = copied_bytes.saturating_add(metadata.len()); - if *copied_bytes > limit { - bail!( - "workspace copy exceeded the configured limit of {} MiB; \ - add exclusions or increase `workspace.max_size_mib`", - config.max_size_mib - ); + PlannedKind::File { + len: metadata.len(), + modified: metadata.modified().ok(), } - if let Some(parent) = to.parent() { - fs::create_dir_all(parent)?; + } else { + bail!( + "workspace source `{}` has an unsupported special file type", + relative.display() + ); + }; + Ok(PlannedEntry { + relative: relative.to_path_buf(), + source, + resolved_source: Some(resolved_source), + kind, + }) +} + +fn relative_path(from_directory: &Path, to: &Path) -> PathBuf { + let from: Vec<_> = from_directory.components().collect(); + let to: Vec<_> = to.components().collect(); + let common = from + .iter() + .zip(&to) + .take_while(|(left, right)| left == right) + .count(); + let mut result = PathBuf::new(); + for _ in common..from.len() { + result.push(".."); + } + for component in &to[common..] { + result.push(component.as_os_str()); + } + if result.as_os_str().is_empty() { + result.push("."); + } + result +} + +fn ensure_contained(root: &Path, candidate: &Path, label: &str) -> Result<()> { + if candidate.strip_prefix(root).is_err() { + bail!( + "{label} `{}` resolves outside trusted root `{}`", + candidate.display(), + root.display() + ); + } + Ok(()) +} + +fn populate( + source_root: &Path, + temporary_root: &Path, + project: &Path, + plan: &CopyPlan, +) -> Result<()> { + ensure_contained(temporary_root, project, "workspace destination")?; + fs::create_dir(project).context("could not create the isolated project directory")?; + let project_root = project + .canonicalize() + .context("isolated project directory could not be resolved")?; + ensure_contained(temporary_root, &project_root, "workspace destination")?; + + if let Err(error) = execute_plan(source_root, &project_root, plan) { + if let Err(cleanup) = fs::remove_dir_all(&project_root) { + return Err(error).context(format!( + "workspace copy failed and partial workspace cleanup also failed: {cleanup}" + )); } - fs::copy(from, to)?; - fs::set_permissions(to, metadata.permissions())?; + return Err(error); + } + Ok(()) +} + +fn execute_plan(source_root: &Path, project_root: &Path, plan: &CopyPlan) -> Result<()> { + for entry in plan + .entries + .iter() + .filter(|entry| matches!(entry.kind, PlannedKind::Directory)) + { + let destination = destination_path(project_root, &entry.relative)?; + fs::create_dir_all(&destination)?; + let resolved = destination.canonicalize()?; + ensure_contained(project_root, &resolved, "workspace destination")?; + } + for entry in plan + .entries + .iter() + .filter(|entry| matches!(entry.kind, PlannedKind::File { .. })) + { + copy_planned_file(source_root, project_root, entry)?; + } + for entry in plan + .entries + .iter() + .filter(|entry| matches!(entry.kind, PlannedKind::Symlink { .. })) + { + copy_planned_symlink(source_root, project_root, entry)?; + } + Ok(()) +} + +fn destination_path(project_root: &Path, relative: &Path) -> Result { + validate_copy_relative(relative)?; + let destination = project_root.join(relative); + ensure_contained(project_root, &destination, "workspace destination")?; + Ok(destination) +} + +fn ensure_destination_parent(project_root: &Path, destination: &Path) -> Result<()> { + let parent = destination.parent().unwrap_or(project_root); + fs::create_dir_all(parent)?; + let resolved = parent.canonicalize()?; + ensure_contained(project_root, &resolved, "workspace destination parent") +} + +fn copy_planned_file(source_root: &Path, project_root: &Path, entry: &PlannedEntry) -> Result<()> { + let PlannedKind::File { len, modified } = entry.kind else { + bail!("internal workspace plan type mismatch"); + }; + ensure_no_symlink_ancestor(source_root, &entry.relative)?; + let current_metadata = fs::symlink_metadata(&entry.source)?; + if !current_metadata.is_file() || current_metadata.file_type().is_symlink() { + bail!( + "workspace source `{}` changed type after preflight", + entry.relative.display() + ); + } + if current_metadata.len() != len + || modified.is_some_and(|expected| current_metadata.modified().ok() != Some(expected)) + { + bail!( + "workspace source `{}` changed after preflight", + entry.relative.display() + ); } + let resolved = entry.source.canonicalize()?; + let planned_resolved = entry + .resolved_source + .as_ref() + .context("file plan did not contain a resolved source")?; + if &resolved != planned_resolved { + bail!( + "workspace source `{}` resolved differently after preflight", + entry.relative.display() + ); + } + ensure_contained(source_root, &resolved, "workspace source")?; + let mut source_file = File::open(planned_resolved)?; + let opened_metadata = source_file.metadata()?; + if !opened_metadata.is_file() || opened_metadata.len() != len { + bail!( + "workspace source `{}` changed while it was opened", + entry.relative.display() + ); + } + let resolved_after_open = planned_resolved.canonicalize()?; + if resolved_after_open != *planned_resolved { + bail!( + "workspace source `{}` changed while it was opened", + entry.relative.display() + ); + } + ensure_contained(source_root, &resolved_after_open, "workspace source")?; + + let destination = destination_path(project_root, &entry.relative)?; + ensure_destination_parent(project_root, &destination)?; + let mut destination_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&destination)?; + let copied = io::copy( + &mut source_file.by_ref().take(len.saturating_add(1)), + &mut destination_file, + )?; + if copied != len { + bail!( + "workspace source `{}` changed size during copy", + entry.relative.display() + ); + } + destination_file.set_permissions(opened_metadata.permissions())?; Ok(()) } +fn copy_planned_symlink( + source_root: &Path, + project_root: &Path, + entry: &PlannedEntry, +) -> Result<()> { + let PlannedKind::Symlink { + ref source_target, + ref destination_target, + target_is_dir, + } = entry.kind + else { + bail!("internal workspace plan type mismatch"); + }; + ensure_no_symlink_ancestor(source_root, &entry.relative)?; + let metadata = fs::symlink_metadata(&entry.source)?; + if !metadata.file_type().is_symlink() || fs::read_link(&entry.source)? != *source_target { + bail!( + "workspace symlink `{}` changed after preflight", + entry.relative.display() + ); + } + let destination = destination_path(project_root, &entry.relative)?; + ensure_destination_parent(project_root, &destination)?; + create_symlink(destination_target, &destination, target_is_dir) +} + #[cfg(unix)] fn create_symlink(target: &Path, link: &Path, _is_dir: bool) -> Result<()> { std::os::unix::fs::symlink(target, link)?; @@ -194,6 +716,18 @@ mod tests { ); } + #[test] + fn normal_nested_files_are_copied() { + let source = tempfile::tempdir().expect("source"); + fs::create_dir_all(source.path().join("a/b")).expect("mkdir"); + fs::write(source.path().join("a/b/file"), "inside").expect("write"); + let copy = create(source.path(), &WorkspaceConfig::default(), "project").expect("copy"); + assert_eq!( + fs::read_to_string(copy.project().join("a/b/file")).expect("read"), + "inside" + ); + } + #[test] fn excludes_are_not_copied() { let source = tempfile::tempdir().expect("source"); @@ -203,6 +737,74 @@ mod tests { assert!(!copy.project().join(".git").exists()); } + #[test] + fn workspace_name_must_be_one_portable_normal_component() { + for valid in ["project", "project copy", "项目-Δ"] { + assert!(validate_workspace_name(valid).is_ok(), "{valid}"); + } + for invalid in [ + "", + ".", + "..", + "/absolute", + "nested/path", + "nested\\path", + "C:relative", + "C:\\absolute", + "\\\\server\\share", + "//server/share", + ] { + assert!(validate_workspace_name(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn budget_is_rejected_before_destination_mutation() { + let source = tempfile::tempdir().expect("source"); + fs::write(source.path().join("small"), "small").expect("write"); + let large = File::create(source.path().join("large")).expect("large"); + large.set_len(1_048_577).expect("size"); + let config = WorkspaceConfig { + max_size_mib: 1, + ..WorkspaceConfig::default() + }; + let result = plan_working_tree( + &source.path().canonicalize().expect("canonical source"), + &config, + ); + assert!(result.is_err()); + + let root = tempfile::tempdir().expect("destination root"); + assert!(!root.path().join("project").exists()); + } + + #[test] + fn entry_budget_covers_zero_length_entries() { + let source = tempfile::tempdir().expect("source"); + fs::write(source.path().join("one"), "").expect("one"); + fs::write(source.path().join("two"), "").expect("two"); + let config = WorkspaceConfig { + max_entries: 1, + ..WorkspaceConfig::default() + }; + assert!(create(source.path(), &config, "project").is_err()); + } + + #[test] + fn source_change_after_preflight_fails_and_cleans_project() { + let source = tempfile::tempdir().expect("source"); + fs::write(source.path().join("file"), "before").expect("write"); + let source_root = source.path().canonicalize().expect("source root"); + let plan = plan_working_tree(&source_root, &WorkspaceConfig::default()).expect("plan"); + fs::write(source.path().join("file"), "after-with-a-different-size").expect("change"); + + let temporary = tempfile::tempdir().expect("temporary root"); + let temporary_root = temporary.path().canonicalize().expect("temporary root"); + let project = temporary_root.join("project"); + assert!(populate(&source_root, &temporary_root, &project, &plan).is_err()); + assert!(!project.exists()); + } + #[test] fn git_clean_copies_tracked_and_explicitly_allowed_untracked_files() { let source = tempfile::tempdir().expect("source"); @@ -235,11 +837,201 @@ mod tests { #[cfg(unix)] #[test] - fn external_symlinks_are_refused_without_reading_target() { + fn internal_final_symlinks_are_rewritten_inside_the_copy() { let source = tempfile::tempdir().expect("source"); - std::os::unix::fs::symlink("/definitely/not/read", source.path().join("outside")) - .expect("symlink"); - let result = create(source.path(), &WorkspaceConfig::default(), "project"); - assert!(result.is_err()); + fs::create_dir(source.path().join("dir")).expect("dir"); + fs::write(source.path().join("sibling"), "inside").expect("write"); + std::os::unix::fs::symlink("../sibling", source.path().join("dir/link")).expect("symlink"); + let copy = create(source.path(), &WorkspaceConfig::default(), "project").expect("copy"); + assert_eq!( + fs::read_to_string(copy.project().join("dir/link")).expect("read"), + "inside" + ); + assert!(!fs::read_link(copy.project().join("dir/link")) + .expect("target") + .is_absolute()); + } + + #[cfg(unix)] + #[test] + fn absolute_internal_symlink_does_not_point_back_to_source() { + let source = tempfile::tempdir().expect("source"); + fs::write(source.path().join("target"), "source").expect("write"); + std::os::unix::fs::symlink( + source.path().join("target"), + source.path().join("absolute-link"), + ) + .expect("symlink"); + let copy = create(source.path(), &WorkspaceConfig::default(), "project").expect("copy"); + fs::write(copy.project().join("absolute-link"), "copy").expect("write through link"); + assert_eq!( + fs::read_to_string(source.path().join("target")).expect("source"), + "source" + ); + } + + #[cfg(unix)] + #[test] + fn internal_parent_symlink_is_rewritten_inside_the_copy() { + let source = tempfile::tempdir().expect("source"); + fs::create_dir(source.path().join("dir")).expect("dir"); + fs::write(source.path().join("target"), "source").expect("target"); + std::os::unix::fs::symlink("..", source.path().join("dir/up")).expect("symlink"); + let copy = create(source.path(), &WorkspaceConfig::default(), "project").expect("copy"); + fs::write(copy.project().join("dir/up/target"), "copy").expect("write copy"); + assert_eq!( + fs::read_to_string(source.path().join("target")).expect("source target"), + "source" + ); + } + + #[cfg(unix)] + #[test] + fn external_and_broken_final_symlinks_are_refused() { + let source = tempfile::tempdir().expect("source"); + let outside = tempfile::tempdir().expect("outside"); + fs::write(outside.path().join("secret"), "outside").expect("outside"); + std::os::unix::fs::symlink( + outside.path().join("secret"), + source.path().join("absolute"), + ) + .expect("absolute link"); + assert!(create(source.path(), &WorkspaceConfig::default(), "project").is_err()); + + fs::remove_file(source.path().join("absolute")).expect("remove"); + std::os::unix::fs::symlink("missing", source.path().join("broken")).expect("broken link"); + assert!(create(source.path(), &WorkspaceConfig::default(), "project").is_err()); + } + + #[cfg(unix)] + #[test] + fn relative_external_final_symlink_is_refused() { + let parent = tempfile::tempdir().expect("parent"); + let source = parent.path().join("repo"); + let outside = parent.path().join("outside"); + fs::create_dir(&source).expect("source"); + fs::create_dir(&outside).expect("outside"); + fs::write(outside.join("secret"), "outside").expect("outside file"); + std::os::unix::fs::symlink("../outside/secret", source.join("link")) + .expect("relative external symlink"); + assert!(create(&source, &WorkspaceConfig::default(), "project").is_err()); + } + + #[cfg(unix)] + #[test] + fn replaced_project_root_fails_boundary_validation() { + let source = tempfile::tempdir().expect("source"); + let outside = tempfile::tempdir().expect("outside"); + fs::write(source.path().join("file"), "inside").expect("source file"); + let copy = create(source.path(), &WorkspaceConfig::default(), "project").expect("copy"); + let moved = copy.temporary_root().join("moved-project"); + fs::rename(copy.project(), &moved).expect("move project"); + std::os::unix::fs::symlink(outside.path(), copy.project()).expect("replace project"); + assert!(copy.validate_boundary().is_err()); + } + + #[cfg(unix)] + #[test] + fn intermediate_symlinks_never_copy_external_files() { + let source = tempfile::tempdir().expect("source"); + let outside = tempfile::tempdir().expect("outside"); + fs::write(outside.path().join("secret"), "outside sentinel").expect("outside"); + std::os::unix::fs::symlink(outside.path(), source.path().join("link")).expect("symlink"); + let init = Command::new("git") + .arg("init") + .current_dir(source.path()) + .output() + .expect("git init"); + assert!(init.status.success()); + + let mut config = WorkspaceConfig { + mode: WorkspaceMode::GitClean, + allow_external_symlinks: true, + ..WorkspaceConfig::default() + }; + config.include_untracked.push(PathBuf::from("link/secret")); + let error = create(source.path(), &config, "project") + .err() + .expect("intermediate symlink must fail"); + assert!(error.to_string().contains("symlink ancestor")); + assert!(!error.to_string().contains("outside sentinel")); + } + + #[cfg(unix)] + #[test] + fn relative_intermediate_symlink_to_sibling_is_refused() { + let parent = tempfile::tempdir().expect("parent"); + let source = parent.path().join("repo"); + let sibling = parent.path().join("repo2"); + fs::create_dir(&source).expect("source"); + fs::create_dir(&sibling).expect("sibling"); + fs::write(sibling.join("secret"), "outside").expect("secret"); + std::os::unix::fs::symlink("../repo2", source.join("link")).expect("symlink"); + let init = Command::new("git") + .arg("init") + .current_dir(&source) + .output() + .expect("git init"); + assert!(init.status.success()); + let mut config = WorkspaceConfig { + mode: WorkspaceMode::GitClean, + ..WorkspaceConfig::default() + }; + config.include_untracked.push(PathBuf::from("link/secret")); + assert!(create(&source, &config, "project").is_err()); + } + + #[cfg(unix)] + #[test] + fn git_paths_preserve_non_utf8_names() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let invalid = parse_git_path(b"invalid-\xff").expect("invalid path"); + let replacement = parse_git_path(b"replacement-\xef\xbf\xbd").expect("replacement path"); + assert_eq!(invalid.as_os_str().as_bytes(), b"invalid-\xff"); + assert_eq!( + replacement.as_os_str().as_bytes(), + OsString::from_vec(b"replacement-\xef\xbf\xbd".to_vec()) + .as_os_str() + .as_bytes() + ); + assert_ne!(invalid, replacement); + } + + #[test] + fn streamed_git_paths_are_length_bounded_and_require_nul() { + let mut complete = std::io::Cursor::new(b"tracked\0".to_vec()); + let mut path = Vec::new(); + assert!(read_nul_path(&mut complete, &mut path).expect("path")); + assert_eq!(path, b"tracked"); + assert!(!read_nul_path(&mut complete, &mut path).expect("end")); + + let mut truncated = std::io::Cursor::new(b"tracked".to_vec()); + assert!(read_nul_path(&mut truncated, &mut path).is_err()); + + let mut oversized = std::io::Cursor::new(vec![b'x'; MAX_GIT_PATH_BYTES + 1]); + assert!(read_nul_path(&mut oversized, &mut path).is_err()); + } + + #[cfg(windows)] + #[test] + fn windows_external_symlink_is_refused_when_symlink_creation_is_available() { + use std::io::ErrorKind; + + let source = tempfile::tempdir().expect("source"); + let outside = tempfile::tempdir().expect("outside"); + let target = outside.path().join("secret"); + fs::write(&target, "outside").expect("outside file"); + match std::os::windows::fs::symlink_file(&target, source.path().join("link")) { + Ok(()) => { + assert!(create(source.path(), &WorkspaceConfig::default(), "project").is_err()); + } + Err(error) if error.kind() == ErrorKind::PermissionDenied => { + eprintln!( + "Windows symlink privilege is unavailable; portable path tests still ran" + ); + } + Err(error) => panic!("unexpected symlink creation error: {error}"), + } } } diff --git a/tests/e2e.rs b/tests/e2e.rs index 2ce4584..d1a8986 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -68,6 +68,14 @@ fn all_except(kept: &str) -> Vec<&'static str> { .collect() } +fn all_scenarios() -> Vec<&'static str> { + [ + "AZ-S001", "AZ-S002", "AZ-S003", "AZ-S004", "AZ-S005", "AZ-S006", "AZ-S007", "AZ-S008", + "AZ-S009", "AZ-S010", + ] + .to_vec() +} + fn parse_json(output: &Output) -> Value { assert!( output.status.success() @@ -102,6 +110,10 @@ fn scan_files(root: &Path) -> Vec { result } +fn toml_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + #[test] fn init_doctor_help_and_scenario_listing_work() { let project = tempfile::tempdir().expect("project"); @@ -347,6 +359,62 @@ fn reports_regenerate_as_markdown_json_and_junit() { String::from_utf8_lossy(&fs::read(directory.join("report.junit.xml")).expect("junit")) .contains(" { + for argument in arguments.iter().skip(1) { + println!("stdout-argument={argument}"); + eprintln!("stderr-argument={argument}"); + } + true + } "create-file" => arguments .get(1) .is_some_and(|path| fs::write(path, "created").is_ok()), + "replace-cwd-with-symlink" => arguments + .get(1) + .is_some_and(|target| replace_cwd_with_symlink(Path::new(target))), _ => false, }; process::exit(i32::from(!success)); } +fn replace_cwd_with_symlink(target: &Path) -> bool { + #[cfg(unix)] + { + let Ok(current) = env::current_dir() else { + return false; + }; + let Some(parent) = current.parent() else { + return false; + }; + let moved = parent.join("moved-project"); + fs::rename(¤t, &moved).is_ok() && std::os::unix::fs::symlink(target, ¤t).is_ok() + } + #[cfg(not(unix))] + { + let _ = target; + false + } +} + fn home_directory() -> Option { #[cfg(windows)] { From dcabab7978abbd4afa6d830d63d8d6a844ec0cfc Mon Sep 17 00:00:00 2001 From: Yahoo <119646314+y4ho0@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:12:37 +0800 Subject: [PATCH 2/3] Close remaining redaction and report boundaries --- CHANGELOG.md | 8 +- CHANGELOG.zh-CN.md | 8 +- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 2 +- README.zh-CN.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/JSON_SCHEMA.md | 2 + docs/LIMITATIONS.md | 7 +- docs/PRODUCT_SPEC.md | 2 + docs/SECURITY_MODEL.md | 10 +- docs/zh-CN/ARCHITECTURE.md | 2 +- docs/zh-CN/JSON_SCHEMA.md | 2 + docs/zh-CN/LIMITATIONS.md | 7 +- docs/zh-CN/PRODUCT_SPEC.md | 2 + docs/zh-CN/SECURITY_MODEL.md | 10 +- schemas/config-v1.schema.json | 8 +- scripts/validate-schemas.sh | 36 +++- src/cli.rs | 147 ++++++++++++--- src/config.rs | 17 +- src/engine.rs | 19 +- src/model.rs | 8 + src/redaction.rs | 309 +++++++++++++++++++++++++----- src/report.rs | 344 ++++++++++++++++++++++++++++------ src/workspace.rs | 5 +- tests/e2e.rs | 302 +++++++++++++++++++++++++++-- 26 files changed, 1065 insertions(+), 198 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a2e1b..68918ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,14 @@ All notable changes are documented here. The format follows Keep a Changelog, an ### Security - Reject workspace paths that escape through intermediate symlinks, unsafe workspace names, destination ancestors, or file-Oracle symlinks. -- Reject report run-ID traversal and report-root/output symlink escapes; stage report files before publication and apply the same 64 MiB limit to generated and loaded JSON reports. +- Reject report run-ID traversal and report-root/output symlink escapes; stage report files before publication, cap all generated artifacts at 64 MiB, and apply the same limit when loading JSON reports. - Preflight workspace byte and entry budgets before destination mutation, preserve Git path bytes on Unix, and stream source fingerprinting. - Frame raw platform path bytes in source fingerprints and persist Git status as a SHA-256 digest; integrity hashes are version-specific rather than cross-version identifiers. -- Redact sensitive CLI option values consistently in command displays, verbose output, JSON, Markdown, Oracle details, and persisted evidence. +- Redact sensitive environment values of any non-empty length, `HOME`/`USERPROFILE` paths, and recognized or configured CLI option values consistently across displays and persisted evidence, including configured attached short forms such as `-pVALUE`. +- Require `report.redact_home = true` in configuration v1 so the documented home-path privacy boundary cannot be disabled accidentally. +- Revalidate saved report-v1 structure before rendering, reject unknown fields without echoing parser details, and reapply command-derived redaction to legacy report metadata during regeneration. +- Refuse single-string opaque shell scripts and fail closed on regeneration of saved Shell-wrapper reports when historical output cannot be redacted safely. +- Use bounded multi-pattern literal redaction and render control characters visibly in terminal, Markdown, and valid XML evidence. - Bound `deep_path_length` to 240 bytes so generated workspace names remain one portable component; configurations above that limit now fail validation. ## [0.1.0] - 2026-07-27 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index b49cd00..5e21cce 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -13,10 +13,14 @@ ### 安全 - 拒绝经中间符号链接、不安全工作区名称、目标祖先或文件 Oracle 符号链接逃逸的工作区路径。 -- 拒绝报告运行 ID 穿越及报告根目录/输出文件符号链接逃逸,在发布前暂存报告文件,并对生成和读取的 JSON 报告应用相同的 64 MiB 上限。 +- 拒绝报告运行 ID 穿越及报告根目录/输出文件符号链接逃逸,在发布前暂存报告文件,把所有生成产物限制为 64 MiB,并在读取 JSON 报告时应用相同上限。 - 在修改目标前预检工作区字节和条目预算,在 Unix 上保真处理 Git 路径字节,并以流式方式计算源码指纹。 - 在源码指纹中对平台原始路径字节进行分帧,并以 SHA-256 摘要持久化 Git 状态;完整性哈希与版本相关,不是跨版本标识符。 -- 在命令显示、详细输出、JSON、Markdown、Oracle 详情和持久化证据中一致脱敏敏感 CLI 选项值。 +- 在显示和持久化证据中一致脱敏任意非空长度的敏感环境值、`HOME`/`USERPROFILE` 路径,以及已识别或已配置的 CLI 选项值,包括 `-pVALUE` 这类已配置短选项紧贴形式。 +- 配置 v1 要求 `report.redact_home = true`,防止意外关闭文档承诺的主目录路径隐私边界。 +- 渲染前重新校验已保存 report-v1 的结构,在不回显解析器细节的情况下拒绝未知字段,并在重新生成时对旧报告元数据再次应用由命令值驱动的脱敏。 +- 当只存在于 CLI 的值无法从证据中安全脱敏时,拒绝单字符串不透明 Shell 脚本,并在重新生成已保存的 Shell-wrapper 报告时 fail-closed。 +- 使用有界多模式字面量脱敏,并在终端、Markdown 和有效 XML 证据中把控制字符显示为可见形式。 - 将 `deep_path_length` 限制为 240 字节,使生成的工作区名称保持为单一可移植组件;超过该限制的既有配置现在会校验失败。 ## [0.1.0] - 2026-07-27 diff --git a/Cargo.lock b/Cargo.lock index 719e663..d0e5da4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,7 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" name = "assume-zero" version = "0.1.0" dependencies = [ + "aho-corasick", "anyhow", "clap", "ctrlc", diff --git a/Cargo.toml b/Cargo.toml index b358d8e..3aa20a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ default = [] test-fixtures = [] [dependencies] +aho-corasick = "1.1" anyhow = "1.0" clap = { version = "=4.5.23", features = ["derive"] } ctrlc = "3.4" diff --git a/README.md b/README.md index 963f658..1b81f68 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ Verified fixture transcripts for hidden environment variables, hidden child tool ## Privacy -AssumeZero itself does not upload files, call external APIs, send telemetry, inspect the contents of the real home directory, or persist recognized secret values. Sensitive environment values and values of recognized long CLI options such as `--token`, `--password`, and `--api-key` are used only in memory to redact command/output evidence before it is written. Configure ambiguous short or custom options in `report.sensitive_options`. Reports contain names, presence/classification metadata, and redacted output summaries. +AssumeZero itself does not upload files, call external APIs, send telemetry, inspect the contents of the real home directory, or persist recognized secret values. Sensitive environment values and values of recognized long CLI options such as `--token`, `--password`, and `--api-key` are used only in memory to redact command/output evidence before it is written. Configure ambiguous short or custom options in `report.sensitive_options`; a configured single-character short option covers `-p value`, `-p=value`, and `-pVALUE`. Reports contain names, presence/classification metadata, and redacted output summaries. User-provided preparation and tested commands can still access the network and other resources available to the current user. Redaction is defense in depth; pattern matching can have both false positives and false negatives. diff --git a/README.zh-CN.md b/README.zh-CN.md index 30feda9..207ccfd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -175,7 +175,7 @@ assumezero explain ## 隐私 -AssumeZero 本身不会上传文件、调用外部 API、发送遥测、检查真实主目录的内容或持久化已识别的秘密值。敏感环境值以及 `--token`、`--password`、`--api-key` 等已识别长 CLI 选项的值只在内存中用于命令/输出证据脱敏,随后才写入报告。存在歧义的短选项或自定义选项需在 `report.sensitive_options` 中声明。报告只包含名称、存在性/分类元数据和脱敏后的输出摘要。 +AssumeZero 本身不会上传文件、调用外部 API、发送遥测、检查真实主目录的内容或持久化已识别的秘密值。敏感环境值以及 `--token`、`--password`、`--api-key` 等已识别长 CLI 选项的值只在内存中用于命令/输出证据脱敏,随后才写入报告。存在歧义的短选项或自定义选项需在 `report.sensitive_options` 中声明;已声明的单字符短选项会覆盖 `-p value`、`-p=value` 和 `-pVALUE`。报告只包含名称、存在性/分类元数据和脱敏后的输出摘要。 用户提供的准备命令和被测命令仍可访问当前用户可访问的网络及其他资源。脱敏是纵深防御,模式匹配可能出现误报或漏报。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d9b3d8..3a5573f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,7 +31,7 @@ AssumeZero is a Rust library plus a thin CLI binary. The main modules are: 10. Fingerprint and compare source state. 11. Redact in memory and write report schema v1. -The process runner receives an executable and argument vector; it does not concatenate a shell string. Explicit `--shell` converts the user script into platform shell arguments only after displaying the security warning. +The process runner receives an executable and argument vector; it does not concatenate a shell string by default. Explicit `--shell` can convert multiple structured tokens into platform shell arguments after displaying the security warning. A single opaque script token is refused because its CLI-only secrets cannot be redacted reliably from output evidence. ## Cross-platform design diff --git a/docs/JSON_SCHEMA.md b/docs/JSON_SCHEMA.md index 7139c77..da53023 100644 --- a/docs/JSON_SCHEMA.md +++ b/docs/JSON_SCHEMA.md @@ -31,6 +31,8 @@ workspace_integrity `run_id` is a canonical 26-character ULID and a portable single path component. Repository and workspace fingerprints are opaque, version-dependent integrity evidence intended for before/after comparison within the same run; do not compare values across AssumeZero versions. `git_status_before` and `git_status_after` may contain legacy porcelain text from older v1 reports or a `sha256:` digest from hardened builds; new reports persist only the digest so repository path names do not re-enter evidence. +Before `explain` or report regeneration, AssumeZero revalidates the saved v1 structure. It rejects unsupported versions, invalid required values, and unknown fields in every fixed-shape object, including nested run, scenario, and finding structures. Parser details and offending values are suppressed because saved reports are untrusted and may contain secrets. + Environment-variable values are not fields anywhere in the schema. Recognized or configured sensitive CLI option values are replaced before the `command` field is constructed. `restored_names` contains variable names or redacted normalized `PATH` entries. Captured output and Oracle details are bounded where applicable and redacted before Report construction; captured output carries an `output_truncated` flag. Consumers must: diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 2410ead..41c69aa 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -9,7 +9,10 @@ Version 0.1.0 intentionally supports only finite, non-interactive commands. - `workspace.max_entries` also bounds full-source fingerprint collection; only `.git` and `.assumezero` are excluded from that count, and stored fingerprint path names have a 64 MiB aggregate limit. - Absolute-path and deliberate out-of-workspace writes cannot be prevented. - Timeout/interruption termination targets the direct child; complete descendant-tree termination is best effort. +- Disposable workspace cleanup is best effort after execution; a surviving descendant process or operating-system file lock can leave a temporary copy behind, especially on Windows. - Only Unicode environment variables available through Rust's portable string API participate in v0.1.0 minimization. +- Very short exact secret values can cause broad false-positive masking. When `-p` is configured as sensitive, every longer `-p...` token is treated as an attached value. +- Literal redaction is limited to 2,048 rules and 256 KiB of rule text; exceeding either limit fails closed by suppressing affected free-text evidence. - `EMPTY_HOME` can confirm dependence on home-level state but does not trace the specific file. - Cache redirection covers a conservative known-variable list and cannot prove a cache was actually read. - `TZ=UTC` is a process-level best-effort setting, not an operating-system timezone change. @@ -20,8 +23,10 @@ Version 0.1.0 intentionally supports only finite, non-interactive commands. - Pairwise scenario reduction is documented for a future release and is not enabled in v0.1.0. - Shell mode is explicitly trusted-input only. - Log redaction can miss encoded, transformed, fragmented, or unfamiliar secrets. -- Raw secrets passed as command arguments remain visible to the operating system while the process runs. A single opaque shell script is hidden from command metadata, but transformed/echoed values and undeclared short-option meanings cannot be redacted reliably. +- Raw secrets passed as command arguments remain visible to the operating system while the process runs. Single-string opaque shell scripts are refused; transformed values and undeclared short-option meanings in structured commands can still evade exact redaction. - Regenerating an older report reapplies current built-in long-option redaction, but historical custom/short-option semantics cannot be reconstructed. Inspect historical `.assumezero/runs` content before regenerating or sharing it. +- Report v1 has no trusted Shell provenance, so `explain` and regeneration reject every saved `sh -c` or Windows `/D /S /C` wrapper, including direct commands that were not created with `--shell`. +- Human-readable renderers expose C0/C1 control characters as visible code points, but Unicode bidirectional formatting characters are not normalized in v0.1.0. - The tool does not trace arbitrary filesystem access, syscalls, network faults, databases, services, or containers. - No crates.io package, telemetry, account system, cloud backend, or Marketplace Action is provided. diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index a983bf9..b173960 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -32,6 +32,8 @@ assumezero report --format markdown|json|junit Global options are `--verbose`, `--quiet`, `--no-color`, `--json`, and `--config `. +`--shell` remains trusted-input only. v0.1.0 refuses a single opaque script token; use multiple structured tokens, direct argv execution, or recognized environment variables when evidence may contain sensitive values. + ## Baseline The default is two runs, each in a new project copy with the same command, source state, configuration, and original inherited environment. Every run must satisfy the oracle before attribution starts. Mixed accepted/rejected runs produce `BASELINE_UNSTABLE`; consistently rejected runs produce `BASELINE_FAILED`. `--strict-output` additionally requires identical redacted stdout/stderr summaries and exit codes. diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index 87c9a21..33303a0 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -19,7 +19,7 @@ A tested or preparation command can intentionally access the network, home direc - `.git` and prior `.assumezero` evidence are excluded by default. - Symlink targets are resolved for containment without reading target file contents; external targets are refused unless explicitly allowed. - Source fingerprint and Git-status evidence are compared before report persistence. -- Git-status evidence is persisted as a SHA-256 digest rather than raw path output. Report run IDs are one normal path component; `.assumezero`, `runs`, run directories, and report files are checked to reject symlink escapes, and generated files are staged before publication. Generated and loaded JSON reports share a 64 MiB limit. +- Git-status evidence is persisted as a SHA-256 digest rather than raw path output. Report run IDs are one normal path component; `.assumezero`, `runs`, run directories, and report files are checked to reject symlink escapes, and generated files are staged before publication. Every generated report artifact is capped at 64 MiB, and loaded JSON reports use the same read limit. Loaded reports are structurally revalidated before rendering; unsupported versions, unknown fields, and invalid constrained metadata are rejected. - `.assumezero` is deliberate tool metadata and is excluded from source-content integrity claims. Commands can still deliberately write outside their working directory. Do not test untrusted projects or commands. @@ -30,7 +30,9 @@ Arguments are passed directly to the process API. `--shell` is explicit and warn ## Environment values -Environment values are held only in process memory for execution, recovery, and exact-value redaction. Report structures contain names—not values. Sensitive names include token, secret, password, API/access/private-key, authentication, and credential patterns. Semantic long CLI options such as `--token`, `--password`, and `--api-key` are also recognized. Ambiguous short options are redacted only when named in `report.sensitive_options`, for example `["-p"]`. +Environment values are held only in process memory for execution, recovery, and exact-value redaction. Report structures contain names—not values. Sensitive names include token, secret, password, API/access/private-key, authentication, and credential patterns. Semantic long CLI options such as `--token`, `--password`, and `--api-key` are also recognized. Ambiguous short options are redacted only when named in `report.sensitive_options`, for example `["-p"]`; configured single-character short options recognize separate, equals-sign, and attached values. + +Home-path redaction is mandatory in configuration v1: `report.redact_home = false` is rejected rather than weakening this privacy boundary. Before any output summary is written, redaction replaces: @@ -42,11 +44,11 @@ Before any output summary is written, redaction replaces: - JWT shapes; - private-key headers; - common database connection strings; -- home, project, and scenario-temporary paths. +- home (`HOME` and `USERPROFILE` where present), project, and scenario-temporary paths. Redaction has unavoidable false-positive and false-negative risk. A tested command that transforms or fragments a secret can evade exact matching. Use test credentials and inspect evidence before sharing it. -Verbose output is emitted only after bounded capture and redaction, rather than streamed before the redaction boundary. Raw CLI values still exist in process memory and the operating system's process argument view while the command runs. A single opaque `--shell` script is hidden in command displays and persisted command metadata because its option semantics cannot be parsed reliably; values echoed by that script may still evade output redaction. Do not place real credentials directly in command arguments. +Verbose output is emitted only after bounded capture and redaction, rather than streamed before the redaction boundary. Raw CLI values still exist in process memory and the operating system's process argument view while the command runs. A single opaque `--shell` script is refused because its option semantics cannot be parsed reliably enough to redact CLI-only values from output evidence; use structured arguments or recognized environment variables instead. Because report v1 has no trusted Shell provenance, `explain` and regeneration conservatively refuse every saved `sh -c` or Windows `/D /S /C` wrapper, even when its command field was previously masked. Do not place real credentials directly in command arguments. ## Reporting a vulnerability diff --git a/docs/zh-CN/ARCHITECTURE.md b/docs/zh-CN/ARCHITECTURE.md index 6cdc641..cdfece3 100644 --- a/docs/zh-CN/ARCHITECTURE.md +++ b/docs/zh-CN/ARCHITECTURE.md @@ -31,7 +31,7 @@ AssumeZero 由一个 Rust 库和一个轻量 CLI 二进制组成。主要模块 10. 再次生成并比较源状态指纹。 11. 在内存中脱敏,然后写出报告 Schema v1。 -进程运行器接收可执行文件和参数向量,不会拼接 Shell 字符串。显式使用 `--shell` 时,只有在显示安全警告后,才会把用户脚本转换为对应平台的 Shell 参数。 +默认情况下,进程运行器接收可执行文件和参数向量,不会拼接 Shell 字符串。显式使用 `--shell` 时,可以在显示安全警告后把多个结构化 Token 转换为对应平台的 Shell 参数。单个不透明脚本 Token 会被拒绝,因为无法可靠地从输出证据中脱敏其中只存在于 CLI 的秘密。 ## 跨平台设计 diff --git a/docs/zh-CN/JSON_SCHEMA.md b/docs/zh-CN/JSON_SCHEMA.md index 99f0a98..794bde1 100644 --- a/docs/zh-CN/JSON_SCHEMA.md +++ b/docs/zh-CN/JSON_SCHEMA.md @@ -31,6 +31,8 @@ Schema v1 中,`started_at` 和 `finished_at` 是 Unix Epoch 秒数字符串, `run_id` 是规范的 26 字符 ULID,同时也是可移植的单一路径组件。仓库和工作区指纹是不透明、与版本相关的完整性证据,只用于同一次运行的前后比较;不要跨 AssumeZero 版本比较。`git_status_before` 和 `git_status_after` 可能包含旧版 v1 报告的 porcelain 文本,或加固构建生成的 `sha256:` 摘要;新报告只持久化摘要,避免仓库路径名称重新进入证据。 +执行 `explain` 或重新生成报告之前,AssumeZero 会再次校验已保存的 v1 结构。不受支持的版本、无效必填值以及所有固定结构对象中的未知字段(包括嵌套的运行、场景和发现结构)都会被拒绝。由于已保存报告不受信任且可能包含秘密,错误不会回显解析器详情或违规值。 + 环境变量值不会出现在 Schema 的任何字段中。已识别或已配置的敏感 CLI 选项值会在构造 `command` 字段之前被替换。`restored_names` 只包含变量名或脱敏、规范化后的 `PATH` 条目。捕获输出及适用的 Oracle 详情会在构造 Report 前完成有界处理和脱敏;捕获输出带有 `output_truncated` 标志。 使用方必须: diff --git a/docs/zh-CN/LIMITATIONS.md b/docs/zh-CN/LIMITATIONS.md index 995f77f..01a9a6d 100644 --- a/docs/zh-CN/LIMITATIONS.md +++ b/docs/zh-CN/LIMITATIONS.md @@ -9,7 +9,10 @@ v0.1.0 有意只支持有限、非交互式命令。 - `workspace.max_entries` 同时限制全源指纹收集;该计数只排除 `.git` 和 `.assumezero`,保存的指纹路径名称另有 64 MiB 聚合上限。 - 无法阻止绝对路径写入或命令故意写入工作区之外。 - 超时/中断时会终止直接子进程;完整后代进程树终止是 best effort。 +- 执行结束后的临时工作区清理是 best effort;仍存活的后代进程或操作系统文件锁可能遗留临时副本,尤其是在 Windows 上。 - 只有 Rust 可移植字符串 API 能表示的 Unicode 环境变量参与 v0.1.0 最小化。 +- 很短的精确秘密值可能造成大范围误脱敏。把 `-p` 声明为敏感选项后,所有更长的 `-p...` Token 都会被视为紧贴值。 +- 字面量脱敏最多允许 2,048 条规则和 256 KiB 规则文本;超过任一上限后会 fail-closed,抑制受影响的自由文本证据。 - `EMPTY_HOME` 可以确认对主目录状态的依赖,但不会跟踪具体文件。 - 缓存重定向只覆盖保守的已知变量列表,无法证明命令实际读取了缓存。 - `TZ=UTC` 是进程级 best-effort 设置,不会改变操作系统时区。 @@ -20,8 +23,10 @@ v0.1.0 有意只支持有限、非交互式命令。 - 两两场景约简计划用于未来版本,v0.1.0 未启用。 - Shell 模式只允许显式信任的输入。 - 日志脱敏可能漏掉编码、变换、拆分或未知形式的秘密。 -- 作为命令参数传入的原始秘密在进程运行期间仍对操作系统可见;单个不透明 Shell 脚本会从命令元数据中整体隐藏,但经过转换/回显的值以及未声明短选项的含义仍无法可靠脱敏。 +- 作为命令参数传入的原始秘密在进程运行期间仍对操作系统可见;单字符串不透明 Shell 脚本会被拒绝,而结构化命令中经过转换的值及未声明短选项的含义仍可能绕过精确值脱敏。 - 重新生成旧报告时会再次应用当前内置长选项脱敏,但无法恢复历史自定义/短选项的语义;重新生成或分享历史 `.assumezero/runs` 内容前请先人工检查。 +- report v1 没有可信的 Shell 来源信息,因此 `explain` 和报告重新生成会拒绝所有保存的 `sh -c` 或 Windows `/D /S /C` wrapper,包括并非由 `--shell` 创建的直接命令。 +- 人类可读渲染会把 C0/C1 控制字符显示为可见码位,但 v0.1.0 不会规范化 Unicode 双向格式字符。 - 工具不跟踪任意文件系统访问、系统调用、网络故障、数据库、服务或容器。 - 不提供 crates.io 包、遥测、账号系统、云后端或 Marketplace Action。 diff --git a/docs/zh-CN/PRODUCT_SPEC.md b/docs/zh-CN/PRODUCT_SPEC.md index f1686c2..240603f 100644 --- a/docs/zh-CN/PRODUCT_SPEC.md +++ b/docs/zh-CN/PRODUCT_SPEC.md @@ -32,6 +32,8 @@ assumezero report --format markdown|json|junit 全局选项为 `--verbose`、`--quiet`、`--no-color`、`--json` 和 `--config `。 +`--shell` 仍只适用于受信任输入。v0.1.0 会拒绝单个不透明脚本 Token;当证据可能包含敏感值时,请使用多个结构化 Token、直接 argv 执行或已识别的环境变量。 + ## 基线 默认运行两次,每次使用一个新的项目副本,并保持命令、源状态、配置和最初继承的环境一致。开始归因之前,每次运行都必须满足 Oracle。接受/拒绝混合的结果会产生 `BASELINE_UNSTABLE`;持续拒绝会产生 `BASELINE_FAILED`。`--strict-output` 还要求脱敏后的 stdout/stderr 摘要和退出码完全相同。 diff --git a/docs/zh-CN/SECURITY_MODEL.md b/docs/zh-CN/SECURITY_MODEL.md index af60c08..4d6f139 100644 --- a/docs/zh-CN/SECURITY_MODEL.md +++ b/docs/zh-CN/SECURITY_MODEL.md @@ -19,7 +19,7 @@ AssumeZero 使用当前用户权限,在工作区副本中执行用户提供的 - 默认排除 `.git` 和以前的 `.assumezero` 证据。 - 符号链接目标只会为边界判断而解析,不会读取目标文件内容;除非显式允许,否则拒绝外部目标。 - 在报告持久化前比较源文件指纹和 Git 状态证据。 -- Git 状态证据以 SHA-256 摘要持久化,不写入原始路径输出。报告运行 ID 必须是一个普通路径组件;工具会检查 `.assumezero`、`runs`、运行目录和报告文件以拒绝符号链接逃逸,并在发布前暂存生成文件。生成和读取的 JSON 报告共用 64 MiB 上限。 +- Git 状态证据以 SHA-256 摘要持久化,不写入原始路径输出。报告运行 ID 必须是一个普通路径组件;工具会检查 `.assumezero`、`runs`、运行目录和报告文件以拒绝符号链接逃逸,并在发布前暂存生成文件。所有生成的报告产物都限制为 64 MiB,读取 JSON 报告时也使用相同上限。读取旧报告后会在渲染前重新校验结构;不受支持的版本、未知字段和无效的受约束元数据都会被拒绝。 - `.assumezero` 是有意生成的工具元数据,不计入源文件内容完整性声明。 命令仍然可以故意写入工作目录之外。不要测试不受信任的项目或命令。 @@ -30,7 +30,9 @@ AssumeZero 使用当前用户权限,在工作区副本中执行用户提供的 ## 环境值 -环境值只在进程内存中用于执行、恢复和精确值脱敏。报告结构包含名称,不包含值。敏感名称包括 token、secret、password、API/access/private-key、authentication 和 credential 等模式。工具也会识别 `--token`、`--password`、`--api-key` 等语义明确的长 CLI 选项。存在歧义的短选项只有在 `report.sensitive_options` 中声明后才会脱敏,例如 `["-p"]`。 +环境值只在进程内存中用于执行、恢复和精确值脱敏。报告结构包含名称,不包含值。敏感名称包括 token、secret、password、API/access/private-key、authentication 和 credential 等模式。工具也会识别 `--token`、`--password`、`--api-key` 等语义明确的长 CLI 选项。存在歧义的短选项只有在 `report.sensitive_options` 中声明后才会脱敏,例如 `["-p"]`;已声明的单字符短选项会识别分离、等号和紧贴三种值形式。 + +配置 v1 强制启用主目录路径脱敏:`report.redact_home = false` 会被拒绝,而不是削弱这条隐私边界。 任何输出摘要写入之前,脱敏会替换: @@ -42,11 +44,11 @@ AssumeZero 使用当前用户权限,在工作区副本中执行用户提供的 - JWT 形态; - 私钥头; - 常见数据库连接字符串; -- 主目录、项目目录和场景临时路径。 +- 主目录(存在时同时覆盖 `HOME` 与 `USERPROFILE`)、项目目录和场景临时路径。 脱敏不可避免地存在误报和漏报风险。被测命令如果转换或拆分秘密,可能绕过精确匹配。请使用测试凭据,并在分享证据前自行检查。 -详细输出会在有界捕获并脱敏后再显示,不会在脱敏边界之前流式直出。命令运行期间,原始 CLI 值仍会存在于进程内存和操作系统的进程参数视图中。单个不透明 `--shell` 脚本的选项语义无法可靠解析,因此会在命令显示和持久化命令元数据中整体隐藏;该脚本回显的值仍可能绕过输出脱敏。不要把真实凭据直接放入命令参数。 +详细输出会在有界捕获并脱敏后再显示,不会在脱敏边界之前流式直出。命令运行期间,原始 CLI 值仍会存在于进程内存和操作系统的进程参数视图中。单个不透明 `--shell` 脚本的选项语义无法被可靠解析,因而无法保证从输出证据中脱敏只存在于 CLI 的值,所以工具会拒绝这类脚本;请改用结构化参数或已识别的环境变量。由于 report v1 没有可信的 Shell 来源信息,`explain` 和报告重新生成会保守拒绝所有保存的 `sh -c` 或 Windows `/D /S /C` wrapper,即使命令字段以前已被遮蔽。不要把真实凭据直接放入命令参数。 ## 报告漏洞 diff --git a/schemas/config-v1.schema.json b/schemas/config-v1.schema.json index b93f02e..e2e0380 100644 --- a/schemas/config-v1.schema.json +++ b/schemas/config-v1.schema.json @@ -158,7 +158,11 @@ }, "default": ["terminal", "json"] }, - "redact_home": { "type": "boolean", "default": true }, + "redact_home": { + "const": true, + "default": true, + "description": "Mandatory in configuration v1: home-path redaction cannot be disabled." + }, "log_limit_bytes": { "type": "integer", "minimum": 1024, @@ -168,7 +172,7 @@ "type": "array", "items": { "type": "string", "pattern": "^-{1,2}[^\\s=-][^\\s=]*$" }, "default": [], - "description": "Additional command options whose following or inline value is sensitive; intended especially for ambiguous short options." + "description": "Additional command options whose following or inline value is sensitive; configured single-character short options recognize -p value, -p=value, and -pVALUE." } } } diff --git a/scripts/validate-schemas.sh b/scripts/validate-schemas.sh index 5b812ac..c33c4b5 100755 --- a/scripts/validate-schemas.sh +++ b/scripts/validate-schemas.sh @@ -33,16 +33,28 @@ npx --yes ajv-cli@5.0.0 validate \ -s "$repository_root/schemas/report-v1.schema.json" \ -d "$repository_root/docs/demo/report-v1.example.json" -python3 - "$repository_root/docs/demo/report-v1.example.json" "$validation_root/invalid-report.json" <<'PY' +python3 - \ + "$repository_root/docs/demo/report-v1.example.json" \ + "$validation_root/invalid-report.json" \ + "$validation_root/invalid-nested-report.json" \ + "$validation_root/invalid-config.json" <<'PY' import json import pathlib import sys source = pathlib.Path(sys.argv[1]) -destination = pathlib.Path(sys.argv[2]) report = json.loads(source.read_text(encoding="utf-8")) -report["run_id"] = "Z0000000000000000000000000" -destination.write_text(json.dumps(report), encoding="utf-8") + +overflow = dict(report) +overflow["run_id"] = "Z0000000000000000000000000" +pathlib.Path(sys.argv[2]).write_text(json.dumps(overflow), encoding="utf-8") + +nested = json.loads(source.read_text(encoding="utf-8")) +nested["configuration"]["unknown_field"] = True +pathlib.Path(sys.argv[3]).write_text(json.dumps(nested), encoding="utf-8") + +invalid_config = {"version": 1, "report": {"redact_home": False}} +pathlib.Path(sys.argv[4]).write_text(json.dumps(invalid_config), encoding="utf-8") PY if npx --yes ajv-cli@5.0.0 validate \ @@ -52,3 +64,19 @@ if npx --yes ajv-cli@5.0.0 validate \ echo "invalid overflow ULID unexpectedly passed report schema" >&2 exit 1 fi + +if npx --yes ajv-cli@5.0.0 validate \ + --spec=draft2020 \ + -s "$repository_root/schemas/report-v1.schema.json" \ + -d "$validation_root/invalid-nested-report.json" >/dev/null 2>&1; then + echo "nested unknown report field unexpectedly passed report schema" >&2 + exit 1 +fi + +if npx --yes ajv-cli@5.0.0 validate \ + --spec=draft2020 \ + -s "$repository_root/schemas/config-v1.schema.json" \ + -d "$validation_root/invalid-config.json" >/dev/null 2>&1; then + echo "redact_home=false unexpectedly passed configuration schema" >&2 + exit 1 +fi diff --git a/src/cli.rs b/src/cli.rs index b8f6b46..1566906 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -103,9 +103,11 @@ pub fn run_cli() -> Result { Ok(code) => Ok(code), Err(error) => { let rendered = env::current_dir().ok().map_or_else( - || format!("{error:#}"), + || report::terminal_safe(&format!("{error:#}")), |project| { - Redactor::new(&env::vars().collect(), &project).redact(&format!("{error:#}")) + let redacted = Redactor::new(&env::vars().collect(), &project) + .redact(&format!("{error:#}")); + report::terminal_safe(&redacted) }, ); eprintln!("AssumeZero could not use the requested configuration or command."); @@ -130,7 +132,7 @@ fn execute(cli: Cli) -> Result { } Commands::Explain { run_id } => { let mut saved = report::load(¤t, &run_id)?; - redact_loaded_report(¤t, &mut saved); + redact_loaded_report(¤t, &mut saved)?; if cli.json { println!("{}", serde_json::to_string_pretty(&saved.findings)?); } else { @@ -140,11 +142,17 @@ fn execute(cli: Cli) -> Result { } Commands::Report { run_id, format } => { let mut saved = report::load(¤t, &run_id)?; - redact_loaded_report(¤t, &mut saved); + redact_loaded_report(¤t, &mut saved)?; let path = report::write_requested_format(¤t, &saved, format.as_str())?; println!( "Wrote /{}", - path.strip_prefix(¤t).unwrap_or(&path).display() + report::terminal_safe( + &path + .strip_prefix(¤t) + .unwrap_or(&path) + .display() + .to_string() + ) ); Ok(0) } @@ -180,7 +188,11 @@ fn execute(cli: Cli) -> Result { ); } let logical_command = final_command.clone(); - let opaque_shell_script = shell && logical_command.len() == 1; + if shell && logical_command.len() == 1 { + anyhow::bail!( + "a single opaque `--shell` script is refused because its CLI secret values cannot be redacted reliably; pass a structured argument vector without `--shell`, or move sensitive values into recognized environment variables" + ); + } if shell { let script = if final_command.len() == 1 { final_command.remove(0) @@ -209,19 +221,21 @@ fn execute(cli: Cli) -> Result { config.validate()?; let command_redactor = redactor_for_commands(&config, ¤t, &final_command, &logical_command); - let redacted_command = if opaque_shell_script { - redact_opaque_shell_command(&final_command) - } else { - command_redactor.redact_command(&final_command) - }; + let redacted_command = command_redactor.redact_command(&final_command); let redacted_source = command_redactor.redact(&source); if dry_run { dry_run_summary(&config, &redacted_source, &redacted_command, cli.json); return Ok(0); } if !cli.quiet { - eprintln!("Final command: {}", redacted_command.join(" ")); - eprintln!("Configuration source: {redacted_source}"); + eprintln!( + "Final command: {}", + report::terminal_safe(&redacted_command.join(" ")) + ); + eprintln!( + "Configuration source: {}", + report::terminal_safe(&redacted_source) + ); } let output = engine::check( ¤t, @@ -229,7 +243,6 @@ fn execute(cli: Cli) -> Result { &redacted_source, &final_command, &logical_command, - opaque_shell_script, cli.verbose, ) .map_err(|error| anyhow::anyhow!(command_redactor.redact(&format!("{error:#}"))))?; @@ -240,11 +253,14 @@ fn execute(cli: Cli) -> Result { if !cli.quiet { println!( "Evidence: /{}", - output - .directory - .strip_prefix(¤t) - .unwrap_or(&output.directory) - .display() + report::terminal_safe( + &output + .directory + .strip_prefix(¤t) + .unwrap_or(&output.directory) + .display() + .to_string() + ) ); } } @@ -291,18 +307,38 @@ fn redactor_for_commands( redactor } -fn redact_loaded_report(project: &Path, report: &mut crate::model::Report) { +fn redact_loaded_report(project: &Path, report: &mut crate::model::Report) -> Result<()> { + reject_raw_opaque_shell_report(&report.command)?; let mut redactor = Redactor::new(&env::vars().collect(), project); redactor.add_commands([report.command.as_slice()], &[]); - redactor.redact_report(report); + redactor.redact_loaded_report(report); + Ok(()) } -fn redact_opaque_shell_command(command: &[String]) -> Vec { - let mut redacted = command.to_vec(); - if let Some(script) = redacted.last_mut() { - *script = "".into(); +fn reject_raw_opaque_shell_report(command: &[String]) -> Result<()> { + if is_shell_wrapper(command) { + anyhow::bail!( + "saved report contains a shell wrapper whose historical output cannot be redacted reliably and therefore cannot be rendered safely; command details are suppressed" + ); } - redacted + Ok(()) +} + +fn is_shell_wrapper(command: &[String]) -> bool { + let Some(executable) = command.first() else { + return false; + }; + let basename = executable + .rsplit(['/', '\\']) + .next() + .unwrap_or(executable) + .to_ascii_lowercase(); + (basename == "sh" && command.len() == 3 && command[1] == "-c") + || (command.len() == 5 + && command[1..4] + .iter() + .zip(["/d", "/s", "/c"]) + .all(|(actual, expected)| actual.eq_ignore_ascii_case(expected))) } fn init(path: &Path, force: bool, json_output: bool) -> Result { @@ -321,7 +357,10 @@ fn init(path: &Path, force: bool, json_output: bool) -> Result { if json_output { println!("{}", json!({"created": path, "overwritten": force})); } else { - println!("Created {}", path.display()); + println!( + "Created {}", + report::terminal_safe(&path.display().to_string()) + ); println!("Next: edit [run].command, then run `assumezero doctor`."); } Ok(0) @@ -483,8 +522,11 @@ fn dry_run_summary(config: &Config, source: &str, command: &[String], json_outpu ); } else { println!("AssumeZero dry run\n"); - println!("Final command: {}", command.join(" ")); - println!("Configuration source: {source}"); + println!( + "Final command: {}", + report::terminal_safe(&command.join(" ")) + ); + println!("Configuration source: {}", report::terminal_safe(source)); println!("Profile: {}", config.scenarios.profile); println!("Baseline runs: {}", config.run.baseline_runs); println!("Scenarios: {}", selected.join(", ")); @@ -514,4 +556,51 @@ mod tests { }; assert_eq!(final_command, vec!["cli"]); } + + #[test] + fn legacy_opaque_shell_wrappers_are_detected_portably() { + for command in [ + vec!["/bin/sh", "-c", "raw"], + vec!["/sh", "-c", "raw"], + vec![r"C:\Windows\System32\cmd.exe", "/D", "/S", "/C", "raw"], + vec!["/cmd.exe", "/d", "/s", "/c", "raw"], + vec![ + "/custom-comspec.exe", + "/D", + "/S", + "/C", + "raw", + ], + ] { + let command = command.into_iter().map(String::from).collect::>(); + assert!(is_shell_wrapper(&command), "{command:?}"); + } + for command in [ + vec!["bash", "-c", "raw"], + vec!["sh", "-lc", "raw"], + vec!["sh", "script.sh"], + vec!["cmd.exe", "/S", "/C", "raw"], + vec!["tool", "-c", "ordinary"], + ] { + let command = command.into_iter().map(String::from).collect::>(); + assert!(!is_shell_wrapper(&command), "{command:?}"); + } + + let sentinel = "AZ_INVALID_LEGACY_SHELL_UNIT_7712"; + let raw = ["/sh", "-c", sentinel] + .map(String::from) + .to_vec(); + let error = reject_raw_opaque_shell_report(&raw) + .expect_err("raw shell report must be refused") + .to_string(); + assert!(!error.contains(sentinel)); + + let redacted = ["/sh", "-c", ""] + .map(String::from) + .to_vec(); + let error = reject_raw_opaque_shell_report(&redacted) + .expect_err("historical output remains untrusted even with a redacted command") + .to_string(); + assert!(!error.contains("REDACTED_OPAQUE_SHELL_SCRIPT")); + } } diff --git a/src/config.rs b/src/config.rs index 275cff4..12e70e1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -304,10 +304,7 @@ impl Config { pub fn validate(&self) -> Result<()> { if self.version != 1 { - bail!( - "unsupported configuration field `version = {}`; use `version = 1`", - self.version - ); + bail!("unsupported configuration field `version`; actual value suppressed because configuration values may be sensitive. Use `version = 1`"); } if self.run.timeout_seconds == 0 { bail!("field `run.timeout_seconds` must be at least 1"); @@ -395,6 +392,11 @@ impl Config { ); } } + if !self.report.redact_home { + bail!( + "field `report.redact_home` must be true in configuration v1; home-path redaction is a mandatory privacy boundary" + ); + } if self.report.log_limit_bytes < 1_024 { bail!("field `report.log_limit_bytes` must be at least 1024"); } @@ -518,6 +520,13 @@ mod tests { assert!(config.validate().is_err()); } + #[test] + fn home_path_redaction_cannot_be_disabled() { + let mut config = Config::default(); + config.report.redact_home = false; + assert!(config.validate().is_err()); + } + #[test] fn unsafe_untracked_path_is_rejected() { let mut config = Config::default(); diff --git a/src/engine.rs b/src/engine.rs index 90a4880..e1525d6 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -102,7 +102,6 @@ pub fn check( config_source: &str, command_tokens: &[String], logical_command_tokens: &[String], - opaque_shell_script: bool, verbose: bool, ) -> Result { config.validate()?; @@ -204,15 +203,7 @@ pub fn check( workspace_mode: config.workspace.mode.as_str().into(), report_formats: config.report.formats.clone(), }, - command: if opaque_shell_script { - let mut display = command.display.clone(); - if let Some(script) = display.last_mut() { - *script = "".into(); - } - display - } else { - base_redactor.redact_command(&command.display) - }, + command: base_redactor.redact_command(&command.display), baseline, baseline_status, scenarios: scenario_results, @@ -220,6 +211,10 @@ pub fn check( budget: budget.evidence(), redaction_summary: BTreeMap::from([ ("in_memory_rules".into(), base_redactor.rule_count()), + ( + "literal_rule_budget_exhausted".into(), + usize::from(base_redactor.rule_budget_exhausted()), + ), ("persisted_environment_values".into(), 0), ("persisted_cli_sensitive_values".into(), 0), ]), @@ -760,14 +755,14 @@ fn emit_verbose_evidence(evidence: &RunEvidence) { let _ = writeln!( output, "[tested command stdout]\n{}", - evidence.stdout_summary + report::terminal_safe(&evidence.stdout_summary) ); } if !evidence.stderr_summary.is_empty() { let _ = writeln!( output, "[tested command stderr]\n{}", - evidence.stderr_summary + report::terminal_safe(&evidence.stderr_summary) ); } } diff --git a/src/model.rs b/src/model.rs index 6e9432d..c828eb0 100644 --- a/src/model.rs +++ b/src/model.rs @@ -23,6 +23,7 @@ pub enum EvidenceLevel { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct OracleCheck { pub check: String, pub accepted: bool, @@ -30,6 +31,7 @@ pub struct OracleCheck { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct RunEvidence { pub accepted: bool, pub exit_code: Option, @@ -43,6 +45,7 @@ pub struct RunEvidence { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ScenarioEvidence { pub id: String, pub name: String, @@ -56,6 +59,7 @@ pub struct ScenarioEvidence { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Finding { pub id: String, pub scenario_id: String, @@ -69,6 +73,7 @@ pub struct Finding { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct BudgetEvidence { pub max_total_runs: usize, pub max_total_seconds: u64, @@ -78,6 +83,7 @@ pub struct BudgetEvidence { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct IntegrityEvidence { pub before_fingerprint: String, pub after_fingerprint: String, @@ -88,6 +94,7 @@ pub struct IntegrityEvidence { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ReportConfiguration { pub source: String, pub profile: String, @@ -99,6 +106,7 @@ pub struct ReportConfiguration { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Report { pub schema_version: u32, pub tool_version: String, diff --git a/src/redaction.rs b/src/redaction.rs index 8d564de..0f3775f 100644 --- a/src/redaction.rs +++ b/src/redaction.rs @@ -1,4 +1,5 @@ use crate::model::{Report, RunEvidence}; +use aho_corasick::{AhoCorasick, MatchKind}; use regex::Regex; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -36,17 +37,18 @@ const BUILTIN_SENSITIVE_LONG_OPTIONS: &[&str] = &[ "refresh-token", ]; -#[derive(Clone)] -struct ExactValue { - value: String, - replacement: &'static str, -} +const MAX_LITERAL_RULES: usize = 2_048; +const MAX_LITERAL_RULE_BYTES: usize = 256 * 1_024; +const REDACTION_BUDGET_MARKER: &str = ""; #[derive(Clone)] pub struct Redactor { - exact_values: Vec, + literal_rules: BTreeMap, + literal_rule_bytes: usize, + literal_matcher: Option, + literal_replacements: Vec<&'static str>, + literal_rule_budget_exhausted: bool, sensitive_options: BTreeSet, - home: Option, project: PathBuf, temporary_roots: Vec, patterns: Vec, @@ -56,7 +58,11 @@ impl fmt::Debug for Redactor { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("Redactor") - .field("exact_rule_count", &self.exact_values.len()) + .field("literal_rule_count", &self.literal_rules.len()) + .field( + "literal_rule_budget_exhausted", + &self.literal_rule_budget_exhausted, + ) .field("sensitive_option_count", &self.sensitive_options.len()) .field("temporary_root_count", &self.temporary_roots.len()) .field("pattern_rule_count", &self.patterns.len()) @@ -78,18 +84,35 @@ impl Redactor { .filter_map(|pattern| Regex::new(pattern).ok()) .collect(); let mut redactor = Self { - exact_values: Vec::new(), + literal_rules: BTreeMap::new(), + literal_rule_bytes: 0, + literal_matcher: None, + literal_replacements: Vec::new(), + literal_rule_budget_exhausted: false, sensitive_options: BTreeSet::new(), - home: std::env::var_os("HOME").map(PathBuf::from), project: project.to_path_buf(), temporary_roots: Vec::new(), patterns, }; + redactor.add_path_value(project, ""); + for (name, value) in environment { + if matches!(name.to_ascii_uppercase().as_str(), "HOME" | "USERPROFILE") + && !value.is_empty() + { + redactor.add_path_value(Path::new(value), ""); + } + } + for name in ["HOME", "USERPROFILE"] { + if let Some(value) = std::env::var_os(name) { + redactor.add_path_value(Path::new(&value), ""); + } + } for (name, value) in environment { - if is_sensitive_name(name) && value.len() >= 4 { + if is_sensitive_name(name) && !value.is_empty() { redactor.add_exact(value, ""); } } + redactor.rebuild_literal_matcher(); redactor } @@ -106,29 +129,46 @@ impl Redactor { for command in commands { self.add_command_values(command); } + self.rebuild_literal_matcher(); } pub fn add_temporary_root(&mut self, path: &Path) { self.temporary_roots.push(path.to_path_buf()); + self.add_path_value(path, ""); + self.rebuild_literal_matcher(); } pub fn redact(&self, input: &str) -> String { - let mut result = input.to_owned(); - for exact in &self.exact_values { - result = result.replace(&exact.value, exact.replacement); + if self.literal_rule_budget_exhausted { + return REDACTION_BUDGET_MARKER.into(); } + let mut result = self.redact_exact_values(input); for pattern in &self.patterns { result = pattern .replace_all(&result, "") .into_owned(); } - if let Some(home) = &self.home { - result = replace_path(&result, home, ""); - } - result = replace_path(&result, &self.project, ""); - for temporary in &self.temporary_roots { - result = replace_path(&result, temporary, ""); + result + } + + fn redact_exact_values(&self, input: &str) -> String { + let Some(matcher) = &self.literal_matcher else { + return input.into(); + }; + let mut result = String::with_capacity(input.len()); + let mut offset = 0; + for matched in matcher.find_iter(input) { + result.push_str(&input[offset..matched.start()]); + let replacement = self.literal_replacements[matched.pattern().as_usize()]; + let matched_length = matched.end() - matched.start(); + if replacement.len() <= matched_length { + result.push_str(replacement); + } else { + result.extend(std::iter::repeat('*').take(matched_length)); + } + offset = matched.end(); } + result.push_str(&input[offset..]); result } @@ -147,6 +187,10 @@ impl Redactor { continue; } } + if let Some((option, _value)) = self.attached_sensitive_short(part) { + result.push(format!("{option}")); + continue; + } if self.is_sensitive_option(part) { result.push(part.clone()); redact_next = true; @@ -158,7 +202,11 @@ impl Redactor { } pub fn rule_count(&self) -> usize { - self.exact_values.len() + self.patterns.len() + self.literal_rules.len() + self.patterns.len() + } + + pub const fn rule_budget_exhausted(&self) -> bool { + self.literal_rule_budget_exhausted } pub fn redact_report(&self, report: &mut Report) { @@ -197,6 +245,29 @@ impl Redactor { report.workspace_integrity.note = self.redact(&report.workspace_integrity.note); } + pub fn redact_loaded_report(&self, report: &mut Report) { + self.redact_report(report); + report.tool_version = self.redact(&report.tool_version); + let platform = std::mem::take(&mut report.platform); + for (key, value) in platform { + let key = if matches!(key.as_str(), "os" | "arch" | "family") { + key + } else { + self.redact(&key) + }; + report.platform.insert(key, self.redact(&value)); + } + for format in &mut report.configuration.report_formats { + *format = self.redact(format); + } + let summary = std::mem::take(&mut report.redaction_summary); + for (key, value) in summary { + let key = self.redact(&key); + let entry = report.redaction_summary.entry(key).or_default(); + *entry = entry.saturating_add(value); + } + } + fn add_command_values(&mut self, command: &[String]) { let mut capture_next = false; for part in command { @@ -211,32 +282,90 @@ impl Redactor { continue; } } + if let Some((_option, value)) = self.attached_sensitive_short(part) { + self.add_exact(value, ""); + continue; + } if self.is_sensitive_option(part) { capture_next = true; } } } + fn attached_sensitive_short<'a>(&self, part: &'a str) -> Option<(&'a str, &'a str)> { + let lowercase = part.to_ascii_lowercase(); + self.sensitive_options.iter().find_map(|configured| { + let name = configured.strip_prefix('-')?; + if configured.starts_with("--") + || name.chars().count() != 1 + || lowercase.len() <= configured.len() + || !lowercase.starts_with(configured) + { + return None; + } + Some(part.split_at(configured.len())) + }) + } + fn add_exact(&mut self, value: &str, replacement: &'static str) { if value.is_empty() { return; } - if let Some(existing) = self - .exact_values - .iter_mut() - .find(|candidate| candidate.value == value) - { + if let Some(existing) = self.literal_rules.get_mut(value) { if replacement == "" { - existing.replacement = replacement; + *existing = replacement; } return; } - self.exact_values.push(ExactValue { - value: value.into(), - replacement, - }); - self.exact_values - .sort_by_key(|exact| std::cmp::Reverse(exact.value.len())); + self.add_literal_rule(value, replacement); + } + + fn add_path_value(&mut self, path: &Path, replacement: &'static str) { + let native = path.to_string_lossy(); + if !native.is_empty() { + self.add_literal_rule(native.as_ref(), replacement); + } + let slash = native.replace('\\', "/"); + if !slash.is_empty() { + self.add_literal_rule(&slash, replacement); + } + } + + fn add_literal_rule(&mut self, value: &str, replacement: &'static str) { + if self.literal_rule_budget_exhausted || self.literal_rules.contains_key(value) { + return; + } + let Some(next_bytes) = self.literal_rule_bytes.checked_add(value.len()) else { + self.literal_rule_budget_exhausted = true; + return; + }; + if self.literal_rules.len() >= MAX_LITERAL_RULES || next_bytes > MAX_LITERAL_RULE_BYTES { + self.literal_rule_budget_exhausted = true; + return; + } + self.literal_rules.insert(value.into(), replacement); + self.literal_rule_bytes = next_bytes; + } + + fn rebuild_literal_matcher(&mut self) { + if self.literal_rule_budget_exhausted || self.literal_rules.is_empty() { + self.literal_matcher = None; + self.literal_replacements.clear(); + return; + } + let patterns: Vec<&str> = self.literal_rules.keys().map(String::as_str).collect(); + self.literal_replacements = self.literal_rules.values().copied().collect(); + self.literal_matcher = match AhoCorasick::builder() + .match_kind(MatchKind::LeftmostLongest) + .build(patterns) + { + Ok(matcher) => Some(matcher), + Err(_) => { + self.literal_rule_budget_exhausted = true; + self.literal_replacements.clear(); + None + } + }; } fn is_sensitive_option(&self, option: &str) -> bool { @@ -286,10 +415,8 @@ impl Redactor { let path = Path::new(part); if path.is_absolute() { if let Ok(relative) = path.strip_prefix(&self.project) { - return format!( - "/{}", - relative.to_string_lossy().replace('\\', "/") - ); + let relative = relative.to_string_lossy().replace('\\', "/"); + return format!("/{}", self.redact(&relative)); } } let redacted = self.redact(part); @@ -311,16 +438,6 @@ pub fn is_sensitive_name(name: &str) -> bool { .any(|needle| upper.contains(needle)) } -fn replace_path(input: &str, path: &Path, replacement: &str) -> String { - let native = path.to_string_lossy(); - let mut result = input.replace(native.as_ref(), replacement); - let slash = native.replace('\\', "/"); - if slash != native { - result = result.replace(&slash, replacement); - } - result -} - #[cfg(test)] mod tests { use super::*; @@ -342,6 +459,86 @@ mod tests { assert!(output.contains("REDACTED_ENV_VALUE")); } + #[test] + fn short_sensitive_environment_values_are_removed() { + let env = BTreeMap::from([("DEMO_TOKEN".into(), "abc".into())]); + let redactor = Redactor::new(&env, Path::new("/project")); + let output = redactor.redact("fixture-secret=abc"); + assert_eq!(output, "fixture-secret=***"); + } + + #[test] + fn exact_replacements_do_not_rescan_inserted_markers_or_expand_output() { + let env = BTreeMap::from([ + ("A_TOKEN".into(), "A".into()), + ("D_TOKEN".into(), "D".into()), + ("E_TOKEN".into(), "E".into()), + ("N_TOKEN".into(), "N".into()), + ("R_TOKEN".into(), "R".into()), + ("V_TOKEN".into(), "V".into()), + ]); + let redactor = Redactor::new(&env, Path::new("/project")); + assert_eq!(redactor.redact("A"), "*"); + } + + #[test] + fn repeated_short_values_are_redacted_without_output_growth() { + let env = BTreeMap::from([ + ("X_TOKEN".into(), "x".into()), + ("Z_TOKEN".into(), "z".into()), + ]); + let redactor = Redactor::new(&env, Path::new("/project")); + let input = "x".repeat(50_000); + let output = redactor.redact(&input); + assert_eq!(output.len(), input.len()); + assert!(output.bytes().all(|byte| byte == b'*')); + } + + #[test] + fn overlapping_and_multibyte_literals_use_leftmost_longest_matches() { + let env = BTreeMap::from([ + ("SHORT_TOKEN".into(), "abc".into()), + ("LONG_TOKEN".into(), "abcd".into()), + ("UNICODE_TOKEN".into(), "秘密".into()), + ]); + let redactor = Redactor::new(&env, Path::new("/project")); + assert_eq!(redactor.redact("abcd"), "****"); + assert_eq!(redactor.redact("秘密"), "******"); + } + + #[test] + fn excessive_literal_rules_fail_closed() { + let env: BTreeMap = (0..=MAX_LITERAL_RULES) + .map(|index| (format!("TOKEN_{index}"), format!("fake-secret-{index}"))) + .collect(); + let redactor = Redactor::new(&env, Path::new("/project")); + assert!(redactor.rule_budget_exhausted()); + assert_eq!( + redactor.redact("ordinary evidence"), + REDACTION_BUDGET_MARKER + ); + } + + #[test] + fn windows_userprofile_paths_are_redacted_portably() { + let environment = + BTreeMap::from([("UserProfile".into(), r"C:\Users\AssumeZeroExample".into())]); + let redactor = Redactor::new(&environment, Path::new("/project")); + let rendered = redactor.redact(r"path=C:\Users\AssumeZeroExample\settings.toml"); + assert_eq!(rendered, r"path=\settings.toml"); + } + + #[test] + fn project_relative_path_segments_are_exact_value_redacted() { + let redactor = command_redactor(&["tool", "--token", "abc", "/project/abc"], &[]); + let command = ["tool", "--token", "abc", "/project/abc"] + .map(String::from) + .to_vec(); + let rendered = redactor.redact_command(&command).join(" "); + assert!(!rendered.contains("abc")); + assert!(rendered.contains("/***")); + } + #[test] fn common_token_patterns_are_removed() { let redactor = Redactor::new(&BTreeMap::new(), Path::new("/project")); @@ -405,6 +602,22 @@ mod tests { ); } + #[test] + fn configured_short_option_redacts_attached_values() { + let raw = ["tool", "-pfake-attached", "-j4"]; + let redactor = command_redactor(&raw, &["-p"]); + let command: Vec = raw.iter().map(|part| (*part).into()).collect(); + assert_eq!( + redactor.redact_command(&command), + ["tool", "-p", "-j4"] + .map(String::from) + .to_vec() + ); + assert!(!redactor + .redact("echo fake-attached") + .contains("fake-attached")); + } + #[test] fn ordinary_and_similarly_named_options_are_not_redacted() { let raw = [ @@ -444,7 +657,7 @@ mod tests { let redactor = command_redactor(&["tool", "--token", "fake-debug-secret"], &[]); let debug = format!("{redactor:?}"); assert!(!debug.contains("fake-debug-secret")); - assert!(debug.contains("exact_rule_count")); + assert!(debug.contains("literal_rule_count")); } #[test] diff --git a/src/report.rs b/src/report.rs index 18c595c..00c86e4 100644 --- a/src/report.rs +++ b/src/report.rs @@ -8,13 +8,23 @@ use std::path::{Path, PathBuf}; const MAX_SAVED_REPORT_BYTES: u64 = 64 * 1_048_576; pub fn persist(project: &Path, report: &Report, formats: &[String]) -> Result { - validate_run_id(&report.run_id)?; + validate_report_contract(report)?; let json = serde_json::to_vec_pretty(report)?; - if json.len() as u64 > MAX_SAVED_REPORT_BYTES { - bail!( - "generated report exceeds the {} MiB persisted-report limit; reduce run or log budgets", - MAX_SAVED_REPORT_BYTES / 1_048_576 - ); + validate_artifact_size(json.len())?; + let markdown = formats + .iter() + .any(|format| format == "markdown") + .then(|| markdown(report).into_bytes()); + if let Some(bytes) = &markdown { + validate_artifact_size(bytes.len())?; + } + let junit = formats + .iter() + .any(|format| format == "junit") + .then(|| junit(report)) + .transpose()?; + if let Some(bytes) = &junit { + validate_artifact_size(bytes.len())?; } let runs = report_root(project, true)?; let directory = runs.join(&report.run_id); @@ -32,11 +42,11 @@ pub fn persist(project: &Path, report: &Report, formats: &[String]) -> Result Result { MAX_SAVED_REPORT_BYTES / 1_048_576 ); } - let report: Report = - serde_json::from_slice(&bytes).context("saved report is not valid report schema v1")?; - validate_run_id(&report.run_id).context("saved report contains an unsafe run ID")?; + let report: Report = serde_json::from_slice(&bytes).map_err(|_| { + anyhow::anyhow!( + "saved report is not valid report schema v1; parser details are suppressed because report fields may be sensitive" + ) + })?; + validate_report_contract(&report)?; if report.run_id != run_id { - bail!( - "saved report run ID `{}` does not match requested run `{run_id}`", - report.run_id - ); + bail!("saved report run ID does not match the requested run directory"); } Ok(report) } @@ -123,11 +133,11 @@ pub fn print_terminal(report: &Report, quiet: bool) { finding.id, finding.scenario_id, evidence_label(finding.evidence), - finding.changed, - finding.observed, - finding.conclusion, - finding.next_step, - finding.not_proven + escape_control_text(&finding.changed), + escape_control_text(&finding.observed), + escape_control_text(&finding.conclusion), + escape_control_text(&finding.next_step), + escape_control_text(&finding.not_proven) ); } println!( @@ -156,18 +166,20 @@ pub fn markdown(report: &Report) -> String { report.run_id, escape_markdown(&display_command(&report.command)), report.baseline_status, - report.platform.get("os").map_or("unknown", String::as_str), - report - .platform - .get("arch") - .map_or("unknown", String::as_str), + escape_markdown(report.platform.get("os").map_or("unknown", String::as_str)), + escape_markdown( + report + .platform + .get("arch") + .map_or("unknown", String::as_str) + ), report.workspace_integrity.source_unchanged, ); for scenario in &report.scenarios { output.push_str(&format!( "| {} | {} | {:?} | {} | {} |\n", scenario.id, - scenario.name, + escape_markdown(&scenario.name), scenario.status, scenario.runs.len(), escape_markdown(&scenario.note) @@ -188,11 +200,11 @@ pub fn markdown(report: &Report) -> String { finding.id, finding.scenario_id, evidence_label(finding.evidence), - finding.changed, - finding.observed, - finding.conclusion, - finding.next_step, - finding.not_proven + escape_markdown(&finding.changed), + escape_markdown(&finding.observed), + escape_markdown(&finding.conclusion), + escape_markdown(&finding.next_step), + escape_markdown(&finding.not_proven) )); } output.push_str("## Safety note\n\nAssumeZero ran the command in copied workspaces. This protects source files from direct command writes; it does not sandbox untrusted code or prevent network and other machine access.\n"); @@ -246,7 +258,7 @@ pub fn junit(report: &Report) -> Result> { } pub fn write_requested_format(project: &Path, report: &Report, format: &str) -> Result { - validate_run_id(&report.run_id)?; + validate_report_contract(report)?; let directory = existing_run_directory(project, &report.run_id)?; let (path, bytes) = match format { "json" => ( @@ -257,14 +269,19 @@ pub fn write_requested_format(project: &Path, report: &Report, format: &str) -> "junit" => (directory.join("report.junit.xml"), junit(report)?), other => anyhow::bail!("unsupported report format `{other}`"), }; - if format == "json" && bytes.len() as u64 > MAX_SAVED_REPORT_BYTES { + validate_artifact_size(bytes.len())?; + atomic_write_regular(&directory, &path, &bytes)?; + Ok(path) +} + +fn validate_artifact_size(bytes: usize) -> Result<()> { + if bytes as u64 > MAX_SAVED_REPORT_BYTES { bail!( - "generated report exceeds the {} MiB persisted-report limit; reduce run or log budgets", + "generated report artifact exceeds the {} MiB persisted-report limit; reduce run or log budgets", MAX_SAVED_REPORT_BYTES / 1_048_576 ); } - atomic_write_regular(&directory, &path, &bytes)?; - Ok(path) + Ok(()) } fn validate_run_id(run_id: &str) -> Result<()> { @@ -272,13 +289,86 @@ fn validate_run_id(run_id: &str) -> Result<()> { .parse::() .is_ok_and(|parsed| parsed.to_string() == run_id); if !valid_ulid || !platform::is_single_normal_component(run_id) { - bail!( - "run ID `{run_id}` must be a canonical 26-character ULID and one normal path component" - ); + bail!("run ID must be a canonical 26-character ULID and one normal path component"); } Ok(()) } +fn validate_report_contract(report: &Report) -> Result<()> { + if report.schema_version != 1 { + bail!("saved report uses an unsupported schema version; actual value suppressed"); + } + validate_run_id(&report.run_id).context("saved report contains an unsafe run ID")?; + if !decimal_string(&report.started_at) || !decimal_string(&report.finished_at) { + bail!("saved report contains an invalid timestamp field; value suppressed"); + } + if !report.platform.contains_key("os") + || !report.platform.contains_key("arch") + || !report.platform.contains_key("family") + { + bail!("saved report is missing required platform fields"); + } + if !lower_hex_64(&report.repository_fingerprint) { + bail!("saved report contains an invalid repository fingerprint"); + } + if !matches!(report.configuration.profile.as_str(), "quick" | "deep") + || report.configuration.timeout_seconds == 0 + || report.configuration.baseline_runs < 2 + || report.configuration.confirm_failures == 0 + || !matches!( + report.configuration.workspace_mode.as_str(), + "working-tree" | "git-clean" + ) + { + bail!("saved report contains invalid configuration metadata; values suppressed"); + } + if report.command.is_empty() { + bail!("saved report command must not be empty"); + } + if !matches!( + report.baseline_status.as_str(), + "STABLE" | "BASELINE_FAILED" | "BASELINE_UNSTABLE" + ) { + bail!("saved report contains an invalid baseline status; value suppressed"); + } + for scenario in &report.scenarios { + if !stable_id(&scenario.id, "AZ-S") { + bail!("saved report contains an invalid scenario ID; value suppressed"); + } + } + for finding in &report.findings { + if !stable_id(&finding.id, "AZ-F") || !stable_id(&finding.scenario_id, "AZ-S") { + bail!("saved report contains an invalid finding ID; value suppressed"); + } + } + if report.budget.max_total_runs == 0 || report.budget.max_total_seconds == 0 { + bail!("saved report contains invalid execution-budget metadata"); + } + if !lower_hex_64(&report.workspace_integrity.before_fingerprint) + || !lower_hex_64(&report.workspace_integrity.after_fingerprint) + { + bail!("saved report contains an invalid workspace fingerprint"); + } + Ok(()) +} + +fn decimal_string(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn lower_hex_64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn stable_id(value: &str, prefix: &str) -> bool { + value + .strip_prefix(prefix) + .is_some_and(|suffix| suffix.len() == 3 && suffix.bytes().all(|byte| byte.is_ascii_digit())) +} + fn report_root(project: &Path, create: bool) -> Result { let project_root = project .canonicalize() @@ -425,11 +515,11 @@ pub fn explain(report: &Report) -> String { "{} ({})\nChanged: {}\nObserved: {}\nConclusion: {}\nNext: {}\nNot proven: {}\n\n", finding.scenario_id, evidence_label(finding.evidence), - finding.changed, - finding.observed, - finding.conclusion, - finding.next_step, - finding.not_proven + escape_control_text(&finding.changed), + escape_control_text(&finding.observed), + escape_control_text(&finding.conclusion), + escape_control_text(&finding.next_step), + escape_control_text(&finding.not_proven) )); } output @@ -462,10 +552,11 @@ fn display_command(command: &[String]) -> String { command .iter() .map(|part| { - if part.chars().any(char::is_whitespace) { - format!("{part:?}") + let safe = escape_control_text(part); + if safe.chars().any(char::is_whitespace) { + format!("{safe:?}") } else { - part.clone() + safe } }) .collect::>() @@ -473,16 +564,44 @@ fn display_command(command: &[String]) -> String { } fn escape_markdown(input: &str) -> String { - input.replace('|', "\\|").replace('\n', " ") + escape_control_text(input).replace('|', "\\|") } fn xml_escape(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") + let safe = escape_control_text(input); + let mut output = String::with_capacity(safe.len()); + for character in safe.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '"' => output.push_str("""), + '\'' => output.push_str("'"), + _ if xml_10_character(character) => output.push(character), + _ => output.push_str(&format!("[U+{:04X}]", character as u32)), + } + } + output +} + +fn escape_control_text(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + for character in input.chars() { + if character.is_control() { + output.push_str(&format!("[U+{:04X}]", character as u32)); + } else { + output.push(character); + } + } + output +} + +pub(crate) fn terminal_safe(input: &str) -> String { + escape_control_text(input) +} + +const fn xml_10_character(character: char) -> bool { + matches!(character as u32, 0x9 | 0xA | 0xD | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF) } const fn evidence_label(level: EvidenceLevel) -> &'static str { @@ -508,8 +627,12 @@ mod tests { run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV".into(), started_at: "0".into(), finished_at: "1".into(), - platform: BTreeMap::new(), - repository_fingerprint: "abc".into(), + platform: BTreeMap::from([ + ("os".into(), "test".into()), + ("arch".into(), "test".into()), + ("family".into(), "test".into()), + ]), + repository_fingerprint: "0".repeat(64), configuration: ReportConfiguration { source: "defaults".into(), profile: "quick".into(), @@ -543,8 +666,8 @@ mod tests { }, redaction_summary: BTreeMap::new(), workspace_integrity: IntegrityEvidence { - before_fingerprint: "a".into(), - after_fingerprint: "a".into(), + before_fingerprint: "a".repeat(64), + after_fingerprint: "a".repeat(64), source_unchanged: true, git_status_before: None, git_status_after: None, @@ -576,6 +699,57 @@ mod tests { assert!(xml.contains("a & "b" < c")); } + #[test] + fn human_renderers_make_control_characters_visible() { + let mut report = sample_report(ScenarioStatus::Fail); + let injected = "before\u{1b}]0;title\u{7}after\nforged"; + report.command = vec![injected.into()]; + report.scenarios[0].name = injected.into(); + report.scenarios[0].note = injected.into(); + report.findings.push(crate::model::Finding { + id: "AZ-F001".into(), + scenario_id: "AZ-S001".into(), + evidence: EvidenceLevel::Confirmed, + changed: injected.into(), + observed: injected.into(), + conclusion: injected.into(), + next_step: injected.into(), + not_proven: injected.into(), + restored_names: vec![], + }); + + let explanation = explain(&report); + let markdown = markdown(&report); + for rendered in [explanation, markdown] { + assert!(!rendered.contains('\u{1b}')); + assert!(!rendered.contains('\u{7}')); + assert!(rendered.contains("[U+001B]")); + assert!(rendered.contains("[U+0007]")); + assert!(rendered.contains("[U+000A]")); + } + let command = display_command(&report.command); + assert!(!command.contains('\u{1b}')); + assert!(command.contains("[U+001B]")); + } + + #[test] + fn junit_replaces_non_xml_control_characters() { + let mut report = sample_report(ScenarioStatus::Fail); + report.scenarios[0].name = "nul\0 one\u{1} esc\u{1b} c1\u{85} bad\u{fffe}".into(); + let xml = String::from_utf8(junit(&report).expect("junit")).expect("UTF-8"); + assert!(!xml.contains('\0')); + assert!(!xml.contains('\u{1}')); + assert!(!xml.contains('\u{1b}')); + assert!(!xml.contains('\u{85}')); + assert!(!xml.contains('\u{fffe}')); + assert!(xml.contains("[U+0000]")); + assert!(xml.contains("[U+0001]")); + assert!(xml.contains("[U+001B]")); + assert!(xml.contains("[U+0085]")); + assert!(xml.contains("[U+FFFE]")); + assert!(xml.chars().all(xml_10_character)); + } + #[test] fn run_ids_must_be_one_portable_normal_component() { let project = tempfile::tempdir().expect("project"); @@ -598,6 +772,12 @@ mod tests { } } + #[test] + fn every_generated_report_artifact_uses_the_same_size_limit() { + assert!(validate_artifact_size(MAX_SAVED_REPORT_BYTES as usize).is_ok()); + assert!(validate_artifact_size(MAX_SAVED_REPORT_BYTES as usize + 1).is_err()); + } + #[test] fn loaded_report_id_must_match_requested_directory() { let project = tempfile::tempdir().expect("project"); @@ -613,6 +793,50 @@ mod tests { assert!(load(project.path(), "01ARZ3NDEKTSV4RRFFQ69G5FAV").is_err()); } + #[test] + fn loaded_reports_reject_invalid_contracts_without_echoing_values() { + let project = tempfile::tempdir().expect("project"); + let report = sample_report(ScenarioStatus::Pass); + let directory = persist(project.path(), &report, &["json".into()]).expect("persist"); + let path = directory.join("report.json"); + + let mut unsupported = serde_json::to_value(&report).expect("value"); + unsupported["schema_version"] = serde_json::json!(918273645); + fs::write( + &path, + serde_json::to_vec_pretty(&unsupported).expect("json"), + ) + .expect("replace fixture"); + let error = load(project.path(), &report.run_id) + .expect_err("unsupported schema version") + .to_string(); + assert!(!error.contains("918273645")); + + let mut unknown = serde_json::to_value(&report).expect("value"); + unknown["AZ_INVALID_UNKNOWN_REPORT_FIELD_5511"] = serde_json::json!(true); + fs::write(&path, serde_json::to_vec_pretty(&unknown).expect("json")) + .expect("replace fixture"); + let error = load(project.path(), &report.run_id) + .expect_err("unknown report field") + .to_string(); + assert!(!error.contains("AZ_INVALID_UNKNOWN_REPORT_FIELD_5511")); + assert!(error.contains("parser details are suppressed")); + + let mut nested_unknown = serde_json::to_value(&report).expect("value"); + nested_unknown["configuration"]["AZ_INVALID_NESTED_REPORT_FIELD_5512"] = + serde_json::json!(true); + fs::write( + &path, + serde_json::to_vec_pretty(&nested_unknown).expect("json"), + ) + .expect("replace fixture"); + let error = load(project.path(), &report.run_id) + .expect_err("nested unknown report field") + .to_string(); + assert!(!error.contains("AZ_INVALID_NESTED_REPORT_FIELD_5512")); + assert!(error.contains("parser details are suppressed")); + } + #[cfg(unix)] #[test] fn report_roots_and_outputs_refuse_symlink_escape() { diff --git a/src/workspace.rs b/src/workspace.rs index 8b32b86..41a8e37 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -759,7 +759,7 @@ mod tests { } #[test] - fn budget_is_rejected_before_destination_mutation() { + fn byte_budget_is_rejected_during_preflight() { let source = tempfile::tempdir().expect("source"); fs::write(source.path().join("small"), "small").expect("write"); let large = File::create(source.path().join("large")).expect("large"); @@ -773,9 +773,6 @@ mod tests { &config, ); assert!(result.is_err()); - - let root = tempfile::tempdir().expect("destination root"); - assert!(!root.path().join("project").exists()); } #[test] diff --git a/tests/e2e.rs b/tests/e2e.rs index d1a8986..11c8ecf 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -189,6 +189,62 @@ fn clean_env_is_minimized_without_persisting_the_value() { assert!(!String::from_utf8_lossy(&persisted).contains(fake_secret)); } +#[test] +fn short_sensitive_environment_values_are_redacted_across_outputs_and_reports() { + let project = tempfile::tempdir().expect("project"); + let config = write_config(project.path(), &all_scenarios(), ""); + let secret_name = "AZ_SHORT_TOKEN"; + let short_secret = "abc"; + let exposed = "fixture-secret=abc"; + + let json_output = run(assumezero() + .current_dir(project.path()) + .env(secret_name, short_secret) + .args([ + "--json", + "--config", + config.to_str().expect("path"), + "check", + "--", + fixture(), + "secret-output", + secret_name, + ])); + assert!(json_output.status.success()); + let report = parse_json(&json_output); + let rendered = format!( + "{}{}{}", + String::from_utf8_lossy(&json_output.stdout), + String::from_utf8_lossy(&json_output.stderr), + serde_json::to_string(&report).expect("report") + ); + assert!(!rendered.contains(exposed), "{rendered}"); + assert!(rendered.contains("fixture-secret=***")); + let run_id = report["run_id"].as_str().expect("run id"); + let directory = project.path().join(".assumezero/runs").join(run_id); + assert!(!String::from_utf8_lossy(&scan_files(&directory)).contains(exposed)); + + let terminal_output = run(assumezero() + .current_dir(project.path()) + .env(secret_name, short_secret) + .args([ + "--config", + config.to_str().expect("path"), + "check", + "--", + fixture(), + "secret-output", + secret_name, + ])); + assert!(terminal_output.status.success()); + let terminal = format!( + "{}{}", + String::from_utf8_lossy(&terminal_output.stdout), + String::from_utf8_lossy(&terminal_output.stderr) + ); + assert!(!terminal.contains(exposed), "{terminal}"); +} + #[test] fn empty_home_failure_is_confirmed() { let project = tempfile::tempdir().expect("project"); @@ -480,6 +536,8 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { let sentinel_short = "AZ_INVALID_CLI_SHORT_4104"; let sentinel_prepare = "AZ_INVALID_CLI_PREPARE_4105"; let sentinel_github = "AZ_INVALID_CLI_GITHUB_4106"; + let sentinel_attached_short = "AZ_INVALID_CLI_ATTACHED_SHORT_4107"; + let attached_short = format!("-p{sentinel_attached_short}"); let config_text = fs::read_to_string(&initial_config) .expect("config") .replace( @@ -512,6 +570,7 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { &format!("--api-key={sentinel_api_key}"), "-p", sentinel_short, + attached_short.as_str(), "--github-token", sentinel_github, "--output", @@ -567,6 +626,7 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { sentinel_short, sentinel_prepare, sentinel_github, + sentinel_attached_short, ] { assert!(!rendered.contains(sentinel)); } @@ -603,6 +663,7 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { sentinel_short, sentinel_prepare, sentinel_github, + sentinel_attached_short, ] { assert!(!rendered.contains(sentinel)); } @@ -642,6 +703,7 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { sentinel_short, sentinel_prepare, sentinel_github, + sentinel_attached_short, ] { assert!(!rendered.contains(sentinel)); } @@ -658,6 +720,7 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { sentinel_short, sentinel_prepare, sentinel_github, + sentinel_attached_short, ] { assert!(!persisted.contains(sentinel)); } @@ -692,6 +755,7 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { sentinel_short, sentinel_prepare, sentinel_github, + sentinel_attached_short, ] { assert!(!rendered.contains(sentinel)); } @@ -706,6 +770,7 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { sentinel_short, sentinel_prepare, sentinel_github, + sentinel_attached_short, ] { assert!(!shell_persisted.contains(sentinel)); } @@ -727,6 +792,7 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { sentinel_short, sentinel_prepare, sentinel_github, + sentinel_attached_short, ] { assert!(!terminal_rendered.contains(sentinel)); } @@ -749,6 +815,7 @@ fn cli_only_secrets_are_redacted_across_outputs_and_reports() { sentinel_short, sentinel_prepare, sentinel_github, + sentinel_attached_short, ] { assert!(!persisted.contains(sentinel)); } @@ -866,6 +933,63 @@ fn configuration_unknown_values_do_not_reintroduce_command_secrets() { assert!(rendered.contains("value suppressed")); } +#[test] +fn configuration_version_errors_do_not_reintroduce_command_secrets() { + let project = tempfile::tempdir().expect("project"); + let sentinel = "918273645"; + let config = project.path().join("invalid-version.toml"); + fs::write( + &config, + format!("version = {sentinel}\n[run]\ncommand = [\"tool\", \"--token\", \"{sentinel}\"]\n"), + ) + .expect("invalid config"); + let output = run(assumezero().current_dir(project.path()).args([ + "--config", + config.to_str().expect("path"), + "check", + ])); + assert_eq!(output.status.code(), Some(3)); + let rendered = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!rendered.contains(sentinel), "{rendered}"); + assert!(rendered.contains("actual value suppressed")); +} + +#[test] +fn sensitive_values_do_not_reenter_through_project_paths() { + let project = tempfile::tempdir().expect("project"); + let config = write_config(project.path(), &all_scenarios(), ""); + let sentinel = "AZ_INVALID_SECRET_PATH_4304"; + let absolute_secret_path = project.path().join(sentinel); + let output = run(assumezero().current_dir(project.path()).args([ + "--json", + "--config", + config.to_str().expect("path"), + "check", + "--", + fixture(), + "echo-args", + "--token", + sentinel, + absolute_secret_path.to_str().expect("secret path"), + ])); + assert!(output.status.success()); + let report = parse_json(&output); + let rendered = format!( + "{}{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + serde_json::to_string(&report).expect("report") + ); + assert!(!rendered.contains(sentinel), "{rendered}"); + let run_id = report["run_id"].as_str().expect("run id"); + let directory = project.path().join(".assumezero/runs").join(run_id); + assert!(!String::from_utf8_lossy(&scan_files(&directory)).contains(sentinel)); +} + #[test] fn historical_report_regeneration_reapplies_builtin_redaction() { let project = tempfile::tempdir().expect("project"); @@ -886,6 +1010,18 @@ fn historical_report_regeneration_reapplies_builtin_redaction() { let directory = project.path().join(".assumezero/runs").join(&run_id); let mut historical = report; historical["command"] = serde_json::json!(["tool", "--token", sentinel]); + historical["tool_version"] = Value::String(sentinel.into()); + historical["platform"]["os"] = Value::String(sentinel.into()); + historical["platform"] + .as_object_mut() + .expect("platform") + .insert(sentinel.into(), Value::String("custom".into())); + historical["configuration"]["report_formats"] = serde_json::json!([sentinel]); + historical["redaction_summary"] = Value::Object( + [(sentinel.to_string(), Value::from(1))] + .into_iter() + .collect(), + ); historical["workspace_integrity"]["note"] = Value::String(sentinel.into()); historical["scenarios"] = serde_json::json!([{ "id": "AZ-S001", @@ -904,22 +1040,27 @@ fn historical_report_regeneration_reapplies_builtin_redaction() { ) .expect("historical fixture"); - let regenerated = run(assumezero().current_dir(project.path()).args([ - "report", - run_id.as_str(), - "--format", - "markdown", - ])); - assert!(regenerated.status.success()); + let mut regenerated_output = Vec::new(); + for format in ["markdown", "junit", "json"] { + let regenerated = run(assumezero().current_dir(project.path()).args([ + "report", + run_id.as_str(), + "--format", + format, + ])); + assert!(regenerated.status.success(), "{format}"); + regenerated_output.extend(regenerated.stdout); + regenerated_output.extend(regenerated.stderr); + } let explained = run(assumezero() .current_dir(project.path()) .args(["--json", "explain", run_id.as_str()])); assert!(explained.status.success()); - let persisted = fs::read(directory.join("report.md")).expect("regenerated markdown"); + let persisted = scan_files(&directory); let rendered = format!( "{}{}{}", - String::from_utf8_lossy(®enerated.stdout), + String::from_utf8_lossy(®enerated_output), String::from_utf8_lossy(&explained.stdout), String::from_utf8_lossy(&persisted) ); @@ -928,7 +1069,131 @@ fn historical_report_regeneration_reapplies_builtin_redaction() { } #[test] -fn opaque_shell_script_is_hidden_from_display_and_persisted_command() { +fn legacy_raw_shell_reports_are_refused_without_echoing_or_rewriting() { + let project = tempfile::tempdir().expect("project"); + let sentinel = "AZ_INVALID_LEGACY_SHELL_4402"; + let config = write_config(project.path(), &all_scenarios(), ""); + let output = run(assumezero().current_dir(project.path()).args([ + "--json", + "--config", + config.to_str().expect("path"), + "check", + "--", + fixture(), + "pass", + ])); + assert!(output.status.success()); + let mut report = parse_json(&output); + let run_id = report["run_id"].as_str().expect("run id").to_owned(); + let directory = project.path().join(".assumezero/runs").join(&run_id); + report["command"] = serde_json::json!([ + "/sh", + "-c", + format!("echo --token {sentinel}") + ]); + report["baseline"][0]["stdout_summary"] = Value::String(sentinel.into()); + let report_path = directory.join("report.json"); + let raw = serde_json::to_vec_pretty(&report).expect("legacy raw shell report"); + fs::write(&report_path, &raw).expect("replace report"); + for artifact in ["report.md", "report.junit.xml"] { + let path = directory.join(artifact); + if path.exists() { + fs::remove_file(path).expect("remove prior artifact"); + } + } + + for arguments in [ + vec!["explain", run_id.as_str()], + vec!["report", run_id.as_str(), "--format", "markdown"], + vec!["report", run_id.as_str(), "--format", "junit"], + vec!["report", run_id.as_str(), "--format", "json"], + ] { + let rejected = run(assumezero().current_dir(project.path()).args(arguments)); + assert_eq!(rejected.status.code(), Some(3)); + let rendered = format!( + "{}{}", + String::from_utf8_lossy(&rejected.stdout), + String::from_utf8_lossy(&rejected.stderr) + ); + assert!(!rendered.contains(sentinel), "{rendered}"); + assert!(rendered.contains("saved report contains a shell wrapper")); + } + assert_eq!(fs::read(&report_path).expect("original report"), raw); + assert!(!directory.join("report.md").exists()); + assert!(!directory.join("report.junit.xml").exists()); + + report["command"] = + serde_json::json!(["/sh", "-c", ""]); + let marked = serde_json::to_vec_pretty(&report).expect("marked legacy shell report"); + fs::write(&report_path, &marked).expect("replace marked report"); + for arguments in [ + vec!["explain", run_id.as_str()], + vec!["report", run_id.as_str(), "--format", "markdown"], + vec!["report", run_id.as_str(), "--format", "junit"], + vec!["report", run_id.as_str(), "--format", "json"], + ] { + let rejected = run(assumezero().current_dir(project.path()).args(arguments)); + assert_eq!(rejected.status.code(), Some(3)); + let rendered = format!( + "{}{}", + String::from_utf8_lossy(&rejected.stdout), + String::from_utf8_lossy(&rejected.stderr) + ); + assert!(!rendered.contains(sentinel), "{rendered}"); + assert!(!rendered.contains("REDACTED_OPAQUE_SHELL_SCRIPT")); + } + assert_eq!(fs::read(&report_path).expect("marked report"), marked); + assert!(!directory.join("report.md").exists()); + assert!(!directory.join("report.junit.xml").exists()); +} + +#[test] +fn explain_escapes_control_characters_from_saved_reports() { + let project = tempfile::tempdir().expect("project"); + let config = write_config(project.path(), &all_scenarios(), ""); + let output = run(assumezero().current_dir(project.path()).args([ + "--json", + "--config", + config.to_str().expect("path"), + "check", + "--", + fixture(), + "pass", + ])); + assert!(output.status.success()); + let mut report = parse_json(&output); + let run_id = report["run_id"].as_str().expect("run id").to_owned(); + let directory = project.path().join(".assumezero/runs").join(&run_id); + report["findings"] = serde_json::json!([{ + "id": "AZ-F001", + "scenario_id": "AZ-S001", + "evidence": "CONFIRMED", + "changed": "before\u{001b}]0;title\u{0007}after", + "observed": "ordinary", + "conclusion": "ordinary", + "next_step": "ordinary", + "not_proven": "ordinary", + "restored_names": [] + }]); + fs::write( + directory.join("report.json"), + serde_json::to_vec_pretty(&report).expect("control report"), + ) + .expect("replace report"); + + let explained = run(assumezero() + .current_dir(project.path()) + .args(["explain", run_id.as_str()])); + assert!(explained.status.success()); + assert!(!explained.stdout.contains(&0x1b)); + assert!(!explained.stdout.contains(&0x07)); + let rendered = String::from_utf8_lossy(&explained.stdout); + assert!(rendered.contains("[U+001B]")); + assert!(rendered.contains("[U+0007]")); +} + +#[test] +fn opaque_shell_scripts_are_refused_without_echoing_the_script() { let project = tempfile::tempdir().expect("project"); let sentinel = "AZ_INVALID_OPAQUE_SHELL_4501"; let config = write_config(project.path(), &all_scenarios(), ""); @@ -944,14 +1209,14 @@ fn opaque_shell_script_is_hidden_from_display_and_persisted_command() { "--", &script, ])); - assert!(dry_run.status.success()); + assert_eq!(dry_run.status.code(), Some(3)); let rendered = format!( "{}{}", String::from_utf8_lossy(&dry_run.stdout), String::from_utf8_lossy(&dry_run.stderr) ); assert!(!rendered.contains(sentinel)); - assert!(rendered.contains("REDACTED_OPAQUE_SHELL_SCRIPT")); + assert!(rendered.contains("single opaque `--shell` script is refused")); let check = run(assumezero().current_dir(project.path()).args([ "--json", @@ -962,9 +1227,12 @@ fn opaque_shell_script_is_hidden_from_display_and_persisted_command() { "--", &script, ])); - assert!(check.status.success()); - let report = parse_json(&check); - let command = serde_json::to_string(&report["command"]).expect("command"); - assert!(!command.contains(sentinel)); - assert!(command.contains("REDACTED_OPAQUE_SHELL_SCRIPT")); + assert_eq!(check.status.code(), Some(3)); + let rendered = format!( + "{}{}", + String::from_utf8_lossy(&check.stdout), + String::from_utf8_lossy(&check.stderr) + ); + assert!(!rendered.contains(sentinel)); + assert!(!project.path().join(".assumezero").exists()); } From 86bddccae77011cd4ce3b3214c30a2e4edec8974 Mon Sep 17 00:00:00 2001 From: Yahoo <119646314+y4ho0@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:18:01 +0800 Subject: [PATCH 3/3] Make path redaction test portable --- src/redaction.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/redaction.rs b/src/redaction.rs index 0f3775f..043db74 100644 --- a/src/redaction.rs +++ b/src/redaction.rs @@ -530,10 +530,11 @@ mod tests { #[test] fn project_relative_path_segments_are_exact_value_redacted() { - let redactor = command_redactor(&["tool", "--token", "abc", "/project/abc"], &[]); - let command = ["tool", "--token", "abc", "/project/abc"] - .map(String::from) - .to_vec(); + let project = tempfile::tempdir().expect("project"); + let secret_path = project.path().join("abc").to_string_lossy().into_owned(); + let command = vec!["tool".into(), "--token".into(), "abc".into(), secret_path]; + let mut redactor = Redactor::new(&BTreeMap::new(), project.path()); + redactor.add_commands([command.as_slice()], &[]); let rendered = redactor.redact_command(&command).join(" "); assert!(!rendered.contains("abc")); assert!(rendered.contains("/***"));