diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 00d6546a7..00cae9310 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -36,6 +36,9 @@ jobs: - name: Create SBOM with Trivy uses: aquasecurity/trivy-action@v0.36.0 + env: + TRIVY_SHOW_SUPPRESSED: 1 + TRIVY_IGNOREFILE: "./.trivyignore.yaml" with: scan-type: 'fs' format: 'spdx-json' @@ -47,6 +50,9 @@ jobs: - name: Create Docker image SBOM with Trivy uses: aquasecurity/trivy-action@v0.36.0 + env: + TRIVY_SHOW_SUPPRESSED: 1 + TRIVY_IGNOREFILE: "./.trivyignore.yaml" with: image-ref: "ghcr.io/defguard/defguard:${{ steps.vars.outputs.VERSION }}" scan-type: 'image' @@ -57,6 +63,9 @@ jobs: - name: Create security advisory file with Trivy uses: aquasecurity/trivy-action@v0.36.0 + env: + TRIVY_SHOW_SUPPRESSED: 1 + TRIVY_IGNOREFILE: "./.trivyignore.yaml" with: scan-type: 'fs' format: 'json' @@ -68,6 +77,9 @@ jobs: - name: Create docker image security advisory file with Trivy uses: aquasecurity/trivy-action@v0.36.0 + env: + TRIVY_SHOW_SUPPRESSED: 1 + TRIVY_IGNOREFILE: "./.trivyignore.yaml" with: image-ref: "ghcr.io/defguard/defguard:${{ steps.vars.outputs.VERSION }}" scan-type: 'image' diff --git a/.trivyignore.yaml b/.trivyignore.yaml index 5777c194a..00b8c6b98 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -5,3 +5,9 @@ vulnerabilities: - id: GHSA-w5hq-g745-h8pq expired_at: 2026-05-23 statement: "Waiting for upstream patch in paraglide" +- id: CVE-2026-29111 + expired_at: 2026-05-31 + statement: "No fixed version available in debian:13-slim - waiting for Debian to backport systemd patch" +- id: CVE-2025-69720 + expired_at: 2026-05-31 + statement: "No fixed version available in debian:13-slim - waiting for Debian to release ncurses patch" diff --git a/crates/defguard_core/src/handlers/user.rs b/crates/defguard_core/src/handlers/user.rs index bad75554c..b198bee8e 100644 --- a/crates/defguard_core/src/handlers/user.rs +++ b/crates/defguard_core/src/handlers/user.rs @@ -332,7 +332,7 @@ pub(crate) async fn add_user( if get_cached_license() .as_ref() .and_then(|l| l.limits.as_ref()) - .is_some_and(|l| l.users == user_count) + .is_some_and(|l| user_count >= l.users) { error!("Adding user {username} blocked! License limit reached."); return Ok(WebError::Forbidden("License limit reached").into()); diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 843d0a2fa..d7b03b424 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -204,7 +204,7 @@ pub(crate) async fn create_network( if get_cached_license() .as_ref() .and_then(|l| l.limits.as_ref()) - .is_some_and(|l| l.locations == location_count) + .is_some_and(|l| location_count >= l.locations) { error!("Adding location {network_name} blocked! License limit reached."); return Ok(WebError::Forbidden("License limit reached").into()); diff --git a/crates/defguard_core/tests/integration/api/user.rs b/crates/defguard_core/tests/integration/api/user.rs index 881a69287..f7ecc9c88 100644 --- a/crates/defguard_core/tests/integration/api/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -16,7 +16,12 @@ use defguard_common::{ types::user_info::UserInfo, }; use defguard_core::{ + enterprise::{ + license::{License, LicenseTier, SupportType, get_cached_license, set_cached_license}, + limits::update_counts, + }, events::ApiEventType, + grpc::proto::enterprise::license::LicenseLimits, handlers::{ AddUserData, Auth, PasswordChange, PasswordChangeSelf, Username, openid_clients::NewOpenIDClient, @@ -612,6 +617,49 @@ async fn test_crud_user(_: PgPoolOptions, options: PgConnectOptions) { ]); } +#[sqlx::test] +async fn test_add_user_blocked_when_user_count_exceeds_license_limit( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let (mut client, pool) = make_client_with_db(pool).await; + + client.login_user("admin", "pass123").await; + update_counts(&pool).await.unwrap(); + + let license = get_cached_license().clone(); + set_cached_license(Some(License::new( + "test_customer".to_string(), + false, + None, + Some(LicenseLimits { + users: 1, + devices: 100, + locations: 100, + network_devices: Some(100), + }), + None, + LicenseTier::Business, + SupportType::Basic, + ))); + + let new_user = AddUserData { + username: "adumbledore".into(), + last_name: "Dumbledore".into(), + first_name: "Albus".into(), + email: "a.dumbledore@hogwart.edu.uk".into(), + phone: Some("1234".into()), + password: Some("Password1234543$!".into()), + }; + let response = client.post("/api/v1/user").json(&new_user).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + set_cached_license(license); + client.assert_event_queue_is_empty(); +} + #[sqlx::test] async fn test_check_username(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index b4126015f..ce1d191ef 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -19,9 +19,10 @@ use defguard_core::{ DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProviderKind, }, handlers::openid_providers::AddProviderData, - license::{get_cached_license, set_cached_license}, + license::{License, LicenseTier, SupportType, get_cached_license, set_cached_license}, + limits::update_counts, }, - grpc::GatewayEvent, + grpc::{GatewayEvent, proto::enterprise::license::LicenseLimits}, handlers::{Auth, GroupInfo, wireguard::WireguardNetworkData}, }; use ipnetwork::IpNetwork; @@ -137,6 +138,63 @@ async fn test_network(_: PgPoolOptions, options: PgConnectOptions) { assert_matches!(event, GatewayEvent::NetworkDeleted(..)); } +#[sqlx::test] +async fn test_create_network_blocked_when_location_count_exceeds_license_limit( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let (mut client, client_state) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + make_network(&client, "network1").await; + make_network(&client, "network2").await; + update_counts(&client_state.pool).await.unwrap(); + + let license = get_cached_license().clone(); + set_cached_license(Some(License::new( + "test_customer".to_string(), + false, + None, + Some(LicenseLimits { + users: 100, + devices: 100, + locations: 1, + network_devices: Some(100), + }), + None, + LicenseTier::Business, + SupportType::Basic, + ))); + + let response = client + .post("/api/v1/network") + .json(&json!({ + "name": "network3", + "address": "10.1.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "mtu": 1420, + "fwmark": 0, + "allowed_groups": ["admin"], + "allow_all_groups": false, + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "location_mfa_mode": "disabled", + "service_location_mode": "disabled" + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + set_cached_license(license); +} + #[sqlx::test] async fn test_location_mfa_mode_validation_create(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await;