Keep the executable run spec out of reach of event redaction

Two root-cause fixes for the sandbox failure where an inline Dockerfile
came back from the store as `ARG REDACTED` and the Daytona snapshot
build died on the unset variable.

Entropy redaction measures values, not assignment pairs. The detector
matched `NAME=value` as one token, so an uppercase name merged its
charset into a pure-hex value (which alone can never exceed 4.0 bits)
and pushed the pair over the 4.5-bit threshold — then replaced the
whole pair, destroying the name. `find_entropy_regions` now strips an
identifier-shaped `NAME=` prefix before measuring and redacts only the
value, matching the gitleaks layer's `key=REDACTED` shape.

Execution no longer reads redacted content. Every stored event passes
through the redaction sink, and `load_from_store` rehydrated the
worker's RunSpec from the projection folded from those events — so a
redactor false positive silently rewrote the spec the sandbox builds
from (and changed its snapshot identity). The creation path now writes
the exact spec bytes to the content-addressed blob store and records
`spec_blob` on run.created; `load_from_store` loads the spec from the
blob, keeping the event stream authoritative for run identity,
provenance, and event-recorded blob ids. Retry and fork carry the
source run's `spec_blob` forward, so derived runs stop inheriting the
redacted copy. Runs created before the blob existed fall back to the
folded spec.

The projection and every API surface keep serving the redacted fold;
blobs were already stored unredacted (the workflow bundle carries the
same bytes), so this adds no new exposure at rest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-08-06 21:44:46 -04:00
parent 4e24dcb68a
commit 3421c4f06f
No known key found for this signature in database
50 changed files with 229 additions and 20 deletions

View file

@ -11304,6 +11304,8 @@ components:
type: ["string", "null"]
definition_blob:
type: ["string", "null"]
spec_blob:
type: ["string", "null"]
git:
oneOf:
- $ref: "#/components/schemas/GitContext"

View file

@ -849,6 +849,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
};

View file

@ -1024,6 +1024,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
}
},
"source_directory": "[TEMP_DIR]",
"spec_blob": "[BLOB_ID]",
"title": "Wait for approval",
"web_url": "http://localhost:3000/runs/[ULID]",
"workflow_slug": "human-gate",

View file

@ -53,6 +53,7 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
};

View file

@ -2387,6 +2387,7 @@ index 1111111..2222222 160000
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},

View file

@ -627,6 +627,7 @@ mod stage_events_tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -1027,6 +1027,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -1923,6 +1923,7 @@ reasoning = false
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
};

View file

@ -4650,6 +4650,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
@ -4701,6 +4702,7 @@ async fn create_slack_notification_run(
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
@ -5774,6 +5776,7 @@ async fn list_run_stages_distinguishes_visits() {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
@ -5910,6 +5913,7 @@ async fn list_run_stages_exposes_execution_identity_for_resumed_stage() {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
@ -7097,6 +7101,7 @@ async fn create_completed_run_ready_for_pull_request(
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
@ -7113,6 +7118,7 @@ async fn create_completed_run_ready_for_pull_request(
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git,
fork_source_ref: None,
retried_from: None,
@ -14082,6 +14088,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
@ -14831,6 +14838,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -68,6 +68,7 @@ async fn append_completed_run_with_final_patch(
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -501,6 +501,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
}
}

View file

@ -1046,6 +1046,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result<RunProjection> {
provenance: props.provenance.clone(),
manifest_blob: props.manifest_blob,
definition_blob: None,
spec_blob: props.spec_blob,
git: props.git.clone(),
fork_source_ref: props.fork_source_ref.clone(),
};
@ -2292,6 +2293,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
}
@ -4098,6 +4100,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
@ -4124,6 +4127,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};

View file

@ -601,6 +601,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},

View file

@ -602,6 +602,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: Some(fabro_types::GitContext {
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
branch: "main".to_string(),

View file

@ -25,6 +25,7 @@ fn sample_run_spec() -> RunSpec {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: Some(fabro_types::GitContext {
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
branch: "main".to_string(),

View file

@ -322,6 +322,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
}

View file

@ -36,6 +36,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
automation,
provenance,
manifest_blob,
spec_blob,
git,
fork_source_ref,
retried_from,
@ -54,6 +55,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
automation: automation.clone(),
provenance: provenance.clone(),
manifest_blob: *manifest_blob,
spec_blob: *spec_blob,
git: git.clone(),
fork_source_ref: fork_source_ref.clone(),
retried_from: *retried_from,
@ -2669,6 +2671,7 @@ mod tests {
automation: Some(automation.clone()),
provenance,
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -41,6 +41,8 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
manifest_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
spec_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
git: Option<GitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
fork_source_ref: Option<ForkSourceRef>,

View file

@ -290,6 +290,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -364,6 +364,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -501,6 +501,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -374,6 +374,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},
@ -471,6 +472,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -956,6 +956,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -279,6 +279,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -750,6 +750,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -227,6 +227,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -470,6 +470,7 @@ pub async fn persist_create_run(
provenance,
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git,
fork_source_ref,
};
@ -528,6 +529,12 @@ async fn persist_created_run(
}
None => None,
};
// The spec on the run.created event is subject to secret redaction in
// stored copies; the blob keeps the exact bytes execution needs.
let spec_blob = {
let bytes = serde_json::to_vec(record).map_err(|err| Error::engine(err.to_string()))?;
Some(run_store.write_blob(&bytes).await.map_err(store_error)?)
};
let title = explicit_title.unwrap_or_else(|| fabro_types::infer_run_title(record.graph.goal()));
let stored = to_run_event_at(
@ -554,6 +561,7 @@ async fn persist_created_run(
automation: record.automation.clone(),
provenance: record.provenance.clone(),
manifest_blob,
spec_blob,
git: record.git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
retried_from: None,

View file

@ -162,6 +162,9 @@ async fn persist_forked_run(
automation: spec.automation.clone(),
provenance: spec.provenance.clone(),
manifest_blob: spec.manifest_blob,
// Content-addressed, so the forked run reads the source run's
// unredacted spec bytes through the same id.
spec_blob: spec.spec_blob,
git: spec.git.clone(),
fork_source_ref: spec.fork_source_ref.clone(),
retried_from: None,
@ -381,6 +384,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: Some(fabro_types::GitContext {
origin_url: "https://github.com/example/repo.git".to_string(),
branch: "main".to_string(),

View file

@ -54,6 +54,7 @@ pub async fn retry_run(
provenance: _,
manifest_blob,
definition_blob,
spec_blob,
git,
fork_source_ref,
} = source.spec;
@ -78,6 +79,9 @@ pub async fn retry_run(
automation,
provenance: input.provenance.clone(),
manifest_blob,
// Blobs are content-addressed, so the retried run reads the source
// run's unredacted spec bytes through the same id.
spec_blob,
git,
fork_source_ref,
retried_from: Some(source_run_id),
@ -185,6 +189,7 @@ mod tests {
automation: None,
provenance: provenance("source-user"),
manifest_blob,
spec_blob: None,
git: Some(git_context()),
fork_source_ref,
retried_from: None,

View file

@ -252,6 +252,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},

View file

@ -173,6 +173,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
},
)
@ -218,6 +219,7 @@ async fn seed_created_and_starting(
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
retried_from: None,

View file

@ -788,6 +788,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
@ -906,6 +907,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},

View file

@ -870,6 +870,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref,
},
)
@ -1053,6 +1054,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: run_options.fork_source_ref.clone(),
retried_from: None,

View file

@ -2,6 +2,7 @@ use std::path::Path;
use super::types::{PersistOptions, Persisted, Validated};
use crate::error::Error;
use crate::records::RunSpec;
use crate::runtime_store::RunStoreHandle;
/// PERSIST phase: create the run directory and return durable metadata for
@ -37,7 +38,7 @@ pub(crate) async fn load_from_store(
.state()
.await
.map_err(|err| Error::engine(err.to_string()))?;
let run_spec = state.spec;
let run_spec = executable_run_spec(run_store, state.spec).await?;
let graph = run_spec.graph.clone();
let source = run_spec.graph_source.clone().unwrap_or_default();
@ -50,6 +51,40 @@ pub(crate) async fn load_from_store(
))
}
/// Replace the event-folded spec content with the exact bytes from the spec
/// blob. Stored events pass through secret redaction, so the folded spec is
/// display data; the blob written at creation is what execution must see.
/// Runs created before the blob existed fall back to the folded spec.
async fn executable_run_spec(
run_store: &RunStoreHandle,
folded: RunSpec,
) -> Result<RunSpec, Error> {
let Some(blob_id) = folded.spec_blob else {
return Ok(folded);
};
let bytes = run_store
.read_blob(&blob_id)
.await
.map_err(|err| Error::engine(err.to_string()))?
.ok_or_else(|| {
Error::engine(format!(
"run spec blob is missing from the run store: {blob_id}"
))
})?;
let mut spec: RunSpec =
serde_json::from_slice(&bytes).map_err(|err| Error::Parse(err.to_string()))?;
// The event stream stays authoritative for run identity, for provenance
// (a retry rewrites it), for blob ids recorded on events after the spec
// blob was written, and for a graph source the blob does not carry.
spec.run_id = folded.run_id;
spec.provenance = folded.provenance;
spec.manifest_blob = folded.manifest_blob;
spec.definition_blob = folded.definition_blob;
spec.spec_blob = folded.spec_blob;
spec.graph_source = spec.graph_source.or(folded.graph_source);
Ok(spec)
}
#[cfg(test)]
#[expect(clippy::disallowed_methods, reason = "tests stage pipeline fixtures")]
mod tests {
@ -150,30 +185,52 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
}
}
async fn seeded_store(record: &RunSpec, source: Option<&str>) -> RunDatabase {
seeded_store_with(record, source, true).await
}
async fn seeded_store_with(
record: &RunSpec,
source: Option<&str>,
write_spec_blob: bool,
) -> RunDatabase {
let store = memory_store();
let run_store = store.create_run(&record.run_id).await.unwrap();
// Mirror the production producer: the unredacted spec rides a blob
// and the redacted event carries its id.
let spec_blob = if write_spec_blob {
Some(
run_store
.write_blob(&serde_json::to_vec(record).unwrap())
.await
.unwrap(),
)
} else {
None
};
append_event(&run_store, &record.run_id, &Event::RunCreated {
run_id: record.run_id,
title: None,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: source.map(ToOwned::to_owned),
labels: record.labels.clone().into_iter().collect(),
run_id: record.run_id,
title: None,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: source.map(ToOwned::to_owned),
labels: record.labels.clone().into_iter().collect(),
source_directory: record.source_directory.clone(),
workflow_slug: record.workflow_slug.clone(),
automation: record.automation.clone(),
provenance: record.provenance.clone(),
manifest_blob: None,
git: record.git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
workflow_slug: record.workflow_slug.clone(),
automation: record.automation.clone(),
provenance: record.provenance.clone(),
manifest_blob: None,
spec_blob,
git: record.git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
@ -316,6 +373,26 @@ mod tests {
);
}
#[tokio::test]
async fn load_from_store_falls_back_to_folded_spec_without_spec_blob() {
// Runs created before the spec blob existed carry no spec_blob on
// run.created; the folded spec is their only copy.
let temp = tempfile::tempdir().unwrap();
let run_dir = temp.path().join("run");
std::fs::create_dir_all(&run_dir).unwrap();
let (graph, source) = graph_and_source();
let mut record = sample_record(different_graph());
record.graph = graph;
let run_store = seeded_store_with(&record, Some(&source), false).await;
let loaded = load_from_store(&run_store.clone().into(), &run_dir)
.await
.unwrap();
assert_eq!(loaded.run_spec().settings, record.settings);
assert_eq!(loaded.run_spec().spec_blob, None);
}
#[test]
fn persist_returns_error_on_io_failure() {
let temp = tempfile::tempdir().unwrap();

View file

@ -830,6 +830,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},
@ -1112,6 +1113,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
@ -1126,6 +1128,7 @@ mod tests {
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
retried_from: None,
@ -1179,6 +1182,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
@ -1193,6 +1197,7 @@ mod tests {
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
retried_from: None,
@ -1596,6 +1601,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
@ -1610,6 +1616,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
@ -1813,6 +1820,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
@ -1827,6 +1835,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -487,6 +487,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
}
}
@ -512,6 +513,7 @@ mod tests {
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: run_spec.fork_source_ref.clone(),
retried_from: None,

View file

@ -641,6 +641,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
},
chrono::Utc::now(),

View file

@ -151,6 +151,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
}
}
@ -169,6 +170,7 @@ mod tests {
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -209,6 +209,7 @@ mod tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
};

View file

@ -204,6 +204,7 @@ async fn initialized(
},
},
manifest_blob: None,
spec_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
retried_from: None,

View file

@ -140,6 +140,7 @@ fn run_spec_json() -> serde_json::Value {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
})

View file

@ -36,6 +36,12 @@ pub(super) fn shannon_entropy(s: &str) -> f64 {
/// Returns regions where tokens match `[A-Za-z0-9+_=-]{10,}` and have
/// Shannon entropy above the threshold (4.5 bits). Protects against
/// consuming characters from JSON escape sequences.
///
/// An assignment token (`NAME=value`) is measured and redacted by its
/// value alone. Measuring the pair merges the name's charset into the
/// value's and pushes innocuous values (a pure-hex git SHA can never
/// exceed 4.0 bits by itself) over the threshold, and redacting the
/// pair destroys the name that says what was redacted.
pub(super) fn find_entropy_regions(s: &str) -> Vec<Region> {
let mut regions = Vec::new();
for m in SECRET_PATTERN.find_iter(s) {
@ -58,6 +64,10 @@ pub(super) fn find_entropy_regions(s: &str) -> Vec<Region> {
}
}
if let Some(offset) = assignment_value_offset(&s[start..end]) {
start += offset;
}
if shannon_entropy(&s[start..end]) > ENTROPY_THRESHOLD {
regions.push(Region { start, end });
}
@ -65,6 +75,24 @@ pub(super) fn find_entropy_regions(s: &str) -> Vec<Region> {
regions
}
/// For an assignment token (`NAME=value` with an identifier-shaped name),
/// return the byte offset where the value begins. Entropy above the 4.5-bit
/// threshold needs at least 23 distinct characters, so a value too short to
/// qualify simply measures under the threshold; no length guard is needed.
fn assignment_value_offset(token: &str) -> Option<usize> {
let eq = token.find('=')?;
let name = &token[..eq];
let mut chars = name.chars();
let first = chars.next()?;
if !(first.is_ascii_alphabetic() || first == '_') {
return None;
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
Some(eq + 1)
}
#[cfg(test)]
mod tests {
use super::*;
@ -98,11 +126,12 @@ mod tests {
#[test]
fn regions_finds_high_entropy_token() {
// `=` is in the regex pattern, so "key=xK9..." matches as one token
// "key=xK9..." matches as one token, but only the value is
// measured and flagged; the name survives redaction.
let input = "key=xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6p";
let regions = find_entropy_regions(input);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].start, 0);
assert_eq!(regions[0].start, "key=".len());
assert_eq!(regions[0].end, input.len());
}

View file

@ -209,7 +209,7 @@ mod tests {
let redacted = redact_json_value(input);
assert_eq!(redacted["name"], "fabro-01KQR3V9D4VPFFWMNTVH09J48G");
assert_eq!(redacted["content"], "REDACTED");
assert_eq!(redacted["content"], "token=REDACTED");
}
#[test]
@ -262,7 +262,7 @@ mod tests {
let redacted = redact_json_value(input);
assert_eq!(redacted["content"], "REDACTED");
assert_eq!(redacted["content"], "key=REDACTED");
assert_eq!(redacted["session_id"], HIGH_ENTROPY_SECRET);
}

View file

@ -1963,6 +1963,10 @@ pub fn json_snapshot_filters(mut filters: Vec<(String, String)>) -> Vec<(String,
r#""definition_blob":\s*"[0-9a-f]{64}""#.to_string(),
r#""definition_blob": "[BLOB_ID]""#.to_string(),
));
filters.push((
r#""spec_blob":\s*"[0-9a-f]{64}""#.to_string(),
r#""spec_blob": "[BLOB_ID]""#.to_string(),
));
filters.push((
r#""run_dir":\s*"\[STORAGE_DIR\]/scratch/\d{8}-\[ULID\]""#.to_string(),
r#""run_dir": "[RUN_DIR]""#.to_string(),

View file

@ -76,6 +76,11 @@ pub struct RunSpec {
pub manifest_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub definition_blob: Option<RunBlobId>,
/// Unredacted copy of this spec in the blob store. Stored events pass
/// through secret redaction, so the spec folded from them is display
/// data; execution must load the spec from this blob.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -28,6 +28,11 @@ pub struct RunCreatedProps {
pub provenance: RunProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_blob: Option<RunBlobId>,
/// Unredacted copy of the run spec in the blob store. The settings and
/// graph on this event are redacted at the sink; execution loads the
/// spec from this blob instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -1087,6 +1087,7 @@ mod title_tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
};
@ -1161,6 +1162,7 @@ mod iter_stages_tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},
@ -1365,6 +1367,7 @@ mod live_timing_tests {
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},

View file

@ -32,6 +32,7 @@ fn run_created_props_round_trip_templated_settings() {
}),
provenance: test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: Some(GitContext {
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
branch: "main".to_string(),
@ -93,6 +94,7 @@ fn run_created_props_omits_web_url_when_absent() {
automation: None,
provenance: test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,

View file

@ -31,6 +31,7 @@ fn sample_run_spec() -> RunSpec {
provenance: test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: Some(GitContext {
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
branch: "main".to_string(),

View file

@ -31,6 +31,7 @@ fn run_spec_round_trips_templated_settings() {
provenance: test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: Some(GitContext {
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
branch: "main".to_string(),