mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
fabro(01KWF7MM3VPXZZA8BTHJXE9VT1): implement (succeeded)
Fabro-Run: 01KWF7MM3VPXZZA8BTHJXE9VT1
Fabro-Completed: 5
Fabro-Checkpoint: cad1fcc115
⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
parent
dec9d74c1c
commit
0f047e169f
2 changed files with 229 additions and 1 deletions
|
|
@ -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.
|
||||
///
|
||||
|
|
@ -65,7 +69,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..]);
|
||||
|
|
|
|||
224
lib/crates/fabro-redact/src/secret_registry.rs
Normal file
224
lib/crates/fabro-redact/src/secret_registry.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
use std::sync::{Arc, PoisonError, RwLock};
|
||||
|
||||
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.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SecretRedactor {
|
||||
values: Arc<RwLock<Vec<String>>>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
let value = value.into();
|
||||
if value.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut values = self.values.write().unwrap_or_else(PoisonError::into_inner);
|
||||
if !values.iter().any(|registered| registered == &value) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return `true` when no secret values have been registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.values
|
||||
.read()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
/// Redact all registered secret values from `s`.
|
||||
pub fn redact_into(&self, s: &str) -> String {
|
||||
let values = self.registered_values_longest_first();
|
||||
redact_string_values(s, &values)
|
||||
}
|
||||
|
||||
/// Redact registered secret values from every JSON string leaf.
|
||||
///
|
||||
/// Object keys are left unchanged.
|
||||
pub fn redact_json(&self, mut value: Value) -> Value {
|
||||
let values = self.registered_values_longest_first();
|
||||
if values.is_empty() {
|
||||
return value;
|
||||
}
|
||||
|
||||
redact_json_value(&mut value, &values);
|
||||
value
|
||||
}
|
||||
|
||||
fn registered_values_longest_first(&self) -> Vec<String> {
|
||||
let values = self.values.read().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut values = values.clone();
|
||||
values.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
|
||||
values
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_json_value(value: &mut Value, values: &[String]) {
|
||||
match value {
|
||||
Value::Object(obj) => {
|
||||
for child in obj.values_mut() {
|
||||
redact_json_value(child, values);
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for child in arr {
|
||||
redact_json_value(child, values);
|
||||
}
|
||||
}
|
||||
Value::String(text) => {
|
||||
let redacted = redact_string_values(text, values);
|
||||
if redacted != *text {
|
||||
*text = redacted;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_string_values(s: &str, values: &[String]) -> String {
|
||||
if values.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
let mut regions = Vec::new();
|
||||
for value in values {
|
||||
for (start, _) in s.match_indices(value) {
|
||||
let end = start + value.len();
|
||||
if !regions
|
||||
.iter()
|
||||
.any(|region: &Region| regions_overlap(start, end, region))
|
||||
{
|
||||
regions.push(Region { start, end });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if regions.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
regions.sort_by_key(|region| region.start);
|
||||
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut previous = 0;
|
||||
for region in ®ions {
|
||||
result.push_str(&s[previous..region.start]);
|
||||
result.push_str(crate::REDACTION_MARKER);
|
||||
previous = region.end;
|
||||
}
|
||||
result.push_str(&s[previous..]);
|
||||
result
|
||||
}
|
||||
|
||||
fn regions_overlap(start: usize, end: usize, region: &Region) -> bool {
|
||||
start < region.end && region.start < end
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue