diff --git a/crates/registry-notary-core/src/config/schema.rs b/crates/registry-notary-core/src/config/schema.rs index 2fa478a0b..16fa870f2 100644 --- a/crates/registry-notary-core/src/config/schema.rs +++ b/crates/registry-notary-core/src/config/schema.rs @@ -18,6 +18,36 @@ use super::{SigningKeyProviderConfig, SigningKeyStatus, StandaloneRegistryNotary pub const CONFIG_SCHEMA_ID: &str = "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json"; +/// Schema-only deployment-waiver reference contract shared with posture. +pub(crate) struct DeploymentWaiverReferenceSchema; + +impl JsonSchema for DeploymentWaiverReferenceSchema { + fn schema_name() -> Cow<'static, str> { + "DeploymentWaiverReference".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + registry_platform_ops::deployment_waiver_reference_schema_fragment() + .try_into() + .expect("the shared waiver-reference fragment is a valid JSON Schema") + } +} + +/// Schema-only structural deployment-waiver summary contract shared with posture. +pub(crate) struct DeploymentWaiverSummarySchema; + +impl JsonSchema for DeploymentWaiverSummarySchema { + fn schema_name() -> Cow<'static, str> { + "DeploymentWaiverSummary".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + registry_platform_ops::deployment_waiver_summary_schema_fragment() + .try_into() + .expect("the shared waiver-summary fragment is a valid JSON Schema") + } +} + /// Schema-only contract for values parsed by `humantime_serde`. pub(crate) struct HumantimeDurationSchema; diff --git a/crates/registry-notary-core/src/config/tests/root.rs b/crates/registry-notary-core/src/config/tests/root.rs index a61c11650..1c0939604 100644 --- a/crates/registry-notary-core/src/config/tests/root.rs +++ b/crates/registry-notary-core/src/config/tests/root.rs @@ -389,7 +389,8 @@ pub(super) fn deployment_block_round_trips_through_yaml() { profile: production waivers: - finding: notary.openapi.public - reason: synthetic partner integration waiver + reference: OPS-2026-0042 + summary: Synthetic partner integration waiver expires: 2099-09-30 "#, ) @@ -403,6 +404,58 @@ waivers: .expect("production config with waivable waiver validates"); } +#[test] +pub(super) fn deployment_waiver_rejects_legacy_reason_as_unknown() { + let result: Result = serde_norway::from_str( + r#" +profile: hosted_lab +waivers: + - finding: notary.openapi.public + reference: OPS-2026-0042 + reason: legacy waiver text + expires: 2099-09-30 +"#, + ); + assert!( + result.is_err(), + "the removed reason field must fail strict deserialization" + ); +} + +#[test] +pub(super) fn deployment_waiver_rejects_missing_reference() { + let result: Result = serde_norway::from_str( + r#" +profile: hosted_lab +waivers: + - finding: notary.openapi.public + expires: 2099-09-30 +"#, + ); + assert!( + result.is_err(), + "a waiver without the required reference must fail deserialization" + ); +} + +#[test] +pub(super) fn deployment_waiver_rejects_explicit_null_summary() { + let result: Result = serde_norway::from_str( + r#" +profile: hosted_lab +waivers: + - finding: notary.openapi.public + reference: OPS-2026-0042 + summary: null + expires: 2099-09-30 +"#, + ); + assert!( + result.is_err(), + "summary must be a string when present, not null: {result:?}" + ); +} + #[test] pub(super) fn deployment_evidence_block_round_trips_through_yaml() { let mut config = minimal_config(); diff --git a/crates/registry-notary-core/src/deployment.rs b/crates/registry-notary-core/src/deployment.rs index c1310642c..8b5e6ca33 100644 --- a/crates/registry-notary-core/src/deployment.rs +++ b/crates/registry-notary-core/src/deployment.rs @@ -162,23 +162,54 @@ impl DeploymentEvidenceConfig { /// One operator-configured waiver. /// -/// A waiver names exactly one finding id, a free-text reason, and a mandatory -/// expiry date (`YYYY-MM-DD`). Reasons must not contain secrets. +/// A waiver names exactly one finding id, a required operator reference, an +/// optional summary, and a mandatory expiry date (`YYYY-MM-DD`). The shared +/// operations contract validates metadata before it can reach posture or logs. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, from = "DeploymentWaiverConfigDocument")] +#[schemars(!from)] pub struct DeploymentWaiverConfig { pub finding: String, - pub reason: String, + #[schemars(with = "crate::config::schema::DeploymentWaiverReferenceSchema")] + pub reference: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "crate::config::schema::DeploymentWaiverSummarySchema")] + pub summary: Option, pub expires: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeploymentWaiverConfigDocument { + finding: String, + reference: String, + #[serde(default)] + summary: registry_platform_ops::OptionalDeploymentWaiverSummary, + expires: String, +} + +impl From for DeploymentWaiverConfig { + fn from(value: DeploymentWaiverConfigDocument) -> Self { + Self { + finding: value.finding, + reference: value.reference, + summary: value.summary.into(), + expires: value.expires, + } + } +} + /// Errors raised while validating the deployment block at config load. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum DeploymentConfigError { #[error("deployment.waivers[{index}].finding must not be empty")] EmptyWaiverFinding { index: usize }, - #[error("deployment.waivers[{index}].reason must not be empty")] - EmptyWaiverReason { index: usize }, + #[error("deployment.waivers[{index}].{field} is invalid: {error}")] + InvalidWaiverMetadata { + index: usize, + field: &'static str, + error: registry_platform_ops::DeploymentWaiverMetadataError, + }, #[error("deployment.waivers[{index}].expires must be a YYYY-MM-DD date")] InvalidWaiverExpiry { index: usize }, #[error( @@ -194,19 +225,25 @@ pub enum DeploymentConfigError { impl DeploymentConfig { /// Validate the deployment block at config load. /// - /// This checks waiver shape (non-empty fields, parseable expiry) and the - /// hard rule that `startup_fail` and `readiness_fail` gates can never be - /// waived under the declared profile. An undeclared profile still validates - /// waiver shape here so typos are caught early; startup refusal is handled by - /// gate evaluation. + /// This checks waiver shape (shared metadata contract, parseable expiry) + /// and the hard rule that `startup_fail` and `readiness_fail` gates can + /// never be waived under the declared profile. An undeclared profile still + /// validates waiver shape here so typos are caught early; startup refusal + /// is handled by gate evaluation. pub fn validate(&self) -> Result<(), DeploymentConfigError> { for (index, waiver) in self.waivers.iter().enumerate() { if waiver.finding.trim().is_empty() { return Err(DeploymentConfigError::EmptyWaiverFinding { index }); } - if waiver.reason.trim().is_empty() { - return Err(DeploymentConfigError::EmptyWaiverReason { index }); - } + registry_platform_ops::validate_deployment_waiver_metadata( + &waiver.reference, + waiver.summary.as_deref(), + ) + .map_err(|error| DeploymentConfigError::InvalidWaiverMetadata { + index, + field: error.field(), + error, + })?; if parse_iso_date(&waiver.expires).is_none() { return Err(DeploymentConfigError::InvalidWaiverExpiry { index }); } @@ -461,7 +498,8 @@ pub struct EvaluatedFinding { #[derive(Debug, Clone, PartialEq, Eq)] pub struct EvaluatedWaiver { pub finding: String, - pub reason: String, + pub reference: String, + pub summary: Option, pub expires: String, } @@ -518,7 +556,8 @@ pub fn evaluate_gates( status: DeploymentFindingStatus::Active, waiver: Some(EvaluatedWaiver { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }), }); @@ -536,7 +575,8 @@ pub fn evaluate_gates( waived_findings.push(waiver); evaluation.active_waivers.push(EvaluatedWaiver { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }); } @@ -568,7 +608,8 @@ pub fn evaluate_gates( status: DeploymentFindingStatus::Waived, waiver: Some(EvaluatedWaiver { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }), }); @@ -627,7 +668,8 @@ mod tests { fn waiver(finding: &str, expires: &str) -> DeploymentWaiverConfig { DeploymentWaiverConfig { finding: finding.to_string(), - reason: "synthetic test waiver reason".to_string(), + reference: "OPS-TEST-DEPLOYMENT".to_string(), + summary: Some("Synthetic test waiver summary".to_string()), expires: expires.to_string(), } } @@ -834,6 +876,77 @@ mod tests { )); } + #[test] + fn validate_accepts_absent_summary_and_ordinary_metadata() { + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.reference = "OPS-2026:INC_42".to_string(); + waiver_config.summary = None; + let config = DeploymentConfig { + profile: Some(DeploymentProfile::HostedLab), + multi_instance: false, + waivers: vec![waiver_config], + evidence: DeploymentEvidenceConfig::default(), + }; + config + .validate() + .expect("ordinary reference with absent summary is valid"); + + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.summary = + Some("Public API catalog approved in the operations ticket".to_string()); + let config = DeploymentConfig { + profile: Some(DeploymentProfile::HostedLab), + multi_instance: false, + waivers: vec![waiver_config], + evidence: DeploymentEvidenceConfig::default(), + }; + config.validate().expect("ordinary short summary is valid"); + } + + #[test] + fn validate_rejects_invalid_waiver_metadata_with_field_and_limit() { + let mut cases = Vec::new(); + for reference in ["", " OPS-42", "OPS/42"] { + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.reference = reference.to_string(); + cases.push((waiver_config, "reference", "128")); + } + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.reference = "x".repeat(129); + cases.push((waiver_config, "reference", "128")); + + for summary in [ + "", + " summary", + "summary\ncontinued", + "Bearer credential-value", + "Basic credential-value", + "rotated leaked Bearer abcdef", + "-----BEGIN OPENSSH PRIVATE KEY-----", + concat!("-----BEGIN PGP PRIVATE KEY ", "BLOCK-----"), + ] { + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.summary = Some(summary.to_string()); + cases.push((waiver_config, "summary", "256")); + } + let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); + waiver_config.summary = Some("x".repeat(257)); + cases.push((waiver_config, "summary", "256")); + + for (waiver, field, limit) in cases { + let config = DeploymentConfig { + profile: Some(DeploymentProfile::HostedLab), + multi_instance: false, + waivers: vec![waiver], + evidence: DeploymentEvidenceConfig::default(), + }; + let error = config.validate().expect_err("invalid metadata rejected"); + let rendered = error.to_string(); + assert!(rendered.contains(&format!("deployment.waivers[0].{field}"))); + assert!(rendered.contains(limit), "limit missing from: {rendered}"); + } + } + #[test] fn validate_rejects_missing_or_malformed_expiry() { let config = DeploymentConfig { diff --git a/crates/registry-notary-server/src/posture.rs b/crates/registry-notary-server/src/posture.rs index 54c6b758e..fd75fbe3b 100644 --- a/crates/registry-notary-server/src/posture.rs +++ b/crates/registry-notary-server/src/posture.rs @@ -558,8 +558,9 @@ fn audit_posture( /// Render the operator-declared deployment profile, gate findings, and active /// waivers as the posture `deployment` object. The default tier strips waiver -/// reasons via the allowlist; this object carries them so the restricted tier -/// can surface them to Trust Operations. +/// metadata via the allowlist; this object carries it so the restricted tier +/// can surface the validated reference and optional summary to Trust +/// Operations. fn deployment_object(gates: &DeploymentGateState) -> Value { let mut object = Map::new(); if let Some(profile) = gates.profile { @@ -574,13 +575,7 @@ fn deployment_object(gates: &DeploymentGateState) -> Value { entry.insert("severity".to_string(), json!(finding.severity.as_str())); entry.insert("status".to_string(), json!(finding.status.as_str())); if let Some(waiver) = &finding.waiver { - entry.insert( - "waiver".to_string(), - json!({ - "reason": waiver.reason, - "expires": waiver.expires, - }), - ); + entry.insert("waiver".to_string(), evaluated_waiver_metadata_json(waiver)); } Value::Object(entry) }) @@ -589,18 +584,33 @@ fn deployment_object(gates: &DeploymentGateState) -> Value { let waivers = gates .active_waivers .iter() - .map(|waiver| { - json!({ - "finding": waiver.finding, - "reason": waiver.reason, - "expires": waiver.expires, - }) - }) + .map(evaluated_waiver_json) .collect::>(); object.insert("waivers".to_string(), Value::Array(waivers)); Value::Object(object) } +fn evaluated_waiver_metadata_json( + waiver: ®istry_notary_core::deployment::EvaluatedWaiver, +) -> Value { + let mut object = Map::new(); + object.insert("reference".to_string(), json!(waiver.reference)); + if let Some(summary) = &waiver.summary { + object.insert("summary".to_string(), json!(summary)); + } + object.insert("expires".to_string(), json!(waiver.expires)); + Value::Object(object) +} + +fn evaluated_waiver_json(waiver: ®istry_notary_core::deployment::EvaluatedWaiver) -> Value { + let mut object = evaluated_waiver_metadata_json(waiver) + .as_object() + .expect("waiver metadata renders as an object") + .clone(); + object.insert("finding".to_string(), json!(waiver.finding)); + Value::Object(object) +} + /// Render the audit assurance object: eight separate facts so "audit exists" /// cannot be overclaimed. Protected routes fail closed once a hash secret is /// configured; the keyed integrity is HMAC whenever that secret is present. diff --git a/crates/registry-notary-server/src/standalone/assembly.rs b/crates/registry-notary-server/src/standalone/assembly.rs index 4f0a86ba7..fcbd73c64 100644 --- a/crates/registry-notary-server/src/standalone/assembly.rs +++ b/crates/registry-notary-server/src/standalone/assembly.rs @@ -218,6 +218,7 @@ fn compile_notary_runtime_with_state_override( ) -> Result { config.validate()?; let deployment_gates = DeploymentGateState::evaluate_with_config_source(&config, config_source); + deployment_gates.log_boot_waivers(); deployment_gates.fail_startup_if_blocked()?; let federation_enabled = config.federation.enabled; let http_limits = NotaryHttpLimits { diff --git a/crates/registry-notary-server/src/standalone/deployment.rs b/crates/registry-notary-server/src/standalone/deployment.rs index 7928fb3bd..6b9a9a808 100644 --- a/crates/registry-notary-server/src/standalone/deployment.rs +++ b/crates/registry-notary-server/src/standalone/deployment.rs @@ -89,6 +89,36 @@ impl DeploymentGateState { }) } + /// Emit one boot warning for every active or expired waiver. Metadata has + /// already passed the shared operations validator, so logs expose only the + /// required reference, optional summary, and expiry. + pub(super) fn log_boot_waivers(&self) { + for finding in &self.findings { + let Some(waiver) = &finding.waiver else { + continue; + }; + if finding.status == DeploymentFindingStatus::Waived { + tracing::warn!( + code = "deployment.gate_waived", + finding = %finding.id, + reference = %waiver.reference, + summary = ?waiver.summary, + expires = %waiver.expires, + "deployment gate finding is suppressed by an active waiver" + ); + } else if finding.id == FINDING_WAIVER_EXPIRED { + tracing::warn!( + code = "deployment.waiver_expired", + finding = %waiver.finding, + reference = %waiver.reference, + summary = ?waiver.summary, + expires = %waiver.expires, + "deployment waiver is expired; its gate binds again" + ); + } + } + } + /// True when a profile is declared, so its gates participate in readiness. /// Runtime compilation refuses undeclared profiles before readiness is served. pub(crate) fn is_bound(&self) -> bool { diff --git a/crates/registry-notary-server/src/standalone/runtime.rs b/crates/registry-notary-server/src/standalone/runtime.rs index 44d949fc1..96bdfac99 100644 --- a/crates/registry-notary-server/src/standalone/runtime.rs +++ b/crates/registry-notary-server/src/standalone/runtime.rs @@ -24,7 +24,8 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD; use base64::Engine as _; use jsonwebtoken::Algorithm; use registry_notary_core::deployment::{ - evaluate_gates, EvaluatedFinding, EvaluatedWaiver, GateEvaluation, + evaluate_gates, DeploymentFindingStatus, EvaluatedFinding, EvaluatedWaiver, GateEvaluation, + FINDING_WAIVER_EXPIRED, }; use registry_notary_core::sd_jwt::EvidenceIssuer; use registry_notary_core::{ diff --git a/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs b/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs index 37542f123..3d9a7e090 100644 --- a/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs +++ b/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs @@ -428,6 +428,10 @@ fn assert_matches_posture_schema(body: &Value) { } async fn fetch_posture(config: StandaloneRegistryNotaryConfig) -> Value { + fetch_posture_for_tier(config, "restricted").await +} + +async fn fetch_posture_for_tier(config: StandaloneRegistryNotaryConfig, tier: &str) -> Value { let mut config = config; config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::SharedWithPublic; add_ops_read_api_key(&mut config); @@ -439,13 +443,39 @@ async fn fetch_posture(config: StandaloneRegistryNotaryConfig) -> Value { let app = notary_shared_router_from_runtime(runtime).expect("activated runtime is serve-ready"); let server = TestServer::builder().http_transport().build(app); let response = server - .get("/admin/v1/posture?tier=restricted") + .get(&format!("/admin/v1/posture?tier={tier}")) .add_header("x-api-key", "ops-token") .await; response.assert_status_ok(); response.json() } +async fn fetch_default_and_restricted_posture( + config: StandaloneRegistryNotaryConfig, +) -> (Value, Value) { + let mut config = config; + config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::SharedWithPublic; + add_ops_read_api_key(&mut config); + let runtime = compile_notary_runtime(config) + .expect("runtime compiles for posture") + .activate() + .await + .expect("source-free runtime activates"); + let app = notary_shared_router_from_runtime(runtime).expect("activated runtime is serve-ready"); + let server = TestServer::builder().http_transport().build(app); + let default = server + .get("/admin/v1/posture?tier=default") + .add_header("x-api-key", "ops-token") + .await; + default.assert_status_ok(); + let restricted = server + .get("/admin/v1/posture?tier=restricted") + .add_header("x-api-key", "ops-token") + .await; + restricted.assert_status_ok(); + (default.json(), restricted.json()) +} + fn audit_path(tmp: &tempfile::TempDir) -> String { tmp.path() .join("audit.jsonl") @@ -670,7 +700,7 @@ fn waiver_for_startup_fail_gate_is_rejected_at_load() { let config = ConfigBuilder::new(&audit_path(&tmp)) .durable_audit(false) .deployment( - "deployment:\n profile: production\n waivers:\n - finding: notary.audit.sink_missing\n reason: \"synthetic test waiver\"\n expires: 2999-01-01\n", + "deployment:\n profile: production\n waivers:\n - finding: notary.audit.sink_missing\n reference: OPS-TEST-HARD-GATE\n expires: 2999-01-01\n", ) .build(); @@ -1197,11 +1227,16 @@ async fn posture_reports_waived_finding_with_active_waiver() { let config = ConfigBuilder::new(&audit_path(&tmp)) .openapi_public(true) .deployment( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: notary.openapi.public\n reason: \"synthetic single-node lab, ticket TEST-1\"\n expires: 2999-01-01\n", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: notary.openapi.public\n reference: OPS-TEST-POSTURE\n summary: \"Synthetic single-node lab\"\n expires: 2999-01-01\n", ) .build(); - let posture = fetch_posture(config).await; + let (default, posture) = fetch_default_and_restricted_posture(config).await; + let default_rendered = default.to_string(); + assert!(!default_rendered.contains("OPS-TEST-POSTURE")); + assert!(!default_rendered.contains("Synthetic single-node lab")); + assert!(default["deployment"].get("waivers").is_none()); + assert_matches_posture_schema(&posture); let findings = posture["deployment"]["findings"] @@ -1212,17 +1247,21 @@ async fn posture_reports_waived_finding_with_active_waiver() { .find(|finding| finding["id"] == "notary.openapi.public") .expect("public OpenAPI finding is present"); assert_eq!(waived["status"], "waived"); + assert_eq!(waived["waiver"]["reference"], "OPS-TEST-POSTURE"); + assert_eq!(waived["waiver"]["summary"], "Synthetic single-node lab"); assert_eq!(waived["waiver"]["expires"], "2999-01-01"); + assert!(waived["waiver"].get("reason").is_none()); let waivers = posture["deployment"]["waivers"] .as_array() .expect("deployment waivers is an array"); - assert!( - waivers - .iter() - .any(|waiver| waiver["finding"] == "notary.openapi.public"), - "active waiver must be echoed in: {waivers:#?}" - ); + let active = waivers + .iter() + .find(|waiver| waiver["finding"] == "notary.openapi.public") + .unwrap_or_else(|| panic!("active waiver must be echoed in: {waivers:#?}")); + assert_eq!(active["reference"], "OPS-TEST-POSTURE"); + assert_eq!(active["summary"], "Synthetic single-node lab"); + assert!(active.get("reason").is_none()); } #[tokio::test] @@ -1231,7 +1270,7 @@ async fn posture_re_triggers_expired_waiver() { let config = ConfigBuilder::new(&audit_path(&tmp)) .openapi_public(true) .deployment( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: notary.openapi.public\n reason: \"synthetic expired waiver\"\n expires: 2000-01-01\n", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: notary.openapi.public\n reference: OPS-TEST-EXPIRED\n expires: 2000-01-01\n", ) .build(); @@ -1618,7 +1657,8 @@ fn retention_local_only_waiver_is_honored_under_production() { }; let waiver = DeploymentWaiverConfig { finding: FINDING_AUDIT_RETENTION_LOCAL_ONLY.to_string(), - reason: "synthetic test waiver, ticket TEST-1".to_string(), + reference: "OPS-TEST-RETENTION".to_string(), + summary: Some("Synthetic test waiver".to_string()), expires: "2999-01-01".to_string(), }; let evaluation = evaluate_gates( diff --git a/crates/registry-notary/tests/config_schema.rs b/crates/registry-notary/tests/config_schema.rs index 8643a363d..220bbedd3 100644 --- a/crates/registry-notary/tests/config_schema.rs +++ b/crates/registry-notary/tests/config_schema.rs @@ -11,6 +11,10 @@ use registry_notary_core::{ ClaimEvidenceMode, SigningKeyProviderConfig, SigningKeyStatus, StandaloneRegistryNotaryConfig, }; use registry_platform_authcommon::CredentialFingerprintProvider; +use registry_platform_ops::{ + deployment_waiver_reference_schema_fragment, deployment_waiver_summary_schema_fragment, + validate_deployment_waiver_metadata, DeploymentWaiverMetadataError, +}; use serde_json::{json, Value}; const SCHEMA_ARTIFACT: &str = "schemas/registry-notary.config.schema.json"; @@ -67,6 +71,17 @@ fn assert_runtime_rejects(instance: &Value, label: &str) { ); } +fn assert_runtime_load_rejects(instance: &Value, label: &str) { + let yaml = serde_norway::to_string(instance) + .unwrap_or_else(|error| panic!("failed to serialize {label} as YAML: {error}")); + if let Ok(config) = serde_norway::from_str::(&yaml) { + assert!( + config.validate().is_err(), + "{label} must be rejected during runtime validation" + ); + } +} + fn maintained_runtime_fixtures() -> Vec { let profiles = stack_root().join("crates/registry-relay/profiles"); let mut fixtures = fs::read_dir(profiles) @@ -85,6 +100,23 @@ fn example_config() -> Value { )) } +fn config_with_deployment_waiver(reference: &str, summary: Option<&str>) -> Value { + let mut config = example_config(); + let mut waiver = json!({ + "finding": "notary.openapi.public", + "reference": reference, + "expires": "2999-01-01" + }); + if let Some(summary) = summary { + waiver["summary"] = json!(summary); + } + config["deployment"] = json!({ + "profile": "hosted_lab", + "waivers": [waiver] + }); + config +} + fn with_authorization_details(mut config: Value) -> Value { config["auth"]["api_keys"][0]["authorization_details"] = json!({ "type": "registry_notary_authorization", @@ -297,6 +329,125 @@ fn strict_nested_objects_and_tagged_variants_match_runtime_deserialization() { assert_runtime_rejects(&unknown_signing_status, "unknown signing-key status"); } +#[test] +fn deployment_waiver_schema_rejects_retired_and_noncanonical_metadata() { + let schema = document(); + let mut config = example_config(); + config["deployment"] = json!({ + "profile": "hosted_lab", + "waivers": [{ + "finding": "notary.openapi.public", + "reference": "OPS..42", + "expires": "2999-01-01" + }] + }); + assert_invalid(&schema, &config, "waiver reference containing '..'"); + + config["deployment"]["waivers"][0]["reference"] = json!("OPS-42"); + config["deployment"]["waivers"][0]["summary"] = Value::Null; + assert_invalid(&schema, &config, "null deployment waiver summary"); + assert_runtime_load_rejects(&config, "null deployment waiver summary"); + + config["deployment"]["waivers"][0] + .as_object_mut() + .expect("waiver is an object") + .remove("summary"); + config["deployment"]["waivers"][0]["reason"] = json!("retired waiver text"); + assert_invalid(&schema, &config, "retired deployment waiver reason"); + assert_runtime_load_rejects(&config, "retired deployment waiver reason"); +} + +#[test] +fn deployment_waiver_schema_matches_shared_portable_metadata_contract() { + let schema = document(); + assert_eq!( + schema.pointer("/$defs/DeploymentWaiverReference"), + Some(&deployment_waiver_reference_schema_fragment()) + ); + assert_eq!( + schema.pointer("/$defs/DeploymentWaiverSummary"), + Some(&deployment_waiver_summary_schema_fragment()) + ); + + for reference in ["OPS-42", "Bearer:", "Authorization:Basic:"] { + validate_deployment_waiver_metadata(reference, None).unwrap_or_else(|error| { + panic!("runtime rejected valid reference {reference:?}: {error}") + }); + assert_valid( + &schema, + &config_with_deployment_waiver(reference, None), + &format!("portable deployment waiver reference {reference:?}"), + ); + } + for reference in ["Bearer:abcdef", "authorization:bAsIc:abc123", "Bearer::"] { + assert_eq!( + validate_deployment_waiver_metadata(reference, None), + Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral) + ); + assert_invalid( + &schema, + &config_with_deployment_waiver(reference, None), + &format!("credential-shaped deployment waiver reference {reference:?}"), + ); + } + + for summary in [ + "Ordinary operator summary".to_string(), + "\u{feff}summary\u{feff}".to_string(), + "summary\u{2028}continued".to_string(), + "é".repeat(256), + ] { + validate_deployment_waiver_metadata("OPS-42", Some(&summary)) + .unwrap_or_else(|error| panic!("runtime rejected valid summary {summary:?}: {error}")); + assert_valid( + &schema, + &config_with_deployment_waiver("OPS-42", Some(&summary)), + "structurally valid deployment waiver summary", + ); + } + for summary in [ + String::new(), + " summary".to_string(), + "summary\u{3000}".to_string(), + "summary\u{001f}continued".to_string(), + "é".repeat(257), + ] { + assert!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)).is_err(), + "runtime must reject structurally invalid summary {summary:?}" + ); + assert_invalid( + &schema, + &config_with_deployment_waiver("OPS-42", Some(&summary)), + "structurally invalid deployment waiver summary", + ); + } + + for summary in [ + "Authorization: "Bearer abcdef"", + concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), + concat!( + "accidentally pasted -----BEGIN PGP PRIVATE KEY ", + "BLOCK-----" + ), + ] { + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(summary)), + Err(DeploymentWaiverMetadataError::SummaryCredentialLiteral) + ); + let config = config_with_deployment_waiver("OPS-42", Some(summary)); + assert_valid( + &schema, + &config, + "contextual waiver summary left to semantic validation", + ); + assert_runtime_load_rejects( + &config, + "contextual waiver summary rejected by semantic validation", + ); + } +} + #[test] fn authorization_details_preserve_extension_compatibility_at_every_policy_level() { let schema = document(); diff --git a/crates/registry-platform-ops/fixtures/posture/default-allowlist.json b/crates/registry-platform-ops/fixtures/posture/default-allowlist.json index 12fa226e3..c4a251ebf 100644 --- a/crates/registry-platform-ops/fixtures/posture/default-allowlist.json +++ b/crates/registry-platform-ops/fixtures/posture/default-allowlist.json @@ -112,8 +112,10 @@ "/configuration/trusted_roots", "/standards_artifacts/*/url", "/relay/refresh_health", - "/deployment/findings/*/waiver/reason", - "/deployment/waivers/*/reason", + "/deployment/findings/*/waiver/reference", + "/deployment/findings/*/waiver/summary", + "/deployment/waivers/*/reference", + "/deployment/waivers/*/summary", "/notary/signing_keys", "/notary/federation/node_id", "/notary/federation/issuer", diff --git a/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json b/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json index 86883d37b..f753357fc 100644 --- a/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json +++ b/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json @@ -55,7 +55,8 @@ "severity": "finding_warn", "status": "waived", "waiver": { - "reason": "temporary maintenance window approved by operations", + "reference": "OPS-2026-0042", + "summary": "Temporary maintenance window approved by operations", "expires": "2026-07-01" } } @@ -63,7 +64,8 @@ "waivers": [ { "finding": "readiness.cache.degraded", - "reason": "temporary maintenance window approved by operations", + "reference": "OPS-2026-0042", + "summary": "Temporary maintenance window approved by operations", "expires": "2026-07-01" } ] diff --git a/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json b/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json index 96cc914a0..24a66a7cd 100644 --- a/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json +++ b/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json @@ -400,6 +400,39 @@ "waived" ] }, + "waiver_reference": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", + "not": { + "pattern": "^(?:[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]:)?(?:[Bb][Ee][Aa][Rr][Ee][Rr]|[Bb][Aa][Ss][Ii][Cc]):[A-Za-z0-9._:-]+$" + } + }, + "waiver_summary": { + "description": "Structurally valid deployment-waiver summary. Contextual authorization-value and private-key marker exclusions require semantic producer validation.", + "$comment": "Schema acceptance does not replace validate_deployment_waiver_metadata.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "allOf": [ + { + "not": { + "pattern": "[\\u0000-\\u001F\\u007F-\\u009F]" + } + }, + { + "not": { + "pattern": "^[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]" + } + }, + { + "not": { + "pattern": "[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]$" + } + } + ] + }, "deployment_finding": { "type": "object", "additionalProperties": false, @@ -427,13 +460,15 @@ "type": "object", "additionalProperties": false, "required": [ - "reason", + "reference", "expires" ], "properties": { - "reason": { - "type": "string", - "minLength": 1 + "reference": { + "$ref": "#/$defs/waiver_reference" + }, + "summary": { + "$ref": "#/$defs/waiver_summary" }, "expires": { "type": "string", @@ -447,16 +482,18 @@ "additionalProperties": false, "required": [ "finding", - "reason", + "reference", "expires" ], "properties": { "finding": { "$ref": "#/$defs/finding_id" }, - "reason": { - "type": "string", - "minLength": 1 + "reference": { + "$ref": "#/$defs/waiver_reference" + }, + "summary": { + "$ref": "#/$defs/waiver_summary" }, "expires": { "type": "string", diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index 26c90c749..18b71756b 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -25,6 +25,56 @@ use rustix::fs::{Mode, OFlags}; pub const POSTURE_SCHEMA_V1: &str = include_str!("../schemas/registry.ops.posture.v1.schema.json"); +/// Return the portable JSON Schema fragment for deployment-waiver references. +/// +/// Runtime validation remains authoritative, including the deliberate +/// acceptance of an empty suffix after a reserved authorization scheme. +#[must_use] +pub fn deployment_waiver_reference_schema_fragment() -> Value { + serde_json::json!({ + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": r"^(?!.*\.\.)[A-Za-z0-9._:-]+$", + "not": { + "pattern": r"^(?:[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]:)?(?:[Bb][Ee][Aa][Rr][Ee][Rr]|[Bb][Aa][Ss][Ii][Cc]):[A-Za-z0-9._:-]+$" + } + }) +} + +/// Return the portable structural JSON Schema fragment for waiver summaries. +/// +/// Contextual authorization-value and private-key marker exclusions require +/// [`validate_deployment_waiver_metadata`]; schema acceptance alone is not +/// sufficient semantic producer validation. +#[must_use] +pub fn deployment_waiver_summary_schema_fragment() -> Value { + serde_json::json!({ + "description": "Structurally valid deployment-waiver summary. Contextual authorization-value and private-key marker exclusions require semantic producer validation.", + "$comment": "Schema acceptance does not replace validate_deployment_waiver_metadata.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "allOf": [ + { + "not": { + "pattern": r"[\u0000-\u001F\u007F-\u009F]" + } + }, + { + "not": { + "pattern": r"^[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]" + } + }, + { + "not": { + "pattern": r"[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]$" + } + } + ] + }) +} + pub const ADMIN_ERROR_SCHEMA_V1: &str = include_str!("../schemas/registry.admin.error.v1.schema.json"); @@ -126,11 +176,41 @@ impl DeploymentFindingStatus { } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(try_from = "DeploymentFindingWaiverDocument")] pub struct DeploymentFindingWaiver { - pub reason: String, + pub reference: String, + #[serde( + default, + deserialize_with = "deserialize_optional_deployment_waiver_summary", + skip_serializing_if = "Option::is_none" + )] + pub summary: Option, pub expires: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeploymentFindingWaiverDocument { + reference: String, + #[serde(default)] + summary: OptionalDeploymentWaiverSummary, + expires: String, +} + +impl TryFrom for DeploymentFindingWaiver { + type Error = DeploymentWaiverMetadataError; + + fn try_from(value: DeploymentFindingWaiverDocument) -> Result { + let summary: Option = value.summary.into(); + validate_deployment_waiver_metadata(&value.reference, summary.as_deref())?; + Ok(Self { + reference: value.reference, + summary, + expires: value.expires, + }) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub struct DeploymentFinding { pub id: String, @@ -141,12 +221,44 @@ pub struct DeploymentFinding { } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(try_from = "DeploymentWaiverDocument")] pub struct DeploymentWaiver { pub finding: String, - pub reason: String, + pub reference: String, + #[serde( + default, + deserialize_with = "deserialize_optional_deployment_waiver_summary", + skip_serializing_if = "Option::is_none" + )] + pub summary: Option, pub expires: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeploymentWaiverDocument { + finding: String, + reference: String, + #[serde(default)] + summary: OptionalDeploymentWaiverSummary, + expires: String, +} + +impl TryFrom for DeploymentWaiver { + type Error = DeploymentWaiverMetadataError; + + fn try_from(value: DeploymentWaiverDocument) -> Result { + let summary: Option = value.summary.into(); + validate_deployment_waiver_metadata(&value.reference, summary.as_deref())?; + Ok(Self { + finding: value.finding, + reference: value.reference, + summary, + expires: value.expires, + }) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] @@ -1044,6 +1156,364 @@ pub fn is_valid_approval_reference(reference: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) } +/// Maximum UTF-8 byte length for a deployment-waiver reference. +pub const DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES: usize = 128; + +/// Maximum Unicode scalar-value count for an optional deployment-waiver summary. +pub const DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS: usize = 256; + +/// Deserialize an optional waiver summary while distinguishing omission from +/// explicit `null`. The 1.0 contract permits only an omitted field or a string; +/// products reuse this helper so their config parsers cannot drift. +pub fn deserialize_optional_deployment_waiver_summary<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + OptionalDeploymentWaiverSummary::deserialize(deserializer).map(Into::into) +} + +/// Field adapter for optional waiver summaries that rejects explicit `null`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct OptionalDeploymentWaiverSummary(Option); + +impl From for Option { + fn from(value: OptionalDeploymentWaiverSummary) -> Self { + value.0 + } +} + +impl<'de> Deserialize<'de> for OptionalDeploymentWaiverSummary { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct OptionalSummaryVisitor; + + impl<'de> serde::de::Visitor<'de> for OptionalSummaryVisitor { + type Value = OptionalDeploymentWaiverSummary; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an omitted deployment waiver summary or a non-null string") + } + + fn visit_none(self) -> Result + where + E: serde::de::Error, + { + Err(E::invalid_type(serde::de::Unexpected::Unit, &self)) + } + + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Err(E::invalid_type(serde::de::Unexpected::Unit, &self)) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer) + .map(|value| OptionalDeploymentWaiverSummary(Some(value))) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(OptionalDeploymentWaiverSummary(Some(value.to_string()))) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + Ok(OptionalDeploymentWaiverSummary(Some(value))) + } + } + + deserializer.deserialize_option(OptionalSummaryVisitor) + } +} + +/// A field-level deployment-waiver metadata validation failure. +/// +/// Waiver metadata is privileged, reviewed operator configuration, but it is +/// copied into restricted posture, boot logs, screenshots, and support +/// bundles. These narrow checks prevent accidental credential disclosure +/// without applying hostname, URL, JWT, base64, entropy, or key-name +/// heuristics that would reject ordinary operational text. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum DeploymentWaiverMetadataError { + ReferenceLength, + ReferenceNotCanonical, + ReferenceInvalidSyntax, + ReferenceCredentialLiteral, + SummaryLength, + SummaryNotCanonical, + SummaryControlCharacter, + SummaryCredentialLiteral, +} + +impl DeploymentWaiverMetadataError { + pub const fn field(self) -> &'static str { + match self { + Self::ReferenceLength + | Self::ReferenceNotCanonical + | Self::ReferenceInvalidSyntax + | Self::ReferenceCredentialLiteral => "reference", + Self::SummaryLength + | Self::SummaryNotCanonical + | Self::SummaryControlCharacter + | Self::SummaryCredentialLiteral => "summary", + } + } +} + +impl Display for DeploymentWaiverMetadataError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ReferenceLength => write!( + formatter, + "reference must be 1..={DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES} bytes" + ), + Self::ReferenceNotCanonical => write!( + formatter, + "reference must be 1..={DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES} bytes and already canonical without surrounding whitespace" + ), + Self::ReferenceInvalidSyntax => write!( + formatter, + "reference must be 1..={DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES} bytes and use only [A-Za-z0-9._:-] without '..'" + ), + Self::ReferenceCredentialLiteral => write!( + formatter, + "reference must be 1..={DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES} bytes and not encode a Bearer or Basic authorization value" + ), + Self::SummaryLength => write!( + formatter, + "summary must be 1..={DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS} characters when present" + ), + Self::SummaryNotCanonical => write!( + formatter, + "summary must be 1..={DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS} characters and already trimmed when present" + ), + Self::SummaryControlCharacter => write!( + formatter, + "summary must be 1..={DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS} characters and contain no control characters when present" + ), + Self::SummaryCredentialLiteral => write!( + formatter, + "summary must be 1..={DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS} characters and contain no authorization value or private-key begin marker when present" + ), + } + } +} + +impl std::error::Error for DeploymentWaiverMetadataError {} + +/// Validate the cross-product deployment-waiver metadata contract. +pub fn validate_deployment_waiver_metadata( + reference: &str, + summary: Option<&str>, +) -> Result<(), DeploymentWaiverMetadataError> { + if reference.is_empty() || reference.len() > DEPLOYMENT_WAIVER_REFERENCE_MAX_BYTES { + return Err(DeploymentWaiverMetadataError::ReferenceLength); + } + if reference.trim() != reference { + return Err(DeploymentWaiverMetadataError::ReferenceNotCanonical); + } + if !is_valid_approval_reference(reference) { + return Err(DeploymentWaiverMetadataError::ReferenceInvalidSyntax); + } + if contains_reference_authorization_value(reference) { + return Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral); + } + + let Some(summary) = summary else { + return Ok(()); + }; + if summary.is_empty() || summary.chars().count() > DEPLOYMENT_WAIVER_SUMMARY_MAX_CHARS { + return Err(DeploymentWaiverMetadataError::SummaryLength); + } + if summary.trim() != summary { + return Err(DeploymentWaiverMetadataError::SummaryNotCanonical); + } + if summary.chars().any(char::is_control) { + return Err(DeploymentWaiverMetadataError::SummaryControlCharacter); + } + if contains_high_confidence_credential_literal(summary) { + return Err(DeploymentWaiverMetadataError::SummaryCredentialLiteral); + } + Ok(()) +} + +const AUTHORIZATION_SCHEMES: [&str; 2] = ["Bearer", "Basic"]; + +fn contains_reference_authorization_value(reference: &str) -> bool { + // References are opaque identifiers, not prose. An exact scheme prefix + // plus a nonempty value is therefore a high-confidence pasted credential. + let reference = strip_ascii_case_prefix(reference, "Authorization:").unwrap_or(reference); + AUTHORIZATION_SCHEMES.iter().any(|scheme| { + reference + .get(..scheme.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(scheme)) + && reference.as_bytes().get(scheme.len()) == Some(&b':') + && reference.len() > scheme.len() + 1 + }) +} + +fn strip_ascii_case_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + value + .get(..prefix.len()) + .filter(|candidate| candidate.eq_ignore_ascii_case(prefix)) + .and_then(|_| value.get(prefix.len()..)) +} + +fn contains_high_confidence_credential_literal(summary: &str) -> bool { + AUTHORIZATION_SCHEMES + .iter() + .any(|scheme| contains_authorization_value(summary, scheme)) + || summary.split("-----BEGIN ").skip(1).any(|suffix| { + suffix.split_once("-----").is_some_and(|(label, _)| { + label == "PRIVATE KEY" + || label.ends_with(" PRIVATE KEY") + || label == "PGP PRIVATE KEY BLOCK" + }) + }) +} + +fn contains_authorization_value(summary: &str, scheme: &str) -> bool { + let mut value_follows = false; + let mut authorization_context = false; + let mut authorization_colon_follows = false; + for word in summary.split_whitespace() { + if value_follows { + if is_authorization_separator_token(word) { + continue; + } + value_follows = false; + if is_credential_like_authorization_value(word) { + return true; + } + } + + if is_authorization_separator_token(word) { + if authorization_colon_follows { + authorization_context = word.chars().any(is_authorization_colon); + authorization_colon_follows = false; + } + continue; + } + + let authorization_label = authorization_value_in_word(word, "Authorization"); + if let Some(label) = authorization_label { + authorization_context = label.has_colon; + authorization_colon_follows = !label.has_colon; + } else { + authorization_colon_follows = false; + } + + let Some(authorization_value) = authorization_value_in_word(word, scheme) else { + if authorization_label.is_none() { + authorization_context = false; + } + continue; + }; + if authorization_value.standalone_wrapped && !authorization_context { + authorization_context = false; + continue; + } + + authorization_context = false; + if authorization_value.value.is_empty() { + value_follows = true; + } else if is_credential_like_authorization_value(authorization_value.value) { + return true; + } + } + false +} + +#[derive(Clone, Copy)] +struct AuthorizationValue<'a> { + value: &'a str, + has_colon: bool, + standalone_wrapped: bool, +} + +fn authorization_value_in_word<'a>(word: &'a str, scheme: &str) -> Option> { + let mut segment_start = 0; + for (segment_end, delimiter_len) in word + .char_indices() + .filter_map(|(index, character)| { + is_authorization_colon(character).then_some((index, character.len_utf8())) + }) + .chain(std::iter::once((word.len(), 0))) + { + let segment = word.get(segment_start..segment_end)?; + // Normalize only boundaries around ASCII auth tokens. This covers + // Unicode punctuation without changing internal characters or + // maintaining a partial list of quote and bracket code points. + let normalized_scheme = segment.trim_matches(is_authorization_boundary); + if normalized_scheme.eq_ignore_ascii_case(scheme) { + let has_colon = delimiter_len != 0; + let standalone_wrapped = normalized_scheme.len() < segment.len() + && segment.starts_with(is_authorization_boundary) + && segment.ends_with(is_authorization_boundary); + let value = if has_colon { + word.get(segment_end + delimiter_len..)? + .trim_start_matches(is_authorization_colon) + } else { + "" + }; + return Some(AuthorizationValue { + value, + has_colon, + standalone_wrapped, + }); + } + segment_start = segment_end + delimiter_len; + } + None +} + +fn is_credential_like_authorization_value(candidate: &str) -> bool { + let prose_word = candidate.trim_matches(is_authorization_boundary); + !prose_word.is_empty() + && ![ + "access", + "auth", + "authentication", + "authorization", + "credential", + "credentials", + "header", + "scheme", + "token", + "tokens", + "value", + "values", + ] + .iter() + .any(|word| prose_word.eq_ignore_ascii_case(word)) +} + +fn is_authorization_separator_token(candidate: &str) -> bool { + !candidate.is_empty() && candidate.chars().all(is_authorization_boundary) +} + +fn is_authorization_colon(character: char) -> bool { + matches!(character, ':' | '\u{fe55}' | '\u{ff1a}') +} + +fn is_authorization_boundary(character: char) -> bool { + !character.is_alphanumeric() && !character.is_whitespace() && !character.is_control() +} + #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub struct BreakGlassApproval { pub approved_by: String, @@ -3464,6 +3934,298 @@ mod tests { assert!(!is_valid_approval_reference("ctrl\nchar")); } + #[test] + fn deployment_waiver_metadata_accepts_reference_and_optional_summary() { + validate_deployment_waiver_metadata("OPS-2026:INC_42", None) + .expect("reference without summary is valid"); + for reference in ["OPS-BEARER-42", "BasicAuth:OPS-42", "Bearer-rotation:42"] { + validate_deployment_waiver_metadata(reference, None).unwrap_or_else(|error| { + panic!("ordinary operator reference rejected: {reference:?}: {error}") + }); + } + validate_deployment_waiver_metadata( + "OPS-2026:INC_42", + Some("Gateway rate limiting is tracked in the operations ticket"), + ) + .expect("ordinary short summary is valid"); + } + + #[test] + fn deployment_waiver_metadata_rejects_invalid_or_overlong_reference() { + assert_eq!( + validate_deployment_waiver_metadata("", None), + Err(DeploymentWaiverMetadataError::ReferenceLength) + ); + assert_eq!( + validate_deployment_waiver_metadata(" OPS-42", None), + Err(DeploymentWaiverMetadataError::ReferenceNotCanonical) + ); + assert_eq!( + validate_deployment_waiver_metadata("OPS/42", None), + Err(DeploymentWaiverMetadataError::ReferenceInvalidSyntax) + ); + assert_eq!( + validate_deployment_waiver_metadata("OPS..42", None), + Err(DeploymentWaiverMetadataError::ReferenceInvalidSyntax) + ); + assert_eq!( + validate_deployment_waiver_metadata(&"x".repeat(129), None), + Err(DeploymentWaiverMetadataError::ReferenceLength) + ); + for reference in [ + "Bearer:abcdef", + "bearer:abc123", + "Basic:abc123", + "bAsIc:credential-value", + "Authorization:Bearer:abc123", + "authorization:bAsIc:abc123", + ] { + assert_eq!( + validate_deployment_waiver_metadata(reference, None), + Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral), + "credential-like reference must be rejected without echoing it: {reference:?}" + ); + } + } + + #[test] + fn deployment_waiver_metadata_rejects_invalid_summary() { + for (summary, expected) in [ + ("", DeploymentWaiverMetadataError::SummaryLength), + ( + " summary", + DeploymentWaiverMetadataError::SummaryNotCanonical, + ), + ( + "summary\ncontinued", + DeploymentWaiverMetadataError::SummaryControlCharacter, + ), + ( + "Bearer credential-value", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Basic credential-value", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "bearer credential-value", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "basic credential-value", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "rotated leaked Bearer abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "copied Basic Zm9vOmJhcg==", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "rotated leaked bearer abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "rotated leaked Bearer abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "copied Basic Zm9vOmJhcg== before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "rotated leaked bearer abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization:Bearer abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer: abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization:Basic Zm9vOmJhcg== before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: (bearer): abcdef after config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer:abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer:token:abcdef before config apply", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: “Bearer abcdef”", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: "Bearer abcdef"", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: 「Basic Zm9vOmJhcg==」", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: “Bearer” abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization : «Basic» Zm9vOmJhcg==", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: “Bearer”: supported by the gateway", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: «Basic»: enabled", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: (Bearer): API authentication mode", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer : abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Authorization: «Basic Zm9vOmJhcg==»", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer … abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer : abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + "Bearer:abcdef", + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + concat!( + "accidentally pasted -----BEGIN OPENSSH PRIVATE ", + "KEY-----" + ), + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + concat!( + "accidentally pasted -----BEGIN ED25519 PRIVATE ", + "KEY-----" + ), + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ( + concat!( + "accidentally pasted -----BEGIN PGP PRIVATE KEY ", + "BLOCK-----" + ), + DeploymentWaiverMetadataError::SummaryCredentialLiteral, + ), + ] { + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(summary)), + Err(expected), + "summary must be rejected without echoing it: {summary:?}" + ); + } + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(&"x".repeat(257))), + Err(DeploymentWaiverMetadataError::SummaryLength) + ); + validate_deployment_waiver_metadata("OPS-42", Some(&"é".repeat(256))) + .expect("summary limit counts Unicode characters"); + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(&"é".repeat(257))), + Err(DeploymentWaiverMetadataError::SummaryLength), + "summary limit is measured in Unicode characters" + ); + } + + #[test] + fn deployment_waiver_summary_does_not_use_broad_secret_heuristics() { + for summary in [ + "Review URL https://ops.example.test/tickets/42", + "JWT validation follow-up", + "base64 migration review", + "token_name configuration review", + "Bearer credential handling review", + "Basic authentication migration review", + "bearer credential handling review", + "basic authentication migration review", + "Rotated Bearer token handling review", + "Migrated Basic credential storage", + "Bearer credential, handling review before config apply", + "Basic authentication: migration review after config apply", + "Bearer access token handling review", + "Basic auth scheme migration review", + "Bearer header value handling review", + "Authorization: Bearer token handling review", + "Bearer: token handling review", + "Authorization:Basic authentication migration review", + "Bearer-based authentication review", + "Basic-auth migration review", + "Authorization: “Bearer token handling review”", + "Bearer : token handling review", + "Bearer … token handling review", + "“Bearer-based” authentication review", + "Basic:authentication migration review", + "Use “Bearer” for API authentication", + "Document «Basic» for operators", + "Compare "Bearer" and 「Basic」 schemes", + "Document “Bearer”: supported by the gateway", + "Compare «Basic»: enabled", + "Use (Bearer): API authentication mode", + ] { + validate_deployment_waiver_metadata("OPS-42", Some(summary)) + .unwrap_or_else(|error| panic!("ordinary summary rejected: {summary:?}: {error}")); + } + } + + #[test] + fn deployment_waiver_summary_deserializer_rejects_explicit_null() { + #[derive(Deserialize)] + struct Fixture { + #[serde( + default, + deserialize_with = "deserialize_optional_deployment_waiver_summary" + )] + summary: Option, + } + + let omitted: Fixture = + serde_json::from_str(r#"{}"#).expect("omitted optional summary is accepted"); + assert_eq!(omitted.summary, None); + + let present: Fixture = serde_json::from_str(r#"{"summary":"Approved in OPS-42"}"#) + .expect("string summary is accepted"); + assert_eq!(present.summary.as_deref(), Some("Approved in OPS-42")); + + assert!( + serde_json::from_str::(r#"{"summary":null}"#).is_err(), + "explicit null must not be accepted as an omitted summary" + ); + } + #[test] fn bundle_verify_rejection_result_classifies_integrity_failures_as_signature() { assert_eq!( diff --git a/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs b/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs index 068fce006..7e02a3ce3 100644 --- a/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs +++ b/crates/registry-platform-ops/tests/posture_profile_gates_contract.rs @@ -1,8 +1,10 @@ use registry_platform_ops::{ - AuditAnchoring, AuditAssurance, AuditCheckpoints, AuditHashChain, AuditKeyedIntegrity, - AuditRedactionMode, AuditRetentionOwner, AuditSinkClass, AuditWritePolicy, DeploymentFinding, - DeploymentFindingStatus, DeploymentFindingWaiver, DeploymentProfile, DeploymentWaiver, - GateSeverity, POSTURE_SCHEMA_V1, + deployment_waiver_reference_schema_fragment, deployment_waiver_summary_schema_fragment, + validate_deployment_waiver_metadata, AuditAnchoring, AuditAssurance, AuditCheckpoints, + AuditHashChain, AuditKeyedIntegrity, AuditRedactionMode, AuditRetentionOwner, AuditSinkClass, + AuditWritePolicy, DeploymentFinding, DeploymentFindingStatus, DeploymentFindingWaiver, + DeploymentProfile, DeploymentWaiver, DeploymentWaiverMetadataError, GateSeverity, + POSTURE_SCHEMA_V1, }; use serde::de::DeserializeOwned; use serde::Serialize; @@ -57,7 +59,8 @@ fn posture_with_profile_gates() -> Value { "severity": "finding_warn", "status": "waived", "waiver": { - "reason": "temporary maintenance window", + "reference": "OPS-2026-0042", + "summary": "Temporary maintenance window", "expires": "2026-07-01" } } @@ -65,7 +68,8 @@ fn posture_with_profile_gates() -> Value { "waivers": [ { "finding": "readiness.cache.degraded", - "reason": "temporary maintenance window", + "reference": "OPS-2026-0042", + "summary": "Temporary maintenance window", "expires": "2026-07-01" } ] @@ -83,6 +87,18 @@ fn posture_with_profile_gates() -> Value { posture } +const WAIVER_POINTERS: [&str; 2] = ["/deployment/findings/1/waiver", "/deployment/waivers/0"]; + +fn posture_with_waiver_field(waiver_pointer: &str, field: &str, value: Value) -> Value { + let mut posture = posture_with_profile_gates(); + posture + .pointer_mut(waiver_pointer) + .and_then(Value::as_object_mut) + .unwrap_or_else(|| panic!("waiver object exists at {waiver_pointer}")) + .insert(field.to_string(), value); + posture +} + #[test] fn posture_schema_accepts_profile_gates_and_undeclared_profile() { let validator = posture_validator(); @@ -92,6 +108,177 @@ fn posture_schema_accepts_profile_gates_and_undeclared_profile() { assert_valid(&validator, &undeclared); } +#[test] +fn posture_waiver_definitions_share_structural_metadata_contracts() { + let schema = parse(POSTURE_SCHEMA_V1); + assert_eq!( + schema.pointer("/$defs/waiver_reference"), + Some(&deployment_waiver_reference_schema_fragment()) + ); + assert_eq!( + schema.pointer("/$defs/waiver_summary"), + Some(&deployment_waiver_summary_schema_fragment()) + ); + for waiver_definition in ["deployment_finding_waiver", "deployment_waiver"] { + assert_eq!( + schema.pointer(&format!( + "/$defs/{waiver_definition}/properties/reference/$ref" + )), + Some(&json!("#/$defs/waiver_reference")) + ); + assert_eq!( + schema.pointer(&format!( + "/$defs/{waiver_definition}/properties/summary/$ref" + )), + Some(&json!("#/$defs/waiver_summary")) + ); + } +} + +#[test] +fn posture_schema_waiver_reference_matches_runtime_prefix_contract() { + let validator = posture_validator(); + + for reference in [ + "OPS-42", + "Bearer:", + "Basic:", + "Authorization:Bearer:", + "authorization:bAsIc:", + ] { + validate_deployment_waiver_metadata(reference, None).unwrap_or_else(|error| { + panic!("runtime rejected valid reference {reference:?}: {error}") + }); + for waiver_pointer in WAIVER_POINTERS { + assert_valid( + &validator, + &posture_with_waiver_field(waiver_pointer, "reference", json!(reference)), + ); + } + } + + for reference in [ + "Bearer:abcdef", + "bAsIc:credential-value", + "Authorization:Bearer:abc123", + "authorization:bAsIc:abc123", + "Bearer::", + ] { + assert_eq!( + validate_deployment_waiver_metadata(reference, None), + Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral), + "runtime must reject credential-shaped reference {reference:?}" + ); + for waiver_pointer in WAIVER_POINTERS { + assert_invalid( + &validator, + &posture_with_waiver_field(waiver_pointer, "reference", json!(reference)), + ); + } + } +} + +#[test] +fn posture_schema_waiver_summary_matches_runtime_structural_contract() { + let validator = posture_validator(); + + for summary in [ + "Ordinary operator summary".to_string(), + "\u{feff}summary\u{feff}".to_string(), + "summary\u{2028}continued".to_string(), + "é".repeat(256), + ] { + validate_deployment_waiver_metadata("OPS-42", Some(&summary)) + .unwrap_or_else(|error| panic!("runtime rejected valid summary {summary:?}: {error}")); + for waiver_pointer in WAIVER_POINTERS { + assert_valid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } + + for summary in [String::new(), "é".repeat(257)] { + assert!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)).is_err(), + "runtime must reject structurally invalid summary {summary:?}" + ); + for waiver_pointer in WAIVER_POINTERS { + assert_invalid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } + + for whitespace in (0..=char::MAX as u32) + .filter_map(char::from_u32) + .filter(|character| character.is_whitespace()) + { + for summary in [ + format!("{whitespace}summary"), + format!("summary{whitespace}"), + ] { + assert!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)).is_err(), + "runtime must reject edge whitespace U+{:04X}", + whitespace as u32 + ); + for waiver_pointer in WAIVER_POINTERS { + assert_invalid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } + } + + for value in (0..=0x1f).chain(0x7f..=0x9f) { + let control = char::from_u32(value).expect("C0/C1 value is a Unicode scalar"); + assert!(control.is_control()); + let summary = format!("summary{control}continued"); + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)), + Err(DeploymentWaiverMetadataError::SummaryControlCharacter), + "runtime must reject control U+{value:04X}" + ); + for waiver_pointer in WAIVER_POINTERS { + assert_invalid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } +} + +#[test] +fn posture_schema_summary_acceptance_does_not_replace_semantic_validation() { + let validator = posture_validator(); + + // JSON Schema stops at portable structural rules. Producers must also use + // validate_deployment_waiver_metadata for contextual credential and + // private-key marker rejection before emitting either waiver shape. + for summary in [ + "Authorization: "Bearer abcdef"", + concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), + concat!( + "accidentally pasted -----BEGIN PGP PRIVATE KEY ", + "BLOCK-----" + ), + ] { + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(summary)), + Err(DeploymentWaiverMetadataError::SummaryCredentialLiteral) + ); + for waiver_pointer in WAIVER_POINTERS { + assert_valid( + &validator, + &posture_with_waiver_field(waiver_pointer, "summary", json!(summary)), + ); + } + } +} + #[test] fn posture_schema_rejects_unknown_profile_or_severity_and_missing_waiver_expiry() { let validator = posture_validator(); @@ -143,7 +330,8 @@ fn posture_profile_gate_structs_round_trip_and_accept_unknown_finding_ids() { severity: GateSeverity::FindingWarn, status: DeploymentFindingStatus::Waived, waiver: Some(DeploymentFindingWaiver { - reason: "operator approved temporary exception".to_string(), + reference: "OPS-2026-0042".to_string(), + summary: Some("Operator approved temporary exception".to_string()), expires: "2026-07-01".to_string(), }), }, @@ -152,7 +340,8 @@ fn posture_profile_gate_structs_round_trip_and_accept_unknown_finding_ids() { "severity": "finding_warn", "status": "waived", "waiver": { - "reason": "operator approved temporary exception", + "reference": "OPS-2026-0042", + "summary": "Operator approved temporary exception", "expires": "2026-07-01" } }), @@ -161,17 +350,92 @@ fn posture_profile_gate_structs_round_trip_and_accept_unknown_finding_ids() { round_trip( DeploymentWaiver { finding: "future.product.finding".to_string(), - reason: "operator approved temporary exception".to_string(), + reference: "OPS-2026-0042".to_string(), + summary: None, expires: "2026-07-01".to_string(), }, json!({ "finding": "future.product.finding", - "reason": "operator approved temporary exception", + "reference": "OPS-2026-0042", "expires": "2026-07-01" }), ); } +#[test] +fn posture_waiver_structs_reject_explicit_null_summary() { + let finding_waiver = json!({ + "reference": "OPS-2026-0042", + "summary": null, + "expires": "2026-07-01" + }); + assert!( + serde_json::from_value::(finding_waiver).is_err(), + "finding waiver summary must be omitted rather than null" + ); + + let waiver = json!({ + "finding": "future.product.finding", + "reference": "OPS-2026-0042", + "summary": null, + "expires": "2026-07-01" + }); + assert!( + serde_json::from_value::(waiver).is_err(), + "deployment waiver summary must be omitted rather than null" + ); +} + +#[test] +fn posture_waiver_structs_reject_retired_or_invalid_metadata() { + for invalid in [ + json!({ + "finding": "future.product.finding", + "reference": "OPS-42", + "reason": "retired waiver text", + "expires": "2026-07-01" + }), + json!({ + "finding": "future.product.finding", + "reference": "OPS..42", + "expires": "2026-07-01" + }), + json!({ + "finding": "future.product.finding", + "reference": "OPS-42", + "summary": "bearer credential-value", + "expires": "2026-07-01" + }), + ] { + assert!( + serde_json::from_value::(invalid).is_err(), + "shared deployment waiver deserialization must enforce the closed metadata contract" + ); + } + + for invalid in [ + json!({ + "reference": "OPS-42", + "reason": "retired waiver text", + "expires": "2026-07-01" + }), + json!({ + "reference": "OPS..42", + "expires": "2026-07-01" + }), + json!({ + "reference": "OPS-42", + "summary": "summary\nwith control", + "expires": "2026-07-01" + }), + ] { + assert!( + serde_json::from_value::(invalid).is_err(), + "shared finding waiver deserialization must enforce the closed metadata contract" + ); + } +} + #[test] fn audit_assurance_types_round_trip() { round_trip( @@ -224,7 +488,7 @@ fn audit_assurance_types_round_trip() { } #[test] -fn default_filter_drops_waiver_reasons_from_profile_gate_fields() { +fn default_filter_drops_waiver_metadata_from_profile_gate_fields() { let filtered = registry_platform_ops::filter_posture_for_tier( posture_with_profile_gates(), registry_platform_ops::PostureTier::Default, @@ -244,5 +508,6 @@ fn default_filter_drops_waiver_reasons_from_profile_gate_fields() { .expect("deployment is object") .get("waivers") .is_none()); - assert!(!rendered.contains("temporary maintenance window")); + assert!(!rendered.contains("OPS-2026-0042")); + assert!(!rendered.contains("Temporary maintenance window")); } diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index 1f4172efd..fe10ead06 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- BREAKING: Replace deployment-waiver `reason` with a required validated + `reference` and an optional validated `summary`. Strict configuration parsing + rejects the retired field. Restricted posture and boot warnings expose only + the new metadata, default posture continues to omit waiver metadata, and + waived-gate audit records remain minimized to finding IDs. Expiry and + non-waivable startup/readiness gate behavior are unchanged. + ## 0.12.2 - 2026-07-20 ### Security diff --git a/crates/registry-relay/docs/configuration.md b/crates/registry-relay/docs/configuration.md index eaf0ffcee..7487d10b4 100644 --- a/crates/registry-relay/docs/configuration.md +++ b/crates/registry-relay/docs/configuration.md @@ -536,7 +536,8 @@ deployment.waivers deployment.waivers[] deployment.waivers[].expires deployment.waivers[].finding -deployment.waivers[].reason +deployment.waivers[].reference +deployment.waivers[].summary instance instance.environment instance.id @@ -1274,7 +1275,8 @@ deployment: audit_ack_max_age_secs: 900 # how old acked_at may get before the cursor reads as stale waivers: - finding: relay.openapi.public - reason: public API catalog is intentional for this deployment + reference: OPS-2026-0042 + summary: Public API catalog is intentional for this deployment expires: 2026-12-31 ``` @@ -1298,20 +1300,38 @@ Some controls live outside the relay and cannot be observed by the process (for ### Waivers -A triggered, waivable finding can be suppressed by a waiver that names the finding id, carries a free-text reason, and a mandatory expiry date (`YYYY-MM-DD`): +A triggered, waivable finding can be suppressed by a waiver that names the finding id, carries a required operator reference, and sets a mandatory expiry date (`YYYY-MM-DD`). An optional summary can add short operational context: ```yaml deployment: profile: hosted_lab waivers: - finding: relay.ingress.rate_limit_missing - reason: rate limiting is handled by the lab gateway + reference: OPS-2026-0042 + summary: Rate limiting is handled by the lab gateway expires: 2026-09-30 ``` -A waived finding reports status `waived` instead of its severity effect. Once the expiry date passes, the waiver stops suppressing the finding and the posture additionally raises `deployment.waiver_expired`. The expiry date is mandatory; reasons must be non-empty and must not contain secrets. A waiver naming a hard gate (`startup_fail` or `readiness_fail` severity under the active profile) fails config load instead of being silently accepted and ignored: there is no config-level override for a non-waivable gate. - -Waiver reasons are only visible in the restricted posture tier; the default tier reports finding id, severity, and status but not the reason. +A waived finding reports status `waived` instead of its severity effect. +Once the expiry date passes, the waiver stops suppressing the finding and the posture additionally +raises `deployment.waiver_expired`. +The `reference` is 1 to 128 bytes, has no surrounding whitespace, uses only letters, digits, `.`, +`_`, `:`, and `-`, and cannot contain `..`. +References cannot start, case-insensitively, with `Bearer:` or `Basic:`, directly or +after `Authorization:`. +Use a ticket-style reference such as `OPS-2026-0042`. +The optional `summary` is 1 to 256 Unicode characters when present, is already trimmed, contains +no control characters, and cannot be an authorization value or contain a private-key begin +marker. +Omit `summary` when it is not needed; explicit `null` is invalid. +Keep credentials and private keys out of both fields. +These rules implement +[RS-OP-POSTURE](https://docs.registrystack.org/spec/rs-op-posture/) (REQ-OP-POSTURE-011). +A waiver naming a hard gate (`startup_fail` or `readiness_fail` severity under the active profile) +fails config load instead of being silently accepted and ignored: there is no config-level +override for a non-waivable gate. + +Waiver references and summaries are visible only in the restricted posture tier; the default tier reports finding id, severity, and status without the per-finding waiver object or deployment waivers array. ### Findings catalog @@ -1337,7 +1357,7 @@ The current deployment profile, its findings, and active waivers are reported un ### Boot-time visibility -Reduced posture is loud at boot, not only visible on the posture surface. Every config load warns once per waiver-suppressed finding (`deployment.gate_waived`, with the finding id, reason, and expiry), once per expired waiver (`deployment.waiver_expired`), and once when the profile is undeclared (`deployment.profile_undeclared`). The serve path additionally writes one operational audit record per waived gate at boot, once the audit pipeline exists: event `deployment.gate_waived` at audit path `/__events/deployment.gate_waived`, with `error_code` set to the gate id. +Reduced posture is loud at boot, not only visible on the posture surface. Every config load warns once per waiver-suppressed finding (`deployment.gate_waived`, with the finding id, reference, optional summary, and expiry), once per expired waiver (`deployment.waiver_expired`), and once when the profile is undeclared (`deployment.profile_undeclared`). The serve path additionally writes one operational audit record per waived gate at boot, once the audit pipeline exists: event `deployment.gate_waived` at audit path `/__events/deployment.gate_waived`, with `error_code` set to the gate id. That minimized audit record does not copy waiver metadata. This boot-time audit write inherits `audit.write_policy` (see below). Under `fail_closed` (the default), a failed write aborts startup. Under `availability_first`, the failure is logged (`audit.operational_event_write_failed`) and startup continues, so the durable record is best-effort; the per-gate boot log warnings above remain the guaranteed floor. diff --git a/crates/registry-relay/src/api/admin.rs b/crates/registry-relay/src/api/admin.rs index b89b627d2..fdd05c0da 100644 --- a/crates/registry-relay/src/api/admin.rs +++ b/crates/registry-relay/src/api/admin.rs @@ -578,7 +578,7 @@ fn audit_shipping_state(config: &Config) -> (bool, &'static str) { /// Build the `deployment` posture object: declared profile, gate findings, and /// active waivers. Findings carry only `{id, severity, status}` plus an /// optional waiver block; the default-tier posture filter strips waiver -/// reasons. `findings` and `waivers` are always present (possibly empty). +/// metadata. `findings` and `waivers` are always present (possibly empty). fn deployment_summary_with_observation( config: &Config, config_source: ConfigSource, @@ -613,11 +613,7 @@ fn deployment_summary_with_observation( json!(evaluation .active_waivers .iter() - .map(|waiver| json!({ - "finding": waiver.finding, - "reason": waiver.reason, - "expires": waiver.expires, - })) + .map(deployment_waiver_json) .collect::>()), ); (Value::Object(summary), !evaluation.has_readiness_failure()) @@ -653,14 +649,31 @@ fn deployment_finding_json(finding: ®istry_platform_ops::DeploymentFinding) - object.insert("severity".to_string(), json!(finding.severity.as_str())); object.insert("status".to_string(), json!(finding.status.as_str())); if let Some(waiver) = &finding.waiver { - object.insert( - "waiver".to_string(), - json!({ - "reason": waiver.reason, - "expires": waiver.expires, - }), - ); + object.insert("waiver".to_string(), deployment_finding_waiver_json(waiver)); + } + Value::Object(object) +} + +fn deployment_finding_waiver_json( + waiver: ®istry_platform_ops::DeploymentFindingWaiver, +) -> Value { + let mut object = Map::new(); + object.insert("reference".to_string(), json!(waiver.reference)); + if let Some(summary) = &waiver.summary { + object.insert("summary".to_string(), json!(summary)); } + object.insert("expires".to_string(), json!(waiver.expires)); + Value::Object(object) +} + +fn deployment_waiver_json(waiver: ®istry_platform_ops::DeploymentWaiver) -> Value { + let mut object = Map::new(); + object.insert("finding".to_string(), json!(waiver.finding)); + object.insert("reference".to_string(), json!(waiver.reference)); + if let Some(summary) = &waiver.summary { + object.insert("summary".to_string(), json!(summary)); + } + object.insert("expires".to_string(), json!(waiver.expires)); Value::Object(object) } @@ -1176,18 +1189,18 @@ datasets: [] } /// The default-tier allowlist exposes only finding id/severity/status; the - /// whole deployment `waivers` block (finding, reason, expires) is dropped. - /// The restricted tier returns the unfiltered document, so the waiver and - /// its reason appear there. This pins the allowlist contract for the new - /// deployment block, including that synthetic waiver reasons never leak at - /// the default tier. + /// whole deployment `waivers` block is dropped. The restricted tier + /// returns the unfiltered document, so the waiver reference and optional + /// summary appear there. This pins the allowlist contract, including that + /// synthetic waiver metadata never leaks at the default tier. #[test] fn posture_default_tier_drops_waivers_restricted_keeps_them() { let mut config = parse_minimal_config(&minimal_config_yaml()); config.deployment.profile = Some(DeploymentProfile::HostedLab); config.deployment.waivers = vec![crate::config::DeploymentWaiverConfig { finding: "relay.config.unsigned".to_string(), - reason: "synthetic-waiver-reason-not-a-secret".to_string(), + reference: "OPS-TEST-1188".to_string(), + summary: Some("Synthetic waiver summary".to_string()), expires: "2999-01-01".to_string(), }]; @@ -1211,11 +1224,15 @@ datasets: [] ); let serialized = default_tier.to_string(); assert!( - !serialized.contains("synthetic-waiver-reason-not-a-secret"), - "default-tier posture must never leak a waiver reason" + !serialized.contains("OPS-TEST-1188"), + "default-tier posture must never leak a waiver reference" + ); + assert!( + !serialized.contains("Synthetic waiver summary"), + "default-tier posture must never leak a waiver summary" ); - // Restricted tier: full document, waiver and reason present. + // Restricted tier: full document and validated waiver metadata present. let restricted = build_posture( &config, None, @@ -1230,10 +1247,8 @@ datasets: [] assert_eq!(restricted_waivers.len(), 1); assert_eq!(restricted_waivers[0]["finding"], "relay.config.unsigned"); assert_eq!(restricted_waivers[0]["expires"], "2999-01-01"); - assert_eq!( - restricted_waivers[0]["reason"], - "synthetic-waiver-reason-not-a-secret" - ); + assert_eq!(restricted_waivers[0]["reference"], "OPS-TEST-1188"); + assert_eq!(restricted_waivers[0]["summary"], "Synthetic waiver summary"); } /// Default-config posture regression: the gate train adds exactly the diff --git a/crates/registry-relay/src/config/mod.rs b/crates/registry-relay/src/config/mod.rs index 11d7d5c7f..ed94f11d9 100644 --- a/crates/registry-relay/src/config/mod.rs +++ b/crates/registry-relay/src/config/mod.rs @@ -103,9 +103,10 @@ pub struct DeploymentConfig { #[serde(default)] #[schemars(with = "Option")] pub profile: Option, - /// Per-deployment waivers. Each names one finding id, a free-text reason, - /// and a mandatory expiry date. Expired waivers stop suppressing their - /// finding and raise `deployment.waiver_expired`. + /// Per-deployment waivers. Each names one finding id, a required operator + /// reference, an optional summary, and a mandatory expiry date. Expired + /// waivers stop suppressing their finding and raise + /// `deployment.waiver_expired`. #[serde(default)] pub waivers: Vec, /// Operator declarations of assurance evidence the runtime cannot observe @@ -116,15 +117,42 @@ pub struct DeploymentConfig { } /// One declared waiver. `expires` is an ISO 8601 `YYYY-MM-DD` date; format is -/// validated at load time. Reasons must not carry secrets. +/// validated at load time. The reference and optional summary are validated by +/// the shared operations contract before either can reach posture or logs. #[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, from = "DeploymentWaiverConfigDocument")] +#[schemars(!from)] pub struct DeploymentWaiverConfig { pub finding: String, - pub reason: String, + #[schemars(with = "schema::DeploymentWaiverReferenceSchema")] + pub reference: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "schema::DeploymentWaiverSummarySchema")] + pub summary: Option, pub expires: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeploymentWaiverConfigDocument { + finding: String, + reference: String, + #[serde(default)] + summary: registry_platform_ops::OptionalDeploymentWaiverSummary, + expires: String, +} + +impl From for DeploymentWaiverConfig { + fn from(value: DeploymentWaiverConfigDocument) -> Self { + Self { + finding: value.finding, + reference: value.reference, + summary: value.summary.into(), + expires: value.expires, + } + } +} + /// Operator-asserted assurance evidence for conditions the runtime cannot /// observe directly. Each flag defaults to `false`, meaning "no evidence /// declared", which keeps the corresponding gate active until the operator diff --git a/crates/registry-relay/src/config/schema.rs b/crates/registry-relay/src/config/schema.rs index f4ae91637..848a430c9 100644 --- a/crates/registry-relay/src/config/schema.rs +++ b/crates/registry-relay/src/config/schema.rs @@ -21,6 +21,36 @@ use super::Config; pub const CONFIG_SCHEMA_ID: &str = "https://id.registrystack.org/schemas/registry-relay/registry-relay.config.schema.json"; +/// Schema-only deployment-waiver reference contract shared with posture. +pub(crate) struct DeploymentWaiverReferenceSchema; + +impl JsonSchema for DeploymentWaiverReferenceSchema { + fn schema_name() -> Cow<'static, str> { + "DeploymentWaiverReference".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + registry_platform_ops::deployment_waiver_reference_schema_fragment() + .try_into() + .expect("the shared waiver-reference fragment is a valid JSON Schema") + } +} + +/// Schema-only structural deployment-waiver summary contract shared with posture. +pub(crate) struct DeploymentWaiverSummarySchema; + +impl JsonSchema for DeploymentWaiverSummarySchema { + fn schema_name() -> Cow<'static, str> { + "DeploymentWaiverSummary".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + registry_platform_ops::deployment_waiver_summary_schema_fragment() + .try_into() + .expect("the shared waiver-summary fragment is a valid JSON Schema") + } +} + const IPV4_SOCKET_PATTERN: &str = concat!( "^(?:", r"(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.", diff --git a/crates/registry-relay/src/config/validate.rs b/crates/registry-relay/src/config/validate.rs index cae243b18..233272570 100644 --- a/crates/registry-relay/src/config/validate.rs +++ b/crates/registry-relay/src/config/validate.rs @@ -383,10 +383,11 @@ fn validate_audit_ack_cursor(config: &Config) -> Result<(), ConfigError> { /// Validate the deployment block and evaluate startup gates. /// /// Waiver finding ids must match the finding id pattern, waiver dates must be -/// well-formed `YYYY-MM-DD`, reasons must be non-empty, and the deployment must -/// not omit `deployment.profile` or declare a profile under which any unwaived -/// `startup_fail` gate triggers. An invalid profile value is rejected earlier by -/// `serde` (the parse error path); this check covers the conditions that only +/// well-formed `YYYY-MM-DD`, and waiver references and optional summaries must +/// pass the shared operations metadata contract. The deployment must not omit +/// `deployment.profile` or declare a profile under which any unwaived +/// `startup_fail` gate triggers. An invalid profile value is rejected earlier +/// by `serde` (the parse error path); this check covers the conditions that only /// hold once the whole config is deserialised. /// /// `source` is the config's real provenance source. The startup path passes @@ -394,7 +395,7 @@ fn validate_audit_ack_cursor(config: &Config) -> Result<(), ConfigError> { /// candidate's real source so a signed bundle is evaluated as such and the /// `relay.config.unsigned` gate does not fire for it. fn validate_deployment(config: &Config, source: ConfigSource) -> Result<(), ConfigError> { - for waiver in &config.deployment.waivers { + for (index, waiver) in config.deployment.waivers.iter().enumerate() { if waiver.finding.trim().is_empty() { tracing::error!( code = "config.validation_error", @@ -410,11 +411,16 @@ fn validate_deployment(config: &Config, source: ConfigSource) -> Result<(), Conf ); return Err(ConfigError::ValidationError); } - if waiver.reason.trim().is_empty() { + if let Err(error) = registry_platform_ops::validate_deployment_waiver_metadata( + &waiver.reference, + waiver.summary.as_deref(), + ) { tracing::error!( code = "config.validation_error", finding = %waiver.finding, - "deployment waiver is missing a reason" + field = %format!("deployment.waivers[{index}].{}", error.field()), + error = %error, + "deployment waiver metadata is invalid" ); return Err(ConfigError::ValidationError); } @@ -482,27 +488,35 @@ fn validate_deployment(config: &Config, source: ConfigSource) -> Result<(), Conf /// profile each get a warn line, so a boot that runs with reduced posture is /// loud in the log instead of visible only on the posture surface. fn log_deployment_boot_findings(evaluation: &crate::deployment::GateEvaluation) { - fn waiver_fields(finding: ®istry_platform_ops::DeploymentFinding) -> (&str, &str) { - finding.waiver.as_ref().map_or(("", ""), |waiver| { - (waiver.reason.as_str(), waiver.expires.as_str()) + fn waiver_fields( + finding: ®istry_platform_ops::DeploymentFinding, + ) -> (&str, Option<&str>, &str) { + finding.waiver.as_ref().map_or(("", None, ""), |waiver| { + ( + waiver.reference.as_str(), + waiver.summary.as_deref(), + waiver.expires.as_str(), + ) }) } for finding in &evaluation.findings { if finding.status == registry_platform_ops::DeploymentFindingStatus::Waived { - let (reason, expires) = waiver_fields(finding); + let (reference, summary, expires) = waiver_fields(finding); tracing::warn!( code = "deployment.gate_waived", finding = %finding.id, - reason = %reason, + reference = %reference, + summary = ?summary, expires = %expires, "deployment gate finding is suppressed by an active waiver" ); } else if finding.id == crate::deployment::WAIVER_EXPIRED { - let (reason, expires) = waiver_fields(finding); + let (reference, summary, expires) = waiver_fields(finding); tracing::warn!( code = "deployment.waiver_expired", - reason = %reason, + reference = %reference, + summary = ?summary, expires = %expires, "deployment waiver is expired; its gate binds again" ); @@ -5092,7 +5106,7 @@ deployment: #[test] fn active_waiver_is_loud_in_the_boot_log() { let config = parse_deployment_config( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-BOOT\n summary: \"Synthetic waiver summary\"\n expires: \"2999-01-01\"", ); let (result, rendered) = run_with_captured_logs(&config); result.expect("a waived hosted_lab gate must pass validation"); @@ -5105,8 +5119,12 @@ deployment: "expected the waived finding id in boot log: {rendered}" ); assert!( - rendered.contains("synthetic-waiver-not-a-secret"), - "expected the waiver reason in boot log: {rendered}" + rendered.contains("OPS-TEST-BOOT"), + "expected the waiver reference in boot log: {rendered}" + ); + assert!( + rendered.contains("Synthetic waiver summary"), + "expected the waiver summary in boot log: {rendered}" ); assert!( rendered.contains("2999-01-01"), @@ -5117,7 +5135,7 @@ deployment: #[test] fn expired_waiver_is_loud_in_the_boot_log() { let config = parse_deployment_config( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2000-01-01\"", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-EXPIRED\n expires: \"2000-01-01\"", ); let (result, rendered) = run_with_captured_logs(&config); result.expect("an expired hosted_lab waiver must still pass validation"); @@ -5206,7 +5224,7 @@ deployment: // A malformed finding id is rejected at config load, before it could // surface later as restricted-tier posture schema invalidity. let config = parse_deployment_config( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: \"Relay.Config.Unsigned\"\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: \"Relay.Config.Unsigned\"\n reference: OPS-TEST-BAD-ID\n expires: \"2999-01-01\"", ); assert!( run(&config).is_err(), @@ -5217,7 +5235,7 @@ deployment: #[test] fn waiver_finding_id_accepts_canonical_dotted_id() { let config = parse_deployment_config( - "deployment:\n profile: hosted_lab\n waivers:\n - finding: \"relay.config.unsigned\"\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: hosted_lab\n waivers:\n - finding: \"relay.config.unsigned\"\n reference: OPS-TEST-VALID-ID\n expires: \"2999-01-01\"", ); run(&config).expect("a canonical dotted finding id must pass validation"); } @@ -5228,10 +5246,10 @@ deployment: // cannot be waived. The waiver must be rejected at load, not silently // dropped. Evaluate as a signed bundle so the `relay.config.unsigned` // startup gate does not fire: the hard-gate waiver is then the only - // reason validation can fail (the base config uses a stdout sink, so + // validation failure (the base config uses a stdout sink, so // the retention condition itself does not even trigger here). let config = parse_deployment_config( - "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.retention_local_only\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.retention_local_only\n reference: OPS-TEST-HARD-RETENTION\n expires: \"2999-01-01\"", ); let (result, rendered) = capture_logs(|| run_with_source(&config, ConfigSource::SignedBundleFile)); @@ -5277,7 +5295,7 @@ deployment: profile: production waivers: - finding: relay.audit.retention_local_only - reason: "synthetic-waiver-not-a-secret" + reference: OPS-TEST-WAIVABLE-RETENTION expires: "2999-01-01" "#, ) @@ -5306,9 +5324,9 @@ deployment: // validation error, not silently dropped, even though the condition // itself does not hold here. Evaluate as a signed bundle so the // `relay.config.unsigned` startup gate does not fire and the readiness - // waiver is the only reason validation can fail. + // waiver is the only validation failure. let config = parse_deployment_config( - "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.best_effort\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.best_effort\n reference: OPS-TEST-HARD-BEST-EFFORT\n expires: \"2999-01-01\"", ); let (result, rendered) = capture_logs(|| run_with_source(&config, ConfigSource::SignedBundleFile)); @@ -5436,7 +5454,7 @@ deployment: // does not hold. Evaluate as a signed bundle so the // `relay.config.unsigned` startup gate does not fire. let config = parse_deployment_config( - "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.shipping_stale\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"", + "deployment:\n profile: evidence_grade\n waivers:\n - finding: relay.audit.shipping_stale\n reference: OPS-TEST-HARD-SHIPPING\n expires: \"2999-01-01\"", ); let (result, rendered) = capture_logs(|| run_with_source(&config, ConfigSource::SignedBundleFile)); diff --git a/crates/registry-relay/src/deployment/mod.rs b/crates/registry-relay/src/deployment/mod.rs index 31aa7660f..cc69faa40 100644 --- a/crates/registry-relay/src/deployment/mod.rs +++ b/crates/registry-relay/src/deployment/mod.rs @@ -16,7 +16,8 @@ //! * `finding_error` / `finding_warn`: a posture finding only. //! //! A triggered gate can be suppressed by a config waiver that names the -//! finding, carries a free-text reason, and a mandatory expiry date. A waived +//! finding, carries a required operator reference, and sets a mandatory expiry +//! date. An optional summary can add validated operational context. A waived //! finding reports status `waived` instead of its severity effect. An expired //! waiver stops suppressing the finding and additionally raises //! `deployment.waiver_expired`. `startup_fail` and `readiness_fail` gates are @@ -52,12 +53,14 @@ pub const PROFILE_UNDECLARED: &str = "deployment.profile_undeclared"; /// has passed its expiry date. pub const WAIVER_EXPIRED: &str = "deployment.waiver_expired"; -/// A waiver as declared in config: one finding id, a reason, and a mandatory -/// expiry date in `YYYY-MM-DD` form. +/// A waiver as declared in config: one finding id, a required operator +/// reference, an optional summary, and a mandatory expiry date in `YYYY-MM-DD` +/// form. #[derive(Clone, Debug, Eq, PartialEq)] pub struct WaiverInput { pub finding: String, - pub reason: String, + pub reference: String, + pub summary: Option, pub expires: String, } @@ -322,7 +325,8 @@ pub fn evaluate( severity, status: DeploymentFindingStatus::Waived, waiver: Some(DeploymentFindingWaiver { - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }), }); @@ -358,14 +362,16 @@ pub fn evaluate( severity: FindingError, status: DeploymentFindingStatus::Active, waiver: Some(DeploymentFindingWaiver { - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }), }); } else { evaluation.active_waivers.push(DeploymentWaiver { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }); } @@ -645,7 +651,8 @@ pub fn waivers_from_config(config: &Config) -> Vec { .iter() .map(|waiver| WaiverInput { finding: waiver.finding.clone(), - reason: waiver.reason.clone(), + reference: waiver.reference.clone(), + summary: waiver.summary.clone(), expires: waiver.expires.clone(), }) .collect() @@ -1118,7 +1125,8 @@ mod tests { let id = "relay.audit.retention_local_only"; let waivers = [WaiverInput { finding: id.to_string(), - reason: "synthetic test waiver".to_string(), + reference: "OPS-TEST-1125".to_string(), + summary: Some("Synthetic test waiver".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); @@ -1257,7 +1265,8 @@ mod tests { let id = "relay.audit.shipping_stale"; let waivers = [WaiverInput { finding: id.to_string(), - reason: "synthetic stale-shipping waiver".to_string(), + reference: "OPS-TEST-1264".to_string(), + summary: Some("Synthetic stale-shipping waiver".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate( @@ -1283,7 +1292,8 @@ mod tests { }; let waivers = [WaiverInput { finding: "relay.openapi.public".to_string(), - reason: "synthetic test waiver".to_string(), + reference: "OPS-TEST-1290".to_string(), + summary: Some("Synthetic test waiver".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); @@ -1291,8 +1301,8 @@ mod tests { assert_eq!(f.status, DeploymentFindingStatus::Waived); assert_eq!(f.severity, FindingError); assert_eq!( - f.waiver.as_ref().unwrap().reason, - "synthetic test waiver".to_string() + f.waiver.as_ref().unwrap().reference, + "OPS-TEST-1290".to_string() ); // An active waiver is reported in the waivers list. assert_eq!(evaluation.active_waivers.len(), 1); @@ -1313,7 +1323,8 @@ mod tests { let id = "relay.admin.public_exposure"; let waivers = [WaiverInput { finding: id.to_string(), - reason: "synthetic readiness waiver".to_string(), + reference: "OPS-TEST-1320".to_string(), + summary: Some("Synthetic readiness waiver".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); @@ -1337,7 +1348,8 @@ mod tests { }; let waivers = [WaiverInput { finding: "relay.openapi.public".to_string(), - reason: "synthetic expired waiver".to_string(), + reference: "OPS-TEST-1344".to_string(), + summary: Some("Synthetic expired waiver".to_string()), expires: PAST.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); @@ -1359,7 +1371,8 @@ mod tests { }; let waivers = [WaiverInput { finding: "relay.config.unsigned".to_string(), - reason: "synthetic attempt to waive a startup gate".to_string(), + reference: "OPS-TEST-1366".to_string(), + summary: Some("Synthetic attempt to waive a startup gate".to_string()), expires: FUTURE.to_string(), }]; let evaluation = evaluate( @@ -1386,7 +1399,8 @@ mod tests { }; let waivers = [WaiverInput { finding: "relay.openapi.public".to_string(), - reason: "synthetic boundary waiver".to_string(), + reference: "OPS-TEST-1393".to_string(), + summary: Some("Synthetic boundary waiver".to_string()), expires: TODAY.to_string(), }]; let evaluation = evaluate(Some(DeploymentProfile::Production), &facts, &waivers, TODAY); diff --git a/crates/registry-relay/tests/config_schema.rs b/crates/registry-relay/tests/config_schema.rs index c55830e65..732a15d7c 100644 --- a/crates/registry-relay/tests/config_schema.rs +++ b/crates/registry-relay/tests/config_schema.rs @@ -7,6 +7,10 @@ use std::process::Command; use jsonschema::{Draft, JSONSchema}; use registry_platform_audit::{AuditChainProfile, AuditError}; +use registry_platform_ops::{ + deployment_waiver_reference_schema_fragment, deployment_waiver_summary_schema_fragment, + validate_deployment_waiver_metadata, DeploymentWaiverMetadataError, +}; use registry_relay::config::schema::{document, document_json, CONFIG_SCHEMA_ID}; use registry_relay::config::Config; use serde_json::{json, Value}; @@ -72,10 +76,38 @@ fn assert_runtime_rejects(instance: &Value, label: &str) { ); } +fn assert_runtime_load_rejects(instance: &Value, label: &str) { + let yaml = serde_norway::to_string(instance) + .unwrap_or_else(|error| panic!("failed to serialize {label} as YAML: {error}")); + if let Ok(config) = serde_norway::from_str::(&yaml) { + assert!( + registry_relay::config::validate::run(&config).is_err(), + "{label} must be rejected during runtime validation" + ); + } +} + fn example_config() -> Value { parse_yaml(&relay_root().join("config/example.yaml")) } +fn config_with_deployment_waiver(reference: &str, summary: Option<&str>) -> Value { + let mut config = example_config(); + let mut waiver = json!({ + "finding": "relay.config.unsigned", + "reference": reference, + "expires": "2999-01-01" + }); + if let Some(summary) = summary { + waiver["summary"] = json!(summary); + } + config["deployment"] = json!({ + "profile": "hosted_lab", + "waivers": [waiver] + }); + config +} + fn oidc_config() -> Value { parse_yaml(&relay_root().join("config/example.oidc.yaml")) } @@ -252,6 +284,125 @@ fn strict_nested_objects_tagged_variants_and_duration_shapes_are_enforced() { assert_invalid(&schema, &duration_object, "object-form duration"); } +#[test] +fn deployment_waiver_schema_rejects_retired_and_noncanonical_metadata() { + let schema = document(); + let mut config = example_config(); + config["deployment"] = json!({ + "profile": "hosted_lab", + "waivers": [{ + "finding": "relay.config.unsigned", + "reference": "OPS..42", + "expires": "2999-01-01" + }] + }); + assert_invalid(&schema, &config, "waiver reference containing '..'"); + + config["deployment"]["waivers"][0]["reference"] = json!("OPS-42"); + config["deployment"]["waivers"][0]["summary"] = Value::Null; + assert_invalid(&schema, &config, "null deployment waiver summary"); + assert_runtime_load_rejects(&config, "null deployment waiver summary"); + + config["deployment"]["waivers"][0] + .as_object_mut() + .expect("waiver is an object") + .remove("summary"); + config["deployment"]["waivers"][0]["reason"] = json!("retired waiver text"); + assert_invalid(&schema, &config, "retired deployment waiver reason"); + assert_runtime_load_rejects(&config, "retired deployment waiver reason"); +} + +#[test] +fn deployment_waiver_schema_matches_shared_portable_metadata_contract() { + let schema = document(); + assert_eq!( + schema.pointer("/$defs/DeploymentWaiverReference"), + Some(&deployment_waiver_reference_schema_fragment()) + ); + assert_eq!( + schema.pointer("/$defs/DeploymentWaiverSummary"), + Some(&deployment_waiver_summary_schema_fragment()) + ); + + for reference in ["OPS-42", "Bearer:", "Authorization:Basic:"] { + validate_deployment_waiver_metadata(reference, None).unwrap_or_else(|error| { + panic!("runtime rejected valid reference {reference:?}: {error}") + }); + assert_valid( + &schema, + &config_with_deployment_waiver(reference, None), + &format!("portable deployment waiver reference {reference:?}"), + ); + } + for reference in ["Bearer:abcdef", "authorization:bAsIc:abc123", "Bearer::"] { + assert_eq!( + validate_deployment_waiver_metadata(reference, None), + Err(DeploymentWaiverMetadataError::ReferenceCredentialLiteral) + ); + assert_invalid( + &schema, + &config_with_deployment_waiver(reference, None), + &format!("credential-shaped deployment waiver reference {reference:?}"), + ); + } + + for summary in [ + "Ordinary operator summary".to_string(), + "\u{feff}summary\u{feff}".to_string(), + "summary\u{2028}continued".to_string(), + "é".repeat(256), + ] { + validate_deployment_waiver_metadata("OPS-42", Some(&summary)) + .unwrap_or_else(|error| panic!("runtime rejected valid summary {summary:?}: {error}")); + assert_valid( + &schema, + &config_with_deployment_waiver("OPS-42", Some(&summary)), + "structurally valid deployment waiver summary", + ); + } + for summary in [ + String::new(), + " summary".to_string(), + "summary\u{3000}".to_string(), + "summary\u{001f}continued".to_string(), + "é".repeat(257), + ] { + assert!( + validate_deployment_waiver_metadata("OPS-42", Some(&summary)).is_err(), + "runtime must reject structurally invalid summary {summary:?}" + ); + assert_invalid( + &schema, + &config_with_deployment_waiver("OPS-42", Some(&summary)), + "structurally invalid deployment waiver summary", + ); + } + + for summary in [ + "Authorization: "Bearer abcdef"", + concat!("accidentally pasted -----BEGIN PRIVATE ", "KEY-----"), + concat!( + "accidentally pasted -----BEGIN PGP PRIVATE KEY ", + "BLOCK-----" + ), + ] { + assert_eq!( + validate_deployment_waiver_metadata("OPS-42", Some(summary)), + Err(DeploymentWaiverMetadataError::SummaryCredentialLiteral) + ); + let config = config_with_deployment_waiver("OPS-42", Some(summary)); + assert_valid( + &schema, + &config, + "contextual waiver summary left to semantic validation", + ); + assert_runtime_load_rejects( + &config, + "contextual waiver summary rejected by semantic validation", + ); + } +} + #[test] fn socket_addresses_match_schema_and_runtime_portable_syntax() { let schema = document(); diff --git a/crates/registry-relay/tests/deployment_profile_gates.rs b/crates/registry-relay/tests/deployment_profile_gates.rs index 4f50bd757..b892e508a 100644 --- a/crates/registry-relay/tests/deployment_profile_gates.rs +++ b/crates/registry-relay/tests/deployment_profile_gates.rs @@ -8,7 +8,7 @@ //! wiring that only an integration test can reach: //! //! * an invalid profile value fails config parse, -//! * deployment waivers must carry a non-empty reason and a well-formed expiry, +//! * deployment waivers must carry validated metadata and a well-formed expiry, //! * `evidence_grade` from an unsigned local file refuses startup, //! * the audit write-policy hook behaves under the default `fail_closed` //! policy and explicit `availability_first` opt-out, proven with an @@ -113,22 +113,106 @@ fn each_declared_profile_value_parses() { } #[test] -fn waiver_missing_reason_is_rejected() { +fn waiver_missing_reference_is_rejected_at_parse() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); - let config = parse_config(&yaml).expect("config parses"); assert!( - config::validate::run(&config).is_err(), - "a waiver with an empty reason must fail validation" + parse_config(&yaml).is_err(), + "a waiver without the required reference must fail deserialization" + ); +} + +#[test] +fn legacy_waiver_reason_is_rejected_as_unknown() { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n reason: \"legacy waiver text\"\n expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + assert!( + parse_config(&yaml).is_err(), + "the removed reason field must fail strict deserialization" + ); +} + +#[test] +fn invalid_or_overlong_waiver_reference_is_rejected() { + for reference in ["", " OPS-42", "OPS/42", &"x".repeat(129)] { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: \"{reference}\"\n expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + let config = parse_config(&yaml).expect("reference value parses as YAML"); + assert!( + config::validate::run(&config).is_err(), + "invalid reference must fail validation: {reference:?}" + ); + } +} + +#[test] +fn absent_summary_and_ordinary_summary_are_accepted() { + for summary_line in [ + "".to_string(), + " summary: \"Gateway rate limiting is tracked in OPS-42\"\n".to_string(), + ] { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n{summary_line} expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + let config = parse_config(&yaml).expect("config parses"); + config::validate::run(&config).expect("valid waiver metadata is accepted"); + } +} + +#[test] +fn explicit_null_waiver_summary_is_rejected() { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n summary: null\n expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + assert!( + parse_config(&yaml).is_err(), + "summary must be a string when present, not null" + ); +} + +#[test] +fn invalid_waiver_summary_is_rejected() { + for summary in [ + "", + " summary", + "Bearer credential-value", + "Basic credential-value", + "rotated leaked Bearer abcdef", + "-----BEGIN PRIVATE KEY-----", + concat!("-----BEGIN PGP PRIVATE KEY ", "BLOCK-----"), + &"x".repeat(257), + ] { + let yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n summary: \"{summary}\"\n expires: \"2999-01-01\"\n", + minimal_config_yaml() + ); + let config = parse_config(&yaml).expect("summary value parses as YAML"); + assert!( + config::validate::run(&config).is_err(), + "invalid summary must fail validation: {summary:?}" + ); + } + + let control_yaml = format!( + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-42\n summary: \"summary\\twith control\"\n expires: \"2999-01-01\"\n", + minimal_config_yaml() ); + let config = parse_config(&control_yaml).expect("escaped control parses"); + assert!(config::validate::run(&config).is_err()); } #[test] fn waiver_bad_expiry_is_rejected() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"soon\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-EXPIRY\n expires: \"soon\"\n", minimal_config_yaml() ); let config = parse_config(&yaml).expect("config parses"); @@ -141,7 +225,7 @@ fn waiver_bad_expiry_is_rejected() { #[test] fn waiver_with_valid_expiry_passes_validation() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-VALID\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); let config = parse_config(&yaml).expect("config parses"); @@ -233,7 +317,7 @@ fn governed_candidate_apply_accepts_evidence_grade_with_signed_provenance() { #[tokio::test] async fn boot_audit_records_waived_gate() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-AUDIT\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); let config = parse_config(&yaml).expect("config parses"); @@ -290,7 +374,7 @@ async fn boot_audit_writes_nothing_without_waivers() { #[tokio::test] async fn boot_audit_failure_fails_closed_for_waived_gate() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-FAIL-CLOSED\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); let config = parse_config(&yaml).expect("config parses"); @@ -314,7 +398,7 @@ async fn boot_audit_failure_fails_closed_for_waived_gate() { #[tokio::test] async fn boot_audit_failure_is_best_effort_when_availability_first() { let yaml = format!( - "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reason: \"synthetic-waiver-not-a-secret\"\n expires: \"2999-01-01\"\n", + "{}\ndeployment:\n profile: hosted_lab\n waivers:\n - finding: relay.config.unsigned\n reference: OPS-TEST-AVAILABILITY\n expires: \"2999-01-01\"\n", minimal_config_yaml() ); let mut config = parse_config(&yaml).expect("config parses"); diff --git a/docs/site/src/content/docs/security/hardening-checklist.mdx b/docs/site/src/content/docs/security/hardening-checklist.mdx index 3de1522f5..a05dc8a98 100644 --- a/docs/site/src/content/docs/security/hardening-checklist.mdx +++ b/docs/site/src/content/docs/security/hardening-checklist.mdx @@ -146,9 +146,10 @@ or the [Registry Notary operator configuration reference](../../products/registr active waivers, so the deployment's actual state is inspectable rather than asserted. Read [RS-OP-POSTURE](../../spec/rs-op-posture/) before building operational tooling around its default or restricted tier. -- Give every waiver a non-empty, non-secret reason and a mandatory expiry date. `startup_fail` - gates are never waivable; a waiver naming one is rejected. Waiver reasons appear only in the - restricted posture tier. +- Give every waiver a validated operator reference and a mandatory expiry date. Add a short + summary only when operators need more context, and keep credentials and private keys out of + both fields. `startup_fail` and `readiness_fail` gates are never waivable; a waiver naming one + is rejected. Waiver references and summaries appear only in the restricted posture tier. - Use `deployment.evidence.*` flags (for example `ingress_rate_limit`, `api_key_rotation`) only to assert controls that live outside the process and cannot be observed by it. Each flag defaults to `false`. diff --git a/docs/site/src/content/docs/security/index.mdx b/docs/site/src/content/docs/security/index.mdx index e2979a592..548ca0716 100644 --- a/docs/site/src/content/docs/security/index.mdx +++ b/docs/site/src/content/docs/security/index.mdx @@ -105,9 +105,10 @@ Both runtime services accept a `deployment` block with a profile of `local`, `ho Each profile binds a findings catalog at escalating severities: a `production` deployment with no durable audit sink refuses to start, and an `evidence_grade` deployment refuses to start from an unsigned local config file. -Findings, waivers (each with a mandatory reason and expiry), and the declared profile are reported -by the operations posture endpoint, so the deployment's actual state is inspectable rather than -asserted. +Findings, waivers (each with a required operator reference, optional summary, and mandatory +expiry), and the declared profile are reported by the restricted operations posture endpoint, so +the deployment's actual state is inspectable rather than asserted. The default tier omits waiver +metadata. The gate catalogs are documented in the [Registry Relay configuration reference](../products/registry-relay/configuration/) and the [Registry Notary operator configuration reference](../products/registry-notary/operator-config-reference/). diff --git a/docs/site/src/content/docs/spec/rs-op-posture.mdx b/docs/site/src/content/docs/spec/rs-op-posture.mdx index 14746515c..de87e539a 100644 --- a/docs/site/src/content/docs/spec/rs-op-posture.mdx +++ b/docs/site/src/content/docs/spec/rs-op-posture.mdx @@ -32,9 +32,12 @@ source of secrets. The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section 2. Defined terms are used per [RS-TERMS](../rs-terms/). -The [Registry Ops Posture v1 JSON Schema](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json) -is the source of truth for validation; this specification defines the operational meaning and -evolution rules that JSON Schema alone does not express. +{/* TODO[evidence]: Add a release-tagged Registry Ops Posture v1 JSON Schema source link after + the waiver definitions and shared portable metadata constraints merge. The earlier pinned + schema predates those definitions and is not evidence for this draft revision. */} +Portable structural validation is defined by the in-tree draft schema; release-tagged source +evidence for this revision is pending. This specification defines the operational meaning, +semantic producer validation, and evolution rules that JSON Schema alone does not express. ## Version history @@ -42,6 +45,9 @@ evolution rules that JSON Schema alone does not express. | --- | --- | --- | --- | | 0.1.0 | 2026-07-19 | draft | Initial posture-document contract for `registry.ops.posture.v1`. | | 0.1.1 | 2026-07-19 | draft | Added restricted Relay per-resource refresh health before the 1.0 schema freeze. | +| 0.1.2 | 2026-07-20 | draft | Replaced waiver reason text with validated operator references and optional summaries before the 1.0 schema freeze. | +| 0.1.3 | 2026-07-23 | draft | Reserved credential-shaped waiver-reference prefixes before the 1.0 schema freeze. | +| 0.1.4 | 2026-07-24 | draft | Separated portable waiver-schema checks from contextual semantic validation before the 1.0 schema freeze. | ## 1. Scope and surface @@ -116,6 +122,36 @@ restricted per-resource refresh health. The `notary` section reports claim and credential-profile counts plus state, credential-status, federation, OID4VCI, and optional subject-access observations. +### Deployment waiver metadata + +A deployment waiver names one finding and expiry date. Its `reference` is a required opaque +operator-local approval, incident, or ticket identifier. The optional `summary` adds short +operational context. Neither value grants authority or changes which gate severities can be +waived. + +REQ-OP-POSTURE-011: A producer that emits a deployment waiver MUST include `reference` as an +already canonical 1 to 128 byte string using only ASCII letters, digits, `.`, `_`, `:`, and `-`. +The reference MUST NOT contain `..`. +It MUST NOT start, case-insensitively, with the credential-shaped prefix `Bearer:` or +`Basic:`, directly or after `Authorization:`. +`OPS-2026-0042` is an accepted ticket-style reference. +If the producer emits `summary`, the summary MUST be an already trimmed 1 to 256 +Unicode-character string without control characters, an authorization value, or a private-key +begin marker. +The producer MUST omit `summary` when no summary was configured and MUST NOT emit the retired +`reason` property. +The JSON Schema enforces the reference syntax and reserved prefixes. It also enforces the +summary length, C0 and C1 control-character exclusion, and leading or trailing whitespace +exclusion. +The schema does not encode context-sensitive authorization-value or private-key marker detection. +Before emitting either waiver shape, a producer MUST apply semantic validation equivalent to +`validate_deployment_waiver_metadata`. +Schema acceptance alone does not satisfy the contextual exclusions in REQ-OP-POSTURE-011. +These metadata rules do not alter expiry behavior: an expired waiver stops suppressing its +finding. +They also do not alter the hard-gate boundary: `startup_fail` and `readiness_fail` findings +remain non-waivable. + ## 3. Examples The schema-valid [Registry Relay default example](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/examples/registry-relay.posture.valid.json) @@ -147,8 +183,8 @@ it in an opaque nested value. The default projection includes operational status, counts, selected digests, finding identifiers and severities, and non-secret audit-shipping observations. It excludes values that can reveal topology or operational free text, including instance URLs, -build revisions and features, trusted roots, artifact URLs, waiver reasons, signing-key details, -and Notary federation identities or peers. +build revisions and features, trusted roots, artifact URLs, waiver references and summaries, +signing-key details, and Notary federation identities or peers. The checked-in sensitive fixture proves that the projection excludes credentials, private keys, source URLs, subject identifiers, raw rows, claim values, and the restricted topology fields. The default tier also excludes `relay.refresh_health`, including its dataset and resource @@ -229,8 +265,9 @@ freshness service-level objective requires it. A producer conforms to this specification when it emits a schema-valid, component-consistent, tier-labeled observation; uses the emit-only allowlist for default output; keeps restricted output within the same secret and source-data boundary; assigns a new schema identifier for every -post-1.0 shape change; and reports Relay refresh health when applicable (REQ-OP-POSTURE-001 through -REQ-OP-POSTURE-006, REQ-OP-POSTURE-008, and REQ-OP-POSTURE-010). +post-1.0 shape change; reports Relay refresh health when applicable; and emits only validated +deployment waiver metadata (REQ-OP-POSTURE-001 through REQ-OP-POSTURE-006, +REQ-OP-POSTURE-008, REQ-OP-POSTURE-010, and REQ-OP-POSTURE-011). A consumer conforms when it validates and dispatches by schema, enforces component pairing, handles omissions as unreported, and does not elevate posture observations into proof or authority @@ -241,9 +278,8 @@ handles omissions as unreported, and does not elevate posture observations into This specification is `verified`: its document shape, examples, component pairing, and tier filter are shipped in Registry Platform and exercised by contract tests. -- The [v1 JSON Schema](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json) - defines the closed envelope, schema constant, and Relay/Notary component pairing - (Sections 2 and 5). +- Release-tagged source evidence for the v1 JSON Schema's waiver definitions and shared portable + metadata constraints is pending. The source pack will be linked after those changes merge. - The [posture contract tests](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/tests/posture_contract.rs) validate both examples, the default allowlist projection, the sensitive fixture, and exclusion of restricted fields from default output (Sections 3, 4, and 6). diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index dcafbe3b4..1e88ca66e 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- BREAKING: Replace deployment-waiver `reason` with a required validated + `reference` and an optional validated `summary`. Strict configuration parsing + rejects the retired field. Restricted posture and boot warnings expose only + the new metadata, default posture continues to omit waiver metadata, and + hard startup/readiness gates remain non-waivable. + ## [0.12.2] - 2026-07-20 - No new Notary product features or public API contract changes. This release diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index 14b113f70..b447b227b 100644 --- a/products/notary/docs/operator-config-reference.md +++ b/products/notary/docs/operator-config-reference.md @@ -210,7 +210,8 @@ deployment.waivers deployment.waivers[] deployment.waivers[].expires deployment.waivers[].finding -deployment.waivers[].reason +deployment.waivers[].reference +deployment.waivers[].summary evidence evidence.allowed_purposes evidence.allowed_purposes[] @@ -723,6 +724,34 @@ authenticity boundary. ## State and operations +Deployment waivers name one finding, a required operator reference, and a mandatory expiry date. +The reference is 1 to 128 bytes, has no surrounding whitespace, uses only letters, digits, `.`, +`_`, `:`, and `-`, and cannot contain `..`. +References cannot start, case-insensitively, with `Bearer:` or `Basic:`, directly or +after `Authorization:`. +Use a ticket-style reference such as `OPS-2026-0042`. +An optional summary is 1 to 256 Unicode characters, already trimmed, contains no control +characters, and cannot be an authorization value or contain a private-key begin marker. +Omit `summary` when it is not needed; explicit `null` is invalid. +Keep credentials and private keys out of both fields. +These rules implement +[RS-OP-POSTURE](https://docs.registrystack.org/spec/rs-op-posture/) (REQ-OP-POSTURE-011). + +```yaml +deployment: + profile: hosted_lab + waivers: + - finding: notary.openapi.public + reference: OPS-2026-0042 + summary: Public API catalog is approved for the lab + expires: 2026-09-30 +``` + +Expired waivers stop suppressing their finding and raise `deployment.waiver_expired`. +`startup_fail` and `readiness_fail` gates are not waivable. The default posture tier omits waiver +metadata; the restricted tier can report only the validated reference, optional summary, and +expiry. Boot warnings use the same metadata. Audit records remain minimized to finding IDs. + Use the typed Notary-owned PostgreSQL state schema for multi-instance or production deployments. The schema holds replay, nonce, evaluation, idempotency, credential-status, quota, and preauthorization state. Explicit diff --git a/products/platform/CHANGELOG.md b/products/platform/CHANGELOG.md index eb401cea6..dc6ec9124 100644 --- a/products/platform/CHANGELOG.md +++ b/products/platform/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- BREAKING: `registry-platform-ops` replaces deployment-waiver `reason` with + required `reference` plus optional omitted `summary` in the shared posture + contract. It now owns the common 128-byte reference and 256-character summary + validation authority used by Relay and Notary. The default posture allowlist + continues to exclude all waiver metadata. Relay, Notary, and posture schemas + now share the portable structural reference and summary constraints; + contextual credential and private-key marker checks remain in the shared + semantic validator. + ## v0.12.2 - 2026-07-20 - No user-visible Registry Platform changes. This release fixes forward from diff --git a/schemas/registry-notary.config.schema.json b/schemas/registry-notary.config.schema.json index e7705e411..fef2cacd7 100644 --- a/schemas/registry-notary.config.schema.json +++ b/schemas/registry-notary.config.schema.json @@ -641,7 +641,7 @@ }, "DeploymentWaiverConfig": { "additionalProperties": false, - "description": "One operator-configured waiver.\n\nA waiver names exactly one finding id, a free-text reason, and a mandatory\nexpiry date (`YYYY-MM-DD`). Reasons must not contain secrets.", + "description": "One operator-configured waiver.\n\nA waiver names exactly one finding id, a required operator reference, an\noptional summary, and a mandatory expiry date (`YYYY-MM-DD`). The shared\noperations contract validates metadata before it can reach posture or logs.", "properties": { "expires": { "type": "string" @@ -649,17 +649,53 @@ "finding": { "type": "string" }, - "reason": { - "type": "string" + "reference": { + "$ref": "#/$defs/DeploymentWaiverReference" + }, + "summary": { + "$ref": "#/$defs/DeploymentWaiverSummary" } }, "required": [ "finding", - "reason", + "reference", "expires" ], "type": "object" }, + "DeploymentWaiverReference": { + "maxLength": 128, + "minLength": 1, + "not": { + "pattern": "^(?:[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]:)?(?:[Bb][Ee][Aa][Rr][Ee][Rr]|[Bb][Aa][Ss][Ii][Cc]):[A-Za-z0-9._:-]+$" + }, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", + "type": "string" + }, + "DeploymentWaiverSummary": { + "$comment": "Schema acceptance does not replace validate_deployment_waiver_metadata.", + "allOf": [ + { + "not": { + "pattern": "[\\u0000-\\u001F\\u007F-\\u009F]" + } + }, + { + "not": { + "pattern": "^[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]" + } + }, + { + "not": { + "pattern": "[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]$" + } + } + ], + "description": "Structurally valid deployment-waiver summary. Contextual authorization-value and private-key marker exclusions require semantic producer validation.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, "DisclosureConfig": { "additionalProperties": false, "properties": { diff --git a/schemas/registry-relay.config.schema.json b/schemas/registry-relay.config.schema.json index f0ad5514b..c4f022660 100644 --- a/schemas/registry-relay.config.schema.json +++ b/schemas/registry-relay.config.schema.json @@ -1446,7 +1446,7 @@ "default": null }, "waivers": { - "description": "Per-deployment waivers. Each names one finding id, a free-text reason,\nand a mandatory expiry date. Expired waivers stop suppressing their\nfinding and raise `deployment.waiver_expired`.", + "description": "Per-deployment waivers. Each names one finding id, a required operator\nreference, an optional summary, and a mandatory expiry date. Expired\nwaivers stop suppressing their finding and raise\n`deployment.waiver_expired`.", "items": { "$ref": "#/$defs/DeploymentWaiverConfig" }, @@ -1507,7 +1507,7 @@ }, "DeploymentWaiverConfig": { "additionalProperties": false, - "description": "One declared waiver. `expires` is an ISO 8601 `YYYY-MM-DD` date; format is\nvalidated at load time. Reasons must not carry secrets.", + "description": "One declared waiver. `expires` is an ISO 8601 `YYYY-MM-DD` date; format is\nvalidated at load time. The reference and optional summary are validated by\nthe shared operations contract before either can reach posture or logs.", "properties": { "expires": { "type": "string" @@ -1515,17 +1515,53 @@ "finding": { "type": "string" }, - "reason": { - "type": "string" + "reference": { + "$ref": "#/$defs/DeploymentWaiverReference" + }, + "summary": { + "$ref": "#/$defs/DeploymentWaiverSummary" } }, "required": [ "finding", - "reason", + "reference", "expires" ], "type": "object" }, + "DeploymentWaiverReference": { + "maxLength": 128, + "minLength": 1, + "not": { + "pattern": "^(?:[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]:)?(?:[Bb][Ee][Aa][Rr][Ee][Rr]|[Bb][Aa][Ss][Ii][Cc]):[A-Za-z0-9._:-]+$" + }, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9._:-]+$", + "type": "string" + }, + "DeploymentWaiverSummary": { + "$comment": "Schema acceptance does not replace validate_deployment_waiver_metadata.", + "allOf": [ + { + "not": { + "pattern": "[\\u0000-\\u001F\\u007F-\\u009F]" + } + }, + { + "not": { + "pattern": "^[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]" + } + }, + { + "not": { + "pattern": "[\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]$" + } + } + ], + "description": "Structurally valid deployment-waiver summary. Contextual authorization-value and private-key marker exclusions require semantic producer validation.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, "DisclosureControlConfig": { "additionalProperties": false, "description": "Disclosure control settings per aggregate. Defaults to\n`min_group_size: 5`, `suppression: omit`.",