diff --git a/.github/workflows/sandbox-log-redaction-quality-ci.yml b/.github/workflows/sandbox-log-redaction-quality-ci.yml new file mode 100644 index 000000000..10f331ab4 --- /dev/null +++ b/.github/workflows/sandbox-log-redaction-quality-ci.yml @@ -0,0 +1,113 @@ +name: Sandbox Log Redaction Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/sandbox-log-redaction-quality-ci.yml" + - "ARCHITECTURE.md" + - "CHANGELOG.md" + - "docs/doctoring/sandbox-log-redaction.md" + - "scripts/ci/redact_sensitive_log.py" + - "scripts/ci/sandboxed_verify.py" + - "scripts/ci/sandboxed_web_e2e.py" + - "tests/test_atomic_json_redaction.py" + - "tests/test_command_wrapper_redaction.py" + - "tests/test_opencode_security_boundaries.py" + - "tests/test_sandboxed_verify.py" + - "tests/test_sandboxed_web_e2e.py" + - "tests/test_sandboxed_log_redaction_regression.py" + +permissions: + contents: read + +concurrency: + group: sandbox-log-redaction-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + exact-head-redaction-contract: + name: Exact-head sandbox redaction contract + if: github.event_name != 'pull_request' || github.event.action != 'closed' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Install exact hash-verified test dependencies + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_NO_INPUT: "1" + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + cat >"${RUNNER_TEMP}/sandbox-redaction-quality-requirements.txt" <<'EOF' + coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f + iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + EOF + python -m pip install \ + --only-binary=:all: \ + --require-hashes \ + -r "${RUNNER_TEMP}/sandbox-redaction-quality-requirements.txt" + + - name: Verify fail-closed sandbox redaction contract + env: + STRIX_TEST_PROCESS_TIMEOUT_SECONDS: "3" + STRIX_TEST_FAKE_SLEEP_SECONDS: "5" + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + python -m coverage run --branch -m pytest \ + tests/test_atomic_json_redaction.py \ + tests/test_command_wrapper_redaction.py \ + tests/test_opencode_security_boundaries.py \ + tests/test_sandboxed_verify.py \ + tests/test_sandboxed_web_e2e.py \ + tests/test_sandboxed_log_redaction_regression.py \ + -q + python -m coverage report \ + --include='scripts/ci/redact_sensitive_log.py,scripts/ci/sandboxed_verify.py,scripts/ci/sandboxed_web_e2e.py' \ + --fail-under=100 + python - <<'PY' + import ast + from pathlib import Path + + missing = [] + for filename in ( + "scripts/ci/redact_sensitive_log.py", + "scripts/ci/sandboxed_verify.py", + "scripts/ci/sandboxed_web_e2e.py", + ): + tree = ast.parse(Path(filename).read_text(encoding="utf-8"), filename=filename) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if not node.name.startswith("_") and ast.get_docstring(node) is None: + missing.append(f"{filename}:{node.lineno}:{node.name}") + if missing: + raise SystemExit("public docstrings missing: " + ", ".join(missing)) + PY + python -m pytest tests -q + bash scripts/ci/test_strix_quick_gate.sh + python -m compileall -q \ + scripts/ci/redact_sensitive_log.py \ + scripts/ci/sandboxed_verify.py \ + scripts/ci/sandboxed_web_e2e.py \ + tests/test_atomic_json_redaction.py \ + tests/test_command_wrapper_redaction.py \ + tests/test_opencode_security_boundaries.py \ + tests/test_sandboxed_verify.py \ + tests/test_sandboxed_web_e2e.py \ + tests/test_sandboxed_log_redaction_regression.py + git diff --exit-code diff --git a/AGENTS.md b/AGENTS.md index 16f0981c0..b2e50f802 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. +Downloaded Actions job logs keep per-line RFC 3339 runner timestamps (`Z` or `time-numoffset`, SPACE or HTAB); `redact_sensitive_log` skips them inside JSON spans and does not treat `[INFO]` as an array opener. See [`docs/doctoring/sandbox-log-redaction.md`](docs/doctoring/sandbox-log-redaction.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fe6621b6..e9a6519b2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -69,11 +69,30 @@ sequenceDiagram PR->>RW: pull_request_target on trusted base RW->>OC: bounded evidence + NVIDIA NIM / OpenCode OC->>SV: PoC command in isolated copy - SV-->>OC: redacted stdout/stderr + command metadata + SV-->>OC: layout-preserving redacted stdout/stderr + command metadata OC-->>PR: APPROVE or request changes MS->>PR: merge only on current-head approval + green checks ``` +## Sandbox evidence redaction + +```mermaid +flowchart TD + Cap["Captured stdout / stderr / service tail"] + Span["Bounded JSON span rewriter"] + Line["Line-oriented fallback"] + Pub["CI / review evidence"] + + Cap --> Span + Span -->|"complete JSON span"| Pub + Span -->|"no complete span"| Line + Line --> Pub +``` + +Operators reading a pretty-printed job log should still see the original +layout, duplicate keys, and scalar categories. Only credential leaves are +replaced. See [`docs/doctoring/sandbox-log-redaction.md`](docs/doctoring/sandbox-log-redaction.md). + ## Trust boundaries - Required review workflows execute **base-branch** scripts. A PR that edits @@ -84,6 +103,22 @@ sequenceDiagram - Logs and review receipts redact credential shapes (tokens, bearer values, known provider prefixes). They do not mask operational PII that the control plane must process. +- Raw JSON evidence is rewritten as source spans before any line split. + Duplicate member names keep order and count because RFC 8259 §4 treats + receiver behavior as unpredictable, while ECMA-404 / ISO/IEC 21778 leave + uniqueness to the processor (Bray, 2017; Ecma International, 2017; + International Organization for Standardization, 2017). A dictionary + collapse would drop the first secret of a duplicate `token` pair. A + failed opener is scored only until the next plausible start, so + `##[group]` and prose `[timeout]` cannot erase a later complete object. + Downloaded Actions job logs prefix every line with an RFC 3339 + runner timestamp. The span parser skips `Z` and `time-numoffset` + prefixes plus a following SPACE or HTAB the same way it skips JSON + whitespace, so a pretty-printed password object remains one span. A `[` opens an array + only when the next significant token can start a JSON value + (`true` / `false` / `null` / number / string / container / `]`), so + line-start `[INFO]` diagnostics stay visible (Klyne & Newman, 2002; + Bray, 2017). - LLM and scheduled agents bind `NVIDIA_NIM_API_KEY` (env may be `NVIDIA_API_KEY`). They never use `COPILOT_GITHUB_TOKEN`. Existing review-agent key schemes stay unchanged. @@ -104,6 +139,8 @@ tests pin workflow structure and governance prose so drift fails closed. — Project #1 operation. - [`PR_GOVERNANCE_AUDIT.md`](PR_GOVERNANCE_AUDIT.md) — live review/merge contract. +- [`docs/doctoring/sandbox-log-redaction.md`](docs/doctoring/sandbox-log-redaction.md) + — atomic JSON evidence redaction, RFC 8259 / ECMA-404 / ISO/IEC 21778. - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..72205dea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Treat GitHub Actions runner timestamps as line metadata inside raw JSON spans, and treat `[` as an array opener only when the next significant token can start a JSON value, so a downloaded pretty-printed password dump keeps `##[group]` / status text instead of fail-closing the entire job log to `[REDACTED]`. +- Skip RFC 3339 `time-numoffset` prefixes (`+00:00`, `-07:00`) and a following space or HTAB the same way `Z` timestamps are skipped, so a collector that emits offsets or tab-separated job logs cannot fail-close a later pretty-printed password object. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. @@ -39,6 +41,10 @@ Semantic Versioning where the repository publishes a release. ### Security +- Preserve raw JSON layout atomically during sandbox log redaction so command wrappers cannot leak secrets through pretty-printed dumps. +- Cite RFC 8259, ECMA-404, and ISO/IEC 21778 for duplicate JSON member handling, pin the sandbox redaction quality-gate operator prose, and keep architecture drift on the same exact-head quality path. +- Stop treating GitHub Actions `##[group]` markers and prose `[timeout]` brackets as JSON array starts, so a later pretty-printed password object is rewritten in place instead of erasing the whole job log. + - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. - Snapshot the complete pre-model worktree for ordinary and conflict repair and reject every model-caused created, deleted, modified, mode-changed, retargeted, ignored, dangling, directory-backed, external-link, metadata-race, or out-of-scope path before staging or push. @@ -60,4 +66,5 @@ Semantic Versioning where the repository publishes a release. - Added DiskSage operational documentation for the hourly RCA loop, bounded retry cadence, permission model, standalone and MSA reuse, verification, rollback, and APA 7 references. - Added fast-mlsirm operational documentation for the hourly RCA loop, psychometric scientific gates, Rust ownership, bounded retry cadence, credential isolation, modular reuse, rollback, and APA 7 references. - Documented the ordinary and conflict repair write-scope parity, ignored-path and symlink inventory, Git-control-file denial, hook suppression, explicit push destination, RED/GREEN evidence, operator response, and local-versus-protected evidence boundary. -- Documented the review-authentication boundary that excludes autonomous writer control-plane paths from review-derived file authority, its test-first Strix security evidence, exact-head coverage contract, and rollback prohibition. \ No newline at end of file +- Documented the review-authentication boundary that excludes autonomous writer control-plane paths from review-derived file authority, its test-first Strix security evidence, exact-head coverage contract, and rollback prohibition. +- Recorded RFC 8259 unpredictable duplicate-name behavior, ECMA-404 / ISO/IEC 21778 syntax neutrality, and the operator next step: treat `[REDACTED]` as evidence suppression, then rerun the exact-head sandbox redaction quality job after any layout-preserving change. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 7127d3c1c..f022ed1c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,10 @@ repeatable compile command. - **Review output must go through the Python normalizer** (`scripts/ci/opencode_review_normalize_output.py`) — it escapes `<`, `>`, `&` when embedding JSON in HTML comments to prevent Markdown-comment breakout. Do not reintroduce bash fast-path extraction. +- **Downloaded Actions job logs keep per-line RFC 3339 runner timestamps.** `redact_sensitive_log` + skips `Z` and `time-numoffset` prefixes plus SPACE or HTAB inside JSON spans and opens `[` only + for a real JSON value, so `##[group]` and `[INFO]` diagnostics are not fail-closed to + `[REDACTED]`. See `docs/doctoring/sandbox-log-redaction.md`. - **Cloudflare changes are dry-run by default**; nothing is deleted unless `prune = true` is set explicitly. PRs never see the Cloudflare API token. - **Org-wide binding conventions** (permissive licenses only — verify SPDX before adding anything; diff --git a/docs/doctoring/sandbox-log-redaction.md b/docs/doctoring/sandbox-log-redaction.md new file mode 100644 index 000000000..39c6efa78 --- /dev/null +++ b/docs/doctoring/sandbox-log-redaction.md @@ -0,0 +1,118 @@ +# Sandbox subprocess evidence redaction + +## Incident boundary + +The organization sandbox wrappers already removed ambient secret-bearing environment variables before launching pull-request verification commands. That control did not cover a different disclosure path: child processes and long-running services can emit credential-shaped values to stdout, stderr, timeout evidence, or service log files. `sandboxed_verify.py` and `sandboxed_web_e2e.py` forwarded those captured values into GitHub Actions/review evidence without passing them through the existing `redact_sensitive_log.redact_text` boundary. + +This is an evidence-handling defect. It is not a shell-injection defect, a reason to change subprocess argv/process-group semantics, a provider-routing problem, or a reason to broaden/revoke repository credentials. + +## RCA + +The causal chain is: + +1. a verification command or local web service emits text controlled by the repository-under-review; +2. the sandbox correctly captures that text through `subprocess.PIPE`, `TimeoutExpired`, or a service log file; +3. the wrapper prints the captured text into CI/review evidence; +4. the mature central log redactor was not invoked on this output boundary; and +5. therefore a token/password/session-key-shaped value can cross the sandbox boundary even though the corresponding ambient environment variable was scrubbed. + +Python documents `subprocess.run(..., shell=False, stdout=PIPE, stderr=PIPE, timeout=...)` and `TimeoutExpired` as normal captured-output mechanisms. The repair therefore leaves process execution semantics unchanged and treats the captured text as untrusted evidence that requires redaction before publication. + +## Feasibility analysis + +The following candidates were evaluated: + +- **Change or remove subprocess execution. Rejected.** The defect occurs after capture, so changing argv, shell mode, process groups, or timeouts would not address the disclosure mechanism and would add unrelated behavioral risk. +- **Rely only on GitHub Actions secret masking. Rejected.** GitHub recommends masking sensitive values and notes that log redaction is not a complete substitute for avoiding sensitive output; child output may contain transformed or non-registered sensitive data. Repository-under-review output must therefore cross the product's own deterministic redaction boundary. +- **Import the mixed sentinel #841. Rejected.** That branch combined this defect with unrelated readiness-URL hardening, production changes preceded its tests, and the external writer reported that its narrowed result could not be published. Rewriting or manually reconstructing unpublished UI state would weaken provenance. +- **Apply the existing redactor at the evidence-output boundary. Accepted.** This is the smallest reversible change, requires no new credential or permission, preserves process semantics, and is directly testable with credential-shaped fixtures. + +The first GREEN implementation exposed five narrower defects in that shared boundary during an exhaustive diff review: + +- valid JSON took a structured branch that redacted only sensitive key names and never scanned string values under opaque keys; +- substring key matching hid benign diagnostics such as `token_count` and `password_policy`; +- terminal ANSI sequences could split a sensitive key or provider-token signature before detection; +- explicitly allowed environment values were not supplied to the redactor, so an opaque value printed without a recognizable key or provider prefix survived; and +- `_redact_assignments()` restarted a suffix scan at every character in a long key-like line, producing observed quadratic growth even though its contract claimed linear parsing. + +The root cause was not missing calls in the two wrappers anymore. It was an incomplete normalization and value-provenance contract in the shared redactor, plus repeated suffix scanning. The follow-up repair canonicalizes terminal evidence before matching, recursively scans JSON keys and string values, classifies credential fields by semantic words while exempting explicit diagnostic-metadata endings, passes explicitly allowed environment values through a validated literal-sensitive path, and advances the assignment parser by complete key spans. + +A consumer-path review then found additional integration hazards: post-processing the prefixed result marker could corrupt otherwise valid JSON, selecting a service tail before literal redaction could expose a clipped multiline value, a permissive three-segment pattern hid the `api.deepseek.com` failure signal as if it were a JWT, excessive valid JSON nesting could abort the central failed-check collector, literal values such as `true` could corrupt JSON types, terminal overwrite/format controls could reconstruct rendered secrets, separated CLI options could leave their following credential visible, and setup, launch, or cleanup exceptions could escape the boundary. The bounded repair now redacts trusted result schemas before serialization, protects existing markers with a single-pass matcher, redacts complete service logs before tail selection, verifies decoded JOSE headers, preserves JSON scalar types and every supported line separator, handles separated credential options, fails closed across multiline or unterminated terminal controls, falls back safely on excessive JSON recursion, and captures explicitly allowed values before fallible setup. It does not implement the separate output-memory and service-file quotas tracked by #766. + +Issue #907 then exposed a distinct argv-evidence boundary: direct Docker/Podman login options were protected, but opaque credentials inside supported `env` split-string and shell `-c` operands were still treated as ordinary data. The repair recognizes only exact `env -S`, `env --split-string`, `env --split-string=...`, and exact `sh`/`bash`/`dash`/`ksh`/`zsh` basenames with an exact or combined `-c` selector. One root-owned context compiles caller literals once and shares limits of 65,536 UTF-8 input bytes, 4,096 parsed tokens, 262,144 cumulative scan bytes, and four wrapper levels. Unsupported quoting, expansion, comments, escapes, compound-shell syntax, ambiguous trailing argv, or exhausted root budgets fail closed; the depth boundary replaces the remaining nested operand rather than scanning it again. The command is never executed or rewritten at its execution boundary. + +The same repair fixes option-context false positives: only an exact Docker/Podman `login` subcommand gives `-p` password meaning; Docker publish ports, SSH ports, unrelated `login` arguments, GNU env unset/chdir operands, and the registry after valid `--password-stdin` remain visible. The invalid `--password-stdin=...` spelling remains conservatively redacted. Output/service-file memory quotas remain separate Issue #766. + +Issue #908 then exposed an ordering and representation defect in the raw-text path. Splitting evidence into lines before structural handling loses the association between a multiline sensitive key, colon, and value. Parsing a complete document into a Python dictionary would avoid that split but collapse duplicate members and normalize whitespace, escapes, number spelling, and punctuation. RFC 8259 §4 says object member names SHOULD be unique and that the behavior of software that receives duplicate names is unpredictable: some keep the last pair, some error, and some preserve every pair (Bray, 2017). ECMA-404 and ISO/IEC 21778 impose no uniqueness or order restriction; those are processor semantics (Ecma International, 2017; International Organization for Standardization, 2017). The repair therefore recognizes complete JSON and JSON spans before line splitting with an iterative token/span state machine. It decodes only bounded key and string tokens for classification, retains every untouched source slice, and applies non-overlapping scalar/key span replacements afterward. Duplicate members retain order and count so a later parser cannot reconstruct a secret from a discarded first pair; sensitive arrays and objects retain shape while scalar leaves keep their string, integer, floating-point, boolean, or null category. + +The raw parser shares one root budget of 65,536 input bytes, depth 64, 8,192 tokens, 32,768 bytes per string token, 2,048 replacements, and 262,144 cumulative work units. Limit exhaustion and malformed structural evidence with a sensitive key fail closed to one stable marker without exception text. A failed opener is charged only against the window until the next plausible JSON start, so GitHub Actions `##[group]` markers and prose `[timeout]` brackets cannot erase a later complete object. Downloaded job logs still prefix every line with an RFC 3339 timestamp before the payload (GitHub, n.d.-a; Klyne & Newman, 2002). RFC 3339 §5.6 `time-offset` is either `Z` or a `time-numoffset` such as `+00:00` / `-07:00`; collectors may separate that prefix from the payload with SPACE or HTAB. Treating an unrecognized prefix as JSON text made `{` after the timestamp a plausible opener, then the next timestamp broke the parse and `_looks_like_sensitive_json_candidate` fail-closed the entire log. The parser now skips line-start `Z` and `time-numoffset` prefixes plus the following SPACE or HTAB the same way it skips JSON whitespace and maps replacements onto the original source, so a pretty-printed password object remains one span. RFC 8259 §2 `begin-array` allows only a value or `]` after optional whitespace (Bray, 2017). A `[` therefore opens a span only when the next significant token is `true`, `false`, `null`, a number, a string, a container, or `]`; line-start `[INFO]` / `[timeout]` labels stay in the unstructured gap. Command strings and argv arrays reuse the bounded wrapper redactor; when its fail-closed representation changes argv length, the JSON path preserves array shape by replacing every original element. Complete valid spans can coexist with prefixes, suffixes, and multiple records. This does not change `redact_json_value()` for already-materialized trusted objects and does not claim Issue #766's output-memory closure. + +## Test-first evidence + +A clean branch was created from protected `main` `6eb06cdd08c79a06f7b390069d4ffa49e2eb7dba`. + +The first commit `f33b37d882ddb8ab0ef8ffd4e843cb5edce9adc9` changed only `tests/test_sandboxed_log_redaction_regression.py`. Hosted exact-head quality run `31291746435`, job `93190018774`, then produced the intended RED result: **4 failed, 23 passed**. Each new failure exposed credential-shaped text crossing one of the required output boundaries. + +Only after that hosted RED evidence did production change. The implementation: + +- redacts completed `sandboxed_verify` stdout/stderr; +- decodes and redacts `sandboxed_verify` timeout stdout/stderr, including byte-valued `TimeoutExpired` evidence; +- redacts completed web-E2E stdout/stderr; +- reuses the redacted timeout helper for web-E2E timeout evidence; and +- redacts complete backend/frontend service-log text before selecting and printing the bounded tail. + +Normal child-command exit codes, readiness URL, redirect, executed subprocess argv, process-group, timeout, provider/model, workflow permission, and branch-protection behavior are unchanged. Workspace-setup and process-launch exceptions now return fail-closed code `126` with a redacted diagnostic instead of propagating a raw traceback. A non-empty explicitly allowed environment value shorter than eight characters, or one identical to fixed evidence text that must remain machine-readable, also returns `126` before child execution because it cannot be redacted unambiguously. Other values, including whitespace-bearing credentials, are passed to the child unchanged while their raw and escaped evidence representations are suppressed. + +Exact-head focused acceptance on `9dd2ab31a8eab9ef7b4572e37c68023df6a0f16e` reports **32 passed** and exact **100% statement and branch coverage** for both owned production modules (`265` statements and `82` branches total). The permanent quality workflow also enforces public callable docstrings, exact-head checkout, a complete repository suite, the central Strix quick gate, compilation, and a clean worktree before Ready status is permitted. + +The follow-up fail-first contract independently reproduced eight initial failures: opaque JSON values, benign JSON metadata over-redaction, ANSI-split evidence, quadratic long-line processing, and opaque `--allow-env` values across completed/timeout verification plus completed/timeout/service-tail E2E paths. Consumer review added fail-first cases for marker integrity, truncation order, credential-bearing JSON keys, joined and suffixed credential fields, authorization headers, URL userinfo, private-key blocks, raw and escaped explicit values, all supported line separators, JSON scalar types, diagnostic domains, pathological JSON depth, high line/value counts, separated credential options, short or fixed-evidence-colliding allowed values, terminal overwrite and multiline/unterminated control sequences, and pre-copy/backend/frontend/E2E launch and cleanup exceptions. After the bounded repair, the focused suite reports **102 passed** and exact **100% statement and branch coverage** across `redact_sensitive_log.py`, `sandboxed_verify.py`, and `sandboxed_web_e2e.py` (`605` statements and `206` branches). The exact-head quality workflow now owns all three modules and the shared redactor security tests, so a wrapper-only coverage result cannot promote a redactor regression. + +The wrapper follow-up began with **31 focused failures** covering both GNU env split spellings, all five supported shells, combined `-c` selectors, env-to-shell nesting, malformed/compound operands, trailing positional ambiguity, resource limits, depth, and option false positives. The bounded implementation reports **142 passed** in the permanent focused quality selection with exact **100% statement and branch coverage** across the three owned modules (`784` statements and `302` branches), followed by **1,060 passed plus 16 subtests** and exact complete owned-production coverage (`7,395` statements and `2,960` branches) in the complete repository suite and a passing Strix quick gate. Fixtures construct opaque credentials at runtime. + +The atomic JSON follow-up began on exact parent `18a6d125fead8cb95972fe3e1a97e4cc4163e9d2` with a test-only head that produced the intended local RED result: **5 failed, 1 passed**. The failures reproduced multiline separation, duplicate-key loss, scalar/container normalization, mixed-record handling, and malformed-candidate leakage before production changed. The GREEN boundary reports **155 focused tests passed** with exact **100% statement and branch coverage** across the three owned modules (`1,039` statements and `416` branches). A later exact-head follow-up added a realistic Actions `##[group]` pretty-printed password dump and a prose `[timeout]` control; both now keep diagnostic text and rewrite only credential leaves. After that follow-up the focused selection reports **163 passed** with exact **100% statement and branch coverage** (`1,064` statements and `426` branches). A successor then failed first on a per-line-timestamped downloaded job log and a line-start `[INFO]` diagnostic that named `"password":`; both previously collapsed the entire buffer to `[REDACTED]`. After skipping runner timestamps inside spans and requiring a JSON value after `[`, those fixtures keep group/status text and rewrite only credential leaves. The focused selection now reports **170 passed** with exact **100% statement and branch coverage** across the three owned modules (`1,094` statements and `444` branches). A follow-up failed first when the prefix used RFC 3339 `time-numoffset` or HTAB instead of `Z` plus SPACE; both previously collapsed the entire buffer to `[REDACTED]`. After accepting those RFC 3339 §5.6 forms, those fixtures keep group/status text and rewrite only credential leaves. Additional fixtures exercise escaped spelling, empty containers, iterative depth, token, byte, string and replacement exhaustion, malformed parser states, shape-preserving command fallback, benign oversized input, trusted materialized-object collision handling, CRLF timestamps, and literal `[true]` / `[false]` / `[null]` / `[]` arrays without committing a fixed credential-shaped literal. + +## Security and privacy interpretation + +Redaction is defense in depth, not authorization. It does not make arbitrary sensitive material safe to publish and it does not authorize repositories to pass secrets into sandbox commands. The existing environment minimization remains the primary ingress control; deterministic output redaction limits accidental disclosure if a child process or service emits sensitive-looking evidence anyway. + +ANSI styling canonicalization preserves visible diagnostic text and line separators, while cursor movement, backspace, multiline/unterminated control payloads, and invisible Unicode format controls fail closed for every affected evidence line or value. Structured JSON retains benign failure, policy, count, status, expiry, type, and usage metadata, while credential-denoting keys, credential material used as a key, credential-shaped string values, authorization headers, URL userinfo, and multiline private-key blocks are replaced without changing boolean or numeric types. Result markers remain one-line valid JSON with stable trusted keys after redaction, and the collector's literal `api.deepseek.com` classification signal remains visible. Literal protection is limited to non-empty values of names explicitly passed through `--allow-env`; values shorter than eight characters or colliding with fixed evidence text are rejected before execution, while whitespace-bearing values remain supported. The ordinary safe environment allowlist is not treated as secret, avoiding blanket removal of paths, locale data, or other useful diagnostics. + +Raw JSON layout preservation applies before line-oriented normalization. It does not interpret arbitrary prefixes as trusted JSON, publish parser errors, reconstruct a dictionary, or silently accept a partially parsed sensitive candidate. Existing escape spelling and line endings are therefore diagnostic evidence rather than data to canonicalize. A bounded benign non-JSON or malformed fragment still uses the general redactor; a fragment that establishes a sensitive structural key but cannot establish a safe value boundary fails closed. + +The redactor operates on CI-facing text only. It does not mutate files in the copied repository, service log files on disk, subprocess input, or successful child-process status and lifetime. The wrappers separately apply the documented pre-execution and exception code `126` policy. Operators should therefore interpret `[REDACTED]` as evidence suppression, not as successful removal of sensitive data from the source system that produced it. + +## Rollback + +If the redaction integration causes a demonstrated diagnostic incompatibility, revert the production redaction commits while retaining the fail-first regression and this doctoring record. Do not disable the regression, weaken the credential-shaped fixtures, or replace the deterministic boundary with blanket omission of all stdout/stderr. A rollback is incomplete until the resulting protected-main behavior is reassessed for disclosure risk. + +## Operational acceptance + +PR checks prove the code path, not protected-main operation. After protected integration, run one bounded sandbox verification fixture that emits synthetic credential-shaped stdout/stderr and one bounded web-E2E fixture that emits a synthetic service-log credential. Accept the repair only if protected-main workflow evidence shows the synthetic value absent, `[REDACTED]` present, ordinary diagnostic text preserved, and the expected exit code/process cleanup behavior unchanged. + +## References + +Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +Ecma International. (2017). *The JSON data interchange syntax* (Standard ECMA-404, 2nd ed.). https://ecma-international.org/publications-and-standards/standards/ecma-404/ + +GitHub. (n.d.-a). *Using workflow run logs*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/actions/how-tos/monitor-workflows/use-workflow-run-logs + +GitHub. (n.d.-b). *Secure use reference*. GitHub Docs. Retrieved August 9, 2026, from https://docs.github.com/en/actions/reference/security/secure-use + +GitHub. (n.d.-c). *Using secrets in GitHub Actions*. GitHub Docs. Retrieved August 9, 2026, from https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets + +International Organization for Standardization. (2017). *Information technology — The JSON data interchange syntax* (ISO/IEC 21778:2017). https://www.iso.org/standard/71616.html + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). Internet Engineering Task Force. https://doi.org/10.17487/RFC3339 + +MITRE Corporation. (n.d.). *CWE-180: Incorrect behavior order: Validate before canonicalize*. CWE. Retrieved August 9, 2026, from https://cwe.mitre.org/data/definitions/180.html + +MITRE Corporation. (n.d.). *CWE-407: Inefficient algorithmic complexity*. CWE. Retrieved August 9, 2026, from https://cwe.mitre.org/data/definitions/407.html + +MITRE Corporation. (n.d.). *CWE-532: Insertion of sensitive information into log file*. CWE. Retrieved August 9, 2026, from https://cwe.mitre.org/data/definitions/532.html + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +OWASP Foundation. (n.d.). *Logging cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 9, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + +Python Software Foundation. (2026). *subprocess — Subprocess management* (Python 3.14.6 documentation). https://docs.python.org/3.14/library/subprocess.html diff --git a/scripts/ci/redact_sensitive_log.py b/scripts/ci/redact_sensitive_log.py index 16e89f264..ec2fdeb1e 100644 --- a/scripts/ci/redact_sensitive_log.py +++ b/scripts/ci/redact_sensitive_log.py @@ -3,27 +3,152 @@ from __future__ import annotations +import base64 import json import re +import shlex import sys +import unicodedata +from collections.abc import Sequence from typing import Any REDACTED = "[REDACTED]" KEY_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-") -SENSITIVE_KEY_RE = re.compile( - r"(?:token|secret|password|passwd|credential|authorization|jwt|" - r"api[_-]?key|private[_-]?key|access[_-]?key|session[_-]?key)", - re.IGNORECASE, +ANSI_ESCAPE_RE = re.compile( + r"(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]" + r"|(?:\x1b\]|\x9d)[^\x1b\x07\x9c]*(?:\x07|\x9c|\x1b\\)" + r"|(?:\x1b[PX^_]|\x90|\x98|\x9e|\x9f)[^\x1b\x9c]*(?:\x9c|\x1b\\)" + r"|(?:\x1b\]|\x1b[PX^_]|\x90|\x98|\x9d|\x9e|\x9f)[\s\S]*\Z" + r"|\x1b[ -/]*[0-~]" + r"|[\x80-\x84\x86-\x8f\x91-\x9a\x9c]" +) +SGR_ESCAPE_RE = re.compile(r"(?:\x1b\[|\x9b)[0-9:;]*m") +UNSAFE_INLINE_CONTROL_RE = re.compile( + r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x84\x86-\x9f]" +) +LINE_SEPARATOR_RE = re.compile(r"\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]") +LINE_SEPARATOR_END_RE = re.compile( + r"(?:\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029])\Z" +) +CAMEL_ACRONYM_BOUNDARY_RE = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])") +CAMEL_WORD_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") +NON_KEY_WORD_RE = re.compile(r"[^A-Za-z0-9]+") +SENSITIVE_KEY_TERMS = frozenset( + { + "auth", + "authorization", + "credential", + "credentials", + "jwt", + "passwd", + "password", + "secret", + "token", + } +) +SENSITIVE_KEY_PAIRS = frozenset( + { + ("access", "key"), + ("api", "key"), + ("connection", "string"), + ("database", "url"), + ("encryption", "key"), + ("private", "key"), + ("secret", "key"), + ("session", "key"), + ("signing", "key"), + } +) +SENSITIVE_JOINED_KEY_TERMS = frozenset( + "".join(pair) for pair in SENSITIVE_KEY_PAIRS +) +BENIGN_JOINED_KEY_TERMS = frozenset({"notsecret", "retoken"}) +SAFE_METADATA_SUFFIXES = frozenset( + { + ("budget",), + ("count",), + ("decode", "error"), + ("expires", "at"), + ("failure", "reason"), + ("policy",), + ("policy", "status"), + ("rotation", "status"), + ("scan", "count"), + ("status",), + ("type",), + ("usage",), + } ) JWT_RE = re.compile( r"(?\b(?:proxy-)?authorization\s*:\s*)[^\r\n]+", + re.IGNORECASE, +) BEARER_RE = re.compile( - r"(?P\b(?:authorization\s*:\s*)?(?:bearer|basic)\s+)" + r"(?P\b(?:bearer|basic)\s+)" r"[^\s\"'\\]+", re.IGNORECASE, ) +URL_CREDENTIAL_RE = re.compile( + r"(?P\b[a-z][a-z0-9+.-]*://)[^/\s:@]*:[^@\s/]+@", + re.IGNORECASE, +) +PRIVATE_KEY_PEM_RE = re.compile( + r"-----BEGIN (?P