Skip to content

Harden redaction and rewrite the test suite - #1

Merged
kjanat merged 15 commits into
masterfrom
redaction-hardening
Jul 29, 2026
Merged

Harden redaction and rewrite the test suite#1
kjanat merged 15 commits into
masterfrom
redaction-hardening

Conversation

@kjanat

@kjanat kjanat commented Jul 29, 2026

Copy link
Copy Markdown
Owner

What

  • envctl redact filter mode: masks the env file's literal values plus their base64, URL-encoded, JSON-escaped, and hex forms, then runs value-shape heuristics over the rest of the stream; streaming parsers handle multiline JSON, private JWKs at any depth, PEM, PuTTY, and quoted secrets without leaking on malformed or truncated input
  • detectors: Shannon entropy under suspicious key names, Authorization values (4-byte Basic and short Bearer behind an explicit key), credentialed URLs, sensitive query params, connection strings, known token prefixes, JWTs
  • CLI: --, ls/rm aliases, implicit ./.env, bare KEY [VALUE] forms, strict argument errors, non-zero exit on stdout write failure, and filter mode stops at the first failed write instead of draining stdin
  • windows: std streams switch to binary mode once at startup, so output is byte-identical to POSIX
  • installer with SHA256 verification and optional provenance attestation; CONTRIBUTING and SECURITY split out of the README
  • tests: data-driven harness (one %% section case file per scenario, byte-exact compares), unconditional stdout/stderr comparison, pty and fifo modes that skip honestly where the platform cannot express them

Verification

  • linux: 152/152, plus a clean ASan/UBSan suite run
  • windows 11 (MSYS): 147 passed, 5 skipped, exit 0
  • -Werror -Wpedantic -Wshadow clean

kjanat added 10 commits July 25, 2026 03:25
- list/get/dry-run share a span-aware reader; PEM bodies no longer leak
- dry-run prints a unified diff, not the whole file
- new: envctl redact filters stdin; masks known values literally plus
  base64/URL/JSON-escaped variants; --no-env for heuristics only
- detectors: fixed-point Shannon entropy, Authorization values, secret
  URL query params, private JWKs, connection strings, WEBHOOK_URL,
  PuTTY, A3T; values dequoted before matching
- drop envctl.sh, the bash port of the tool
- tests/run.sh: 226 named cases, per-failure output, shellcheck clean
- oversized PEM labels no longer bypass suppression; the key body was
  printing in the clear
- a `-----BEGIN` boundary sharing a line keeps the surrounding text, so
  JSON objects survive redaction instead of being replaced wholesale
- redacted `--dry-run` hunk counts match the emitted body; the diff was
  malformed whenever a multiline span collapsed
- trailing `}`/`]` is trimmed only when unbalanced, so JSON values keep
  their own braces
- literal mask floor 8 -> 6 behind a Shannon entropy gate, plus hex
  encoding variants
- `list --values` prints the whole span instead of the first line
- indented assignments are visible to `list` and `get`
- `read_stream_line` dies on a read error instead of truncating silently
- `scan_text_line` returns a status rather than a `-1` length sentinel
- help synopsis splits the command and bare forms; `--raw` records that
  `redact` rejects it
- text around PEM boundaries goes through the normal scanners, so
  secrets beside a BEGIN or END marker mask instead of passing raw,
  and prose after a boundary is kept instead of dropped
- PuTTY key files: private line blocks and the MAC mask; public lines
  and comments pass
- private JWK objects mask anywhere in a line, not only as `KEY=`
- a pending `KEY=` or `KEY:` consumes a quoted value whole, so
  `export DB_PASSWORD="correct horse"` masks all four words; an
  unterminated quote carries across lines until it closes
- `KEY:` tokens keep their key context behind log prefixes
- Authorization values behind prefixes mask from 8 chars when the
  Authorization context is present; bare Bearer/Basic prose keeps
  the 20-char bar
- lowercase values under strong secret keys enter the literal mask
  set; `changeme` joins the placeholder list
- a public-PEM substring no longer exempts a strong secret key;
  the exemption needs a complete standalone public block
- the first line after an abandoned PEM carry is scanned, not eaten
- write failures on stdout exit non-zero (`get`, `list`, dry-run,
  `redact`)
- `list --values` is one record per key again, matching the README
- suite runs from paths with spaces; `wc -c` compare survives BSD
  padding; `eq` requires exit 0; 265 cases
- install.sh detects OS and arch, downloads the right release asset,
  and verifies it against SHA256SUMS; ENVCTL_INSTALL_DIR and
  ENVCTL_VERSION override destination and tag, ENVCTL_ATTEST=1 also
  requires a passing gh attestation verify
- releases attest build provenance via actions/attest@v4; the publish
  job gets id-token and attestations permissions, the workflow drops
  to contents: read elsewhere
- release assets upload by glob under working_directory: dist
- CONTRIBUTING.md: build, layout, test harness, lint, labels, releasing
- SECURITY.md: threat model, private vulnerability reporting, supported
  versions, artifact verification
- README keeps usage and points at both
- `tests/run.sh` is a harness holding no test data: it reads a case
  file, runs envctl, and compares byte-exact
- every case is a file in `tests/cases/`, every input and expectation a
  file under `tests/fixtures/`; `%% args` takes one argv element per
  line so a value never meets shell word splitting
