ci: trigger production safety remediation #1
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Apply production safety pass | |
| on: | |
| push: | |
| branches: | |
| - remediation/all-20260620 | |
| paths: | |
| - REMEDIATION_TRIGGER | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: production-safety-pass | |
| cancel-in-progress: false | |
| jobs: | |
| patch-test-commit: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 120 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| ref: remediation/all-20260620 | |
| fetch-depth: 0 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Apply cross-crate remediation | |
| shell: bash | |
| run: | | |
| python3 - <<'PY' | |
| from pathlib import Path | |
| from textwrap import dedent | |
| def replace_once(path: str, old: str, new: str) -> None: | |
| p = Path(path) | |
| text = p.read_text() | |
| count = text.count(old) | |
| if count != 1: | |
| raise SystemExit(f"{path}: expected one anchor, found {count}") | |
| p.write_text(text.replace(old, new)) | |
| def append_once(path: str, marker: str, content: str) -> None: | |
| p = Path(path) | |
| text = p.read_text() | |
| if marker not in text: | |
| p.write_text(text.rstrip() + "\n" + content) | |
| # Node startup: explicit opt-in for uncertified multi-validator mode; | |
| # mainnet remains blocked until checkpoint certificates are implemented. | |
| replace_once( | |
| "crates/kanari-node/src/main.rs", | |
| dedent('''\ | |
| /// Bootstrap peer multiaddr to connect to (can be specified multiple times) | |
| #[arg(long, value_name = "MULTIADDR")] | |
| bootstrap: Option<Vec<String>>, | |
| '''), | |
| dedent('''\ | |
| /// Bootstrap peer multiaddr to connect to (can be specified multiple times) | |
| #[arg(long, value_name = "MULTIADDR")] | |
| bootstrap: Option<Vec<String>>, | |
| /// Explicitly opt in to the current uncertified multi-validator protocol. | |
| /// This is intended only for controlled development and test environments. | |
| #[arg(long, default_value = "false")] | |
| unsafe_allow_uncertified_consensus: bool, | |
| '''), | |
| ) | |
| replace_once( | |
| "crates/kanari-node/src/main.rs", | |
| dedent('''\ | |
| consensus_public_keys, | |
| bootstrap, | |
| } => { | |
| validate_start_authority_config(&authority_id, &authorities)?; | |
| '''), | |
| dedent('''\ | |
| consensus_public_keys, | |
| bootstrap, | |
| unsafe_allow_uncertified_consensus, | |
| } => { | |
| validate_start_authority_config(&authority_id, &authorities)?; | |
| validate_network_safety(&network, unsafe_allow_uncertified_consensus)?; | |
| '''), | |
| ) | |
| replace_once( | |
| "crates/kanari-node/src/main.rs", | |
| "fn validate_consensus_private_key_file(path: &Path) -> Result<()> {\n", | |
| dedent('''\ | |
| fn validate_network_safety( | |
| network: &NetworkMode, | |
| unsafe_allow_uncertified_consensus: bool, | |
| ) -> Result<()> { | |
| if matches!(network, NetworkMode::Mainnet) { | |
| anyhow::bail!( | |
| "mainnet startup is disabled until authenticated checkpoint certificates and atomic execution are implemented" | |
| ); | |
| } | |
| if !unsafe_allow_uncertified_consensus { | |
| anyhow::bail!( | |
| "multi-validator startup requires --unsafe-allow-uncertified-consensus until certified finality is implemented" | |
| ); | |
| } | |
| Ok(()) | |
| } | |
| fn validate_consensus_private_key_file(path: &Path) -> Result<()> { | |
| '''), | |
| ) | |
| replace_once( | |
| "crates/kanari-node/src/main.rs", | |
| " use super::{read_consensus_private_key, validate_start_authority_config};\n", | |
| " use super::{\n NetworkMode, read_consensus_private_key, validate_network_safety,\n validate_start_authority_config,\n };\n", | |
| ) | |
| replace_once( | |
| "crates/kanari-node/src/main.rs", | |
| dedent('''\ | |
| #[cfg(unix)] | |
| #[test] | |
| fn consensus_key_file_rejects_group_or_world_permissions() { | |
| '''), | |
| dedent('''\ | |
| #[test] | |
| fn mainnet_is_always_blocked() { | |
| assert!(validate_network_safety(&NetworkMode::Mainnet, true).is_err()); | |
| } | |
| #[test] | |
| fn testnet_requires_explicit_unsafe_opt_in() { | |
| assert!(validate_network_safety(&NetworkMode::Testnet, false).is_err()); | |
| assert!(validate_network_safety(&NetworkMode::Testnet, true).is_ok()); | |
| } | |
| #[cfg(unix)] | |
| #[test] | |
| fn consensus_key_file_rejects_group_or_world_permissions() { | |
| '''), | |
| ) | |
| # P2P compressed payload bounds. | |
| replace_once( | |
| "crates/kanari-node/src/p2p.rs", | |
| "const LARGE_MESSAGE_COMPRESSION_THRESHOLD: usize = 100_000;\n", | |
| dedent('''\ | |
| const LARGE_MESSAGE_COMPRESSION_THRESHOLD: usize = 100_000; | |
| const MAX_COMPRESSED_P2P_PAYLOAD: usize = 1_000_000; | |
| const MAX_DECOMPRESSED_P2P_PAYLOAD: usize = 8 * 1024 * 1024; | |
| '''), | |
| ) | |
| replace_once( | |
| "crates/kanari-node/src/p2p.rs", | |
| dedent('''\ | |
| /// Decompress a compressed UTF-8 P2P payload. | |
| pub fn decompress_payload(compressed_data: Vec<u8>) -> Result<String> { | |
| let mut decoder = GzDecoder::new(&compressed_data[..]); | |
| let mut decompressed = String::new(); | |
| decoder.read_to_string(&mut decompressed)?; | |
| Ok(decompressed) | |
| } | |
| '''), | |
| dedent('''\ | |
| /// Decompress a compressed UTF-8 P2P payload with strict bounds. | |
| pub fn decompress_payload(compressed_data: Vec<u8>) -> Result<String> { | |
| if compressed_data.len() > MAX_COMPRESSED_P2P_PAYLOAD { | |
| anyhow::bail!( | |
| "compressed P2P payload exceeds {} bytes", | |
| MAX_COMPRESSED_P2P_PAYLOAD | |
| ); | |
| } | |
| let decoder = GzDecoder::new(&compressed_data[..]); | |
| let mut limited = decoder.take((MAX_DECOMPRESSED_P2P_PAYLOAD + 1) as u64); | |
| let mut decompressed = Vec::new(); | |
| limited.read_to_end(&mut decompressed)?; | |
| if decompressed.len() > MAX_DECOMPRESSED_P2P_PAYLOAD { | |
| anyhow::bail!( | |
| "decompressed P2P payload exceeds {} bytes", | |
| MAX_DECOMPRESSED_P2P_PAYLOAD | |
| ); | |
| } | |
| String::from_utf8(decompressed) | |
| .map_err(|e| anyhow::anyhow!("decompressed P2P payload is not UTF-8: {}", e)) | |
| } | |
| '''), | |
| ) | |
| append_once( | |
| "crates/kanari-node/src/p2p.rs", | |
| "mod bounded_decompression_tests", | |
| dedent('''\ | |
| #[cfg(test)] | |
| mod bounded_decompression_tests { | |
| use super::*; | |
| #[test] | |
| fn compressed_payload_round_trips() { | |
| let original = "checkpoint-payload".repeat(1024); | |
| let compressed = gzip_string(&original).unwrap(); | |
| assert_eq!(decompress_payload(compressed).unwrap(), original); | |
| } | |
| #[test] | |
| fn decompression_bomb_is_rejected() { | |
| let oversized = "x".repeat(MAX_DECOMPRESSED_P2P_PAYLOAD + 1); | |
| let compressed = gzip_string(&oversized).unwrap(); | |
| assert!(decompress_payload(compressed).is_err()); | |
| } | |
| #[test] | |
| fn oversized_compressed_input_is_rejected() { | |
| assert!( | |
| decompress_payload(vec![0u8; MAX_COMPRESSED_P2P_PAYLOAD + 1]).is_err() | |
| ); | |
| } | |
| } | |
| '''), | |
| ) | |
| # RPC immediate execution is simulation only; query sizes are bounded. | |
| replace_once( | |
| "crates/kanari-rpc-server/src/transaction/mod.rs", | |
| dedent('''\ | |
| let tx_for_broadcast = signed_tx.clone(); | |
| if let Err(e) = state.engine.submit_transactions_batch(vec![signed_tx]) { | |
| error!("Failed to submit executed transaction: {}", e); | |
| return RpcResponse { | |
| jsonrpc: "2.0".into(), | |
| result: None, | |
| error: Some(RpcError::transaction_error(format!( | |
| "Simulation successful, but mempool submission failed: {}", | |
| e | |
| ))), | |
| id: request_id, | |
| }; | |
| } | |
| state.broadcast_submitted_transaction(tx_for_broadcast); | |
| info!( | |
| "{} executed immediately & submitted: {}", | |
| action, tx_hash_hex | |
| ); | |
| respond_with_serialize( | |
| request_id, | |
| serde_json::json!({ | |
| "hash": tx_hash_hex, | |
| "status": "executed", | |
| "action": action, | |
| "changeset": cs_value | |
| }), | |
| ) | |
| '''), | |
| dedent('''\ | |
| info!("{} simulated without submission: {}", action, tx_hash_hex); | |
| respond_with_serialize( | |
| request_id, | |
| serde_json::json!({ | |
| "hash": tx_hash_hex, | |
| "status": "simulated", | |
| "submitted": false, | |
| "action": action, | |
| "changeset": cs_value | |
| }), | |
| ) | |
| '''), | |
| ) | |
| replace_once( | |
| "crates/kanari-rpc-server/src/transaction/mod.rs", | |
| dedent('''\ | |
| let limit = request | |
| .params | |
| .get("limit") | |
| .and_then(|v| v.as_u64()) | |
| .unwrap_or(50) as usize; | |
| '''), | |
| dedent('''\ | |
| const DEFAULT_LIMIT: u64 = 50; | |
| const MAX_LIMIT: u64 = 200; | |
| let requested_limit = request | |
| .params | |
| .get("limit") | |
| .and_then(|v| v.as_u64()) | |
| .unwrap_or(DEFAULT_LIMIT); | |
| if requested_limit == 0 || requested_limit > MAX_LIMIT { | |
| return invalid_params_response( | |
| request.id, | |
| format!("limit must be between 1 and {}", MAX_LIMIT), | |
| ); | |
| } | |
| let limit = match usize::try_from(requested_limit) { | |
| Ok(limit) => limit, | |
| Err(_) => return invalid_params_response(request.id, "limit is too large"), | |
| }; | |
| '''), | |
| ) | |
| # Runtime storage must fail closed instead of silently creating divergent state. | |
| runtime_path = "move-execution/v1/kanari-move-runtime-v1/src/move_runtime/mod.rs" | |
| replace_once( | |
| runtime_path, | |
| dedent('''\ | |
| Err(e) => { | |
| log::warn!("[RUNTIME] shared object store load failed: {}", e); | |
| Arc::from(ObjectStorage::boxed_inmemory()) | |
| } | |
| '''), | |
| dedent('''\ | |
| Err(e) => { | |
| return Err(anyhow::anyhow!( | |
| "[RUNTIME] shared object store load failed: {}", | |
| e | |
| )); | |
| } | |
| '''), | |
| ) | |
| replace_once( | |
| runtime_path, | |
| dedent('''\ | |
| Err(e) => { | |
| log::warn!("[RUNTIME] DB load failed. Fallback to in-memory: {}", e); | |
| Arc::from(ObjectStorage::boxed_inmemory()) | |
| } | |
| '''), | |
| dedent('''\ | |
| Err(e) => { | |
| return Err(anyhow::anyhow!("[RUNTIME] object DB load failed: {}", e)); | |
| } | |
| '''), | |
| ) | |
| replace_once( | |
| runtime_path, | |
| dedent('''\ | |
| Err(e) => { | |
| log::warn!("[RUNTIME] isolated object store load failed: {}", e); | |
| Arc::from(ObjectStorage::boxed_inmemory()) | |
| } | |
| '''), | |
| dedent('''\ | |
| Err(e) => { | |
| return Err(anyhow::anyhow!( | |
| "[RUNTIME] isolated object store load failed: {}", | |
| e | |
| )); | |
| } | |
| '''), | |
| ) | |
| replace_once( | |
| runtime_path, | |
| dedent('''\ | |
| let published_modules: HashSet<ModuleId> = state | |
| .get_all_module_ids() | |
| .unwrap_or_default() | |
| .into_iter() | |
| .collect(); | |
| '''), | |
| dedent('''\ | |
| let published_modules: HashSet<ModuleId> = | |
| state.get_all_module_ids()?.into_iter().collect(); | |
| '''), | |
| ) | |
| # RPC resource limits and restrictive default CORS. | |
| replace_once( | |
| "Cargo.toml", | |
| 'tower-http = { version = "0.7.0", features = ["cors"] }\n', | |
| 'tower-http = { version = "0.7.0", features = ["cors", "limit", "timeout"] }\n' | |
| 'tower = { version = "0.5.3", features = ["limit"] }\n', | |
| ) | |
| replace_once( | |
| "crates/kanari-rpc-server/Cargo.toml", | |
| "tower-http = { workspace = true }\n", | |
| "tower-http = { workspace = true }\ntower.workspace = true\n", | |
| ) | |
| replace_once( | |
| "crates/kanari-rpc-server/Cargo.toml", | |
| 'tower = { version = "0.5.3", features = ["util"] }\n', | |
| 'tower = { workspace = true, features = ["util"] }\n', | |
| ) | |
| rpc_lib = "crates/kanari-rpc-server/src/lib.rs" | |
| replace_once( | |
| rpc_lib, | |
| " http::{StatusCode, header},\n", | |
| " http::{Method, StatusCode, header},\n", | |
| ) | |
| replace_once( | |
| rpc_lib, | |
| dedent('''\ | |
| use std::sync::Arc; | |
| use tower_http::cors::{Any, CorsLayer}; | |
| use tracing::info; | |
| '''), | |
| dedent('''\ | |
| use std::{sync::Arc, time::Duration}; | |
| use tower::limit::ConcurrencyLimitLayer; | |
| use tower_http::{ | |
| cors::CorsLayer, | |
| limit::RequestBodyLimitLayer, | |
| timeout::TimeoutLayer, | |
| }; | |
| use tracing::info; | |
| '''), | |
| ) | |
| replace_once( | |
| rpc_lib, | |
| "type TransactionBroadcaster = Arc<dyn Fn(SignedTransaction) -> Result<()> + Send + Sync>;\n", | |
| dedent('''\ | |
| const MAX_RPC_BODY_BYTES: usize = 2 * 1024 * 1024; | |
| const MAX_RPC_CONCURRENCY: usize = 128; | |
| const RPC_TIMEOUT_SECONDS: u64 = 30; | |
| type TransactionBroadcaster = Arc<dyn Fn(SignedTransaction) -> Result<()> + Send + Sync>; | |
| '''), | |
| ) | |
| replace_once( | |
| rpc_lib, | |
| dedent('''\ | |
| pub fn create_router(state: RpcServerState) -> Router { | |
| let cors = CorsLayer::new() | |
| .allow_origin(Any) | |
| .allow_methods(Any) | |
| .allow_headers(Any); | |
| Router::new() | |
| .route("/", post(handle_rpc)) | |
| .route("/rpc", post(handle_rpc)) | |
| .route("/metrics", get(handle_metrics)) | |
| .layer(cors) | |
| .with_state(state) | |
| } | |
| '''), | |
| dedent('''\ | |
| pub fn create_router(state: RpcServerState) -> Router { | |
| let cors = CorsLayer::new() | |
| .allow_methods([Method::GET, Method::POST]) | |
| .allow_headers([header::CONTENT_TYPE]); | |
| Router::new() | |
| .route("/", post(handle_rpc)) | |
| .route("/rpc", post(handle_rpc)) | |
| .route("/metrics", get(handle_metrics)) | |
| .layer(RequestBodyLimitLayer::new(MAX_RPC_BODY_BYTES)) | |
| .layer(TimeoutLayer::new(Duration::from_secs(RPC_TIMEOUT_SECONDS))) | |
| .layer(ConcurrencyLimitLayer::new(MAX_RPC_CONCURRENCY)) | |
| .layer(cors) | |
| .with_state(state) | |
| } | |
| '''), | |
| ) | |
| PY | |
| - name: Format and validate | |
| run: | | |
| cargo fmt --all | |
| git diff --check | |
| cargo check -p kanari-auth -p kanari-node -p kanari-core -p kanari-rpc-server -p kanari-move-runtime-v1 | |
| cargo test -p kanari-auth --lib | |
| cargo test -p kanari-node --lib | |
| cargo test -p kanari-rpc-server --lib | |
| - name: Commit validated remediation | |
| shell: bash | |
| run: | | |
| git config user.name kanari-security-bot | |
| git config user.email security-bot@users.noreply.github.com | |
| git add -A | |
| if ! git diff --cached --quiet; then | |
| git commit -m "security: apply production safety pass" | |
| git push origin HEAD:remediation/all-20260620 | |
| fi |