From c1ff4a3e338fa41ccf287fbf9a55794be142197f Mon Sep 17 00:00:00 2001 From: "fabro-sh-fabro[bot]" <296591931+fabro-sh-fabro[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:59:41 -0400 Subject: [PATCH] fabro-redact: add SecretRedactor for per-run exact-value redaction (#542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `SecretRedactor` primitive to `fabro-redact` so that low-entropy secret values (e.g. environment names, short tokens) are redacted even when the existing content-based heuristics (`redact_string`, `redact_json_value`) would leave them alone. The type is a cheap, `Clone`-able handle backed by `Arc>>`, so a clone handed to another subsystem shares the same registry. `register` ignores empty/whitespace-only values to prevent a footgun that would blank all output. `redact_into` sorts and merges match regions before substituting, so a secret that is a prefix of another longer secret is handled correctly (longest wins via union). `redact_json` walks string leaves in objects and arrays; object keys are left intact. This is an inert library primitive — it changes no existing behavior and is wired up by Plan C. The existing `"REDACTED"` literal is extracted to a `pub(crate) REDACTION_MARKER` constant so both the old path and the new one stay in sync. ### Fabro Details
Ran 8 stages in 43m 24s for $5.69 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 1s | – | 0 | | preflight_compile | 2m 23s | – | 0 | | preflight_lint | 2m 33s | – | 0 | | implement | 20m 1s | $3.09 | 0 | | simplify_opus | 4m 13s | $1.27 | 0 | | simplify_gpt | 7m 29s | $1.33 | 0 | | verify | 6m 16s | – | 0 | | **Total** | **43m 24s** | **$5.69** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14 edges) ```dot digraph ImplementPlan { graph [ goal="Implement and simplify", model_stylesheet=" * { model: claude-opus-4-8; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> exit [condition="outcome=succeeded"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- lib/crates/fabro-redact/src/lib.rs | 13 +- .../fabro-redact/src/secret_registry.rs | 217 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 lib/crates/fabro-redact/src/secret_registry.rs diff --git a/lib/crates/fabro-redact/src/lib.rs b/lib/crates/fabro-redact/src/lib.rs index 170cf7a81..dd5da867b 100644 --- a/lib/crates/fabro-redact/src/lib.rs +++ b/lib/crates/fabro-redact/src/lib.rs @@ -8,9 +8,13 @@ mod entropy; mod gitleaks; mod jsonl; mod safe_url; +mod secret_registry; pub use jsonl::{redact_json_value, redact_jsonl_line}; pub use safe_url::{DisplaySafeUrl, DisplaySafeUrlError}; +pub use secret_registry::SecretRedactor; + +pub(crate) const REDACTION_MARKER: &str = "REDACTED"; /// Redact a URL string for log or error output. /// @@ -41,7 +45,14 @@ pub struct Region { pub fn redact_string(s: &str) -> String { let mut regions = entropy::find_entropy_regions(s); regions.extend(gitleaks::find_gitleaks_regions(s)); + redact_regions(s, regions) +} +/// Replace each region of `s` with [`REDACTION_MARKER`]. +/// +/// Regions may be unsorted and overlapping; they are sorted by start and +/// overlapping regions are merged so the union is redacted as a single marker. +pub(crate) fn redact_regions(s: &str, mut regions: Vec) -> String { if regions.is_empty() { return s.to_string(); } @@ -65,7 +76,7 @@ pub fn redact_string(s: &str) -> String { let mut prev = 0; for r in &merged { result.push_str(&s[prev..r.start]); - result.push_str("REDACTED"); + result.push_str(REDACTION_MARKER); prev = r.end; } result.push_str(&s[prev..]); diff --git a/lib/crates/fabro-redact/src/secret_registry.rs b/lib/crates/fabro-redact/src/secret_registry.rs new file mode 100644 index 000000000..764eb0fe5 --- /dev/null +++ b/lib/crates/fabro-redact/src/secret_registry.rs @@ -0,0 +1,217 @@ +use std::sync::{Arc, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use serde_json::Value; + +use crate::Region; + +/// Per-run registry of exact secret values to redact from strings and JSON. +/// +/// This complements the crate's content-based redaction by redacting registered +/// values even when they do not look like credentials. Clones share the same +/// registry so callers can hand a redactor to another subsystem and continue to +/// register values through the original. Registered values are exact substring +/// matches and may be low-entropy strings such as environment names. +#[derive(Clone, Default)] +pub struct SecretRedactor { + values: Arc>>, +} + +impl SecretRedactor { + /// Register a secret value for exact substring redaction. + /// + /// Empty or whitespace-only values are ignored so an accidental empty + /// registration cannot redact every output boundary. + pub fn register(&self, value: impl Into) { + let value = value.into(); + if value.trim().is_empty() { + return; + } + + let mut values = self.write(); + if !values.contains(&value) { + values.push(value); + } + } + + /// Return `true` when no secret values have been registered. + pub fn is_empty(&self) -> bool { + self.read().is_empty() + } + + /// Redact all registered secret values from `s`. + pub fn redact_into(&self, s: &str) -> String { + let Some(values) = self.values_snapshot() else { + return s.to_string(); + }; + redact_string_values(s, &values) + } + + /// Redact registered secret values from every JSON string value. + /// + /// Object keys and non-string values are left unchanged. + pub fn redact_json(&self, mut value: Value) -> Value { + let Some(values) = self.values_snapshot() else { + return value; + }; + + redact_json_leaves(&mut value, &values); + value + } + + fn read(&self) -> RwLockReadGuard<'_, Vec> { + self.values.read().unwrap_or_else(PoisonError::into_inner) + } + + fn write(&self) -> RwLockWriteGuard<'_, Vec> { + self.values.write().unwrap_or_else(PoisonError::into_inner) + } + + fn values_snapshot(&self) -> Option> { + let values = self.read(); + if values.is_empty() { + return None; + } + Some(values.clone()) + } +} + +fn redact_json_leaves(value: &mut Value, values: &[String]) { + match value { + Value::Object(obj) => { + for child in obj.values_mut() { + redact_json_leaves(child, values); + } + } + Value::Array(arr) => { + for child in arr { + redact_json_leaves(child, values); + } + } + Value::String(text) => { + let redacted = redact_string_values(text, values); + if redacted != *text { + *text = redacted; + } + } + _ => {} + } +} + +/// Collect every match of each registered value and let +/// [`crate::redact_regions`] sort and merge overlaps, so a secret that overlaps +/// another is fully redacted. +/// +/// Assumes a small number of registered values (bounded by the run's declared +/// secrets), so the per-value scan is not optimized further. +fn redact_string_values(s: &str, values: &[String]) -> String { + let mut regions = Vec::new(); + for value in values { + for (start, _) in s.match_indices(value) { + regions.push(Region { + start, + end: start + value.len(), + }); + } + } + + if regions.is_empty() { + return s.to_string(); + } + + crate::redact_regions(s, regions) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::SecretRedactor; + + #[test] + fn redacts_registered_low_entropy_value() { + let redactor = SecretRedactor::default(); + redactor.register("staging"); + + assert_eq!( + crate::redact_string("deploy to staging"), + "deploy to staging" + ); + assert_eq!( + redactor.redact_into("deploy to staging"), + "deploy to REDACTED" + ); + } + + #[test] + fn ignores_empty_and_whitespace_values() { + let redactor = SecretRedactor::default(); + redactor.register(""); + redactor.register(" "); + + assert_eq!( + redactor.redact_into("deploy to staging"), + "deploy to staging" + ); + } + + #[test] + fn redacts_overlapping_values_longest_first() { + let redactor = SecretRedactor::default(); + redactor.register("abc"); + redactor.register("abcdef"); + + assert_eq!(redactor.redact_into("token=abcdef"), "token=REDACTED"); + } + + #[test] + fn empty_registry_is_identity() { + let redactor = SecretRedactor::default(); + let value = json!({ + "env": "staging", + "items": ["staging", 42], + }); + + assert_eq!( + redactor.redact_into("deploy to staging"), + "deploy to staging" + ); + assert_eq!(redactor.redact_json(value.clone()), value); + assert!(redactor.is_empty()); + } + + #[test] + fn redact_json_redacts_nested_object_values_and_array_elements() { + let redactor = SecretRedactor::default(); + redactor.register("staging"); + let value = json!({ + "environment": "staging", + "items": [ + "keep", + "deploy staging now" + ], + "staging": "object keys are not redacted", + }); + + assert_eq!( + redactor.redact_json(value), + json!({ + "environment": "REDACTED", + "items": [ + "keep", + "deploy REDACTED now" + ], + "staging": "object keys are not redacted", + }) + ); + } + + #[test] + fn clones_share_registered_values() { + let redactor = SecretRedactor::default(); + let clone = redactor.clone(); + + redactor.register("staging"); + + assert_eq!(clone.redact_into("deploy to staging"), "deploy to REDACTED"); + } +}