- cases run under `env -i`, so agent detection starts from a clean
  environment and `%% setenv` opts back in
- new coverage: all 16 agent-detection signals plus the empty-value
  rule, the agent+TTY automatic masking path through a real pty, the
  EPIPE write-failure path through a fifo, and locale stability
- `%% mode pty` and `%% mode nosigpipe` report as skipped where
  `script` or GNU `env` is missing, rather than passing silently
- failures print a labelled diff plus the first differing byte offset,
  which is the only signal when a trailing space or CR is the defect
- `.gitguardian.yml` ignores `tests/**`; fixtures are deliberately full
  of credential-shaped strings

Known defect, tracked but not fixed here: `crlf.txt` and
`crlf-token.txt` are stored without their CRs, so `filter-crlf` and
`filter-crlf-masking` fail on a fresh checkout.
- preserve CRLF and final-newline semantics across env and filter paths
- mask literals spanning lines and parse multiline JSON, JWK, PEM, PuTTY,
  and quoted secret forms without leaking malformed input
- honor `--`, aliases, implicit `.env`, commented keys, and strict argument
  errors while reporting stdout failures consistently
- expand data-driven regression coverage and fix byte-exact fixtures
- buffered JSON at EOF rescans through the plain scanner instead of
  printing raw; benign truncated JSON still passes through
- `Authorization=Basic ...` masks as an assignment; the Basic shortcut
  applies only to `:` headers, and an explicit Authorization key accepts
  4-byte Basic and short Bearer credentials
- JSON property names decode strict ASCII escapes (`api_key`)
  before key matching, and pairs parse across CR/LF whitespace
- private JWKs nested below the top level mask again (`{"keys":[...]}`)
- PuTTY: a completed private block expects the MAC next; an unrelated
  line resumes normal scanning while undeclared body-shaped lines stay
  dropped, so undercounted `Private-Lines` cannot leak key material
- filter mode stops at the first failed write instead of draining stdin
- harness: stdout compares unconditionally against an empty default,
  and the `epipe-open` mode proves the EPIPE exit with stdin held open
  under a watchdog instead of relying on EOF
- `list`, `get`, and mutations free their line sets; ASan/UBSan suite
  runs clean; CFLAGS gain `-Werror -Wpedantic -Wshadow`
- help and README describe the real PEM carry recovery; the long help
  is a shared fixture asserted for `--help` and the no-args path
- binary mode is set once at the top of `main` for stdin, stdout, and
  stderr; the copy inside `act_redact` is gone
- the mingw CRT rewrote every `\n` as `\r\n` on text-mode streams, so
  help, `list`, `get`, and dry-run output failed byte-exact comparison
  everywhere off the filter path, and stderr diagnostics would miss
  their LF fixtures the same way
- a native binary reading stdin from an MSYS fifo dies with a read
  error, so the handshake case can never observe the EPIPE exit there;
  the write-side coverage stays with `epipe-reports-write-failure`,
  whose stdin is a regular file
- the skip reason says "no usable fifo" since `mkfifo` itself succeeds
@gitguardian

This comment was marked as low quality.

@coderabbitai

This comment was marked as spam.

@kjanat kjanat self-assigned this Jul 29, 2026
- macos ships bash 3.2: no `declare -A`, no `mapfile`, and expanding an
  empty array trips `set -u`
- `fail_case` dedupes by scanning the failures array, argv/setenv load
  through `read` loops, and empty `envv`/`argv` expand through the
  `${arr[@]+...}` guard
@kjanat kjanat added bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request leak A secret escapes redaction detector Value-shape and key-name heuristics filter envctl redact stream mode atomicity File writes, atomic replace, mode preservation diff Dry-run diff output parser Env parsing, spans, quoting agent-detect Coding agent detection and TTY rules portability Windows, macOS, BSD behavior build Makefile, CI, release artifacts tests Suite and harness labels Jul 29, 2026
- BSD `script` has no `-E never`, so the EOF it reads from /dev/null
  echoes into the pty log as a leading `^D` and broke the four tty
  cases on macos
- the pty postprocess drops it from the start of line one, in both the
  caret-visualization and raw-EOT encodings, alongside the existing CR
  strip
@kjanat
kjanat force-pushed the redaction-hardening branch from a659788 to d792cae Compare July 29, 2026 02:23
@kjanat kjanat added the cr:review Allow CodeRabbit review label Jul 29, 2026
@kjanat

This comment was marked as off-topic.

@coderabbitai

This comment was marked as spam.

@kjanat

This comment was marked as off-topic.

@coderabbitai

This comment was marked as low quality.

- a review claimed the line closing a streamed object never reaches the
  buffer, so a private member sharing it would escape; the case proves
  that shape masks
@kjanat
kjanat merged commit be8965f into master Jul 29, 2026
10 checks passed
@kjanat
kjanat deleted the redaction-hardening branch July 29, 2026 02:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-detect Coding agent detection and TTY rules atomicity File writes, atomic replace, mode preservation bug Something isn't working build Makefile, CI, release artifacts cr:review Allow CodeRabbit review detector Value-shape and key-name heuristics diff Dry-run diff output documentation Improvements or additions to documentation enhancement New feature or request filter envctl redact stream mode leak A secret escapes redaction parser Env parsing, spans, quoting portability Windows, macOS, BSD behavior tests Suite and harness

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant