mod support;
use std::error::Error;
use std::fmt;
use std::fs;
use std::num::NonZeroU64;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use oxide_batch::{
Clock, DropReportWindow, ExportError, ExportQueueBound, InMemoryExplorer,
InMemoryJobRepository, JobExplorer, JobOperator, RetentionService, SequentialIdGenerator,
TelemetryEventSink, TelemetryExportSink, TelemetryExporter, TelemetryQueue, TelemetryRecord,
};
use oxide_batch_cli::{ExitCategory, NoSchema, Services};
use serde_json::{Value, json};
use support::{TestHost, run, run_with_catalog, services, test_catalog};
const OBSERVATIONS_ENV: &str = "OXIDEBATCH_SECURITY_OBSERVATIONS";
const JOB: &str = "redaction-sweep-job";
const CONFIG: &str = "sweep-config.json";
struct Canary {
class: &'static str,
entry: &'static str,
value: String,
}
struct Artifact {
surface: &'static str,
name: String,
text: String,
structured: Option<Value>,
}
impl Artifact {
fn text(surface: &'static str, name: impl Into<String>, text: impl Into<String>) -> Self {
let text = text.into();
let structured = serde_json::from_str(&text).ok();
Self {
surface,
name: name.into(),
text,
structured,
}
}
fn strings(&self) -> Vec<&str> {
let mut strings = vec![self.text.as_str()];
if let Some(structured) = &self.structured {
collect_strings(structured, &mut strings);
}
strings
}
}
fn collect_strings<'a>(value: &'a Value, into: &mut Vec<&'a str>) {
match value {
Value::String(text) => into.push(text.as_str()),
Value::Array(items) => {
for item in items {
collect_strings(item, into);
}
}
Value::Object(members) => {
for (key, member) in members {
into.push(key.as_str());
collect_strings(member, into);
}
}
_ => {}
}
}
#[test]
fn redaction_sweep_finds_no_prohibited_value_class() -> Result<(), Box<dyn Error>> {
let canaries = canaries();
let mut artifacts = Vec::new();
sweep_cli_and_bundles(&canaries, &mut artifacts);
sweep_errors(&canaries, &mut artifacts);
sweep_telemetry(&canaries, &mut artifacts);
assert!(
!artifacts.is_empty(),
"the sweep collected no artifacts, so it proved nothing",
);
let mut occurrences = Vec::new();
for artifact in &artifacts {
for canary in &canaries {
for string in artifact.strings() {
if string.contains(&canary.value) {
occurrences.push(format!(
"the {} class reached {} in {}",
canary.class, artifact.name, artifact.surface
));
break;
}
}
}
}
assert!(
occurrences.is_empty(),
"prohibited value classes reached diagnostic surfaces: {occurrences:?}",
);
let preserved = require_diagnostics_survive(&artifacts);
let surfaces = surfaces(&artifacts);
retain_observation(&json!({
"report": "redaction sweep across the M5 diagnostic surfaces",
"scenario": "redaction_sweep_finds_no_prohibited_value_class",
"value_classes_scanned": canaries
.iter()
.map(|canary| json!({ "class": canary.class, "entered_through": canary.entry }))
.collect::<Vec<_>>(),
"surfaces_scanned": surfaces,
"artifacts_scanned": artifacts.len(),
"strings_scanned": artifacts
.iter()
.map(|artifact| artifact.strings().len())
.sum::<usize>(),
"prohibited_occurrences": occurrences.len(),
"diagnostics_preserved": preserved,
"violations": Vec::<String>::new(),
"passed": true,
"scenario_result": "passed",
"execution_manifest": execution_manifest()?,
}))?;
Ok(())
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}
fn semantics_paths() -> Result<Vec<String>, Box<dyn Error>> {
let path = workspace_root()
.join("tests")
.join("fixtures")
.join("security")
.join("campaign-semantics.json");
let document: Value = serde_json::from_str(&fs::read_to_string(&path)?)?;
let categories = document
.get("categories")
.and_then(Value::as_object)
.ok_or_else(|| ReportFailure("the semantics document declares no categories".to_owned()))?;
let mut paths = categories
.values()
.filter_map(|category| category.get("paths").and_then(Value::as_array))
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect::<Vec<_>>();
paths.sort();
paths.dedup();
if paths.is_empty() {
return Err(Box::new(ReportFailure(
"the semantics document declares no paths".to_owned(),
)));
}
Ok(paths)
}
fn execution_manifest() -> Result<Value, Box<dyn Error>> {
let root = workspace_root();
let commit = git(&root, &["rev-parse", "HEAD"])
.ok_or_else(|| ReportFailure("the campaign is not running inside a git tree".to_owned()))?;
let mut objects = serde_json::Map::new();
for path in semantics_paths()? {
let object = git(&root, &["rev-parse", &format!("HEAD:{path}")]).ok_or_else(|| {
ReportFailure(format!(
"{path} is declared as campaign semantics and is not present"
))
})?;
objects.insert(path, Value::String(object));
}
Ok(json!({
"execution_commit": commit,
"execution_commit_note": "The tree this run actually executed against, read from the \
checkout the campaign is running in. In CI this is the \
pull-request merge commit rather than the branch head, and it \
is the authority: the objects below are its objects.",
"tree_clean": git(&root, &["status", "--porcelain"]).map(|status| status.is_empty()),
"objects": Value::Object(objects),
}))
}
fn git(root: &Path, arguments: &[&str]) -> Option<String> {
let output = Command::new("git")
.current_dir(root)
.args(arguments)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
#[derive(Debug)]
struct ReportFailure(String);
impl fmt::Display for ReportFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl Error for ReportFailure {}
fn canaries() -> Vec<Canary> {
let run = format!(
"{:x}{:x}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_nanos()),
);
vec![
Canary {
class: "password",
entry: "the repository URL's credential, from the environment and a config file",
value: format!("oxide-secret-password-{run}"),
},
Canary {
class: "database-url-endpoint",
entry: "the repository URL's host and database, from the environment and a file",
value: format!("oxide-secret-endpoint-{run}"),
},
Canary {
class: "certificate",
entry: "the repository CA certificate, from the environment and a config file",
value: format!("oxide-secret-certificate-{run}"),
},
Canary {
class: "payload",
entry: "an identifying job parameter value supplied to launch",
value: format!("oxide-secret-payload-{run}"),
},
]
}
fn canary_url(canaries: &[Canary]) -> String {
format!(
"postgres://batch:{}@{}.invalid:5432/{}",
canary(canaries, "password"),
canary(canaries, "database-url-endpoint"),
canary(canaries, "database-url-endpoint"),
)
}
fn canary<'a>(canaries: &'a [Canary], class: &str) -> &'a str {
canaries
.iter()
.find(|canary| canary.class == class)
.map_or("", |canary| canary.value.as_str())
}
fn configured_host(canaries: &[Canary]) -> TestHost {
let url = canary_url(canaries);
let certificate = canary(canaries, "certificate");
TestHost::new()
.with_env("OXIDE_BATCH_REPOSITORY_URL", &url)
.with_env("OXIDE_BATCH_REPOSITORY_CA_CERTIFICATE", certificate)
.with_file(
CONFIG,
&format!(
"{{\"config_version\":1,\"repository\":\
{{\"url\":\"{url}\",\"ca_certificate\":\"{certificate}\"}}}}"
),
)
.with_mode(CONFIG, 0o600)
}
#[allow(
clippy::too_many_lines,
reason = "the surfaces are one list of invocations, and splitting them would hide which \
artifacts the sweep collects"
)]
fn sweep_cli_and_bundles(canaries: &[Canary], artifacts: &mut Vec<Artifact>) {
let payload = canary(canaries, "payload");
let (services, _repository) = services();
let catalog = test_catalog(JOB);
let mut launch = configured_host(canaries);
let category = run_with_catalog(
&mut launch,
&services,
&catalog,
&format!(
"launch --job {JOB} --actor campaign --operation-id sweep-launch \
--parameter business_key={payload} --output json"
),
);
assert_eq!(
category,
ExitCategory::Success,
"the sweep's launch must succeed for the payload class to have entered: {}",
launch.stderr_text(),
);
artifacts.push(Artifact::text("cli", "launch:stdout", launch.stdout_text()));
artifacts.push(Artifact::text("cli", "launch:stderr", launch.stderr_text()));
let execution = launch.envelope()["data"]["execution"]["execution_id"]
.as_u64()
.unwrap_or_default();
for (form, name) in [("json", "config-show:json"), ("text", "config-show:text")] {
let mut host = configured_host(canaries);
let line = format!("config show --config {CONFIG} --output {form}");
let _ = run(&mut host, &services, &line);
artifacts.push(Artifact::text(
"cli",
format!("{name}:stdout"),
host.stdout_text(),
));
artifacts.push(Artifact::text(
"cli",
format!("{name}:stderr"),
host.stderr_text(),
));
}
for (line, name) in [
("job list --output json", "job-list"),
(
"instance list --job redaction-sweep-job --output json",
"instance-list",
),
(
"execution list --instance 1 --output json",
"execution-list",
),
] {
let mut host = configured_host(canaries);
let _ = run(&mut host, &services, line);
artifacts.push(Artifact::text(
"cli",
format!("{name}:stdout"),
host.stdout_text(),
));
artifacts.push(Artifact::text(
"cli",
format!("{name}:stderr"),
host.stderr_text(),
));
}
for (line, name) in [
("job list --colour red", "invalid-argument"),
(
"execution show --execution 999999 --output json",
"unknown-target",
),
(
"launch --job never-registered --actor a --operation-id o",
"unknown-job",
),
] {
let mut host = configured_host(canaries);
let _ = run(&mut host, &services, line);
artifacts.push(Artifact::text(
"cli",
format!("{name}:stdout"),
host.stdout_text(),
));
artifacts.push(Artifact::text(
"cli",
format!("{name}:stderr"),
host.stderr_text(),
));
}
let mut bundle = configured_host(canaries);
let command = format!(
"diagnostics bundle --execution {execution} --out sweep-bundle --config {CONFIG} \
--output json"
);
assert_eq!(
run(&mut bundle, &services, &command),
ExitCategory::Success,
"the sweep must be able to generate a bundle: {}",
bundle.stderr_text(),
);
artifacts.push(Artifact::text("cli", "bundle:stdout", bundle.stdout_text()));
for name in bundle.directory_files("sweep-bundle") {
let text = bundle.file_text(&format!("sweep-bundle/{name}"));
artifacts.push(Artifact::text("bundle", name, text));
}
}
fn sweep_errors(canaries: &[Canary], artifacts: &mut Vec<Artifact>) {
sweep_adapter_errors(canaries, artifacts);
let mut host = configured_host(canaries);
let arguments = support::words(&format!("config show --config {CONFIG}"));
if let Ok(plan) = oxide_batch_cli::prepare(&mut host, &arguments) {
artifacts.push(Artifact::text(
"errors",
"cli-configuration:debug",
format!("{:?}", plan.config()),
));
sweep_backend_errors(&plan, artifacts);
}
}
#[cfg(feature = "postgres")]
fn sweep_adapter_errors(canaries: &[Canary], artifacts: &mut Vec<Artifact>) {
let url = canary_url(canaries);
let certificate = canary(canaries, "certificate");
let refused = oxide_batch::PostgresConfig::new(format!("{url}?sslmode=disable"));
let error = refused.err().map(|error| render_error(&error));
if let Some(rendered) = error {
artifacts.push(Artifact::text(
"errors",
"postgres-config:refused",
rendered,
));
}
if let Ok(config) = oxide_batch::PostgresConfig::new(url.clone()) {
let config = config.with_tls_mode(oxide_batch::TlsMode::VerifyFull {
ca_certificate: oxide_batch::CaCertificate::new(certificate.as_bytes().to_vec()).ok(),
});
artifacts.push(Artifact::text(
"errors",
"postgres-config:debug",
format!("{config:?}"),
));
let outcome = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()
.map(|runtime| {
runtime.block_on(oxide_batch::PostgresJobRepository::connect(
config,
Arc::new(SweepClock),
))
});
if let Some(Err(error)) = outcome {
artifacts.push(Artifact::text(
"errors",
"postgres-connect:failed",
render_error(&error),
));
}
}
}
#[cfg(not(feature = "postgres"))]
fn sweep_adapter_errors(_canaries: &[Canary], _artifacts: &mut Vec<Artifact>) {}
#[cfg(feature = "postgres")]
fn sweep_backend_errors(plan: &oxide_batch_cli::Plan, artifacts: &mut Vec<Artifact>) {
match oxide_batch_cli::connection_config(plan.config()) {
Ok(config) => artifacts.push(Artifact::text(
"errors",
"cli-backend:config-debug",
format!("{config:?}"),
)),
Err(failure) => artifacts.push(Artifact::text(
"errors",
"cli-backend:refused",
format!("{failure:?} {:?}", failure.diagnostic()),
)),
}
}
#[cfg(not(feature = "postgres"))]
fn sweep_backend_errors(_plan: &oxide_batch_cli::Plan, _artifacts: &mut Vec<Artifact>) {}
#[cfg(feature = "postgres")]
fn render_error(error: &dyn Error) -> String {
let mut rendered = format!("display={error}\ndebug={error:?}");
let mut source = error.source();
while let Some(inner) = source {
let _ = std::fmt::Write::write_fmt(
&mut rendered,
format_args!("\nsource-display={inner}\nsource-debug={inner:?}"),
);
source = inner.source();
}
rendered
}
fn sweep_telemetry(canaries: &[Canary], artifacts: &mut Vec<Artifact>) {
let payload = canary(canaries, "payload");
let catalog = test_catalog(JOB);
let recorder = Arc::new(RecordingSink::default());
let services = sweep_services(&recorder);
let mut host = configured_host(canaries);
let category = run_with_catalog(
&mut host,
&services,
&catalog,
&format!(
"launch --job {JOB} --actor campaign --operation-id sweep-telemetry \
--parameter business_key={payload} --output json"
),
);
assert_eq!(
category,
ExitCategory::Success,
"the sweep's telemetry launch must succeed: {}",
host.stderr_text(),
);
let records = recorder.records();
for (index, record) in records.iter().enumerate() {
artifacts.push(Artifact::text(
"telemetry",
format!("record:{index}:debug"),
format!("{record:?}"),
));
let fields = record
.fields()
.iter()
.map(|field| format!("{}={}", field.key(), field.value()))
.collect::<Vec<_>>()
.join("\n");
artifacts.push(Artifact::text(
"telemetry",
format!("record:{index}:fields"),
fields,
));
}
assert!(
!records.is_empty(),
"the sweep observed no telemetry, so the telemetry surface proved nothing",
);
for (index, rendered) in export(&records).into_iter().enumerate() {
artifacts.push(Artifact::text(
"telemetry",
format!("exported:{index}"),
rendered,
));
}
}
#[derive(Default)]
struct RecordingSink {
records: std::sync::Mutex<Vec<TelemetryRecord>>,
}
impl RecordingSink {
fn records(&self) -> Vec<TelemetryRecord> {
self.records
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl TelemetryEventSink for RecordingSink {
fn emit(&self, event: &TelemetryRecord) {
self.records
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event.clone());
}
}
fn sweep_services(recorder: &Arc<RecordingSink>) -> support::TestServices {
let clock: Arc<dyn Clock> = Arc::new(SweepClock);
let first = NonZeroU64::new(1).unwrap_or(NonZeroU64::MIN);
let identifiers = Arc::new(SequentialIdGenerator::new(first));
let repository = InMemoryJobRepository::new(Arc::clone(&clock), identifiers);
let explorer_repository = InMemoryExplorer::new(&repository);
let sink: Arc<dyn TelemetryEventSink> = Arc::<RecordingSink>::clone(recorder);
Services::new(
JobOperator::new(repository.clone(), Arc::clone(&clock)).with_event_sink(Arc::clone(&sink)),
RetentionService::new(repository, Arc::clone(&clock)).with_event_sink(Arc::clone(&sink)),
JobExplorer::new(explorer_repository).with_event_sink(sink),
Box::new(NoSchema),
)
}
fn export(records: &[TelemetryRecord]) -> Vec<String> {
let Ok(bound) = ExportQueueBound::new(64) else {
return Vec::new();
};
let Ok(window) = DropReportWindow::new(Duration::from_mins(1)) else {
return Vec::new();
};
let queue = TelemetryQueue::new(bound, window);
for record in records {
let _ = queue.enqueue(record.clone(), Duration::ZERO);
}
let sink = CapturingSink::default();
let captured = Arc::clone(&sink.captured);
let exporter = TelemetryExporter::new(queue, sink);
futures_executor::block_on(exporter.flush());
let captured = captured
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
captured.clone()
}
#[derive(Default)]
struct CapturingSink {
captured: Arc<std::sync::Mutex<Vec<String>>>,
}
impl TelemetryExportSink for CapturingSink {
fn export<'a>(
&'a self,
record: &'a TelemetryRecord,
) -> oxide_batch::BoxFuture<'a, Result<(), ExportError>> {
Box::pin(async move {
let fields = record
.fields()
.iter()
.map(|field| format!("{}={}", field.key(), field.value()))
.collect::<Vec<_>>()
.join(" ");
self.captured
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(format!("{record:?} {fields}"));
Ok(())
})
}
}
#[derive(Debug)]
struct SweepClock;
impl oxide_batch::Clock for SweepClock {
fn now(&self) -> SystemTime {
UNIX_EPOCH
}
}
fn require_diagnostics_survive(artifacts: &[Artifact]) -> Value {
let configuration = artifacts
.iter()
.find(|artifact| artifact.surface == "bundle" && artifact.name == "configuration.json")
.and_then(|artifact| artifact.structured.clone())
.unwrap_or(Value::Null);
let keys = configuration
.as_array()
.into_iter()
.flatten()
.filter_map(|value| value.get("key").and_then(Value::as_str))
.collect::<Vec<_>>();
assert!(
keys.contains(&"repository.url"),
"the bundle stopped reporting that a repository URL is configured at all",
);
let redacted = configuration
.as_array()
.into_iter()
.flatten()
.filter(|value| value.get("redacted").and_then(Value::as_bool) == Some(true))
.count();
assert!(
redacted > 0,
"the bundle reports no value as redacted, so it is not distinguishing a withheld value \
from an absent one",
);
let parameters = artifacts
.iter()
.find(|artifact| artifact.surface == "cli" && artifact.name == "instance-list:stdout")
.and_then(|artifact| artifact.structured.clone())
.and_then(|envelope| envelope.get("data").cloned())
.and_then(|data| {
data.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row.get("parameters"))
.cloned()
})
.unwrap_or(Value::Null);
let named = parameters
.as_array()
.into_iter()
.flatten()
.filter_map(|parameter| parameter.get("name").and_then(Value::as_str))
.collect::<Vec<_>>();
assert!(
named.contains(&"business_key"),
"the instance projection stopped naming the parameter the payload arrived in, so \
redaction removed the diagnostic rather than the value",
);
assert!(
parameters
.as_array()
.into_iter()
.flatten()
.all(|parameter| parameter.get("kind").is_some()),
"the instance projection stopped reporting parameter types, which an operator needs to \
read an identity it cannot see the values of",
);
json!({
"configuration_keys_reported": keys.len(),
"configuration_values_marked_redacted": redacted,
"parameter_names_preserved": named.len(),
"parameter_types_preserved": true,
})
}
fn surfaces(artifacts: &[Artifact]) -> Vec<Value> {
let mut surfaces: Vec<&'static str> = Vec::new();
for artifact in artifacts {
if !surfaces.contains(&artifact.surface) {
surfaces.push(artifact.surface);
}
}
surfaces.sort_unstable();
surfaces
.into_iter()
.map(|surface| {
json!({
"surface": surface,
"artifacts": artifacts
.iter()
.filter(|artifact| artifact.surface == surface)
.count(),
})
})
.collect()
}
fn retain_observation(document: &Value) -> Result<(), Box<dyn Error>> {
let Some(directory) = std::env::var(OBSERVATIONS_ENV)
.ok()
.filter(|value| !value.is_empty())
else {
return Ok(());
};
let directory = PathBuf::from(directory);
fs::create_dir_all(&directory)?;
fs::write(
directory.join("redaction-sweep.json"),
format!("{}\n", serde_json::to_string_pretty(document)?),
)?;
Ok(())
}