diff --git a/Dockerfile b/Dockerfile index 811e20b..267f188 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,5 +21,8 @@ VOLUME ["/data"] USER nora +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q --spider http://localhost:4000/health || exit 1 + ENTRYPOINT ["/usr/local/bin/nora"] CMD ["serve"] diff --git a/SECURITY.md b/SECURITY.md index f7cd113..b14531c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,7 @@ | Version | Supported | | ------- | ------------------ | +| 0.3.x | :white_check_mark: | | 0.2.x | :white_check_mark: | | < 0.2 | :x: | diff --git a/nora-registry/src/dashboard_metrics.rs b/nora-registry/src/dashboard_metrics.rs index e14bb3b..62d9cba 100644 --- a/nora-registry/src/dashboard_metrics.rs +++ b/nora-registry/src/dashboard_metrics.rs @@ -76,7 +76,6 @@ impl DashboardMetrics { pub fn with_persistence(storage_path: &str) -> Self { let path = Path::new(storage_path).join("metrics.json"); let mut metrics = Self::new(); - metrics.persist_path = Some(path.clone()); // Load existing metrics if file exists if path.exists() { @@ -108,6 +107,7 @@ impl DashboardMetrics { } } + metrics.persist_path = Some(path); metrics } diff --git a/nora-registry/src/error.rs b/nora-registry/src/error.rs index 4b06a9b..1c83438 100644 --- a/nora-registry/src/error.rs +++ b/nora-registry/src/error.rs @@ -51,11 +51,11 @@ struct ErrorResponse { impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, message) = match &self { - AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), - AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), - AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()), + let (status, message) = match self { + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), + AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), + AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), AppError::Storage(e) => match e { StorageError::NotFound => (StatusCode::NOT_FOUND, "Resource not found".to_string()), StorageError::Validation(v) => (StatusCode::BAD_REQUEST, v.to_string()), diff --git a/nora-registry/src/lib.rs b/nora-registry/src/lib.rs index 0bf4ae6..300e407 100644 --- a/nora-registry/src/lib.rs +++ b/nora-registry/src/lib.rs @@ -1,3 +1,5 @@ +#![deny(clippy::unwrap_used)] +#![forbid(unsafe_code)] //! NORA Registry — library interface for fuzzing and testing pub mod validation; diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index 8b4dcf8..be54101 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -1,6 +1,7 @@ // Copyright (c) 2026 Volkov Pavel | DevITWay // SPDX-License-Identifier: MIT - +#![deny(clippy::unwrap_used)] +#![forbid(unsafe_code)] mod activity_log; mod audit; mod auth; diff --git a/nora-registry/src/metrics.rs b/nora-registry/src/metrics.rs index d62d408..3065eb5 100644 --- a/nora-registry/src/metrics.rs +++ b/nora-registry/src/metrics.rs @@ -26,7 +26,7 @@ lazy_static! { "nora_http_requests_total", "Total number of HTTP requests", &["registry", "method", "status"] - ).expect("metric can be created"); + ).expect("failed to create HTTP_REQUESTS_TOTAL metric at startup"); /// HTTP request duration histogram pub static ref HTTP_REQUEST_DURATION: HistogramVec = register_histogram_vec!( @@ -34,28 +34,28 @@ lazy_static! { "HTTP request latency in seconds", &["registry", "method"], vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] - ).expect("metric can be created"); + ).expect("failed to create HTTP_REQUEST_DURATION metric at startup"); /// Cache requests counter (hit/miss) pub static ref CACHE_REQUESTS: IntCounterVec = register_int_counter_vec!( "nora_cache_requests_total", "Total cache requests", &["registry", "result"] - ).expect("metric can be created"); + ).expect("failed to create CACHE_REQUESTS metric at startup"); /// Storage operations counter pub static ref STORAGE_OPERATIONS: IntCounterVec = register_int_counter_vec!( "nora_storage_operations_total", "Total storage operations", &["operation", "status"] - ).expect("metric can be created"); + ).expect("failed to create STORAGE_OPERATIONS metric at startup"); /// Artifacts count by registry pub static ref ARTIFACTS_TOTAL: IntCounterVec = register_int_counter_vec!( "nora_artifacts_total", "Total artifacts stored", &["registry"] - ).expect("metric can be created"); + ).expect("failed to create ARTIFACTS_TOTAL metric at startup"); } /// Routes for metrics endpoint diff --git a/nora-registry/src/mirror/mod.rs b/nora-registry/src/mirror/mod.rs index 6f226de..308a790 100644 --- a/nora-registry/src/mirror/mod.rs +++ b/nora-registry/src/mirror/mod.rs @@ -64,7 +64,7 @@ pub fn create_progress_bar(total: u64) -> ProgressBar { .template( "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}", ) - .unwrap() + .expect("static progress bar template is valid") .progress_chars("=>-"), ); pb @@ -220,7 +220,7 @@ fn parse_requirements_txt(content: &str) -> Vec { .lines() .filter(|l| !l.trim().is_empty() && !l.starts_with('#') && !l.starts_with('-')) .filter_map(|line| { - let line = line.split('#').next().unwrap().trim(); + let line = line.split('#').next().unwrap_or(line).trim(); if let Some((name, version)) = line.split_once("==") { Some(MirrorTarget { name: name.trim().to_string(), diff --git a/nora-registry/src/mirror/npm.rs b/nora-registry/src/mirror/npm.rs index ef158c1..3c301e6 100644 --- a/nora-registry/src/mirror/npm.rs +++ b/nora-registry/src/mirror/npm.rs @@ -200,7 +200,11 @@ async fn mirror_npm_packages( let mut handles = Vec::new(); for target in targets { - let permit = sem.clone().acquire_owned().await.unwrap(); + let permit = sem + .clone() + .acquire_owned() + .await + .expect("semaphore closed unexpectedly"); let client = client.clone(); let pb = pb.clone(); let fetched = fetched.clone(); diff --git a/nora-registry/src/rate_limit.rs b/nora-registry/src/rate_limit.rs index 807d211..4da3dbf 100644 --- a/nora-registry/src/rate_limit.rs +++ b/nora-registry/src/rate_limit.rs @@ -25,7 +25,7 @@ pub fn auth_rate_limiter( .burst_size(config.auth_burst) .use_headers() .finish() - .expect("Failed to build auth rate limiter"); + .expect("failed to build auth rate limiter: invalid RateLimitConfig"); tower_governor::GovernorLayer::new(gov_config) } @@ -46,7 +46,7 @@ pub fn upload_rate_limiter( .burst_size(config.upload_burst) .use_headers() .finish() - .expect("Failed to build upload rate limiter"); + .expect("failed to build upload rate limiter: invalid RateLimitConfig"); tower_governor::GovernorLayer::new(gov_config) } @@ -65,7 +65,7 @@ pub fn general_rate_limiter( .burst_size(config.general_burst) .use_headers() .finish() - .expect("Failed to build general rate limiter"); + .expect("failed to build general rate limiter: invalid RateLimitConfig"); tower_governor::GovernorLayer::new(gov_config) } diff --git a/nora-registry/src/registry/cargo_registry.rs b/nora-registry/src/registry/cargo_registry.rs index 70a76eb..acf68c3 100644 --- a/nora-registry/src/registry/cargo_registry.rs +++ b/nora-registry/src/registry/cargo_registry.rs @@ -3,6 +3,7 @@ use crate::activity_log::{ActionType, ActivityEntry}; use crate::audit::AuditEntry; +use crate::validation::validate_storage_key; use crate::AppState; use axum::{ extract::{Path, State}, @@ -26,6 +27,10 @@ async fn get_metadata( State(state): State>, Path(crate_name): Path, ) -> Response { + // Validate input to prevent path traversal + if validate_storage_key(&crate_name).is_err() { + return StatusCode::BAD_REQUEST.into_response(); + } let key = format!("cargo/{}/metadata.json", crate_name); match state.storage.get(&key).await { Ok(data) => (StatusCode::OK, data).into_response(), @@ -37,6 +42,10 @@ async fn download( State(state): State>, Path((crate_name, version)): Path<(String, String)>, ) -> Response { + // Validate inputs to prevent path traversal + if validate_storage_key(&crate_name).is_err() || validate_storage_key(&version).is_err() { + return StatusCode::BAD_REQUEST.into_response(); + } let key = format!( "cargo/{}/{}/{}-{}.crate", crate_name, version, crate_name, version diff --git a/nora-registry/src/registry/docker.rs b/nora-registry/src/registry/docker.rs index 2b2c9dc..27ebcc4 100644 --- a/nora-registry/src/registry/docker.rs +++ b/nora-registry/src/registry/docker.rs @@ -346,7 +346,7 @@ async fn start_upload(Path(name): Path) -> Response { ( StatusCode::ACCEPTED, [ - (header::LOCATION, location.clone()), + (header::LOCATION, location), (HeaderName::from_static("docker-upload-uuid"), uuid), ], ) diff --git a/nora-registry/src/registry/npm.rs b/nora-registry/src/registry/npm.rs index a58ab99..a8194ec 100644 --- a/nora-registry/src/registry/npm.rs +++ b/nora-registry/src/registry/npm.rs @@ -176,8 +176,7 @@ async fn handle_request(State(state): State>, Path(path): Path, path: &str, key: &str) -> Optio .ok()?; let nora_base = nora_base_url(state); - let rewritten = - rewrite_tarball_urls(&data, &nora_base, proxy_url).unwrap_or_else(|_| data.clone()); + let rewritten = rewrite_tarball_urls(&data, &nora_base, proxy_url).unwrap_or(data); let storage = state.storage.clone(); let key_clone = key.to_string(); @@ -346,7 +344,9 @@ async fn handle_publish( } // Merge versions - let meta_obj = metadata.as_object_mut().unwrap(); + let Some(meta_obj) = metadata.as_object_mut() else { + return (StatusCode::INTERNAL_SERVER_ERROR, "invalid metadata format").into_response(); + }; let stored_versions = meta_obj.entry("versions").or_insert(serde_json::json!({})); if let Some(sv) = stored_versions.as_object_mut() { for (ver, ver_data) in new_versions { diff --git a/nora-registry/src/validation.rs b/nora-registry/src/validation.rs index 2364ff8..8ea3d6f 100644 --- a/nora-registry/src/validation.rs +++ b/nora-registry/src/validation.rs @@ -178,7 +178,12 @@ pub fn validate_docker_name(name: &str) -> Result<(), ValidationError> { "empty path segment".to_string(), )); } - let first = segment.chars().next().unwrap(); + // Safety: segment.is_empty() checked above, but use match for defense-in-depth + let Some(first) = segment.chars().next() else { + return Err(ValidationError::InvalidDockerName( + "empty path segment".to_string(), + )); + }; if !first.is_ascii_alphanumeric() { return Err(ValidationError::InvalidDockerName( "segment must start with alphanumeric".to_string(), @@ -292,7 +297,10 @@ pub fn validate_docker_reference(reference: &str) -> Result<(), ValidationError> } // Validate as tag - let first = reference.chars().next().unwrap(); + // Safety: empty check at function start, but use let-else for defense-in-depth + let Some(first) = reference.chars().next() else { + return Err(ValidationError::EmptyInput); + }; if !first.is_ascii_alphanumeric() { return Err(ValidationError::InvalidReference( "tag must start with alphanumeric".to_string(), diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d0ead5e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] diff --git a/tests/smoke.sh b/tests/smoke.sh index 2bd2f19..1c8c0f9 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -324,6 +324,135 @@ else fi echo "" +# ============================================ +# Go Proxy Tests +# ============================================ +echo "" +echo "=== Go Proxy ===" + +# Pre-seed a Go module for testing +GO_MODULE="example.com/testmod" +GO_VERSION="v1.0.0" +GO_STORAGE="$STORAGE_DIR/go" +mkdir -p "$GO_STORAGE/example.com/testmod/@v" + +# Create .info file +echo '{"Version":"v1.0.0","Time":"2026-01-01T00:00:00Z"}' > "$GO_STORAGE/example.com/testmod/@v/v1.0.0.info" + +# Create .mod file +echo 'module example.com/testmod + +go 1.21' > "$GO_STORAGE/example.com/testmod/@v/v1.0.0.mod" + +# Create list file +echo "v1.0.0" > "$GO_STORAGE/example.com/testmod/@v/list" + +# Test: Go module list +check "Go list versions" \ + curl -sf "$BASE/go/example.com/testmod/@v/list" -o /dev/null + +# Test: Go module .info +INFO_RESULT=$(curl -sf "$BASE/go/example.com/testmod/@v/v1.0.0.info" 2>/dev/null) +if echo "$INFO_RESULT" | grep -q "v1.0.0"; then + pass "Go .info returns version" +else + fail "Go .info: $INFO_RESULT" +fi + +# Test: Go module .mod +MOD_RESULT=$(curl -sf "$BASE/go/example.com/testmod/@v/v1.0.0.mod" 2>/dev/null) +if echo "$MOD_RESULT" | grep -q "module example.com/testmod"; then + pass "Go .mod returns module content" +else + fail "Go .mod: $MOD_RESULT" +fi + +# Test: Go @latest (200 with upstream, 404 without — both valid) +LATEST_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/go/example.com/testmod/@latest") +if [ "$LATEST_CODE" = "200" ] || [ "$LATEST_CODE" = "404" ]; then + pass "Go @latest handled ($LATEST_CODE)" +else + fail "Go @latest returned $LATEST_CODE" +fi + +# Test: Go path traversal rejection +TRAVERSAL_RESULT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/go/../../etc/passwd/@v/list") +if [ "$TRAVERSAL_RESULT" = "400" ] || [ "$TRAVERSAL_RESULT" = "404" ]; then + pass "Go path traversal rejected ($TRAVERSAL_RESULT)" +else + fail "Go path traversal returned $TRAVERSAL_RESULT" +fi + +# Test: Go nonexistent module +NOTFOUND=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/go/nonexistent.com/pkg/@v/list") +if [ "$NOTFOUND" = "404" ]; then + pass "Go 404 on nonexistent module" +else + fail "Go nonexistent returned $NOTFOUND" +fi + +# ============================================ +# Raw Registry Extended Tests +# ============================================ +echo "" +echo "=== Raw Registry (extended) ===" + +# Test: Raw upload and download (basic — already exists, extend) +echo "integration-test-data-$(date +%s)" | curl -sf -X PUT --data-binary @- "$BASE/raw/integration/test.txt" >/dev/null 2>&1 +check "Raw upload + download" \ + curl -sf "$BASE/raw/integration/test.txt" -o /dev/null + +# Test: Raw HEAD (check exists) +HEAD_RESULT=$(curl -sf -o /dev/null -w "%{http_code}" --head "$BASE/raw/integration/test.txt") +if [ "$HEAD_RESULT" = "200" ]; then + pass "Raw HEAD returns 200" +else + fail "Raw HEAD returned $HEAD_RESULT" +fi + +# Test: Raw 404 on nonexistent +NOTFOUND=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/raw/nonexistent/file.bin") +if [ "$NOTFOUND" = "404" ]; then + pass "Raw 404 on nonexistent file" +else + fail "Raw nonexistent returned $NOTFOUND" +fi + +# Test: Raw path traversal +TRAVERSAL=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/raw/../../../etc/passwd") +if [ "$TRAVERSAL" = "400" ] || [ "$TRAVERSAL" = "404" ]; then + pass "Raw path traversal rejected ($TRAVERSAL)" +else + fail "Raw path traversal returned $TRAVERSAL" +fi + +# Test: Raw overwrite +echo "version-1" | curl -sf -X PUT --data-binary @- "$BASE/raw/integration/overwrite.txt" >/dev/null 2>&1 +echo "version-2" | curl -sf -X PUT --data-binary @- "$BASE/raw/integration/overwrite.txt" >/dev/null 2>&1 +CONTENT=$(curl -sf "$BASE/raw/integration/overwrite.txt" 2>/dev/null) +if [ "$CONTENT" = "version-2" ]; then + pass "Raw overwrite works" +else + fail "Raw overwrite: got '$CONTENT'" +fi + +# Test: Raw delete +curl -sf -X DELETE "$BASE/raw/integration/overwrite.txt" >/dev/null 2>&1 +DELETE_CHECK=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/raw/integration/overwrite.txt") +if [ "$DELETE_CHECK" = "404" ]; then + pass "Raw delete works" +else + fail "Raw delete: file still returns $DELETE_CHECK" +fi + +# Test: Raw binary data (not just text) +dd if=/dev/urandom bs=1024 count=10 2>/dev/null | curl -sf -X PUT --data-binary @- "$BASE/raw/integration/binary.bin" >/dev/null 2>&1 +BIN_SIZE=$(curl -sf "$BASE/raw/integration/binary.bin" 2>/dev/null | wc -c) +if [ "$BIN_SIZE" -ge 10000 ]; then + pass "Raw binary upload/download (${BIN_SIZE} bytes)" +else + fail "Raw binary: expected ~10240, got $BIN_SIZE" +fi echo "--- Mirror CLI ---" # Create a minimal lockfile LOCKFILE=$(mktemp)