diff --git a/.env.sample b/.env.sample index 6445cf6..474454c 100644 --- a/.env.sample +++ b/.env.sample @@ -7,7 +7,7 @@ DATABASE_URL=stackdog.db RUST_BACKTRACE=full # Log Sniff Configuration -#STACKDOG_LOG_SOURCES=/var/log/syslog,/var/log/auth.log +#STACKDOG_LOG_SOURCES=/var/log/syslog,/var/log/auth.log,/var/log/nginx/access.log #STACKDOG_SNIFF_INTERVAL=30 #STACKDOG_SNIFF_OUTPUT_DIR=./stackdog-logs/ #STACKDOG_SERVE_SNIFF_ENABLED=true @@ -20,9 +20,21 @@ RUST_BACKTRACE=full #STACKDOG_AI_API_KEY= #STACKDOG_AI_MODEL=llama3 +# How long the same finding stays suppressed before it alerts again (seconds). +# Default 21600 (6h). Standing misconfigurations are re-detected every pass, so +# a short window means the same alert all day. +#STACKDOG_ALERT_DEDUP_WINDOW_SECS=21600 + # Notification Channels # Slack: create an incoming webhook at https://api.slack.com/messaging/webhooks #STACKDOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxxxx +# Slack sender identity. Only honored by legacy custom-integration webhooks or a +# bot token with chat:write.customize; Slack-app webhooks use the app's own name +# and icon, which you set under Basic Information > Display Information. +#STACKDOG_SLACK_USERNAME=Stackdog +# Defaults to https://stackdog.stacker.my/stackdog-mark.png, falling back to the +# GitHub raw copy if that host does not answer. Setting this skips the probe. +#STACKDOG_SLACK_ICON_URL=https://stackdog.stacker.my/stackdog-mark.png # Generic webhook endpoint for alert notifications #STACKDOG_WEBHOOK_URL=https://example.com/webhook #STACKDOG_SMTP_HOST=smtp.example.com @@ -35,4 +47,9 @@ RUST_BACKTRACE=full # # Action notification toggles #STACKDOG_NOTIFY_IP_BAN_ACTIONS=true + +# Never ban these, whatever the logs say. Comma-separated, bare addresses or +# CIDR notation. Put load balancers, health checkers and VPN gateways here: +# banning them takes the service down with them. Empty by default. +#STACKDOG_IP_BAN_ALLOWLIST=167.233.9.19,10.0.0.0/8 #STACKDOG_NOTIFY_QUARANTINE_ACTIONS=true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7e82fd7..439c776 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -100,5 +100,5 @@ jobs: context: . push: true tags: | - vsilent/stackdog:latest - vsilent/stackdog:${{ github.sha }} + trydirect/stackdog:latest + trydirect/stackdog:${{ github.sha }} diff --git a/.github/workflows/website-docker.yml b/.github/workflows/website-docker.yml new file mode 100644 index 0000000..67a213b --- /dev/null +++ b/.github/workflows/website-docker.yml @@ -0,0 +1,52 @@ +name: Website Docker CICD + +on: + push: + branches: [main, dev] + paths: + - 'website/**' + pull_request: + branches: [main, dev] + paths: + - 'website/**' + +jobs: + build-and-push: + name: Build & Push Website Image + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: trydirect/stackdog-website + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} + type=sha,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./website + file: ./website/Dockerfile + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/BUGS.md b/BUGS.md index 46766de..972da23 100644 --- a/BUGS.md +++ b/BUGS.md @@ -169,5 +169,5 @@ When fixing bugs, ensure: ## Contact For bug-related questions: -- **GitHub Issues:** https://github.com/vsilent/stackdog/issues +- **GitHub Issues:** https://github.com/trydirect/stackdog/issues - **Gitter:** https://gitter.im/stackdog/community diff --git a/CHANGELOG.md b/CHANGELOG.md index 169152b..ebb9575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -306,7 +306,7 @@ This release was made possible by contributions from: ## Links -- **GitHub:** https://github.com/vsilent/stackdog +- **GitHub:** https://github.com/trydirect/stackdog - **Documentation:** See docs/ directory -- **Issues:** https://github.com/vsilent/stackdog/issues -- **Discussions:** https://github.com/vsilent/stackdog/discussions +- **Issues:** https://github.com/trydirect/stackdog/issues +- **Discussions:** https://github.com/trydirect/stackdog/discussions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6982b06..f535a22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ git clone https://github.com/YOUR_USERNAME/stackdog cd stackdog # Add upstream remote -git remote add upstream https://github.com/vsilent/stackdog +git remote add upstream https://github.com/trydirect/stackdog ``` ### 2. Setup Development Environment @@ -353,8 +353,8 @@ Update relevant documentation: ## Questions? - **General questions:** [Gitter](https://gitter.im/stackdog/community) -- **Bug reports:** [GitHub Issues](https://github.com/vsilent/stackdog/issues) -- **Feature requests:** [GitHub Discussions](https://github.com/vsilent/stackdog/discussions) +- **Bug reports:** [GitHub Issues](https://github.com/trydirect/stackdog/issues) +- **Feature requests:** [GitHub Discussions](https://github.com/trydirect/stackdog/discussions) --- diff --git a/Cargo.toml b/Cargo.toml index 7cdef41..87b7046 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stackdog" -version = "0.2.2" +version = "0.2.4" authors = ["Vasili Pascal "] edition = "2021" description = "Security platform for Docker containers and Linux servers" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index afa725c..34a7985 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -597,7 +597,7 @@ cargo-deny = "0.14" ```bash # Clone repository -git clone https://github.com/vsilent/stackdog +git clone https://github.com/trydirect/stackdog cd stackdog # Install Rust (if not installed) diff --git a/QWEN.md b/QWEN.md index 9ce8ee0..946405d 100644 --- a/QWEN.md +++ b/QWEN.md @@ -74,7 +74,7 @@ stackdog/ ```bash # Clone and setup -git clone https://github.com/vsilent/stackdog +git clone https://github.com/trydirect/stackdog cd stackdog # Environment setup diff --git a/README.md b/README.md index 479f335..6e66340 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,8 @@ docker volume create stackdog-data docker run --rm -it \ --name stackdog \ - -p 5000:5000 \ + --network host \ + --cap-add=NET_ADMIN \ -e APP_HOST=0.0.0.0 \ -e APP_PORT=5000 \ -e DATABASE_URL=/data/stackdog.db \ @@ -92,6 +93,8 @@ docker run --rm -it \ trydirect/stackdog:latest ``` +> **Note:** `--network host` and `--cap-add=NET_ADMIN` are required for IP banning (iptables/nftables) to work. Without them, firewall rules from inside the container cannot affect host traffic. + Then open another shell and hit the API: ```bash @@ -127,7 +130,8 @@ docker build -f docker/backend/Dockerfile -t stackdog-local . docker run --rm -it \ --name stackdog-local \ - -p 5000:5000 \ + --network host \ + --cap-add=NET_ADMIN \ -e APP_HOST=0.0.0.0 \ -e APP_PORT=5000 \ -e DATABASE_URL=/data/stackdog.db \ @@ -151,10 +155,12 @@ This starts: The compose stack uses: -- `stackdog` service — builds `docker/backend/Dockerfile`, runs `stackdog serve`, and mounts `/var/run/docker.sock` +- `stackdog` service — builds `docker/backend/Dockerfile`, runs `stackdog serve`, mounts `/var/run/docker.sock`, uses `network_mode: host`, and adds `NET_ADMIN` capability for IP banning - `stackdog-ui` service — builds the React app and serves it with Nginx - `stackdog-data` volume — persists the SQLite database between restarts +> **Prerequisite for IP banning:** The `network_mode: host` and `cap_add: NET_ADMIN` settings are required so that `iptables`/`nftables` rules applied inside the container affect the host's network stack. Without them, IP ban firewall rules cannot reach host traffic. + To stop it: ```bash @@ -750,7 +756,7 @@ copies of the Software... - **Project Lead:** Vasili Pascal - **Email:** info@try.direct - **X:** [@VasiliiPascal](https://twitter.com/VasiliiPascal) -- **GitHub:** [vsilent/stackdog](https://github.com/vsilent/stackdog) +- **GitHub:** [trydirect/stackdog](https://github.com/trydirect/stackdog) --- diff --git a/STATUS.md b/STATUS.md index 79fbc26..9d8f7f5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -347,7 +347,7 @@ All Phase 1 tasks are now complete. The foundation for Stackdog Security is read - **Project Lead:** Vasili Pascal - **Email:** info@try.direct -- **GitHub:** https://github.com/vsilent/stackdog +- **GitHub:** https://github.com/trydirect/stackdog - **Gitter:** https://gitter.im/stackdog/community --- diff --git a/docker-compose.app.yml b/docker-compose.app.yml index 18b917f..1026fd5 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -5,12 +5,14 @@ services: dockerfile: docker/backend/Dockerfile command: ["serve"] container_name: stackdog + network_mode: host + cap_add: + - NET_ADMIN environment: APP_HOST: 0.0.0.0 APP_PORT: 5000 DATABASE_URL: /data/stackdog.db - ports: - - "5000:5000" + STACKDOG_SNIFF_INTERVAL: 30 volumes: - stackdog-data:/data - /var/run/docker.sock:/var/run/docker.sock diff --git a/docker-compose.yml b/docker-compose.yml index 67ca91c..4c51a94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,24 @@ services: stackdog: image: trydirect/stackdog:latest - ports: - - target: 5000 - published: 5000 + network_mode: host + cap_add: + - NET_ADMIN + labels: + # Keeps Stackdog from reading its own logs and reporting its own errors + # as findings. More reliable than inferring the container ID from /proc. + com.trydirect.stackdog.ignore: "true" environment: APP_HOST: 0.0.0.0 APP_PORT: 5000 DATABASE_URL: /data/stackdog.db - STACKDOG_SNIFF_INTERVAL: 600 + STACKDOG_SNIFF_INTERVAL: 30 STACKDOG_AI_PROVIDER: openai STACKDOG_AI_API_URL: https://api.openai.com/v1 STACKDOG_AI_MODEL: gpt-4o-mini STACKDOG_AI_API_KEY: STACKDOG_SLACK_WEBHOOK_URL: bool { + if !self.config.enabled { + return false; + } + + let fingerprint = Fingerprint::new(key.to_string()); + let now = Utc::now(); + + if let Some(entry) = self.fingerprints.get(&fingerprint) { + let elapsed = now - entry.last_seen; + if elapsed.num_seconds() as u64 <= self.config.window_seconds { + return true; + } + } + + self.fingerprints.insert( + fingerprint, + FingerprintEntry { + first_seen: now, + last_seen: now, + count: 1, + }, + ); + + false + } + /// Check alert and return result with count pub fn check(&mut self, alert: &Alert) -> DedupResult { self.stats.total_checked += 1; diff --git a/src/alerting/notifications.rs b/src/alerting/notifications.rs index 90e0a80..e5514b2 100644 --- a/src/alerting/notifications.rs +++ b/src/alerting/notifications.rs @@ -10,10 +10,61 @@ use std::env; use crate::alerting::alert::{Alert, AlertSeverity}; +/// Display name used on Slack messages unless overridden +const DEFAULT_SLACK_USERNAME: &str = "Stackdog"; + +/// Avatar used on Slack messages unless overridden +const DEFAULT_SLACK_ICON_URL: &str = "https://stackdog.stacker.my/stackdog-mark.png"; + +/// Avatar used when the primary icon host is unreachable +const FALLBACK_SLACK_ICON_URL: &str = + "https://raw.githubusercontent.com/trydirect/stackdog/main/website/public/stackdog-mark.png"; + +/// Cached result of the icon-host probe, resolved once per process +static RESOLVED_SLACK_ICON_URL: tokio::sync::OnceCell<&'static str> = + tokio::sync::OnceCell::const_new(); + +/// Pick the default avatar, falling back to the GitHub copy when the primary +/// host does not answer. +/// +/// The probe runs from this host rather than from Slack's fetchers, so it is a +/// proxy for reachability, not a guarantee. It runs at most once per process. +async fn resolve_default_slack_icon_url() -> &'static str { + *RESOLVED_SLACK_ICON_URL + .get_or_init(|| async { + let reachable = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(3)) + .build() + .map(|client| client.head(DEFAULT_SLACK_ICON_URL).send()) + .ok(); + + match reachable { + Some(request) => match request.await { + Ok(resp) if resp.status().is_success() => DEFAULT_SLACK_ICON_URL, + Ok(resp) => { + log::debug!( + "Slack icon host returned {}, using fallback avatar", + resp.status() + ); + FALLBACK_SLACK_ICON_URL + } + Err(e) => { + log::debug!("Slack icon host unreachable ({e}), using fallback avatar"); + FALLBACK_SLACK_ICON_URL + } + }, + None => FALLBACK_SLACK_ICON_URL, + } + }) + .await +} + /// Notification configuration #[derive(Debug, Clone)] pub struct NotificationConfig { slack_webhook: Option, + slack_username: Option, + slack_icon_url: Option, smtp_host: Option, smtp_port: Option, smtp_user: Option, @@ -29,6 +80,8 @@ impl NotificationConfig { pub fn new() -> Self { Self { slack_webhook: None, + slack_username: None, + slack_icon_url: None, smtp_host: None, smtp_port: None, smtp_user: None, @@ -44,6 +97,8 @@ impl NotificationConfig { pub fn from_env() -> Self { Self { slack_webhook: env::var("STACKDOG_SLACK_WEBHOOK_URL").ok(), + slack_username: env::var("STACKDOG_SLACK_USERNAME").ok(), + slack_icon_url: env::var("STACKDOG_SLACK_ICON_URL").ok(), smtp_host: env::var("STACKDOG_SMTP_HOST").ok(), smtp_port: env::var("STACKDOG_SMTP_PORT") .ok() @@ -75,6 +130,18 @@ impl NotificationConfig { self } + /// Set the display name shown on Slack messages + pub fn with_slack_username(mut self, username: String) -> Self { + self.slack_username = Some(username); + self + } + + /// Set the avatar image used on Slack messages + pub fn with_slack_icon_url(mut self, url: String) -> Self { + self.slack_icon_url = Some(url); + self + } + /// Set SMTP host pub fn with_smtp_host(mut self, host: String) -> Self { self.smtp_host = Some(host); @@ -128,6 +195,29 @@ impl NotificationConfig { self.slack_webhook.as_deref() } + /// Get the Slack display name, falling back to the Stackdog default + pub fn slack_username(&self) -> &str { + self.slack_username + .as_deref() + .unwrap_or(DEFAULT_SLACK_USERNAME) + } + + /// Get the Slack avatar URL, falling back to the Stackdog mark + pub fn slack_icon_url(&self) -> &str { + self.slack_icon_url + .as_deref() + .unwrap_or(DEFAULT_SLACK_ICON_URL) + } + + /// Resolve the Slack avatar, probing the primary host when no explicit + /// override is configured. + pub async fn resolved_slack_icon_url(&self) -> &str { + match self.slack_icon_url.as_deref() { + Some(url) => url, + None => resolve_default_slack_icon_url().await, + } + } + /// Get SMTP host pub fn smtp_host(&self) -> Option<&str> { self.smtp_host.as_deref() @@ -306,7 +396,8 @@ impl NotificationChannel { config: &NotificationConfig, ) -> Result { if let Some(webhook_url) = config.slack_webhook() { - let payload = build_slack_message(alert, config); + let icon_url = config.resolved_slack_icon_url().await; + let payload = build_slack_message_with_icon(alert, config, icon_url); log::debug!("Sending Slack notification to webhook"); log::trace!("Slack payload: {}", payload); @@ -553,6 +644,15 @@ pub fn severity_to_slack_color(severity: AlertSeverity) -> &'static str { /// Build Slack message payload pub fn build_slack_message(alert: &Alert, config: &NotificationConfig) -> String { + build_slack_message_with_icon(alert, config, config.slack_icon_url()) +} + +/// Build Slack message payload with an explicit avatar URL +pub fn build_slack_message_with_icon( + alert: &Alert, + config: &NotificationConfig, + icon_url: &str, +) -> String { let mut fields = vec![ serde_json::json!({"title": "Severity", "value": alert.severity().to_string(), "short": true}), serde_json::json!({"title": "Status", "value": alert.status().to_string(), "short": true}), @@ -563,7 +663,9 @@ pub fn build_slack_message(alert: &Alert, config: &NotificationConfig) -> String } serde_json::json!({ - "text": "🐕 Stackdog Security Alert", + "username": config.slack_username(), + "icon_url": icon_url, + "text": "Stackdog Security Alert", "attachments": [{ "color": severity_to_slack_color(alert.severity()), "title": format!("{:?}", alert.alert_type()), @@ -787,6 +889,66 @@ mod tests { assert_eq!(json["instance_label"], "prod-eu-1"); } + #[test] + fn test_build_slack_message_uses_default_identity() { + let alert = Alert::new( + crate::alerting::alert::AlertType::ThreatDetected, + AlertSeverity::High, + "Slack test".to_string(), + ); + + let payload = build_slack_message(&alert, &NotificationConfig::default()); + let json: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(json["username"], DEFAULT_SLACK_USERNAME); + assert_eq!(json["icon_url"], DEFAULT_SLACK_ICON_URL); + } + + #[test] + fn test_build_slack_message_honors_custom_identity() { + let alert = Alert::new( + crate::alerting::alert::AlertType::ThreatDetected, + AlertSeverity::High, + "Slack test".to_string(), + ); + + let payload = build_slack_message( + &alert, + &NotificationConfig::default() + .with_slack_username("Stackdog prod".into()) + .with_slack_icon_url("https://example.test/mark.png".into()), + ); + let json: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(json["username"], "Stackdog prod"); + assert_eq!(json["icon_url"], "https://example.test/mark.png"); + } + + #[tokio::test] + async fn test_resolved_slack_icon_url_short_circuits_on_override() { + let config = + NotificationConfig::default().with_slack_icon_url("https://x.test/a.png".into()); + assert_eq!( + config.resolved_slack_icon_url().await, + "https://x.test/a.png" + ); + } + + #[test] + fn test_build_slack_message_with_icon_uses_given_url() { + let alert = Alert::new( + crate::alerting::alert::AlertType::ThreatDetected, + AlertSeverity::High, + "Slack test".to_string(), + ); + + let payload = build_slack_message_with_icon( + &alert, + &NotificationConfig::default(), + FALLBACK_SLACK_ICON_URL, + ); + let json: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(json["icon_url"], FALLBACK_SLACK_ICON_URL); + } + #[test] fn test_build_slack_message_includes_instance_label() { let alert = Alert::new( diff --git a/src/api/containers.rs b/src/api/containers.rs index 6864c88..5e8b33a 100644 --- a/src/api/containers.rs +++ b/src/api/containers.rs @@ -218,6 +218,7 @@ mod tests { status: "Running".into(), created: "2026-01-01T00:00:00Z".into(), network_settings: std::collections::HashMap::new(), + labels: std::collections::HashMap::new(), } } diff --git a/src/api/security.rs b/src/api/security.rs index 44a3945..7a4c47e 100644 --- a/src/api/security.rs +++ b/src/api/security.rs @@ -1,8 +1,22 @@ //! Security API endpoints +use crate::database::repositories::offenses::{list_offenses, OffenseStatus}; use crate::database::{get_security_status_snapshot, DbPool, SecurityStatusSnapshot}; +use crate::ip_ban::{IpBanConfig, IpBanEngine}; use crate::models::api::security::SecurityStatusResponse; use actix_web::{web, HttpResponse, Responder}; +use serde::Deserialize; + +/// Upper bound on `?limit=`, so one request cannot pull the whole table. +const MAX_BAN_LIMIT: usize = 500; +const DEFAULT_BAN_LIMIT: usize = 100; + +#[derive(Debug, Deserialize)] +pub struct BanQuery { + /// active, blocked, or released. Case-insensitive; omit for all. + status: Option, + limit: Option, +} /// Get overall security status /// @@ -19,9 +33,76 @@ pub async fn get_security_status(pool: web::Data) -> impl Responder { } } +/// List IP ban offenses +/// +/// GET /api/security/bans?status=blocked&limit=100 +pub async fn list_bans(pool: web::Data, query: web::Query) -> impl Responder { + let status = match query.status.as_deref() { + None => None, + Some(raw) => match parse_offense_status(raw) { + Some(status) => Some(status), + None => { + return HttpResponse::BadRequest().json(serde_json::json!({ + "error": "Invalid status. Expected one of: active, blocked, released" + })) + } + }, + }; + let limit = query.limit.unwrap_or(DEFAULT_BAN_LIMIT).min(MAX_BAN_LIMIT); + + match list_offenses(pool.get_ref(), status, limit) { + Ok(offenses) => HttpResponse::Ok().json(offenses), + Err(err) => { + log::error!("Failed to list IP bans: {}", err); + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": "Failed to list IP bans" + })) + } + } +} + +/// Release an IP ban ahead of its expiry +/// +/// DELETE /api/security/bans/{ip} +pub async fn delete_ban(pool: web::Data, path: web::Path) -> impl Responder { + let ip_address = path.into_inner(); + let engine = IpBanEngine::new(pool.get_ref().clone(), IpBanConfig::from_env()); + + match engine.unban_ip(&ip_address).await { + Ok(true) => HttpResponse::Ok().json(serde_json::json!({ + "ip_address": ip_address, + "status": "Released" + })), + Ok(false) => HttpResponse::NotFound().json(serde_json::json!({ + "error": format!("No active block for {}", ip_address) + })), + Err(err) => { + log::error!("Failed to release ban for {}: {}", ip_address, err); + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": "Failed to release ban" + })) + } + } +} + +/// Accept the lowercase spellings a URL query would realistically use. +fn parse_offense_status(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "active" => Some(OffenseStatus::Active), + "blocked" => Some(OffenseStatus::Blocked), + "released" => Some(OffenseStatus::Released), + _ => None, + } +} + /// Configure security routes pub fn configure_routes(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("/api/security").route("/status", web::get().to(get_security_status))); + cfg.service( + web::scope("/api/security") + .route("/status", web::get().to(get_security_status)) + .route("/bans", web::get().to(list_bans)) + .route("/bans/{ip}", web::delete().to(delete_ban)), + ); } pub(crate) fn build_security_status(pool: &DbPool) -> anyhow::Result { @@ -51,6 +132,113 @@ mod tests { use actix_web::{test, App}; use chrono::Utc; + fn insert_blocked_offense(pool: &DbPool, ip: &str) { + use crate::database::repositories::offenses::{ + mark_blocked, record_offense_occurrence, NewIpOffense, + }; + + record_offense_occurrence( + pool, + &NewIpOffense { + id: format!("offense-{ip}"), + ip_address: ip.to_string(), + source_type: "sniff".into(), + container_id: None, + first_seen: Utc::now(), + reason: "repeated offenses".into(), + metadata: None, + }, + Utc::now() - chrono::Duration::minutes(5), + ) + .unwrap(); + mark_blocked( + pool, + ip, + "sniff", + Utc::now() + chrono::Duration::minutes(30), + ) + .unwrap(); + } + + #[actix_rt::test] + async fn test_list_bans_returns_offenses() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + insert_blocked_offense(&pool, "46.224.127.228"); + + let app = test::init_service( + App::new() + .app_data(web::Data::new(pool)) + .configure(configure_routes), + ) + .await; + + let req = test::TestRequest::get() + .uri("/api/security/bans?status=blocked") + .to_request(); + let body: serde_json::Value = test::call_and_read_body_json(&app, req).await; + + assert_eq!(body.as_array().unwrap().len(), 1); + assert_eq!(body[0]["ip_address"], "46.224.127.228"); + assert_eq!(body[0]["status"], "Blocked"); + } + + #[actix_rt::test] + async fn test_list_bans_rejects_unknown_status() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(pool)) + .configure(configure_routes), + ) + .await; + + let req = test::TestRequest::get() + .uri("/api/security/bans?status=banned") + .to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), actix_web::http::StatusCode::BAD_REQUEST); + } + + #[actix_rt::test] + async fn test_delete_ban_returns_404_for_unknown_ip() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(pool)) + .configure(configure_routes), + ) + .await; + + let req = test::TestRequest::delete() + .uri("/api/security/bans/203.0.113.99") + .to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND); + } + + // `#[test]` resolves to actix_web::test here, which requires async. + #[actix_rt::test] + async fn test_parse_offense_status_is_case_insensitive() { + assert_eq!( + parse_offense_status("Blocked"), + Some(OffenseStatus::Blocked) + ); + assert_eq!( + parse_offense_status(" active "), + Some(OffenseStatus::Active) + ); + assert_eq!( + parse_offense_status("released"), + Some(OffenseStatus::Released) + ); + assert_eq!(parse_offense_status("nonsense"), None); + } + #[actix_rt::test] async fn test_get_security_status() { let pool = create_pool(":memory:").unwrap(); diff --git a/src/api/threats.rs b/src/api/threats.rs index 2200638..ac18635 100644 --- a/src/api/threats.rs +++ b/src/api/threats.rs @@ -83,48 +83,6 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) { ); } -#[cfg(test)] -mod tests { - use super::*; - use actix_web::{test, App}; - - #[actix_rt::test] - async fn test_get_threats() { - let pool = crate::database::create_pool(":memory:").unwrap(); - crate::database::init_database(&pool).unwrap(); - let app = test::init_service( - App::new() - .app_data(web::Data::new(pool)) - .configure(configure_routes), - ) - .await; - - let req = test::TestRequest::get().uri("/api/threats").to_request(); - let resp = test::call_service(&app, req).await; - - assert!(resp.status().is_success()); - } - - #[actix_rt::test] - async fn test_get_threat_statistics() { - let pool = crate::database::create_pool(":memory:").unwrap(); - crate::database::init_database(&pool).unwrap(); - let app = test::init_service( - App::new() - .app_data(web::Data::new(pool)) - .configure(configure_routes), - ) - .await; - - let req = test::TestRequest::get() - .uri("/api/threats/statistics") - .to_request(); - let resp = test::call_service(&app, req).await; - - assert!(resp.status().is_success()); - } -} - fn severity_to_score(severity: AlertSeverity) -> u32 { match severity { AlertSeverity::Critical => 95, @@ -176,3 +134,45 @@ fn calculate_trend(alerts: &[Alert]) -> String { "stable".to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::{test, App}; + + #[actix_rt::test] + async fn test_get_threats() { + let pool = crate::database::create_pool(":memory:").unwrap(); + crate::database::init_database(&pool).unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(pool)) + .configure(configure_routes), + ) + .await; + + let req = test::TestRequest::get().uri("/api/threats").to_request(); + let resp = test::call_service(&app, req).await; + + assert!(resp.status().is_success()); + } + + #[actix_rt::test] + async fn test_get_threat_statistics() { + let pool = crate::database::create_pool(":memory:").unwrap(); + crate::database::init_database(&pool).unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(pool)) + .configure(configure_routes), + ) + .await; + + let req = test::TestRequest::get() + .uri("/api/threats/statistics") + .to_request(); + let resp = test::call_service(&app, req).await; + + assert!(resp.status().is_success()); + } +} diff --git a/src/cli.rs b/src/cli.rs index a2f30ef..8d3c20e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -28,6 +28,23 @@ pub enum Command { /// Sniff and analyze logs from Docker containers and system sources Sniff(Box), + + /// Ban an IP address immediately + BanIp(BanIpCommand), +} + +#[derive(Args, Debug, Clone)] +pub struct BanIpCommand { + /// IPv4 address to ban + pub ip_address: String, + + /// Ban duration (e.g. "30m", "1h", "2h", "24h") + #[arg(long, default_value = "30m")] + pub duration: String, + + /// Reason for the ban + #[arg(long, default_value = "manual ban")] + pub reason: String, } #[derive(Args, Debug, Clone)] diff --git a/src/collectors/network.rs b/src/collectors/network.rs index 5cba009..9d9a919 100644 --- a/src/collectors/network.rs +++ b/src/collectors/network.rs @@ -94,6 +94,7 @@ mod tests { status: "Running".to_string(), created: String::new(), network_settings: HashMap::from([("bridge".to_string(), "172.17.0.5".to_string())]), + labels: HashMap::new(), }; let event = build_network_event(&container, 64_000, 250).unwrap(); @@ -111,6 +112,7 @@ mod tests { status: "Running".to_string(), created: String::new(), network_settings: HashMap::new(), + labels: HashMap::new(), }; assert!(build_network_event(&container, 64_000, 250).is_none()); diff --git a/src/database/connection.rs b/src/database/connection.rs index 98ec13a..a07300a 100644 --- a/src/database/connection.rs +++ b/src/database/connection.rs @@ -237,6 +237,73 @@ pub fn init_database(pool: &DbPool) -> Result<()> { [], ); + collapse_duplicate_offenses(&conn)?; + + Ok(()) +} + +/// Index whose presence marks the one-row-per-(ip, source_type) layout. +const OFFENSE_UNIQUE_INDEX: &str = "idx_ip_offenses_ip_source"; + +/// Collapse the historical one-row-per-detection layout into one row per +/// (ip_address, source_type), then enforce it with a unique index. +/// +/// Older builds inserted a row per detection and left `offense_count` at 1, so +/// a noisy scanner grew the table without bound and a single ban left several +/// rows behind — which is why expiring one ban emitted several notifications. +/// +/// Guarded by the index's existence: re-running the collapse after it has +/// already happened would recount each surviving row as a group of one and +/// reset every counter to 1. +fn collapse_duplicate_offenses(conn: &rusqlite::Connection) -> Result<()> { + let already_migrated: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1", + [OFFENSE_UNIQUE_INDEX], + |row| row.get(0), + )?; + if already_migrated > 0 { + return Ok(()); + } + + // Fold each group's history into the row that will survive it. + conn.execute( + "UPDATE ip_offenses SET + offense_count = ( + SELECT COUNT(*) FROM ip_offenses AS peer + WHERE peer.ip_address = ip_offenses.ip_address + AND peer.source_type = ip_offenses.source_type + ), + first_seen = ( + SELECT MIN(peer.first_seen) FROM ip_offenses AS peer + WHERE peer.ip_address = ip_offenses.ip_address + AND peer.source_type = ip_offenses.source_type + )", + [], + )?; + + // Keep one row per group: a live block first, then the most recent. + conn.execute( + "DELETE FROM ip_offenses WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY ip_address, source_type + ORDER BY + CASE status WHEN 'Blocked' THEN 0 WHEN 'Active' THEN 1 ELSE 2 END, + last_seen DESC + ) AS position + FROM ip_offenses + ) WHERE position > 1 + )", + [], + )?; + + conn.execute( + &format!( + "CREATE UNIQUE INDEX {OFFENSE_UNIQUE_INDEX} ON ip_offenses(ip_address, source_type)" + ), + [], + )?; + Ok(()) } @@ -244,6 +311,104 @@ pub fn init_database(pool: &DbPool) -> Result<()> { mod tests { use super::*; + /// Recreate the pre-migration layout: several rows per (ip, source_type), + /// each with offense_count stuck at 1. + fn seed_legacy_offenses(conn: &rusqlite::Connection) { + let rows = [ + ( + "o1", + "192.0.2.10", + "sniff", + "2026-01-01T00:00:00Z", + "Released", + ), + ( + "o2", + "192.0.2.10", + "sniff", + "2026-01-01T00:05:00Z", + "Blocked", + ), + ( + "o3", + "192.0.2.10", + "sniff", + "2026-01-01T00:03:00Z", + "Active", + ), + ( + "o4", + "192.0.2.10", + "ai-tool", + "2026-01-01T00:04:00Z", + "Active", + ), + ( + "o5", + "198.51.100.7", + "sniff", + "2026-01-01T00:06:00Z", + "Active", + ), + ]; + for (id, ip, source, seen, status) in rows { + conn.execute( + "INSERT INTO ip_offenses ( + id, ip_address, source_type, offense_count, + first_seen, last_seen, status, reason + ) VALUES (?1, ?2, ?3, 1, ?4, ?4, ?5, 'legacy')", + rusqlite::params![id, ip, source, seen, status], + ) + .unwrap(); + } + } + + #[test] + fn test_collapse_duplicate_offenses_folds_history_into_one_row() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + { + let conn = pool.get().unwrap(); + // Drop the index init_database just created to simulate an old DB. + conn.execute(&format!("DROP INDEX IF EXISTS {OFFENSE_UNIQUE_INDEX}"), []) + .unwrap(); + seed_legacy_offenses(&conn); + collapse_duplicate_offenses(&conn).unwrap(); + + let (id, count, first_seen): (String, i64, String) = conn + .query_row( + "SELECT id, offense_count, first_seen FROM ip_offenses + WHERE ip_address = '192.0.2.10' AND source_type = 'sniff'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + + // The live block survives, carrying the group's tally and its + // earliest sighting. + assert_eq!(id, "o2"); + assert_eq!(count, 3); + assert_eq!(first_seen, "2026-01-01T00:00:00Z"); + + // Other pairs are untouched. + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM ip_offenses", [], |row| row.get(0)) + .unwrap(); + assert_eq!(total, 3); + + // Running it again must not recount the survivors as groups of one. + collapse_duplicate_offenses(&conn).unwrap(); + let count: i64 = conn + .query_row( + "SELECT offense_count FROM ip_offenses WHERE id = 'o2'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 3, "the migration must be idempotent"); + } + } + #[test] fn test_create_pool() { let pool = create_pool(":memory:"); diff --git a/src/database/repositories/log_sources.rs b/src/database/repositories/log_sources.rs index d3809e6..73c4b2a 100644 --- a/src/database/repositories/log_sources.rs +++ b/src/database/repositories/log_sources.rs @@ -94,10 +94,16 @@ pub fn get_log_source_by_path(pool: &DbPool, path_or_id: &str) -> Result Result<()> { let conn = pool.get()?; - conn.execute( + let updated = conn.execute( "UPDATE log_sources SET last_read_position = ?1 WHERE path_or_id = ?2", params![position as i64, path_or_id], )?; + if updated == 0 { + return Err(anyhow::anyhow!( + "No log source registered for path_or_id '{}'", + path_or_id + )); + } Ok(()) } diff --git a/src/database/repositories/offenses.rs b/src/database/repositories/offenses.rs index 143470d..750e0c0 100644 --- a/src/database/repositories/offenses.rs +++ b/src/database/repositories/offenses.rs @@ -107,24 +107,62 @@ fn map_row(row: &rusqlite::Row) -> Result { }) } -pub fn insert_offense(pool: &DbPool, offense: &NewIpOffense) -> Result<()> { +/// Record one detection for `(ip_address, source_type)`. +/// +/// One row per pair, with `offense_count` carrying the tally. The counter +/// restarts when the previous activity fell outside `window_start`, or when the +/// address had already served a ban — otherwise an address banned once would +/// stay one detection away from being banned again forever. +/// +/// Returns the offense count after recording. +pub fn record_offense_occurrence( + pool: &DbPool, + offense: &NewIpOffense, + window_start: DateTime, +) -> Result { let conn = pool.get()?; + let window_start = window_start.to_rfc3339(); + let seen_at = offense.first_seen.to_rfc3339(); + conn.execute( "INSERT INTO ip_offenses ( id, ip_address, source_type, container_id, offense_count, first_seen, last_seen, blocked_until, status, reason, metadata - ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, NULL, 'Active', ?6, ?7)", + ) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, NULL, 'Active', ?6, ?7) + ON CONFLICT(ip_address, source_type) DO UPDATE SET + offense_count = CASE + WHEN ip_offenses.status = 'Released' OR ip_offenses.last_seen < ?8 THEN 1 + ELSE ip_offenses.offense_count + 1 + END, + first_seen = CASE + WHEN ip_offenses.status = 'Released' OR ip_offenses.last_seen < ?8 THEN excluded.first_seen + ELSE ip_offenses.first_seen + END, + status = CASE WHEN ip_offenses.status = 'Released' THEN 'Active' ELSE ip_offenses.status END, + blocked_until = CASE WHEN ip_offenses.status = 'Released' THEN NULL ELSE ip_offenses.blocked_until END, + last_seen = excluded.last_seen, + container_id = excluded.container_id, + reason = excluded.reason, + metadata = excluded.metadata", params![ offense.id, offense.ip_address, offense.source_type, offense.container_id, - offense.first_seen.to_rfc3339(), + seen_at, offense.reason, serialize_metadata(offense.metadata.as_ref())?, + window_start, ], )?; - Ok(()) + + let count: i64 = conn.query_row( + "SELECT offense_count FROM ip_offenses WHERE ip_address = ?1 AND source_type = ?2", + params![offense.ip_address, offense.source_type], + |row| row.get(0), + )?; + + Ok(count.max(0) as u32) } pub fn find_recent_offenses( @@ -212,6 +250,41 @@ pub fn expired_blocks(pool: &DbPool, now: DateTime) -> Result, + limit: usize, +) -> Result> { + let conn = pool.get()?; + let base = "SELECT + id, ip_address, source_type, container_id, offense_count, + first_seen, last_seen, blocked_until, status, reason, metadata + FROM ip_offenses"; + + let mut offenses = Vec::new(); + match status { + Some(status) => { + let mut stmt = conn.prepare(&format!( + "{base} WHERE status = ?1 ORDER BY last_seen DESC LIMIT ?2" + ))?; + let rows = stmt.query_map(params![status.to_string(), limit as i64], map_row)?; + for row in rows { + offenses.push(row?); + } + } + None => { + let mut stmt = conn.prepare(&format!("{base} ORDER BY last_seen DESC LIMIT ?1"))?; + let rows = stmt.query_map(params![limit as i64], map_row)?; + for row in rows { + offenses.push(row?); + } + } + } + + Ok(offenses) +} + pub fn mark_released(pool: &DbPool, offense_id: &str) -> Result<()> { let conn = pool.get()?; conn.execute( @@ -232,7 +305,7 @@ mod tests { let pool = create_pool(":memory:").unwrap(); init_database(&pool).unwrap(); - insert_offense( + record_offense_occurrence( &pool, &NewIpOffense { id: "o1".into(), @@ -246,6 +319,7 @@ mod tests { sample_line: None, }), }, + Utc::now() - Duration::minutes(5), ) .unwrap(); @@ -260,13 +334,110 @@ mod tests { assert_eq!(offenses[0].status, OffenseStatus::Active); } + fn detection(ip: &str, reason: &str) -> NewIpOffense { + NewIpOffense { + id: uuid::Uuid::new_v4().to_string(), + ip_address: ip.into(), + source_type: "sniff".into(), + container_id: None, + first_seen: Utc::now(), + reason: reason.into(), + metadata: None, + } + } + + #[test] + fn test_record_offense_occurrence_increments_single_row() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let window_start = Utc::now() - Duration::minutes(5); + + for expected in 1..=4 { + let count = + record_offense_occurrence(&pool, &detection("192.0.2.30", "ssh"), window_start) + .unwrap(); + assert_eq!(count, expected); + } + + let offenses = find_recent_offenses(&pool, "192.0.2.30", "sniff", window_start).unwrap(); + assert_eq!(offenses.len(), 1, "the table must not grow per detection"); + assert_eq!(offenses[0].offense_count, 4); + } + + #[test] + fn test_record_offense_occurrence_restarts_outside_the_window() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + + record_offense_occurrence( + &pool, + &detection("192.0.2.31", "ssh"), + Utc::now() - Duration::minutes(5), + ) + .unwrap(); + + // A window that starts in the future makes the stored activity stale. + let count = record_offense_occurrence( + &pool, + &detection("192.0.2.31", "ssh"), + Utc::now() + Duration::minutes(5), + ) + .unwrap(); + assert_eq!(count, 1, "the counter restarts once activity ages out"); + } + + #[test] + fn test_record_offense_occurrence_reactivates_a_released_row() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let now = Utc::now(); + let window_start = now - Duration::minutes(5); + + record_offense_occurrence(&pool, &detection("192.0.2.32", "ssh"), window_start).unwrap(); + mark_blocked(&pool, "192.0.2.32", "sniff", now + Duration::minutes(5)).unwrap(); + let blocked = active_block_for_ip(&pool, "192.0.2.32").unwrap().unwrap(); + mark_released(&pool, &blocked.id).unwrap(); + + // A served ban starts the count over, rather than leaving the address + // one detection away from being banned again. + let count = record_offense_occurrence(&pool, &detection("192.0.2.32", "ssh"), window_start) + .unwrap(); + assert_eq!(count, 1); + + let offenses = find_recent_offenses(&pool, "192.0.2.32", "sniff", window_start).unwrap(); + assert_eq!(offenses[0].status, OffenseStatus::Active); + assert!(offenses[0].blocked_until.is_none()); + } + + #[test] + fn test_offenses_are_unique_per_ip_and_source() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let window_start = Utc::now() - Duration::minutes(5); + + record_offense_occurrence(&pool, &detection("192.0.2.33", "ssh"), window_start).unwrap(); + let mut other_source = detection("192.0.2.33", "ai"); + other_source.source_type = "ai-tool".into(); + record_offense_occurrence(&pool, &other_source, window_start).unwrap(); + + let conn = pool.get().unwrap(); + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM ip_offenses WHERE ip_address = ?1", + params!["192.0.2.33"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(rows, 2, "different sources keep their own tally"); + } + #[test] fn test_mark_blocked_and_released() { let pool = create_pool(":memory:").unwrap(); init_database(&pool).unwrap(); let now = Utc::now(); - insert_offense( + record_offense_occurrence( &pool, &NewIpOffense { id: "o2".into(), @@ -277,6 +448,7 @@ mod tests { reason: "test".into(), metadata: None, }, + now - Duration::minutes(5), ) .unwrap(); diff --git a/src/detectors/ml.rs b/src/detectors/ml.rs new file mode 100644 index 0000000..280a175 --- /dev/null +++ b/src/detectors/ml.rs @@ -0,0 +1,332 @@ +use std::net::Ipv4Addr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use crate::ml::models::isolation_forest::{IsolationForestConfig, IsolationForestModel}; +use crate::sniff::analyzer::AnomalySeverity; +use crate::sniff::reader::LogEntry; + +use super::{DetectorFamily, DetectorFinding, LogDetector}; + +const MIN_TRAINING_SAMPLES: usize = 10; + +/// ML-powered behavioral anomaly detector that learns normal log patterns +/// and flags deviations using Isolation Forest. +pub struct MlBehavioralDetector { + model: Mutex, + training_buffer: Mutex>, + trained: AtomicBool, + min_training_samples: usize, +} + +impl MlBehavioralDetector { + pub fn new() -> Self { + Self { + model: Mutex::new(IsolationForestModel::with_config(IsolationForestConfig { + trees: 48, + sample_size: 16, + max_depth: 6, + seed: 0x5eed_42ca, + })), + training_buffer: Mutex::new(Vec::with_capacity(64)), + trained: AtomicBool::new(false), + min_training_samples: MIN_TRAINING_SAMPLES, + } + } + + #[allow(dead_code)] + pub fn with_min_training_samples(mut self, n: usize) -> Self { + self.min_training_samples = n.max(3); + self + } + + pub fn is_trained(&self) -> bool { + self.trained.load(Ordering::Relaxed) + } + + /// Extract a 4-element feature vector from a batch of log entries. + fn extract_features(entries: &[LogEntry]) -> [f64; 4] { + if entries.is_empty() { + return [0.0, 0.0, 0.0, 0.0]; + } + + let total = entries.len() as f64; + let mut error_count = 0usize; + let mut warn_count = 0usize; + let mut total_chars = 0usize; + let mut unique_ips = Vec::new(); + + for entry in entries { + let lower = entry.line.to_ascii_lowercase(); + + if lower.contains("error") + || lower.contains("fatal") + || lower.contains("panic") + || lower.contains("exception") + { + error_count += 1; + } + if lower.contains("warn") { + warn_count += 1; + } + + total_chars += entry.line.len(); + + for candidate in entry.line.split_whitespace() { + let cleaned = candidate + .trim_start_matches(|ch: char| !ch.is_ascii_digit()) + .trim_end_matches(|ch: char| !ch.is_ascii_digit() && ch != '.'); + if cleaned.parse::().is_ok() + && !unique_ips.iter().any(|ip: &String| ip == cleaned) + { + unique_ips.push(cleaned.to_string()); + } + } + } + + let f1 = error_count as f64 / total; + let f2 = warn_count as f64 / total; + let f3 = (unique_ips.len() as f64 / total).clamp(0.0, 1.0); + let f4 = (total_chars as f64 / total / 200.0).clamp(0.0, 1.0); + + [f1, f2, f3, f4] + } + + fn try_train(&self) { + let buffer = self.training_buffer.lock().unwrap(); + if buffer.len() < self.min_training_samples { + return; + } + + let mut model = self.model.lock().unwrap(); + model.fit_arrays(&buffer); + self.trained.store(true, Ordering::Relaxed); + log::info!( + "MlBehavioralDetector trained on {} samples ({} trees, {} sample size)", + buffer.len(), + model.sample_size(), + model.sample_size(), + ); + } +} + +impl Default for MlBehavioralDetector { + fn default() -> Self { + Self::new() + } +} + +impl LogDetector for MlBehavioralDetector { + fn id(&self) -> &'static str { + "ml.behavioral-drift" + } + + fn family(&self) -> DetectorFamily { + DetectorFamily::Vulnerability + } + + fn detect(&self, entries: &[LogEntry]) -> Vec { + if entries.len() < 3 { + return Vec::new(); + } + + let features = Self::extract_features(entries); + + if !self.is_trained() { + let mut buffer = self.training_buffer.lock().unwrap(); + buffer.push(features); + if buffer.len() >= self.min_training_samples { + drop(buffer); + self.try_train(); + } + return Vec::new(); + } + + let model = self.model.lock().unwrap(); + let anomaly_score = model.score_array(&features); + + if anomaly_score < 0.55 { + return Vec::new(); + } + + let severity = if anomaly_score >= 0.80 { + AnomalySeverity::High + } else if anomaly_score >= 0.65 { + AnomalySeverity::Medium + } else { + AnomalySeverity::Low + }; + + let sample_line = entries + .iter() + .find(|e| { + let lower = e.line.to_ascii_lowercase(); + lower.contains("error") || lower.contains("fatal") || lower.contains("panic") + }) + .map(|e| e.line.clone()) + .unwrap_or_else(|| entries[0].line.clone()); + + vec![DetectorFinding { + detector_id: self.id().to_string(), + family: self.family(), + description: format!( + "Behavioral anomaly detected: log pattern deviates from baseline (score={:.3})", + anomaly_score + ), + severity, + confidence: (anomaly_score * 100.0) as u8, + sample_line, + }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use std::collections::HashMap; + + fn entry(line: &str) -> LogEntry { + LogEntry { + source_id: "test".into(), + timestamp: Utc::now(), + line: line.into(), + metadata: HashMap::new(), + } + } + + fn normal_batch() -> Vec { + vec![ + entry("INFO: server started on port 8080"), + entry("INFO: connection established from 10.0.0.1"), + entry("GET /health 200 2ms"), + entry("POST /api/data 201 15ms"), + entry("INFO: background job completed"), + ] + } + + fn error_spike_batch() -> Vec { + vec![ + entry("ERROR: connection refused to database"), + entry("FATAL: out of memory - killing process"), + entry("ERROR: timeout writing to socket"), + entry("ERROR: disk I/O error on /dev/sda1"), + entry("PANIC: runtime error: invalid memory address"), + ] + } + + #[test] + fn test_extract_features_empty() { + let features = MlBehavioralDetector::extract_features(&[]); + assert_eq!(features, [0.0, 0.0, 0.0, 0.0]); + } + + #[test] + fn test_extract_features_normal_batch() { + let batch = normal_batch(); + let features = MlBehavioralDetector::extract_features(&batch); + assert_eq!(features[0], 0.0); // no errors + assert_eq!(features[1], 0.0); // no warnings + } + + #[test] + fn test_extract_features_error_spike() { + let batch = error_spike_batch(); + let features = MlBehavioralDetector::extract_features(&batch); + assert!(features[0] > 0.5); // high error ratio + assert!(features[0] <= 1.0); + } + + #[test] + fn test_extract_features_ip_counting() { + let batch = vec![ + entry("connection from 10.0.0.1"), + entry("connection from 10.0.0.2"), + entry("connection from 10.0.0.3"), + entry("connection from 10.0.0.1"), + ]; + let features = MlBehavioralDetector::extract_features(&batch); + assert!((features[2] - 0.75).abs() < 0.01); // 3 unique IPs / 4 entries + } + + #[test] + fn test_detector_returns_empty_during_training_phase() { + let detector = MlBehavioralDetector::new().with_min_training_samples(5); + assert!(!detector.is_trained()); + + for _ in 0..4 { + let findings = detector.detect(&normal_batch()); + assert!(findings.is_empty()); + } + + assert!(!detector.is_trained()); + + // Fifth batch triggers training + let findings = detector.detect(&normal_batch()); + assert!(detector.is_trained()); + assert!(findings.is_empty()); // normal data shouldn't trigger + } + + #[test] + fn test_detector_flags_anomalous_pattern_after_training() { + let detector = MlBehavioralDetector::new().with_min_training_samples(4); + + for _ in 0..4 { + detector.detect(&normal_batch()); + } + + assert!(detector.is_trained()); + + let model = detector.model.lock().unwrap(); + let normal_score = + model.score_array(&MlBehavioralDetector::extract_features(&normal_batch())); + let anomaly_score = + model.score_array(&MlBehavioralDetector::extract_features(&error_spike_batch())); + assert!( + anomaly_score >= normal_score, + "anomaly_score={} should be >= normal_score={}", + anomaly_score, + normal_score + ); + } + + #[test] + fn test_detector_skips_small_batches() { + let detector = MlBehavioralDetector::new(); + let small_batch = vec![entry("INFO: lone entry")]; + let findings = detector.detect(&small_batch); + assert!(findings.is_empty()); + } + + #[test] + fn test_detector_returns_finding_when_anomaly_high() { + let detector = MlBehavioralDetector::new().with_min_training_samples(4); + + // Train on normal data + for _ in 0..4 { + detector.detect(&normal_batch()); + } + assert!(detector.is_trained()); + + // Feed highly anomalous data + let extreme_batch = vec![ + entry("FATAL: kernel panic - out of memory"), + entry("ERROR: segfault at 0x7fff0000"), + entry("PANIC: unrecoverable error in io scheduler"), + entry("ERROR: disk failure on /dev/sda"), + entry("FATAL: system will halt now"), + ]; + + let _findings = detector.detect(&extreme_batch); + let model = detector.model.lock().unwrap(); + let score = model.score_array(&MlBehavioralDetector::extract_features(&extreme_batch)); + let normal_score = + model.score_array(&MlBehavioralDetector::extract_features(&normal_batch())); + assert!( + score >= normal_score, + "extreme score {} should >= normal score {}", + score, + normal_score + ); + } +} diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index a32c54f..d69a685 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -4,8 +4,9 @@ //! that can run built-in detectors over log entries and emit structured //! anomalies that flow through the existing sniff/reporting pipeline. -mod audits; +pub mod audits; mod integrity; +mod ml; use std::collections::HashSet; @@ -16,6 +17,7 @@ pub use self::audits::ContainerPosture; use self::audits::{ConfigAssessmentMonitor, DockerPostureMonitor, PackageInventoryMonitor}; use self::integrity::FileIntegrityMonitor; +use self::ml::MlBehavioralDetector; use crate::database::connection::DbPool; use crate::sniff::analyzer::{AnomalySeverity, LogAnomaly}; use crate::sniff::reader::LogEntry; @@ -72,6 +74,7 @@ impl DetectorFinding { detector_id: Some(self.detector_id.clone()), detector_family: Some(self.family.to_string()), confidence: Some(self.confidence), + suggested_action: None, } } } @@ -121,6 +124,8 @@ impl DetectorRegistry { self.register(SsrfMetadataDetector); self.register(ExfiltrationChainDetector); self.register(SecretLeakageDetector); + self.register(WebArchiveProbeDetector); + self.register(MlBehavioralDetector::new()); } pub fn detect_log_anomalies(&self, entries: &[LogEntry]) -> Vec { @@ -203,6 +208,7 @@ struct SensitiveFileAccessDetector; struct SsrfMetadataDetector; struct ExfiltrationChainDetector; struct SecretLeakageDetector; +struct WebArchiveProbeDetector; impl LogDetector for SqlInjectionProbeDetector { fn id(&self) -> &'static str { @@ -640,6 +646,42 @@ impl LogDetector for SecretLeakageDetector { } } +impl LogDetector for WebArchiveProbeDetector { + fn id(&self) -> &'static str { + "web.archive-probe" + } + + fn family(&self) -> DetectorFamily { + DetectorFamily::Web + } + + fn detect(&self, entries: &[LogEntry]) -> Vec { + let matches = matching_entries( + entries, + &[ + ".zip", ".tar.gz", ".tgz", ".sql", ".7z", ".rar", ".tar.bz2", ".tar.xz", ".dump", + ".bak", + ], + ); + + if matches.len() < 3 { + return Vec::new(); + } + + vec![DetectorFinding { + detector_id: self.id().to_string(), + family: self.family(), + description: format!( + "Suspicious archive/backup file probing detected in {} HTTP requests", + matches.len() + ), + severity: threshold_severity(matches.len(), 3, 8), + confidence: 82, + sample_line: matches[0].line.clone(), + }] + } +} + fn matching_entries<'a>(entries: &'a [LogEntry], patterns: &[&str]) -> Vec<&'a LogEntry> { entries .iter() diff --git a/src/docker/client.rs b/src/docker/client.rs index 9efbaba..67cda0d 100644 --- a/src/docker/client.rs +++ b/src/docker/client.rs @@ -55,6 +55,11 @@ impl DockerClient { } /// Get container info by ID + /// + /// The name comes from the container's own name, not its hostname: under + /// `network_mode: host` the hostname is the host's, and otherwise Docker + /// defaults it to the short ID — so hostname never yields what `docker ps` + /// shows. pub async fn get_container_info(&self, container_id: &str) -> Result { let inspect = self .client @@ -62,14 +67,17 @@ impl DockerClient { .await .context("Failed to inspect container")?; + let name = container_display_name( + inspect.name.as_deref(), + inspect.config.as_ref().and_then(|c| c.hostname.as_deref()), + container_id, + ); let config = inspect.config.unwrap_or_default(); let state = inspect.state.unwrap_or_default(); Ok(ContainerInfo { id: container_id.to_string(), - name: config - .hostname - .unwrap_or_else(|| container_id[..12].to_string()), + name, image: config.image.unwrap_or_else(|| "unknown".to_string()), status: if state.running.unwrap_or(false) { "Running" @@ -80,6 +88,7 @@ impl DockerClient { } .to_string(), created: state.started_at.unwrap_or_default(), + labels: config.labels.unwrap_or_default(), network_settings: inspect .network_settings .map(|ns| { @@ -267,6 +276,26 @@ pub struct ContainerInfo { pub status: String, pub created: String, pub network_settings: HashMap, + /// Docker labels, including any set by docker-compose + pub labels: HashMap, +} + +/// Pick the name to show for a container. +/// +/// Docker returns the inspect name with a leading slash ("/redis"). Falls back +/// to the hostname, then to the short ID, so there is always something to print. +fn container_display_name( + inspect_name: Option<&str>, + hostname: Option<&str>, + container_id: &str, +) -> String { + inspect_name + .map(|name| name.trim_start_matches('/')) + .filter(|name| !name.is_empty()) + .or(hostname) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| container_id.chars().take(12).collect()) } /// Container statistics @@ -285,6 +314,33 @@ pub struct ContainerStats { mod tests { use super::*; + #[test] + fn test_container_display_name_prefers_real_name() { + assert_eq!( + container_display_name(Some("/redis"), Some("0f3b46ca0c16"), "0f3b46ca0c16aaaa"), + "redis" + ); + } + + #[test] + fn test_container_display_name_falls_back_to_hostname_then_id() { + // No name from inspect: hostname is the next best thing. + assert_eq!( + container_display_name(None, Some("web-01"), "0f3b46ca0c16aaaa"), + "web-01" + ); + + // Neither available: short ID keeps the output usable. + assert_eq!( + container_display_name(None, None, "0f3b46ca0c16aaaa"), + "0f3b46ca0c16" + ); + assert_eq!( + container_display_name(Some("/"), Some(""), "0f3b46ca0c16aaaa"), + "0f3b46ca0c16" + ); + } + #[actix_rt::test] async fn test_docker_client_creation() { // This test requires Docker daemon running diff --git a/src/docker/mail_guard.rs b/src/docker/mail_guard.rs index f0d4f2f..0bcad38 100644 --- a/src/docker/mail_guard.rs +++ b/src/docker/mail_guard.rs @@ -374,6 +374,7 @@ mod tests { status: "Running".into(), created: String::new(), network_settings: HashMap::new(), + labels: HashMap::new(), } } diff --git a/src/ip_ban/config.rs b/src/ip_ban/config.rs index b2f04ed..ac88eaf 100644 --- a/src/ip_ban/config.rs +++ b/src/ip_ban/config.rs @@ -1,4 +1,5 @@ use std::env; +use std::net::Ipv4Addr; #[derive(Debug, Clone)] pub struct IpBanConfig { @@ -7,6 +8,15 @@ pub struct IpBanConfig { pub find_time_secs: u64, pub ban_time_secs: u64, pub unban_check_interval_secs: u64, + /// CIDR ranges of reverse proxies; when the source IP falls in one of + /// these ranges the engine will look for the real client IP in + /// X-Forwarded-For / X-Real-IP headers before banning. + pub trusted_proxy_ranges: Vec<(Ipv4Addr, u8)>, + /// CIDR ranges that must never be banned, whatever the logs say. + /// + /// Meant for infrastructure whose loss takes the service down with it: + /// load balancers, health checkers, VPN gateways, the office egress. + pub allowlist_ranges: Vec<(Ipv4Addr, u8)>, } impl IpBanConfig { @@ -20,8 +30,58 @@ impl IpBanConfig { "STACKDOG_IP_BAN_UNBAN_CHECK_INTERVAL_SECS", 60, ), + trusted_proxy_ranges: parse_cidr_list( + &env::var("STACKDOG_TRUSTED_PROXY_RANGES") + .unwrap_or_else(|_| "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16".into()), + ), + allowlist_ranges: parse_cidr_list( + &env::var("STACKDOG_IP_BAN_ALLOWLIST").unwrap_or_default(), + ), } } + + /// Returns true if `ip` is protected from banning. + /// + /// A bare address is accepted as well as CIDR notation: "167.233.9.19" is + /// read as "167.233.9.19/32", since that is how operators write it. + pub fn is_allowlisted(&self, ip: &Ipv4Addr) -> bool { + self.allowlist_ranges + .iter() + .any(|(network, prefix_len)| in_cidr(ip, network, *prefix_len)) + } + + /// Returns true if `ip` falls within any configured trusted proxy range. + pub fn is_trusted_proxy(&self, ip: &Ipv4Addr) -> bool { + self.trusted_proxy_ranges + .iter() + .any(|(network, prefix_len)| in_cidr(ip, network, *prefix_len)) + } +} + +fn in_cidr(ip: &Ipv4Addr, network: &Ipv4Addr, prefix_len: u8) -> bool { + if prefix_len == 0 { + return true; + } + let mask = !0u32 << (32 - prefix_len); + let ip_bits = u32::from_be_bytes(ip.octets()); + let net_bits = u32::from_be_bytes(network.octets()); + (ip_bits & mask) == (net_bits & mask) +} + +pub(crate) fn parse_cidr_list(raw: &str) -> Vec<(Ipv4Addr, u8)> { + raw.split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .filter_map(|cidr| match cidr.split_once('/') { + Some((addr_str, prefix_str)) => { + let addr: Ipv4Addr = addr_str.parse().ok()?; + let prefix: u8 = prefix_str.parse().ok()?; + (prefix <= 32).then_some((addr, prefix)) + } + // A bare address means a single host. + None => cidr.parse::().ok().map(|addr| (addr, 32)), + }) + .collect() } fn parse_bool_env(name: &str, default: bool) -> bool { @@ -48,3 +108,43 @@ fn parse_u32_env(name: &str, default: u32) -> u32 { .and_then(|value| value.trim().parse::().ok()) .unwrap_or(default) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cidr_list_accepts_bare_addresses() { + let ranges = parse_cidr_list("167.233.9.19, 10.0.0.0/8 ,bogus,1.2.3.4/33"); + assert_eq!( + ranges, + vec![ + ("167.233.9.19".parse().unwrap(), 32), + ("10.0.0.0".parse().unwrap(), 8) + ] + ); + } + + #[test] + fn test_is_allowlisted_matches_host_and_range() { + let config = IpBanConfig { + enabled: true, + max_retries: 5, + find_time_secs: 300, + ban_time_secs: 1800, + unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: parse_cidr_list("167.233.9.19,192.168.0.0/16"), + }; + + assert!(config.is_allowlisted(&"167.233.9.19".parse().unwrap())); + assert!(config.is_allowlisted(&"192.168.4.7".parse().unwrap())); + assert!(!config.is_allowlisted(&"167.233.9.20".parse().unwrap())); + assert!(!config.is_allowlisted(&"8.8.8.8".parse().unwrap())); + } + + #[test] + fn test_allowlist_is_empty_by_default() { + assert!(parse_cidr_list("").is_empty()); + } +} diff --git a/src/ip_ban/engine.rs b/src/ip_ban/engine.rs index dbd9963..9c8b984 100644 --- a/src/ip_ban/engine.rs +++ b/src/ip_ban/engine.rs @@ -2,13 +2,14 @@ use crate::alerting::notifications::{dispatch_stored_alert, env_flag_enabled, No use crate::alerting::{AlertSeverity, AlertType}; use crate::database::models::{Alert, AlertMetadata}; use crate::database::repositories::offenses::{ - active_block_for_ip, expired_blocks, find_recent_offenses, insert_offense, mark_blocked, - mark_released, NewIpOffense, OffenseMetadata, + active_block_for_ip, expired_blocks, mark_blocked, mark_released, record_offense_occurrence, + NewIpOffense, OffenseMetadata, }; use crate::database::{create_alert, DbPool}; use crate::ip_ban::config::IpBanConfig; use anyhow::Result; use chrono::{Duration, Utc}; +use std::collections::HashSet; use uuid::Uuid; #[cfg(target_os = "linux")] @@ -40,12 +41,26 @@ impl IpBanEngine { } pub async fn record_offense(&self, offense: OffenseInput) -> Result { + // Checked before the offense is even recorded: banning a load balancer + // or health checker takes the service down, so protected addresses must + // not accumulate offenses that a later config change could act on. + if let Ok(parsed) = offense.ip_address.parse::() { + if self.config.is_allowlisted(&parsed) { + log::info!( + "Skipping offense for allowlisted IP {} ({})", + offense.ip_address, + offense.reason + ); + return Ok(false); + } + } + if active_block_for_ip(&self.pool, &offense.ip_address)?.is_some() { return Ok(false); } let now = Utc::now(); - insert_offense( + let offense_count = record_offense_occurrence( &self.pool, &NewIpOffense { id: Uuid::new_v4().to_string(), @@ -59,16 +74,10 @@ impl IpBanEngine { sample_line: offense.sample_line.clone(), }), }, - )?; - - let recent = find_recent_offenses( - &self.pool, - &offense.ip_address, - &offense.source_type, now - Duration::seconds(self.config.find_time_secs as i64), )?; - if recent.len() as u32 >= self.config.max_retries { + if offense_count >= self.config.max_retries { self.block_ip(&offense, now).await?; return Ok(true); } @@ -76,16 +85,63 @@ impl IpBanEngine { Ok(false) } + /// Release a ban ahead of its expiry. + /// + /// Returns `false` when the address has no active block, so callers can + /// answer 404 rather than pretending something was undone. + pub async fn unban_ip(&self, ip_address: &str) -> Result { + let Some(offense) = active_block_for_ip(&self.pool, ip_address)? else { + return Ok(false); + }; + + #[cfg(target_os = "linux")] + self.with_firewall_backend(|backend| backend.unblock_ip(&offense.ip_address))?; + + mark_released(&self.pool, &offense.id)?; + let alert = create_alert( + &self.pool, + Alert::new( + AlertType::SystemEvent, + AlertSeverity::Info, + format!("Released IP ban for {}", offense.ip_address), + ) + .with_metadata( + AlertMetadata::default() + .with_source("ip_ban") + .with_reason(format!("Manually released ban for {}", offense.ip_address)), + ), + ) + .await?; + self.notify_action_alert(&alert, "STACKDOG_NOTIFY_IP_BAN_ACTIONS", "ip ban release") + .await; + + Ok(true) + } + + /// Release every ban whose window has passed. + /// + /// Returns the number of addresses released, not the number of rows: one + /// address accumulates an offense row per detection, and `mark_blocked` + /// flips all of them, so a single ban expiring leaves several expired rows + /// behind. Releasing per row meant one firewall call and one notification + /// each — an address banned after five offenses produced five identical + /// "Released IP ban" alerts within a second. pub async fn unban_expired(&self) -> Result { let now = Utc::now(); let expired = expired_blocks(&self.pool, now)?; let mut released = 0; + let mut handled: HashSet = HashSet::new(); + + for offense in &expired { + mark_released(&self.pool, &offense.id)?; + + if !handled.insert(offense.ip_address.clone()) { + continue; + } - for offense in expired { #[cfg(target_os = "linux")] self.with_firewall_backend(|backend| backend.unblock_ip(&offense.ip_address))?; - mark_released(&self.pool, &offense.id)?; let alert = create_alert( &self.pool, Alert::new( @@ -180,6 +236,36 @@ impl IpBanEngine { .map(str::to_string) .collect() } + + /// Extract the real client IP from X-Forwarded-For / X-Real-IP headers in a + /// log line. Returns the first public-routable IP found, or None. + pub fn extract_forwarded_ip(line: &str) -> Option { + let lower = line.to_ascii_lowercase(); + + // Try X-Forwarded-For: client, proxy1, proxy2 + if let Some(start) = lower.find("x-forwarded-for:") { + let after = &line[start + 16..]; + let value = after.split('"').next().unwrap_or(after); + for candidate in value.split(',') { + let ip = candidate.trim(); + if is_ipv4(ip) { + return Some(ip.to_string()); + } + } + } + + // Try X-Real-IP: + if let Some(start) = lower.find("x-real-ip:") { + let after = &line[start + 10..]; + let value = after.split('"').next().unwrap_or(after); + let ip = value.trim(); + if is_ipv4(ip) { + return Some(ip.to_string()); + } + } + + None + } } fn is_ipv4(value: &str) -> bool { @@ -196,6 +282,7 @@ mod tests { use crate::database::repositories::offenses::find_recent_offenses; use crate::database::repositories::offenses::OffenseStatus; use crate::database::{create_pool, init_database, list_alerts, AlertFilter}; + use crate::ip_ban::config::parse_cidr_list; use chrono::Utc; #[cfg(target_os = "linux")] use std::process::Command; @@ -231,6 +318,8 @@ mod tests { find_time_secs: 300, ban_time_secs: 60, unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], }, ); @@ -275,6 +364,123 @@ mod tests { assert!(active_block_for_ip(&pool, "192.0.2.44").unwrap().is_some()); } + #[actix_rt::test] + async fn test_allowlisted_ip_is_never_recorded_or_banned() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let engine = IpBanEngine::new( + pool.clone(), + IpBanConfig { + enabled: true, + max_retries: 1, + find_time_secs: 300, + ban_time_secs: 1800, + unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: parse_cidr_list("167.233.9.19,10.0.0.0/8"), + }, + ); + + for ip in ["167.233.9.19", "10.1.2.3"] { + let blocked = engine + .record_offense(OffenseInput { + ip_address: ip.into(), + source_type: "sniff".into(), + reason: "Repeated ssh login failure".into(), + severity: AlertSeverity::Critical, + container_id: None, + source_path: Some("/var/log/auth.log".into()), + sample_line: Some(format!("Failed password from {ip}")), + }) + .await + .unwrap(); + + assert!(!blocked, "{ip} must not be banned"); + assert!(active_block_for_ip(&pool, ip).unwrap().is_none()); + + // No offense row either: a later config change must not be able to + // act on history collected while the address was protected. + let offenses = + find_recent_offenses(&pool, ip, "sniff", Utc::now() - Duration::minutes(5)) + .unwrap(); + assert!(offenses.is_empty(), "{ip} must not accumulate offenses"); + } + } + + #[actix_rt::test] + async fn test_unban_expired_alerts_once_per_address() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let engine = IpBanEngine::new( + pool.clone(), + IpBanConfig { + enabled: true, + max_retries: 3, + find_time_secs: 300, + ban_time_secs: 0, + unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], + }, + ); + + // Detections accumulate on a single row via offense_count. + let mut blocked = Ok(false); + for _ in 0..3 { + blocked = engine + .record_offense(OffenseInput { + ip_address: "192.0.2.77".into(), + source_type: "sniff".into(), + reason: "Repeated ssh login failure".into(), + severity: AlertSeverity::Critical, + container_id: None, + source_path: Some("/var/log/auth.log".into()), + sample_line: Some("Failed password from 192.0.2.77".into()), + }) + .await; + } + + #[cfg(target_os = "linux")] + if !running_as_root() { + assert!(blocked.is_err()); + return; + } + + assert!(blocked.unwrap()); + + let offenses = find_recent_offenses( + &pool, + "192.0.2.77", + "sniff", + Utc::now() - Duration::minutes(5), + ) + .unwrap(); + assert_eq!(offenses.len(), 1, "expected one row per (ip, source_type)"); + assert_eq!(offenses[0].offense_count, 3); + + // One address, one release, one alert. + let released = engine.unban_expired().await.unwrap(); + assert_eq!(released, 1); + + let offenses = find_recent_offenses( + &pool, + "192.0.2.77", + "sniff", + Utc::now() - Duration::minutes(5), + ) + .unwrap(); + assert!(offenses + .iter() + .all(|offense| offense.status == OffenseStatus::Released)); + + let alerts = list_alerts(&pool, AlertFilter::default()).await.unwrap(); + let releases = alerts + .iter() + .filter(|alert| alert.message.contains("Released IP ban for 192.0.2.77")) + .count(); + assert_eq!(releases, 1); + } + #[actix_rt::test] async fn test_unban_expired_releases_ban_and_emits_release_alert() { let pool = create_pool(":memory:").unwrap(); @@ -287,6 +493,8 @@ mod tests { find_time_secs: 300, ban_time_secs: 0, unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], }, ); diff --git a/src/lib.rs b/src/lib.rs index 0888f58..158162d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,9 @@ pub mod api; // Log sniffing pub mod sniff; +// AI tool-use +pub mod tools; + // Re-export commonly used types pub use events::security::{AlertEvent, ContainerEvent, NetworkEvent, SecurityEvent}; pub use events::syscall::{SyscallEvent, SyscallType}; diff --git a/src/main.rs b/src/main.rs index f81e1a2..4995ebd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use actix_web::{web, App, HttpServer}; use clap::Parser; use cli::{Cli, Command}; use stackdog::database::{create_pool, init_database}; +use stackdog::ip_ban::IpBanConfig; use stackdog::sniff; use std::{env, io}; use tracing::{info, Level}; @@ -91,6 +92,10 @@ async fn main() -> io::Result<()> { }); run_sniff(config).await } + Some(Command::BanIp(ban)) => { + let duration_secs = parse_duration(&ban.duration); + run_ban_ip(&ban.ip_address, duration_secs, &ban.reason).await + } // Default: serve (backward compatible) Some(Command::Serve) | None => run_serve().await, } @@ -105,7 +110,7 @@ async fn run_serve() -> io::Result<()> { info!("Port: {}", app_port); info!("Database: {}", database_url); - let app_url = format!("{}:{}", &app_host, &app_port); + let app_url = format!("{}:{}", app_host, app_port); let display_host = if app_host == "0.0.0.0" { "127.0.0.1" } else { @@ -254,3 +259,52 @@ fn parse_bool_env(name: &str, default: bool) -> bool { }) .unwrap_or(default) } + +fn parse_duration(s: &str) -> u64 { + let s = s.trim(); + if let Some(num) = s.strip_suffix("m") { + num.parse::().unwrap_or(30) * 60 + } else if let Some(num) = s.strip_suffix("h") { + num.parse::().unwrap_or(1) * 3600 + } else if let Some(num) = s.strip_suffix("s") { + num.parse::().unwrap_or(0) + } else { + s.parse::().unwrap_or(1800) + } +} + +async fn run_ban_ip(ip: &str, duration_secs: u64, reason: &str) -> io::Result<()> { + let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "./stackdog.db".into()); + let pool = create_pool(&database_url).map_err(io::Error::other)?; + init_database(&pool).map_err(io::Error::other)?; + + let config = IpBanConfig::from_env(); + let result = stackdog::tools::ip_ban::execute_ban_ip( + &pool, + &config, + &serde_json::json!({ + "ip_address": ip, + "reason": reason, + "duration_secs": duration_secs, + }) + .to_string(), + ); + + if result.content.contains("error") { + eprintln!("Error: {}", result.content); + std::process::exit(1); + } + + let parsed: serde_json::Value = serde_json::from_str(&result.content) + .map_err(|e| io::Error::other(format!("Failed to parse result: {}", e)))?; + + println!("IP banned successfully"); + println!(" IP: {}", parsed["ip_address"]); + println!(" Blocked until: {}", parsed["blocked_until"]); + println!(" Duration: {}s", parsed["duration_secs"]); + if let Some(cmd) = parsed["cli_command"].as_str() { + println!(" CLI: {}", cmd); + } + + Ok(()) +} diff --git a/src/ml/models/isolation_forest.rs b/src/ml/models/isolation_forest.rs index ea8e7b4..2a280fa 100644 --- a/src/ml/models/isolation_forest.rs +++ b/src/ml/models/isolation_forest.rs @@ -101,17 +101,19 @@ impl IsolationForestModel { } pub fn fit(&mut self, dataset: &[SecurityFeatures]) { + let rows: Vec<_> = dataset.iter().map(SecurityFeatures::as_vector).collect(); + self.fit_arrays(&rows); + } + + /// Fit the model from raw `[f64; 4]` feature arrays. + pub fn fit_arrays(&mut self, dataset: &[[f64; 4]]) { self.trees.clear(); if dataset.is_empty() { self.sample_size = 0; return; } - let rows = dataset - .iter() - .map(SecurityFeatures::as_vector) - .collect::>(); - + let rows = dataset.to_vec(); self.sample_size = self.config.sample_size.min(rows.len()).max(1); let max_depth = self .config @@ -130,15 +132,19 @@ impl IsolationForestModel { } pub fn score(&self, sample: &SecurityFeatures) -> f64 { + self.score_array(&sample.as_vector()) + } + + /// Score a raw `[f64; 4]` feature vector. Higher scores are more anomalous. + pub fn score_array(&self, vector: &[f64; 4]) -> f64 { if self.trees.is_empty() || self.sample_size <= 1 { return 0.0; } - let vector = sample.as_vector(); let average_path = self .trees .iter() - .map(|tree| path_length(&tree.root, &vector, 0)) + .map(|tree| path_length(&tree.root, vector, 0)) .sum::() / self.trees.len() as f64; diff --git a/src/sniff/analyzer.rs b/src/sniff/analyzer.rs index 05a7d45..8120228 100644 --- a/src/sniff/analyzer.rs +++ b/src/sniff/analyzer.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use crate::sniff::reader::LogEntry; +use crate::tools::ToolRegistry; const MAX_PROMPT_LINES: usize = 200; const MAX_PROMPT_CHARS: usize = 16_000; @@ -42,6 +43,8 @@ pub struct LogAnomaly { pub detector_family: Option, #[serde(skip_serializing_if = "Option::is_none")] pub confidence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suggested_action: Option, } /// Severity of a detected anomaly @@ -69,6 +72,15 @@ impl std::fmt::Display for AnomalySeverity { pub trait LogAnalyzer: Send + Sync { /// Summarize a batch of log entries async fn summarize(&self, entries: &[LogEntry]) -> Result; + + /// Summarize with tool-use support (falls back to summarize by default) + async fn summarize_with_tools( + &self, + entries: &[LogEntry], + _tools: &ToolRegistry, + ) -> Result { + self.summarize(entries).await + } } /// OpenAI-compatible API backend (works with OpenAI, Ollama, vLLM, etc.) @@ -76,6 +88,7 @@ pub struct OpenAiAnalyzer { api_url: String, api_key: Option, model: String, + max_tokens: u32, client: reqwest::Client, } @@ -91,12 +104,32 @@ impl OpenAiAnalyzer { } } - pub fn new(api_url: String, api_key: Option, model: String) -> Self { + pub fn new( + api_url: String, + api_key: Option, + model: String, + timeout_secs: u64, + max_tokens: u32, + ) -> Self { + let mut builder = reqwest::Client::builder(); + if timeout_secs > 0 { + builder = builder.timeout(std::time::Duration::from_secs(timeout_secs)); + } + let client = builder.build().unwrap_or_else(|err| { + log::warn!( + "Failed to build HTTP client with {}s timeout ({}), falling back to default", + timeout_secs, + err + ); + reqwest::Client::new() + }); + Self { api_url, api_key, model, - client: reqwest::Client::new(), + max_tokens, + client, } } @@ -243,6 +276,7 @@ struct LlmAnomaly { description: Option, severity: Option, sample_line: Option, + suggested_action: Option, } /// OpenAI chat completion response @@ -254,12 +288,34 @@ struct ChatCompletionResponse { #[derive(Debug, Deserialize)] struct ChatChoice { message: ChatMessage, + #[serde(default)] + finish_reason: Option, } #[derive(Debug, Deserialize, Serialize)] struct ChatMessage { role: String, - content: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_call_id: Option, +} + +/// Tool call as returned by the AI in a response +#[derive(Debug, Clone, Deserialize, Serialize)] +struct ToolCallDelta { + id: String, + #[serde(rename = "type")] + call_type: String, + function: FunctionCallDelta, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct FunctionCallDelta { + name: String, + arguments: String, } /// Extract JSON from LLM response, handling markdown fences, preamble text, etc. @@ -294,6 +350,87 @@ fn extract_json(content: &str) -> &str { trimmed } +/// Attempt to repair truncated JSON by closing open braces/brackets and +/// trimming incomplete trailing string values. Returns `None` if the +/// input doesn't look like JSON at all. +fn repair_truncated_json(raw: &str) -> Option { + let trimmed = raw.trim(); + let start = trimmed.find('{')?; + let json = &trimmed[start..]; + + // Already valid — nothing to repair. + if serde_json::from_str::(json).is_ok() { + return None; + } + + // Count unmatched openers to decide how many closers we need. + let mut depth: i32 = 0; // braces + let mut bracket_depth: i32 = 0; + let mut in_string = false; + let mut escape = false; + + for ch in json.chars() { + if escape { + escape = false; + continue; + } + if ch == '\\' && in_string { + escape = true; + continue; + } + if ch == '"' { + in_string = !in_string; + continue; + } + if in_string { + continue; + } + match ch { + '{' => depth += 1, + '}' => depth -= 1, + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + _ => {} + } + } + + // If we're inside a string when we ran out of input, close it first. + let mut repair = String::from(json); + if in_string { + repair.push('"'); + } + + // Close any incomplete array entries with a trailing `]` if needed. + // (We don't try to be perfect — just enough for serde to parse the + // fields that *were* fully written.) + for _ in 0..bracket_depth.max(0) { + repair.push(']'); + } + for _ in 0..depth.max(0) { + repair.push('}'); + } + + // If the last meaningful token before our closers is a trailing comma + // or colon, strip it — serde will reject `{"a":}` or `{"a":1,}`. + // We do a simple scan from the end ignoring the closers we just added. + let closers_len = + (bracket_depth.max(0) as usize) + (depth.max(0) as usize) + if in_string { 1 } else { 0 }; + let body_end = repair.len() - closers_len; + let body = &repair[..body_end]; + let trimmed_body = body.trim_end(); + if trimmed_body.ends_with(',') || trimmed_body.ends_with(':') { + let new_body = &trimmed_body[..trimmed_body.len() - 1]; + repair = format!("{}{}", new_body.trim_end(), &repair[body_end..]); + } + + // Only return the repair if it actually parses. + if serde_json::from_str::(&repair).is_ok() { + Some(repair) + } else { + None + } +} + /// Parse LLM severity string to enum fn parse_severity(s: &str) -> AnomalySeverity { match s.to_lowercase().as_str() { @@ -313,10 +450,28 @@ fn parse_llm_response(source_id: &str, entries: &[LogEntry], raw_json: &str) -> ); log::trace!("Raw LLM response:\n{}", raw_json); - let analysis: LlmAnalysis = serde_json::from_str(raw_json).context(format!( - "Failed to parse LLM response as JSON. Response starts with: {}", - &raw_json[..raw_json.len().min(200)] - ))?; + let analysis: LlmAnalysis = match serde_json::from_str(raw_json) { + Ok(a) => a, + Err(e) => { + // Try to repair truncated JSON before giving up. + if let Some(repaired) = repair_truncated_json(raw_json) { + log::warn!( + "LLM response was truncated ({}); repaired to {} bytes", + e, + repaired.len() + ); + serde_json::from_str(&repaired).context(format!( + "Failed to parse repaired LLM response. Original starts with: {}", + &raw_json[..raw_json.len().min(200)] + ))? + } else { + return Err(e).context(format!( + "Failed to parse LLM response as JSON. Response starts with: {}", + &raw_json[..raw_json.len().min(200)] + )); + } + } + }; log::debug!( "LLM analysis parsed — summary: {:?}, errors: {:?}, warnings: {:?}, anomalies: {}", @@ -337,6 +492,7 @@ fn parse_llm_response(source_id: &str, entries: &[LogEntry], raw_json: &str) -> detector_id: None, detector_family: None, confidence: None, + suggested_action: a.suggested_action, }) .collect(); @@ -376,9 +532,12 @@ fn entry_time_range(entries: &[LogEntry]) -> (DateTime, DateTime) { (start, end) } +const MAX_TOOL_ROUNDS: usize = 5; + #[async_trait] impl LogAnalyzer for OpenAiAnalyzer { async fn summarize(&self, entries: &[LogEntry]) -> Result { + // ... existing implementation (unchanged) ... if entries.is_empty() { log::debug!("OpenAiAnalyzer: no entries to analyze, returning empty summary"); return Ok(LogSummary { @@ -411,14 +570,19 @@ impl LogAnalyzer for OpenAiAnalyzer { "messages": [ { "role": "system", - "content": "You are a log analysis assistant. Analyze logs and return structured JSON." + "content": "You are a log analysis assistant. Analyze logs and return structured JSON. Be concise — limit summary to 1-2 sentences, max 5 key events, max 5 anomalies.\n\n\ + When you detect an attack with an identifiable source IP, include a \"suggested_action\" field in the anomaly with a CLI command the operator can run to mitigate it. Examples:\n\ + - \"stackdog ban-ip 167.233.9.19 --duration 30m --reason 'credential scanning'\"\n\ + - \"stackdog firewall add --public-ports 8080/tcp\"\n\ + Only include suggested_action when there is a clear, actionable mitigation." }, { "role": "user", "content": prompt } ], - "temperature": 0.1 + "temperature": 0.1, + "max_tokens": self.max_tokens }); let url = format!("{}/chat/completions", self.api_url.trim_end_matches('/')); @@ -468,7 +632,7 @@ impl LogAnalyzer for OpenAiAnalyzer { let content = completion .choices .first() - .map(|c| c.message.content.clone()) + .and_then(|c| c.message.content.clone()) .unwrap_or_default(); log::debug!( @@ -483,6 +647,143 @@ impl LogAnalyzer for OpenAiAnalyzer { parse_llm_response(source_id, entries, json_str) } + + async fn summarize_with_tools( + &self, + entries: &[LogEntry], + tools: &ToolRegistry, + ) -> Result { + if entries.is_empty() { + return self.summarize(entries).await; + } + + let prompt = Self::build_prompt(entries); + let source_id = entries[0].source_id.clone(); + + let system_msg = serde_json::json!({ + "role": "system", + "content": "You are a log analysis assistant. Analyze logs and return structured JSON. Be concise — limit summary to 1-2 sentences, max 5 key events, max 5 anomalies.\n\n\ + When you detect an attack with an identifiable source IP, include a \"suggested_action\" field in the anomaly with a CLI command the operator can run to mitigate it. Examples:\n\ + - \"stackdog ban-ip 167.233.9.19 --duration 30m --reason 'credential scanning'\"\n\ + - \"stackdog firewall add --public-ports 8080/tcp\"\n\ + Only include suggested_action when there is a clear, actionable mitigation.\n\n\ + You have access to tools. Use them to gather context before making decisions — check if an IP is already banned, inspect container posture, or run detectors on suspicious lines." + }); + + let user_msg = serde_json::json!({ + "role": "user", + "content": prompt + }); + + let tool_defs = tools.definitions(); + let mut messages: Vec = vec![system_msg, user_msg]; + + let url = format!("{}/chat/completions", self.api_url.trim_end_matches('/')); + + for round in 0..MAX_TOOL_ROUNDS { + log::debug!("Tool-use round {}/{}", round + 1, MAX_TOOL_ROUNDS); + + let request_body = serde_json::json!({ + "model": self.model, + "messages": messages, + "tools": tool_defs, + "tool_choice": "auto", + "temperature": 0.1, + "max_tokens": self.max_tokens + }); + + let mut req = self + .client + .post(&url) + .header("Content-Type", "application/json"); + + if let Some(ref key) = self.api_key { + req = req.header("Authorization", format!("Bearer {}", key)); + } + + let response = req + .json(&request_body) + .send() + .await + .context("Failed to send request to AI API")?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("AI API returned status {}: {}", status, body); + } + + let raw_body = response + .text() + .await + .context("Failed to read AI API response body")?; + + let completion: ChatCompletionResponse = + serde_json::from_str(&raw_body).context("Failed to parse AI API response")?; + + let choice = match completion.choices.into_iter().next() { + Some(c) => c, + None => anyhow::bail!("AI API returned no choices"), + }; + + // If the AI wants to call tools + if choice.finish_reason.as_deref() == Some("tool_calls") { + if let Some(tool_calls) = &choice.message.tool_calls { + // Append the assistant message with tool_calls + messages.push(serde_json::json!({ + "role": "assistant", + "tool_calls": tool_calls.iter().map(|tc| { + serde_json::json!({ + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments + } + }) + }).collect::>() + })); + + // Execute each tool and append results + for tc in tool_calls { + let call = crate::tools::types::ToolCall { + id: tc.id.clone(), + call_type: "function".into(), + function: crate::tools::types::FunctionCall { + name: tc.function.name.clone(), + arguments: tc.function.arguments.clone(), + }, + }; + let result = tools.execute(&call).await; + log::debug!( + "Tool {} returned {} chars", + tc.function.name, + result.content.len() + ); + messages.push(serde_json::json!({ + "role": "tool", + "tool_call_id": result.tool_call_id, + "content": result.content + })); + } + continue; // next round + } + } + + // Final response — parse as LogSummary + let content = choice.message.content.unwrap_or_default(); + log::debug!( + "Tool-use final response ({} chars): {}", + content.len(), + &content[..content.len().min(200)] + ); + + let json_str = extract_json(&content); + return parse_llm_response(&source_id, entries, json_str); + } + + anyhow::bail!("AI exceeded max tool-call rounds ({})", MAX_TOOL_ROUNDS) + } } /// Fallback local analyzer that uses pattern matching (no AI required) @@ -566,6 +867,7 @@ impl LogAnalyzer for PatternAnalyzer { detector_id: None, detector_family: None, confidence: None, + suggested_action: None, }); } } @@ -844,8 +1146,13 @@ mod tests { #[test] fn test_openai_analyzer_new() { - let analyzer = - OpenAiAnalyzer::new("http://localhost:11434/v1".into(), None, "llama3".into()); + let analyzer = OpenAiAnalyzer::new( + "http://localhost:11434/v1".into(), + None, + "llama3".into(), + 300, + 2048, + ); assert_eq!(analyzer.api_url, "http://localhost:11434/v1"); assert!(analyzer.api_key.is_none()); assert_eq!(analyzer.model, "llama3"); @@ -853,8 +1160,13 @@ mod tests { #[tokio::test] async fn test_openai_analyzer_empty_entries() { - let analyzer = - OpenAiAnalyzer::new("http://localhost:11434/v1".into(), None, "llama3".into()); + let analyzer = OpenAiAnalyzer::new( + "http://localhost:11434/v1".into(), + None, + "llama3".into(), + 300, + 2048, + ); let summary = analyzer.summarize(&[]).await.unwrap(); assert_eq!(summary.total_entries, 0); } @@ -877,6 +1189,7 @@ mod tests { detector_id: None, detector_family: None, confidence: None, + suggested_action: None, }], }; let json = serde_json::to_string(&summary).unwrap(); @@ -884,4 +1197,52 @@ mod tests { assert_eq!(deserialized.total_entries, 10); assert_eq!(deserialized.anomalies[0].severity, AnomalySeverity::Medium); } + + #[test] + fn test_repair_truncated_json_basic() { + // Simulates a truncated LLM response — missing closing braces + let truncated = r#"{"summary": "Multiple errors detected", "error_count": 42"#; + let repaired = repair_truncated_json(truncated).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); + assert_eq!(parsed["summary"], "Multiple errors detected"); + assert_eq!(parsed["error_count"], 42); + } + + #[test] + fn test_repair_truncated_json_with_trailing_comma() { + let truncated = r#"{"summary": "Test", "error_count": 5, "#; + let repaired = repair_truncated_json(truncated).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); + assert_eq!(parsed["summary"], "Test"); + assert_eq!(parsed["error_count"], 5); + } + + #[test] + fn test_repair_truncated_json_mid_string() { + let truncated = r#"{"summary": "Multiple failed connection at"#; + let repaired = repair_truncated_json(truncated).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); + // The string value will be truncated but still parseable + assert!(parsed["summary"].as_str().unwrap().starts_with("Multiple")); + } + + #[test] + fn test_repair_truncated_json_with_nested_array() { + let truncated = r#"{"summary": "Test", "key_events": ["event1", "event2"#; + let repaired = repair_truncated_json(truncated).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); + assert_eq!(parsed["key_events"][0], "event1"); + assert_eq!(parsed["key_events"][1], "event2"); + } + + #[test] + fn test_repair_truncated_json_already_valid() { + let valid = r#"{"summary": "OK", "error_count": 0}"#; + assert!(repair_truncated_json(valid).is_none()); + } + + #[test] + fn test_repair_truncated_json_not_json() { + assert!(repair_truncated_json("this is not json at all").is_none()); + } } diff --git a/src/sniff/config.rs b/src/sniff/config.rs index 4108bc4..10f704b 100644 --- a/src/sniff/config.rs +++ b/src/sniff/config.rs @@ -3,6 +3,17 @@ use std::env; use std::path::PathBuf; +/// Default AI request timeout. Generous because local models on modest +/// hardware can take minutes per completion, but bounded so a wedged +/// inference host cannot stall the sniff loop indefinitely. +const DEFAULT_AI_TIMEOUT_SECS: u64 = 300; +const DEFAULT_AI_MAX_TOKENS: u32 = 2048; + +/// Default window for suppressing repeats of the same finding. Six hours, +/// because standing misconfigurations (unauthenticated Redis, world-readable +/// config) are re-detected on every pass and would otherwise alert all day. +const DEFAULT_ALERT_DEDUP_WINDOW_SECS: u64 = 6 * 60 * 60; + /// AI provider selection #[derive(Debug, Clone, PartialEq)] pub enum AiProvider { @@ -44,6 +55,8 @@ pub struct SniffConfig { pub package_inventory_paths: Vec, /// Poll interval in seconds pub interval_secs: u64, + /// How long the same finding stays suppressed before alerting again + pub alert_dedup_window_secs: u64, /// AI provider to use for summarization pub ai_provider: AiProvider, /// AI API URL (for OpenAI-compatible providers) @@ -52,6 +65,10 @@ pub struct SniffConfig { pub ai_api_key: Option, /// AI model name pub ai_model: String, + /// Request timeout in seconds for AI API calls (0 disables the timeout) + pub ai_timeout_secs: u64, + /// Max tokens for AI API response (0 lets the provider decide) + pub ai_max_tokens: u32, /// Database URL pub database_url: String, /// Slack webhook URL for alert notifications @@ -68,6 +85,10 @@ pub struct SniffConfig { pub smtp_password: Option, /// Email recipients for alert notifications pub email_recipients: Vec, + /// Container names to skip during posture checks (trusted services) + pub trusted_containers: Vec, + /// Enable AI tool-use (function calling) during analysis + pub ai_tools_enabled: bool, } /// Arguments for building a SniffConfig @@ -183,6 +204,10 @@ impl SniffConfig { config_assessment_paths, package_inventory_paths, interval_secs, + alert_dedup_window_secs: env::var("STACKDOG_ALERT_DEDUP_WINDOW_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_ALERT_DEDUP_WINDOW_SECS), ai_provider: ai_provider_str.parse().unwrap(), ai_api_url: args .ai_api_url @@ -195,6 +220,14 @@ impl SniffConfig { .map(|s| s.to_string()) .or_else(|| env::var("STACKDOG_AI_MODEL").ok()) .unwrap_or_else(|| "llama3".into()), + ai_timeout_secs: env::var("STACKDOG_AI_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_AI_TIMEOUT_SECS), + ai_max_tokens: env::var("STACKDOG_AI_MAX_TOKENS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_AI_MAX_TOKENS), database_url: env::var("DATABASE_URL").unwrap_or_else(|_| "./stackdog.db".into()), slack_webhook: args .slack_webhook @@ -233,6 +266,20 @@ impl SniffConfig { .collect() }) .unwrap_or_default(), + trusted_containers: env::var("STACKDOG_TRUSTED_CONTAINERS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + ai_tools_enabled: env::var("STACKDOG_AI_TOOLS_ENABLED") + .ok() + .and_then(|v| match v.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + }) + .unwrap_or(true), } } } @@ -254,6 +301,8 @@ mod tests { env::remove_var("STACKDOG_AI_API_URL"); env::remove_var("STACKDOG_AI_API_KEY"); env::remove_var("STACKDOG_AI_MODEL"); + env::remove_var("STACKDOG_AI_TIMEOUT_SECS"); + env::remove_var("STACKDOG_AI_MAX_TOKENS"); env::remove_var("STACKDOG_SNIFF_OUTPUT_DIR"); env::remove_var("STACKDOG_SNIFF_INTERVAL"); env::remove_var("STACKDOG_SLACK_WEBHOOK_URL"); @@ -263,6 +312,49 @@ mod tests { env::remove_var("STACKDOG_SMTP_USER"); env::remove_var("STACKDOG_SMTP_PASSWORD"); env::remove_var("STACKDOG_EMAIL_RECIPIENTS"); + env::remove_var("STACKDOG_TRUSTED_CONTAINERS"); + env::remove_var("STACKDOG_ALERT_DEDUP_WINDOW_SECS"); + } + + fn default_args() -> SniffArgs<'static> { + SniffArgs { + once: false, + consume: false, + output: "./stackdog-logs/", + sources: None, + interval: 30, + ai_provider: None, + ai_model: None, + ai_api_url: None, + slack_webhook: None, + webhook_url: None, + smtp_host: None, + smtp_port: None, + smtp_user: None, + smtp_password: None, + email_recipients: None, + } + } + + #[test] + fn test_alert_dedup_window_defaults_to_six_hours() { + let _lock = ENV_MUTEX.lock().unwrap(); + clear_sniff_env(); + + let config = SniffConfig::from_env_and_args(default_args()); + assert_eq!(config.alert_dedup_window_secs, 21_600); + } + + #[test] + fn test_alert_dedup_window_read_from_env() { + let _lock = ENV_MUTEX.lock().unwrap(); + clear_sniff_env(); + env::set_var("STACKDOG_ALERT_DEDUP_WINDOW_SECS", "900"); + + let config = SniffConfig::from_env_and_args(default_args()); + assert_eq!(config.alert_dedup_window_secs, 900); + + clear_sniff_env(); } #[test] diff --git a/src/sniff/discovery.rs b/src/sniff/discovery.rs index e2bc4c4..c35261c 100644 --- a/src/sniff/discovery.rs +++ b/src/sniff/discovery.rs @@ -122,7 +122,92 @@ pub fn discover_custom_sources(paths: &[String]) -> Vec { .collect() } +/// Label that marks a container as one Stackdog should not read logs from. +/// +/// Declaring it in docker-compose is more reliable than inferring our own ID +/// from `/proc`, since it survives any cgroup layout, network mode, or runtime: +/// +/// ```yaml +/// labels: +/// com.trydirect.stackdog.ignore: "true" +/// ``` +pub const IGNORE_LABEL: &str = "com.trydirect.stackdog.ignore"; + +/// Whether a container's labels ask Stackdog to skip it. +fn is_ignored_by_label(labels: &std::collections::HashMap) -> bool { + labels + .get(IGNORE_LABEL) + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +/// Extract a 64-hex container ID from a /proc line, if one is present. +fn extract_container_id(line: &str) -> Option { + line.split(|ch: char| !ch.is_ascii_hexdigit()) + .find(|token| token.len() == 64) + .map(str::to_string) +} + +/// Pull our container ID out of /proc/self/mountinfo contents. +/// +/// Lines mentioning `/containers/` are preferred: Docker bind-mounts +/// `/var/lib/docker/containers//resolv.conf` and friends, so those carry the +/// real container ID. Plain 64-hex tokens elsewhere in the file can be overlay +/// layer hashes, which would be the wrong ID. +fn container_id_from_mountinfo(contents: &str) -> Option { + contents + .lines() + .filter(|line| line.contains("/containers/")) + .find_map(extract_container_id) + .or_else(|| contents.lines().find_map(extract_container_id)) +} + +/// Best-effort detection of the container Stackdog itself runs in. +/// +/// Reading the hostname is not enough: under `network_mode: host` the container +/// inherits the host's hostname instead of its own short ID. +/// +/// Two sources are consulted, because neither covers every setup: +/// `/proc/self/cgroup` carries the ID under cgroup v1 and under v2 with a host +/// cgroup namespace, but collapses to a bare `0::/` under v2 with the private +/// namespace Docker now defaults to. `/proc/self/mountinfo` still names the ID +/// there. Both live under `/proc/self`, which a process can always read for +/// itself, and neither is in Docker's masked-path list. +/// +/// Returns `None` outside containers, and when detection fails; set +/// `STACKDOG_SELF_CONTAINER_ID` to pin the ID by hand in that case. +pub fn self_container_id() -> Option { + if let Ok(id) = std::env::var("STACKDOG_SELF_CONTAINER_ID") { + let id = id.trim().to_string(); + if !id.is_empty() { + return Some(id); + } + } + + if let Ok(contents) = std::fs::read_to_string("/proc/self/cgroup") { + if let Some(id) = contents.lines().find_map(extract_container_id) { + return Some(id); + } + } + + if let Ok(contents) = std::fs::read_to_string("/proc/self/mountinfo") { + if let Some(id) = container_id_from_mountinfo(&contents) { + return Some(id); + } + } + + None +} + /// Discover Docker container log sources +/// +/// Skips Stackdog's own container: reading our own stdout feeds every internal +/// error back into the analyzer, which then reports it as a finding. pub async fn discover_docker_sources() -> Result> { use crate::docker::DockerClient; @@ -135,8 +220,37 @@ pub async fn discover_docker_sources() -> Result> { }; let containers = client.list_containers(false).await?; + let self_id = self_container_id(); + if self_id.is_none() { + log::debug!( + "Could not determine own container ID; if Stackdog runs in Docker its own logs \ + will be analyzed as a source. Set the {} label on the container, or \ + STACKDOG_SELF_CONTAINER_ID, to prevent that.", + IGNORE_LABEL + ); + } let sources = containers .into_iter() + .filter(|c| { + if is_ignored_by_label(&c.labels) { + log::debug!( + "Skipping container {} — {} label is set", + c.name, + IGNORE_LABEL + ); + return false; + } + match &self_id { + Some(self_id) => { + let is_self = self_id.starts_with(&c.id) || c.id.starts_with(self_id.as_str()); + if is_self { + log::debug!("Skipping own container {} in log discovery", c.id); + } + !is_self + } + None => true, + } + }) .map(|c| { let name = format!("docker:{}", c.name); LogSource::new(LogSourceType::DockerContainer, c.id, name) @@ -180,6 +294,77 @@ pub async fn discover_all(extra_paths: &[String]) -> Result> { #[cfg(test)] mod tests { use super::*; + + #[test] + fn test_is_ignored_by_label_accepts_truthy_values() { + for value in ["true", "TRUE", "1", " yes ", "on"] { + let labels = + std::collections::HashMap::from([(IGNORE_LABEL.to_string(), value.to_string())]); + assert!( + is_ignored_by_label(&labels), + "expected {value} to be truthy" + ); + } + } + + #[test] + fn test_is_ignored_by_label_ignores_other_values_and_labels() { + let off = + std::collections::HashMap::from([(IGNORE_LABEL.to_string(), "false".to_string())]); + assert!(!is_ignored_by_label(&off)); + + let unrelated = std::collections::HashMap::from([( + "com.docker.compose.service".to_string(), + "stackdog".to_string(), + )]); + assert!(!is_ignored_by_label(&unrelated)); + + assert!(!is_ignored_by_label(&std::collections::HashMap::new())); + } + + #[test] + fn test_container_id_from_mountinfo_prefers_container_path() { + // Overlay layer hashes appear first and are not container IDs. + let mountinfo = "\ +1234 1200 0:100 / / rw,relatime - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/l/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\ +1250 1234 0:60 /containers/6b55165e7b09f91066e8acfd833c69f9b3cf441b948971c93d7f5a15c621ce7f/resolv.conf /etc/resolv.conf rw - ext4 /dev/vda1 rw\n"; + + assert_eq!( + container_id_from_mountinfo(mountinfo).as_deref(), + Some("6b55165e7b09f91066e8acfd833c69f9b3cf441b948971c93d7f5a15c621ce7f") + ); + } + + #[test] + fn test_container_id_from_mountinfo_returns_none_on_host() { + let mountinfo = "25 30 0:23 / /proc rw,nosuid,nodev,noexec - proc proc rw\n"; + assert_eq!(container_id_from_mountinfo(mountinfo), None); + } + + #[test] + fn test_extract_container_id_from_proc_lines() { + // cgroup v1 + assert_eq!( + extract_container_id( + "11:devices:/docker/a70b8c987795e1f3aa1c0d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f" + ) + .as_deref(), + Some("a70b8c987795e1f3aa1c0d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f") + ); + + // cgroup v2 / systemd scope + assert_eq!( + extract_container_id( + "0::/system.slice/docker-a70b8c987795e1f3aa1c0d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f.scope" + ) + .as_deref(), + Some("a70b8c987795e1f3aa1c0d1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f") + ); + + // Nothing container-shaped on a plain host + assert_eq!(extract_container_id("0::/init.scope"), None); + assert_eq!(extract_container_id("12:pids:/user.slice"), None); + } use std::io::Write; use tempfile::NamedTempFile; diff --git a/src/sniff/mod.rs b/src/sniff/mod.rs index 9518637..be39ad9 100644 --- a/src/sniff/mod.rs +++ b/src/sniff/mod.rs @@ -22,9 +22,12 @@ use crate::sniff::consumer::LogConsumer; use crate::sniff::discovery::LogSourceType; use crate::sniff::reader::{DockerLogReader, FileLogReader, LogReader}; use crate::sniff::reporter::Reporter; +use crate::tools::ToolRegistry; use anyhow::Result; use chrono::Utc; +use std::collections::HashMap; use std::net::Ipv4Addr; +use std::sync::Mutex; /// Main orchestrator for the sniff command pub struct SniffOrchestrator { @@ -33,6 +36,9 @@ pub struct SniffOrchestrator { detectors: DetectorRegistry, reporter: Reporter, ip_ban: Option, + tool_registry: ToolRegistry, + /// Last AI analysis time per source (prevents re-analyzing every 30s) + last_ai_analysis: Mutex>>, } impl SniffOrchestrator { @@ -63,18 +69,26 @@ impl SniffOrchestrator { notification_config = notification_config.with_email_recipients(config.email_recipients.clone()); } - let reporter = Reporter::new(notification_config); + let reporter = Reporter::new(notification_config, config.alert_dedup_window_secs); let ip_ban_config = IpBanConfig::from_env(); let ip_ban = ip_ban_config .enabled .then(|| IpBanEngine::new(pool.clone(), ip_ban_config)); + let tool_registry = ToolRegistry::new( + pool.clone(), + IpBanConfig::from_env(), + DetectorRegistry::default(), + ); + Ok(Self { config, pool, detectors: DetectorRegistry::default(), reporter, ip_ban, + tool_registry, + last_ai_analysis: Mutex::new(HashMap::new()), }) } @@ -91,6 +105,8 @@ impl SniffOrchestrator { self.config.ai_api_url.clone(), self.config.ai_api_key.clone(), self.config.ai_model.clone(), + self.config.ai_timeout_secs, + self.config.ai_max_tokens, )) } config::AiProvider::Candle => { @@ -160,12 +176,18 @@ impl SniffOrchestrator { match DockerClient::new().await { Ok(docker) => { let postures = docker.list_container_postures(true).await?; + // Update tool registry with all postures (before filtering) + self.tool_registry.set_postures(postures.clone()); + let filtered: Vec<_> = postures + .into_iter() + .filter(|p| !self.config.trusted_containers.iter().any(|t| t == &p.name)) + .collect(); self.report_detector_batch( &mut result, "docker-posture", - postures.len(), + filtered.len(), "Docker posture audit", - self.detectors.detect_docker_posture_anomalies(&postures), + self.detectors.detect_docker_posture_anomalies(&filtered), ) .await?; } @@ -217,18 +239,82 @@ impl SniffOrchestrator { // 4. Analyze log::debug!("Step 4: analyzing {} entries...", entries.len()); - let mut summary = match analyzer.summarize(&entries).await { - Ok(summary) => summary, - Err(err) => { - log::warn!( - "Primary analyzer failed for {}: {}. Falling back to local pattern analyzer.", - reader.source_id(), - err + + // Run built-in detectors first (free, local) + let detector_anomalies = self.detectors.detect_log_anomalies(&entries); + let has_errors = entries.iter().any(|e| { + let lower = e.line.to_lowercase(); + lower.contains("error") || lower.contains("fatal") || lower.contains("panic") + }); + + // Skip AI if no errors and no detector findings — use pattern analyzer + let skip_ai = !has_errors && detector_anomalies.is_empty(); + + // Check per-source cooldown (default 5 minutes between AI calls) + let source_key = reader.source_id().to_string(); + let cooldown_secs = std::env::var("STACKDOG_AI_COOLDOWN_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(300); + let within_cooldown = { + let last = self.last_ai_analysis.lock().unwrap(); + last.get(&source_key) + .map(|t| (Utc::now() - t).num_seconds() < cooldown_secs) + .unwrap_or(false) + }; + + let mut summary = if skip_ai || within_cooldown { + if within_cooldown { + log::debug!( + " Source {} within cooldown ({}s), using pattern analyzer", + source_key, + cooldown_secs ); - analyzer::PatternAnalyzer::new().summarize(&entries).await? + } else { + log::debug!(" No errors or detector findings, using pattern analyzer"); + } + analyzer::PatternAnalyzer::new().summarize(&entries).await? + } else if self.config.ai_tools_enabled { + // Tool-use path — single call, no fallback to avoid double billing + match analyzer + .summarize_with_tools(&entries, &self.tool_registry) + .await + { + Ok(summary) => { + self.last_ai_analysis + .lock() + .unwrap() + .insert(source_key.clone(), Utc::now()); + summary + } + Err(err) => { + log::warn!( + "AI analysis failed for {}: {}. Using pattern analyzer.", + reader.source_id(), + err + ); + analyzer::PatternAnalyzer::new().summarize(&entries).await? + } + } + } else { + match analyzer.summarize(&entries).await { + Ok(summary) => { + self.last_ai_analysis + .lock() + .unwrap() + .insert(source_key.clone(), Utc::now()); + summary + } + Err(err) => { + log::warn!( + "AI analysis failed for {}: {}. Using pattern analyzer.", + reader.source_id(), + err + ); + analyzer::PatternAnalyzer::new().summarize(&entries).await? + } } }; - let detector_anomalies = self.detectors.detect_log_anomalies(&entries); if !detector_anomalies.is_empty() { summary.key_events.extend( detector_anomalies @@ -247,12 +333,23 @@ impl SniffOrchestrator { // 5. Report log::debug!("Step 5: reporting results..."); - let report = self.reporter.report(&summary, Some(&self.pool)).await?; - result.anomalies_found += report.anomalies_reported; let source = &sources[i]; + // Per-source failures are logged and skipped rather than propagated: + // one unusable source (a missing firewall backend, an unreadable + // container) must not abort the whole pass and starve every source + // that comes after it. + match self + .reporter + .report(&summary, Some(&self.pool), Some(source)) + .await + { + Ok(report) => result.anomalies_found += report.anomalies_reported, + Err(err) => log::warn!("Reporting failed for {}: {}", source.path_or_id, err), + } if let Some(engine) = &self.ip_ban { - self.apply_ip_ban(&entries, source, &summary, engine) - .await?; + if let Err(err) = self.apply_ip_ban(&entries, source, &summary, engine).await { + log::warn!("IP ban step failed for {}: {}", source.path_or_id, err); + } } // 6. Consume (if enabled) @@ -279,12 +376,22 @@ impl SniffOrchestrator { } // 7. Update read position + // + // Keyed by path_or_id, not source_id: `LogSource::id` is a fresh UUID + // on every discovery pass, so it never matches the stored row and the + // offset would silently reset to 0 — re-reading the whole file forever. log::debug!("Step 7: saving read position ({})", reader.position()); - let _ = log_sources_repo::update_read_position( + if let Err(err) = log_sources_repo::update_read_position( &self.pool, - reader.source_id(), + &source.path_or_id, reader.position(), - ); + ) { + log::warn!( + "Failed to save read position for {}: {}", + source.path_or_id, + err + ); + } } Ok(result) @@ -311,14 +418,25 @@ impl SniffOrchestrator { analyzer::AnomalySeverity::Critical => crate::alerting::AlertSeverity::Critical, }; - for ip in IpBanEngine::extract_ip_candidates(&anomaly.sample_line) { - if !is_public_routable_ipv4(&ip) { + // Try sample_line first, fall back to scanning entries + let mut ips = IpBanEngine::extract_ip_candidates(&anomaly.sample_line); + if ips.is_empty() { + for entry in entries { + ips.extend(IpBanEngine::extract_ip_candidates(&entry.line)); + } + ips.sort(); + ips.dedup(); + } + + for ip in ips { + let target_ip = resolve_ban_target(&ip, &anomaly.sample_line, engine); + if !is_public_routable_ipv4(&target_ip) { continue; } engine .record_offense(OffenseInput { - ip_address: ip, + ip_address: target_ip, source_type: "sniff".into(), reason: anomaly.description.clone(), severity, @@ -345,13 +463,14 @@ impl SniffOrchestrator { }; for ip in IpBanEngine::extract_ip_candidates(&entry.line) { - if !is_public_routable_ipv4(&ip) { + let target_ip = resolve_ban_target(&ip, &entry.line, engine); + if !is_public_routable_ipv4(&target_ip) { continue; } engine .record_offense(OffenseInput { - ip_address: ip, + ip_address: target_ip, source_type: "sniff".into(), reason: reason.into(), severity, @@ -393,7 +512,10 @@ impl SniffOrchestrator { .collect(), anomalies, }; - let report = self.reporter.report(&summary, Some(&self.pool)).await?; + let report = self + .reporter + .report(&summary, Some(&self.pool), None) + .await?; result.anomalies_found += report.anomalies_reported; Ok(()) } @@ -442,7 +564,16 @@ pub struct SniffPassResult { fn should_auto_ban(anomaly: &analyzer::LogAnomaly) -> bool { if let Some(detector_id) = anomaly.detector_id.as_deref() { - if matches!(detector_id, "web.login-bruteforce" | "web.path-traversal") { + if matches!( + detector_id, + "web.login-bruteforce" + | "web.path-traversal" + | "web.archive-probe" + | "web.sqli-probe" + | "web.webshell-probe" + | "file.sensitive-access" + | "cloud.metadata-ssrf" + ) { return true; } } @@ -455,11 +586,43 @@ fn should_auto_ban(anomaly: &analyzer::LogAnomaly) -> bool { "authentication failures", "invalid user", "path traversal", + "credential scanning", + "sensitive file access", + "sql injection probing", + "ssrf", + "metadata access", + // AI-generated attack descriptions + "rejected connection", + "coordinated attack", + "possible attack", + "targeting", + "probing", + "scanning", + "frequent access", ] .iter() .any(|needle| description.contains(needle)) } +/// If `ip` is a trusted proxy, try to extract the real client IP from +/// X-Forwarded-For / X-Real-IP in the log line. Otherwise return `ip` as-is. +fn resolve_ban_target(ip: &str, line: &str, engine: &IpBanEngine) -> String { + let Ok(parsed) = ip.parse::() else { + return ip.to_string(); + }; + if engine.config().is_trusted_proxy(&parsed) { + if let Some(real_ip) = IpBanEngine::extract_forwarded_ip(line) { + log::debug!( + "Resolved proxied IP {} -> {} via X-Forwarded-For", + ip, + real_ip + ); + return real_ip; + } + } + ip.to_string() +} + fn ssh_auth_failure_offense(line: &str) -> Option<(&'static str, AlertSeverity)> { let line = line.to_ascii_lowercase(); @@ -579,6 +742,7 @@ mod tests { detector_id: None, detector_family: None, confidence: None, + suggested_action: None, }], } } @@ -605,6 +769,7 @@ mod tests { detector_id: detector_id.map(str::to_string), detector_family: None, confidence: None, + suggested_action: None, }], } } @@ -635,6 +800,7 @@ mod tests { detector_id: Some("web.path-traversal".into()), detector_family: Some("Web".into()), confidence: Some(82), + suggested_action: None, }; assert!(should_auto_ban(&anomaly)); @@ -649,6 +815,7 @@ mod tests { detector_id: Some("secrets.log-leakage".into()), detector_family: Some("Secrets".into()), confidence: Some(92), + suggested_action: None, }; assert!(!should_auto_ban(&anomaly)); @@ -725,6 +892,67 @@ mod tests { assert!(result.total_entries >= 3); } + #[tokio::test] + async fn test_orchestrator_persists_read_position_across_passes() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let log_path = dir.path().join("position.log"); + { + let mut f = std::fs::File::create(&log_path).unwrap(); + writeln!(f, "INFO: service started").unwrap(); + writeln!(f, "ERROR: connection failed").unwrap(); + } + let path_str = log_path.to_string_lossy().to_string(); + + let mut config = SniffConfig::from_env_and_args(config::SniffArgs { + once: true, + consume: false, + output: "./stackdog-logs/", + sources: Some(&path_str), + interval: 30, + ai_provider: Some("candle"), + ai_model: None, + ai_api_url: None, + slack_webhook: None, + webhook_url: None, + smtp_host: None, + smtp_port: None, + smtp_user: None, + smtp_password: None, + email_recipients: None, + }); + config.database_url = ":memory:".into(); + + let orchestrator = SniffOrchestrator::new(config).unwrap(); + orchestrator.run_once().await.unwrap(); + + let file_len = std::fs::metadata(&log_path).unwrap().len(); + let saved = log_sources_repo::get_log_source_by_path(&orchestrator.pool, &path_str) + .unwrap() + .expect("source should be registered"); + assert_eq!( + saved.last_read_position, file_len, + "read position must persist so the next pass does not re-read the file" + ); + + { + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&log_path) + .unwrap(); + writeln!(f, "WARN: retry in 5s").unwrap(); + } + + orchestrator.run_once().await.unwrap(); + + let grown_len = std::fs::metadata(&log_path).unwrap().len(); + let saved = log_sources_repo::get_log_source_by_path(&orchestrator.pool, &path_str) + .unwrap() + .expect("source should still be registered"); + assert!(grown_len > file_len); + assert_eq!(saved.last_read_position, grown_len); + } + #[tokio::test] async fn test_orchestrator_applies_builtin_detectors_to_log_entries() { use std::io::Write; @@ -782,6 +1010,8 @@ mod tests { find_time_secs: 300, ban_time_secs: 60, unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], }, ); let source = LogSource::new( @@ -815,7 +1045,9 @@ mod tests { Utc::now() - chrono::Duration::minutes(5), ) .unwrap(); - assert_eq!(offenses.len(), 5); + // One row per (ip, source_type), with the tally in offense_count. + assert_eq!(offenses.len(), 1); + assert_eq!(offenses[0].offense_count, 5); assert!(offenses.iter().all(|offense| { offense .metadata @@ -921,6 +1153,8 @@ mod tests { find_time_secs: 300, ban_time_secs: 60, unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], }, ); let summary = make_summary( @@ -966,6 +1200,8 @@ mod tests { find_time_secs: 300, ban_time_secs: 60, unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], }, ); let summary = make_summary( @@ -1035,6 +1271,8 @@ mod tests { find_time_secs: 300, ban_time_secs: 60, unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], }, ); let summary = make_detector_summary( @@ -1071,6 +1309,8 @@ mod tests { find_time_secs: 300, ban_time_secs: 60, unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], }, ); let summary = make_summary( diff --git a/src/sniff/reporter.rs b/src/sniff/reporter.rs index c9c62f4..2b38d98 100644 --- a/src/sniff/reporter.rs +++ b/src/sniff/reporter.rs @@ -3,24 +3,34 @@ //! Converts log summaries and anomalies into alerts, then dispatches //! them via the existing notification channels. +use std::cell::RefCell; + use crate::alerting::alert::{Alert, AlertSeverity, AlertType}; +use crate::alerting::dedup::{AlertDeduplicator, DedupConfig}; use crate::alerting::notifications::{NotificationConfig, NotificationResult}; use crate::database::connection::DbPool; use crate::database::models::{Alert as StoredAlert, AlertMetadata}; use crate::database::repositories::alerts::create_alert; use crate::database::repositories::log_sources; use crate::sniff::analyzer::{AnomalySeverity, LogSummary}; +use crate::sniff::discovery::{LogSource, LogSourceType}; use anyhow::Result; /// Reports log analysis results to alert channels and persists summaries pub struct Reporter { notification_config: NotificationConfig, + deduplicator: RefCell, } impl Reporter { - pub fn new(notification_config: NotificationConfig) -> Self { + /// Build a reporter that suppresses repeats of the same finding for + /// `dedup_window_secs` (see `STACKDOG_ALERT_DEDUP_WINDOW_SECS`). + pub fn new(notification_config: NotificationConfig, dedup_window_secs: u64) -> Self { Self { notification_config, + deduplicator: RefCell::new(AlertDeduplicator::new( + DedupConfig::default().with_window_seconds(dedup_window_secs), + )), } } @@ -35,11 +45,17 @@ impl Reporter { } /// Report a log summary: persist to DB and send anomaly alerts + /// + /// `source` carries the human-readable identity of the log source. It is + /// optional because synthetic summaries (file integrity, package audit) + /// already use a readable `source_id`. pub async fn report( &self, summary: &LogSummary, pool: Option<&DbPool>, + source: Option<&LogSource>, ) -> Result { + let source_label = describe_source(&summary.source_id, source); let mut alerts_sent = 0; // Persist summary to database @@ -72,16 +88,41 @@ impl Reporter { anomaly.description ); - let message = format!( + let mut message = format!( "[Log Sniff] {} — Source: {} | Sample: {}", - anomaly.description, summary.source_id, anomaly.sample_line + anomaly.description, source_label, anomaly.sample_line ); + if let Some(ref action) = anomaly.suggested_action { + message.push_str(&format!("\nSuggested: {}", action)); + } let alert = Alert::new(AlertType::AnomalyDetected, alert_severity, message.clone()); + let dedup_key = dedup_key(anomaly, source); + if self.deduplicator.borrow_mut().is_duplicate_key(&dedup_key) { + log::debug!("Suppressing duplicate alert: {}", anomaly.description); + continue; + } + if let Some(pool) = pool { + // Record the stable identity (path or container ID), not the + // per-pass UUID in `summary.source_id`, so stored alerts can be + // joined back to `log_sources.path_or_id`. let mut metadata = AlertMetadata::default() - .with_source(summary.source_id.clone()) + .with_source( + source + .map(|s| s.path_or_id.clone()) + .unwrap_or_else(|| summary.source_id.clone()), + ) .with_reason(anomaly.description.clone()); + if let Some(s) = source { + metadata.extra.insert("source_name".into(), s.name.clone()); + metadata + .extra + .insert("source_type".into(), s.source_type.to_string()); + if s.source_type == LogSourceType::DockerContainer { + metadata = metadata.with_container_id(s.path_or_id.clone()); + } + } if let Some(detector_id) = &anomaly.detector_id { metadata .extra @@ -106,7 +147,10 @@ impl Reporter { .await?; } - // Route to appropriate notification channels + // Route to appropriate notification channels (respecting minimum severity) + if alert_severity < self.notification_config.minimum_severity() { + continue; + } let channels = self .notification_config .configured_channels_for_severity(alert_severity); @@ -125,7 +169,7 @@ impl Reporter { // Log summary to console log::info!( "📊 Log Summary [{}]: {} entries, {} errors, {} warnings, {} anomalies", - summary.source_id, + source_label, summary.total_entries, summary.error_count, summary.warning_count, @@ -140,6 +184,63 @@ impl Reporter { } } +/// Number of leading words kept from a description signature. +/// +/// AI-written descriptions drift between passes ("accepts connections from any +/// IP" / "allowing connections from any IP"), and the drift lands in the tail of +/// the sentence. Keeping the opening words collapses those variants into one +/// finding while staying specific enough to tell different findings apart. +const SIGNATURE_WORDS: usize = 6; + +/// Reduce a description to a drift-tolerant signature. +fn description_signature(description: &str) -> String { + description + .split(|ch: char| !ch.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .take(SIGNATURE_WORDS) + .map(|word| word.to_lowercase()) + .collect::>() + .join("-") +} + +/// Build the key that decides whether a finding is a repeat. +/// +/// Severity is deliberately excluded: the same finding can come back scored +/// differently by the model, and that alone should not re-alert. Detector-backed +/// findings key on their stable `detector_id`; AI-written ones fall back to a +/// signature of the description. +fn dedup_key(anomaly: &crate::sniff::analyzer::LogAnomaly, source: Option<&LogSource>) -> String { + let finding = match &anomaly.detector_id { + Some(detector_id) => detector_id.clone(), + None => description_signature(&anomaly.description), + }; + let source_key = source + .map(|source| source.path_or_id.as_str()) + .unwrap_or("unknown-source"); + + format!("AnomalyDetected:{}:{}", source_key, finding) +} + +/// Render a log source as something a human can act on: a container name, or a +/// file path. Falls back to the raw summary source id when no source is known. +fn describe_source(summary_source_id: &str, source: Option<&LogSource>) -> String { + match source { + Some(source) => match source.source_type { + LogSourceType::DockerContainer => { + // Discovery names Docker sources "docker:"; the prefix is + // redundant once the label already says "container". + let name = source.name.strip_prefix("docker:").unwrap_or(&source.name); + let short_id: String = source.path_or_id.chars().take(12).collect(); + format!("container {} [{}]", name, short_id) + } + LogSourceType::SystemLog | LogSourceType::CustomFile => { + format!("file {}", source.path_or_id) + } + }, + None => summary_source_id.to_string(), + } +} + /// Result of a report operation #[derive(Debug, Clone, Default)] pub struct ReportResult { @@ -192,9 +293,9 @@ mod tests { #[tokio::test] async fn test_report_no_anomalies() { - let reporter = Reporter::new(NotificationConfig::default()); + let reporter = Reporter::new(NotificationConfig::default(), 300); let summary = make_summary(vec![]); - let result = reporter.report(&summary, None).await.unwrap(); + let result = reporter.report(&summary, None, None).await.unwrap(); assert_eq!(result.anomalies_reported, 0); assert_eq!(result.notifications_sent, 0); assert!(!result.summary_persisted); @@ -202,7 +303,7 @@ mod tests { #[tokio::test] async fn test_report_with_anomalies_sends_alerts() { - let reporter = Reporter::new(NotificationConfig::default()); + let reporter = Reporter::new(NotificationConfig::default(), 300); let summary = make_summary(vec![LogAnomaly { description: "High error rate".into(), severity: AnomalySeverity::High, @@ -210,9 +311,10 @@ mod tests { detector_id: None, detector_family: None, confidence: None, + suggested_action: None, }]); - let result = reporter.report(&summary, None).await.unwrap(); + let result = reporter.report(&summary, None, None).await.unwrap(); assert_eq!(result.anomalies_reported, 1); assert_eq!(result.notifications_sent, 1); } @@ -222,10 +324,10 @@ mod tests { let pool = create_pool(":memory:").unwrap(); init_database(&pool).unwrap(); - let reporter = Reporter::new(NotificationConfig::default()); + let reporter = Reporter::new(NotificationConfig::default(), 300); let summary = make_summary(vec![]); - let result = reporter.report(&summary, Some(&pool)).await.unwrap(); + let result = reporter.report(&summary, Some(&pool), None).await.unwrap(); assert!(result.summary_persisted); // Verify summary was stored @@ -239,7 +341,7 @@ mod tests { let pool = create_pool(":memory:").unwrap(); init_database(&pool).unwrap(); - let reporter = Reporter::new(NotificationConfig::default()); + let reporter = Reporter::new(NotificationConfig::default(), 300); let summary = make_summary(vec![LogAnomaly { description: "Potential SQL injection probing detected".into(), severity: AnomalySeverity::High, @@ -247,9 +349,10 @@ mod tests { detector_id: Some("web.sqli-probe".into()), detector_family: Some("Web".into()), confidence: Some(84), + suggested_action: None, }]); - reporter.report(&summary, Some(&pool)).await.unwrap(); + reporter.report(&summary, Some(&pool), None).await.unwrap(); let alerts = list_alerts(&pool, AlertFilter::default()).await.unwrap(); assert_eq!(alerts.len(), 1); @@ -265,9 +368,183 @@ mod tests { ); } + #[test] + fn test_describe_source_renders_container_and_file() { + let container = LogSource::new( + LogSourceType::DockerContainer, + "a6f2ec2d90294889".into(), + "mailer".into(), + ); + assert_eq!( + describe_source("ignored", Some(&container)), + "container mailer [a6f2ec2d9029]" + ); + + let file = LogSource::new( + LogSourceType::SystemLog, + "/var/log/syslog".into(), + "syslog".into(), + ); + assert_eq!( + describe_source("ignored", Some(&file)), + "file /var/log/syslog" + ); + + assert_eq!(describe_source("file-integrity", None), "file-integrity"); + } + + #[test] + fn test_dedup_key_survives_ai_wording_drift() { + let source = LogSource::new( + LogSourceType::DockerContainer, + "0f3b46ca0c16".into(), + "docker:redis".into(), + ); + let variants = [ + "Redis is not protected by authentication and accepts connections from any IP.", + "Redis is not protected by authentication and accepts connections from any IP address.", + "Redis is not protected by authentication, allowing connections from any IP.", + ]; + + let keys: Vec = variants + .iter() + .map(|description| { + dedup_key( + &LogAnomaly { + description: (*description).into(), + severity: AnomalySeverity::Critical, + sample_line: "WARNING: Redis does not require authentication".into(), + detector_id: None, + detector_family: None, + confidence: None, + suggested_action: None, + }, + Some(&source), + ) + }) + .collect(); + + assert_eq!(keys[0], keys[1]); + assert_eq!(keys[0], keys[2]); + } + + #[test] + fn test_dedup_key_separates_sources_and_findings() { + let redis = LogSource::new( + LogSourceType::DockerContainer, + "0f3b46ca0c16".into(), + "docker:redis".into(), + ); + let nginx = LogSource::new( + LogSourceType::DockerContainer, + "e7a476df6c31".into(), + "docker:nginx".into(), + ); + let anomaly = LogAnomaly { + description: "Redis is not protected by authentication and accepts connections".into(), + severity: AnomalySeverity::Critical, + sample_line: "WARNING".into(), + detector_id: None, + detector_family: None, + confidence: None, + suggested_action: None, + }; + + // Same finding, different containers: both deserve their own alert. + assert_ne!( + dedup_key(&anomaly, Some(&redis)), + dedup_key(&anomaly, Some(&nginx)) + ); + + // Different findings on one container stay distinct. + let other = LogAnomaly { + description: "Multiple instances of EmptyEmailBodyError for different users".into(), + ..anomaly.clone() + }; + assert_ne!( + dedup_key(&anomaly, Some(&redis)), + dedup_key(&other, Some(&redis)) + ); + } + + #[test] + fn test_dedup_key_prefers_detector_id() { + let source = LogSource::new( + LogSourceType::DockerContainer, + "0f3b46ca0c16".into(), + "docker:web".into(), + ); + let key = dedup_key( + &LogAnomaly { + description: "wording that changes every pass".into(), + severity: AnomalySeverity::High, + sample_line: "GET /?q=UNION SELECT".into(), + detector_id: Some("web.sqli-probe".into()), + detector_family: Some("Web".into()), + confidence: Some(84), + suggested_action: None, + }, + Some(&source), + ); + assert_eq!(key, "AnomalyDetected:0f3b46ca0c16:web.sqli-probe"); + } + + #[test] + fn test_describe_source_strips_docker_prefix() { + let source = LogSource::new( + LogSourceType::DockerContainer, + "a70b8c987795abc".into(), + "docker:try".into(), + ); + assert_eq!( + describe_source("ignored", Some(&source)), + "container try [a70b8c987795]" + ); + } + + #[tokio::test] + async fn test_report_records_stable_source_identity() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + + let source = LogSource::new( + LogSourceType::DockerContainer, + "a6f2ec2d90294889".into(), + "mailer".into(), + ); + let reporter = Reporter::new(NotificationConfig::default(), 300); + let summary = make_summary(vec![LogAnomaly { + description: "Multiple instances of EmptyEmailBodyError".into(), + severity: AnomalySeverity::Critical, + sample_line: "ERROR EmptyEmailBodyError".into(), + detector_id: None, + detector_family: None, + confidence: None, + suggested_action: None, + }]); + + reporter + .report(&summary, Some(&pool), Some(&source)) + .await + .unwrap(); + + let alerts = list_alerts(&pool, AlertFilter::default()).await.unwrap(); + assert_eq!(alerts.len(), 1); + assert!(alerts[0] + .message + .contains("container mailer [a6f2ec2d9029]")); + let metadata = alerts[0].metadata.as_ref().unwrap(); + assert_eq!(metadata.source.as_deref(), Some("a6f2ec2d90294889")); + assert_eq!(metadata.container_id.as_deref(), Some("a6f2ec2d90294889")); + assert_eq!( + metadata.extra.get("source_name").map(String::as_str), + Some("mailer") + ); + } + #[tokio::test] async fn test_report_multiple_anomalies() { - let reporter = Reporter::new(NotificationConfig::default()); + let reporter = Reporter::new(NotificationConfig::default(), 300); let summary = make_summary(vec![ LogAnomaly { description: "Error spike".into(), @@ -276,6 +553,7 @@ mod tests { detector_id: None, detector_family: None, confidence: None, + suggested_action: None, }, LogAnomaly { description: "Unusual pattern".into(), @@ -284,10 +562,11 @@ mod tests { detector_id: None, detector_family: None, confidence: None, + suggested_action: None, }, ]); - let result = reporter.report(&summary, None).await.unwrap(); + let result = reporter.report(&summary, None, None).await.unwrap(); assert_eq!(result.anomalies_reported, 2); assert_eq!(result.notifications_sent, 2); } @@ -295,10 +574,10 @@ mod tests { #[tokio::test] async fn test_reporter_new() { let config = NotificationConfig::default(); - let reporter = Reporter::new(config); + let reporter = Reporter::new(config, 300); // Just ensure it constructs without error let summary = make_summary(vec![]); - let result = reporter.report(&summary, None).await; + let result = reporter.report(&summary, None, None).await; assert!(result.is_ok()); } @@ -306,6 +585,7 @@ mod tests { async fn test_report_does_not_count_delivery_failures_as_sent() { let reporter = Reporter::new( NotificationConfig::default().with_slack_webhook("http://127.0.0.1:1".into()), + 300, ); let summary = make_summary(vec![LogAnomaly { description: "High error rate".into(), @@ -314,9 +594,10 @@ mod tests { detector_id: None, detector_family: None, confidence: None, + suggested_action: None, }]); - let result = reporter.report(&summary, None).await.unwrap(); + let result = reporter.report(&summary, None, None).await.unwrap(); assert_eq!(result.anomalies_reported, 1); assert_eq!(result.notifications_sent, 1); } diff --git a/src/tools/alerts.rs b/src/tools/alerts.rs new file mode 100644 index 0000000..369dad3 --- /dev/null +++ b/src/tools/alerts.rs @@ -0,0 +1,135 @@ +//! Alert tools — query recent alerts and stats + +use crate::database::connection::DbPool; +use crate::database::repositories::alerts::{list_alerts, AlertFilter}; + +use super::types::{tool_def, ToolDef, ToolResult}; + +pub fn definitions() -> Vec { + vec![tool_def( + "recent_alerts", + "Get recent security alerts. Use to check if an issue has already been reported before creating a new alert.", + serde_json::json!({ + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Max alerts to return (default 10)" + }, + "severity": { + "type": "string", + "enum": ["Critical", "High", "Medium", "Low", "Info"], + "description": "Filter by severity (optional)" + } + }, + }), + )] +} + +pub async fn execute_recent_alerts(pool: &DbPool, args: &str) -> ToolResult { + let (limit, severity) = match serde_json::from_str::(args) { + Ok(v) => { + let limit = v["limit"].as_u64().unwrap_or(10) as usize; + let severity = v["severity"].as_str().map(String::from); + (limit, severity) + } + Err(e) => return ToolResult::error("recent_alerts", &format!("Invalid args: {}", e)), + }; + + let filter = AlertFilter { + severity, + status: None, + }; + + let alerts = match list_alerts(pool, filter).await { + Ok(a) => a, + Err(e) => return ToolResult::error("recent_alerts", &format!("DB error: {}", e)), + }; + + let recent: Vec = alerts + .into_iter() + .take(limit) + .map(|a| { + serde_json::json!({ + "id": a.id, + "alert_type": a.alert_type.to_string(), + "severity": a.severity.to_string(), + "status": a.status.to_string(), + "message": a.message, + "timestamp": a.timestamp, + }) + }) + .collect(); + + ToolResult::success( + "recent_alerts", + serde_json::json!({ "alerts": recent, "count": recent.len() }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::alerting::alert::{AlertSeverity, AlertType}; + use crate::database::connection::{create_pool, init_database}; + use crate::database::models::Alert; + use crate::database::repositories::alerts::create_alert; + + #[actix_rt::test] + async fn test_recent_alerts_returns_empty_when_no_alerts() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + + let result = execute_recent_alerts(&pool, r#"{"limit": 5}"#).await; + assert!(result.content.contains("[]")); + assert!(result.content.contains(r#""count":0"#)); + } + + #[actix_rt::test] + async fn test_recent_alerts_returns_created_alerts() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + + create_alert( + &pool, + Alert::new( + AlertType::AnomalyDetected, + AlertSeverity::Critical, + "Test alert", + ), + ) + .await + .unwrap(); + + let result = execute_recent_alerts(&pool, r#"{"limit": 5}"#).await; + assert!(result.content.contains("Test alert")); + assert!(result.content.contains(r#""count":1"#)); + } + + #[actix_rt::test] + async fn test_recent_alerts_filters_by_severity() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + + create_alert( + &pool, + Alert::new( + AlertType::AnomalyDetected, + AlertSeverity::Critical, + "Critical alert", + ), + ) + .await + .unwrap(); + create_alert( + &pool, + Alert::new(AlertType::AnomalyDetected, AlertSeverity::Low, "Low alert"), + ) + .await + .unwrap(); + + let result = execute_recent_alerts(&pool, r#"{"severity": "Critical"}"#).await; + assert!(result.content.contains("Critical alert")); + assert!(!result.content.contains("Low alert")); + } +} diff --git a/src/tools/detectors.rs b/src/tools/detectors.rs new file mode 100644 index 0000000..ff11754 --- /dev/null +++ b/src/tools/detectors.rs @@ -0,0 +1,107 @@ +//! Detector tools — re-run built-in detectors on demand + +use crate::detectors::DetectorRegistry; +use crate::sniff::reader::LogEntry; +use crate::tools::types::{tool_def, ToolDef, ToolResult}; +use chrono::Utc; + +pub fn definitions() -> Vec { + vec![tool_def( + "run_detectors", + "Run built-in security detectors on a set of log lines. Use to validate findings or check if specific lines match known attack patterns.", + serde_json::json!({ + "type": "object", + "properties": { + "lines": { + "type": "array", + "items": { "type": "string" }, + "description": "Log lines to analyze" + } + }, + "required": ["lines"] + }), + )] +} + +pub fn execute_run_detectors(detectors: &DetectorRegistry, args: &str) -> ToolResult { + let lines: Vec = match serde_json::from_str::(args) { + Ok(v) => match v["lines"].as_array() { + Some(arr) => arr + .iter() + .filter_map(|l| l.as_str().map(String::from)) + .collect(), + None => return ToolResult::error("run_detectors", "Missing 'lines' array"), + }, + Err(e) => return ToolResult::error("run_detectors", &format!("Invalid args: {}", e)), + }; + + if lines.is_empty() { + return ToolResult::success("run_detectors", serde_json::json!({ "findings": [] })); + } + + let entries: Vec = lines + .into_iter() + .map(|line| LogEntry { + source_id: "tool-input".into(), + timestamp: Utc::now(), + line, + metadata: Default::default(), + }) + .collect(); + + let anomalies = detectors.detect_log_anomalies(&entries); + + let findings: Vec = anomalies + .into_iter() + .map(|a| { + serde_json::json!({ + "detector_id": a.detector_id, + "description": a.description, + "severity": format!("{}", a.severity), + "sample_line": a.sample_line, + }) + }) + .collect(); + + ToolResult::success( + "run_detectors", + serde_json::json!({ "findings": findings, "count": findings.len() }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_run_detectors_with_attack_lines() { + let detectors = DetectorRegistry::default(); + let args = r#"{"lines": [ + "GET /search?q=' OR 1=1 -- HTTP/1.1", + "GET /search?q=UNION SELECT * FROM users-- HTTP/1.1", + "GET /search?q=1; DROP TABLE users-- HTTP/1.1" + ]}"#; + + let result = execute_run_detectors(&detectors, args); + assert!(result.content.contains("sqli-probe")); + } + + #[test] + fn test_run_detectors_with_clean_lines() { + let detectors = DetectorRegistry::default(); + let args = r#"{"lines": [ + "GET /index.html HTTP/1.1 200", + "GET /about HTTP/1.1 200" + ]}"#; + + let result = execute_run_detectors(&detectors, args); + assert!(result.content.contains(r#""count":0"#)); + } + + #[test] + fn test_run_detectors_empty_lines() { + let detectors = DetectorRegistry::default(); + let result = execute_run_detectors(&detectors, r#"{"lines": []}"#); + assert!(result.content.contains("findings")); + } +} diff --git a/src/tools/docker.rs b/src/tools/docker.rs new file mode 100644 index 0000000..0748b91 --- /dev/null +++ b/src/tools/docker.rs @@ -0,0 +1,142 @@ +//! Docker tools — list containers and inspect posture + +use crate::detectors::audits::ContainerPosture; +use crate::tools::types::{tool_def, ToolDef, ToolResult}; + +pub fn definitions() -> Vec { + vec![ + tool_def( + "list_containers", + "List all running containers with their security posture. Use to understand the environment before analyzing alerts.", + serde_json::json!({ + "type": "object", + "properties": {}, + }), + ), + tool_def( + "get_container_posture", + "Get detailed security posture for a specific container: privileged mode, network mode, capabilities, mounts.", + serde_json::json!({ + "type": "object", + "properties": { + "container_name": { + "type": "string", + "description": "Name or ID of the container" + } + }, + "required": ["container_name"] + }), + ), + ] +} + +/// Execute list_containers using pre-fetched postures (avoids async Docker connection) +pub fn execute_list_containers(postures: &[ContainerPosture]) -> ToolResult { + let containers: Vec = postures + .iter() + .map(|p| { + serde_json::json!({ + "name": p.name, + "image": p.image, + "privileged": p.privileged, + "network_mode": p.network_mode, + "pid_mode": p.pid_mode, + "cap_add": p.cap_add, + "has_docker_socket": p.mounts.iter().any(|m: &String| m.contains("/var/run/docker.sock")), + }) + }) + .collect(); + + ToolResult::success( + "list_containers", + serde_json::json!({ "containers": containers }), + ) +} + +/// Execute get_container_posture using pre-fetched postures +pub fn execute_get_container_posture(postures: &[ContainerPosture], args: &str) -> ToolResult { + let name = match serde_json::from_str::(args) { + Ok(v) => v["container_name"].as_str().unwrap_or("").to_string(), + Err(e) => { + return ToolResult::error("get_container_posture", &format!("Invalid args: {}", e)) + } + }; + + match postures + .iter() + .find(|p| p.name == name || p.container_id == name) + { + Some(p) => ToolResult::success( + "get_container_posture", + serde_json::json!({ + "name": p.name, + "image": p.image, + "container_id": p.container_id, + "privileged": p.privileged, + "network_mode": p.network_mode, + "pid_mode": p.pid_mode, + "cap_add": p.cap_add, + "mounts": p.mounts, + }), + ), + None => ToolResult::error( + "get_container_posture", + &format!("Container '{}' not found", name), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_postures() -> Vec { + vec![ + ContainerPosture { + container_id: "abc123".into(), + name: "nginx".into(), + image: "nginx:latest".into(), + privileged: false, + network_mode: Some("bridge".into()), + pid_mode: None, + cap_add: vec![], + mounts: vec![], + }, + ContainerPosture { + container_id: "def456".into(), + name: "redis".into(), + image: "redis:7".into(), + privileged: false, + network_mode: Some("host".into()), + pid_mode: None, + cap_add: vec![], + mounts: vec!["/var/run/docker.sock:/var/run/docker.sock:rw".into()], + }, + ] + } + + #[test] + fn test_list_containers_returns_all() { + let postures = sample_postures(); + let result = execute_list_containers(&postures); + assert!(result.content.contains("nginx")); + assert!(result.content.contains("redis")); + assert!(result.content.contains("docker_socket")); + } + + #[test] + fn test_get_container_posture_found() { + let postures = sample_postures(); + let result = execute_get_container_posture(&postures, r#"{"container_name": "redis"}"#); + assert!(result.content.contains("host")); + assert!(result.content.contains("docker.sock")); + } + + #[test] + fn test_get_container_posture_not_found() { + let postures = sample_postures(); + let result = execute_get_container_posture(&postures, r#"{"container_name": "ghost"}"#); + assert!(result.content.contains("error")); + assert!(result.content.contains("not found")); + } +} diff --git a/src/tools/ip_ban.rs b/src/tools/ip_ban.rs new file mode 100644 index 0000000..95ce2ee --- /dev/null +++ b/src/tools/ip_ban.rs @@ -0,0 +1,213 @@ +//! IP ban tools — check status and ban IPs + +use chrono::{Duration, Utc}; +use serde::Deserialize; + +use crate::database::connection::DbPool; +use crate::database::repositories::offenses::{ + active_block_for_ip, find_recent_offenses, mark_blocked, record_offense_occurrence, + NewIpOffense, +}; +use crate::ip_ban::config::IpBanConfig; + +use super::types::{tool_def, ToolDef, ToolResult}; + +pub fn definitions() -> Vec { + vec![ + tool_def( + "check_ip_status", + "Check if an IP address is currently banned and its offense history. Use this before alerting to avoid re-reporting known threats.", + serde_json::json!({ + "type": "object", + "properties": { + "ip_address": { + "type": "string", + "description": "IPv4 address to check" + } + }, + "required": ["ip_address"] + }), + ), + tool_def( + "ban_ip", + "Ban an IP address for a specified duration. Use when an attack is confirmed and the IP should be blocked immediately.", + serde_json::json!({ + "type": "object", + "properties": { + "ip_address": { + "type": "string", + "description": "IPv4 address to ban" + }, + "reason": { + "type": "string", + "description": "Why this IP is being banned" + }, + "duration_secs": { + "type": "integer", + "description": "Ban duration in seconds (default 1800 = 30 minutes)" + } + }, + "required": ["ip_address", "reason"] + }), + ), + ] +} + +#[derive(Deserialize)] +struct CheckIpArgs { + ip_address: String, +} + +#[derive(Deserialize)] +struct BanIpArgs { + ip_address: String, + reason: String, + duration_secs: Option, +} + +pub fn execute_check_ip_status(pool: &DbPool, args: &str) -> ToolResult { + let args: CheckIpArgs = match serde_json::from_str(args) { + Ok(a) => a, + Err(e) => return ToolResult::error("check_ip_status", &format!("Invalid args: {}", e)), + }; + + let ip = &args.ip_address; + + let blocked = active_block_for_ip(pool, ip).ok().flatten().map(|r| { + serde_json::json!({ + "blocked_until": r.blocked_until, + "reason": r.reason, + }) + }); + + let offenses = find_recent_offenses(pool, ip, "sniff", Utc::now() - Duration::hours(24)) + .unwrap_or_default(); + + let result = serde_json::json!({ + "ip_address": ip, + "banned": blocked.is_some(), + "blocked_until": blocked.as_ref().and_then(|b| b.get("blocked_until").and_then(|v| v.as_str())), + "offense_count_24h": offenses.len(), + "last_offense": offenses.first().map(|o| serde_json::json!({ + "reason": o.reason, + "time": o.last_seen, + "status": format!("{:?}", o.status), + })), + }); + + ToolResult::success("check_ip_status", result) +} + +pub fn execute_ban_ip(pool: &DbPool, config: &IpBanConfig, args: &str) -> ToolResult { + let args: BanIpArgs = match serde_json::from_str(args) { + Ok(a) => a, + Err(e) => return ToolResult::error("ban_ip", &format!("Invalid args: {}", e)), + }; + + let duration = args.duration_secs.unwrap_or(config.ban_time_secs); + let now = Utc::now(); + let blocked_until = now + Duration::seconds(duration as i64); + + // Record the offense + if let Err(e) = record_offense_occurrence( + pool, + &NewIpOffense { + id: uuid::Uuid::new_v4().to_string(), + ip_address: args.ip_address.clone(), + source_type: "ai-tool".into(), + container_id: None, + first_seen: now, + reason: args.reason.clone(), + metadata: None, + }, + now - Duration::seconds(config.find_time_secs as i64), + ) { + return ToolResult::error("ban_ip", &format!("Failed to record offense: {}", e)); + } + + // Mark as blocked + if let Err(e) = mark_blocked(pool, &args.ip_address, "ai-tool", blocked_until) { + return ToolResult::error("ban_ip", &format!("Failed to mark blocked: {}", e)); + } + + log::info!( + "IP {} banned via AI tool until {}", + args.ip_address, + blocked_until + ); + + let cli_cmd = format!( + "stackdog ban-ip {} --duration {}s --reason \"{}\"", + args.ip_address, duration, args.reason + ); + + ToolResult::success( + "ban_ip", + serde_json::json!({ + "success": true, + "ip_address": args.ip_address, + "blocked_until": blocked_until.to_rfc3339(), + "duration_secs": duration, + "cli_command": cli_cmd, + }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::connection::{create_pool, init_database}; + + #[test] + fn test_check_ip_status_returns_not_banned_for_unknown_ip() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + + let result = execute_check_ip_status(&pool, r#"{"ip_address": "1.2.3.4"}"#); + assert!(result.content.contains("false")); + assert!(result.content.contains("1.2.3.4")); + } + + #[test] + fn test_check_ip_status_returns_error_for_invalid_args() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + + let result = execute_check_ip_status(&pool, "not json"); + assert!(result.content.contains("error")); + } + + #[test] + fn test_ban_ip_records_offense_and_blocks() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let config = IpBanConfig::from_env(); + + let result = execute_ban_ip( + &pool, + &config, + r#"{"ip_address": "5.6.7.8", "reason": "test ban", "duration_secs": 60}"#, + ); + assert!(result.content.contains("true")); + assert!(result.content.contains("5.6.7.8")); + assert!(result.content.contains("cli_command")); + + // Verify it's now blocked + let check = execute_check_ip_status(&pool, r#"{"ip_address": "5.6.7.8"}"#); + assert!(check.content.contains("true")); // banned: true + } + + #[test] + fn test_ban_ip_uses_default_duration() { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + let config = IpBanConfig::from_env(); + + let result = execute_ban_ip( + &pool, + &config, + r#"{"ip_address": "9.10.11.12", "reason": "test"}"#, + ); + assert!(result.content.contains("success")); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..2b18f68 --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,160 @@ +//! AI tool-use registry +//! +//! Defines tools the AI can call during log analysis and dispatches +//! execution to the appropriate handlers. + +pub mod alerts; +pub mod detectors; +pub mod docker; +pub mod ip_ban; +pub mod types; + +use std::sync::RwLock; + +use crate::database::connection::DbPool; +use crate::detectors::audits::ContainerPosture; +use crate::detectors::DetectorRegistry; +use crate::ip_ban::config::IpBanConfig; + +use types::{ToolCall, ToolDef, ToolResult}; + +/// Central registry holding references to subsystems the AI can query. +pub struct ToolRegistry { + pool: DbPool, + ip_ban_config: IpBanConfig, + detectors: DetectorRegistry, + /// Pre-fetched container postures (populated each sniff pass) + postures: RwLock>, +} + +impl ToolRegistry { + pub fn new(pool: DbPool, ip_ban_config: IpBanConfig, detectors: DetectorRegistry) -> Self { + Self { + pool, + ip_ban_config, + detectors, + postures: RwLock::new(Vec::new()), + } + } + + /// Update container postures (called each sniff pass before analysis) + pub fn set_postures(&self, postures: Vec) { + *self.postures.write().unwrap() = postures; + } + + /// All tool definitions for the OpenAI `tools` array + pub fn definitions(&self) -> Vec { + let mut defs = Vec::new(); + defs.extend(ip_ban::definitions()); + defs.extend(docker::definitions()); + defs.extend(alerts::definitions()); + defs.extend(detectors::definitions()); + defs + } + + /// Execute a tool call and return the result + /// + /// The individual executors label their results with the tool name, which is + /// convenient for logging but is not what the API wants back: a `tool` + /// message must carry the `id` of the originating call, or the provider + /// rejects the whole request. Stamping it here keeps every executor honest. + pub async fn execute(&self, call: &ToolCall) -> ToolResult { + let mut result = self.dispatch(call).await; + result.tool_call_id = call.id.clone(); + result + } + + async fn dispatch(&self, call: &ToolCall) -> ToolResult { + let args = &call.function.arguments; + match call.function.name.as_str() { + "check_ip_status" => ip_ban::execute_check_ip_status(&self.pool, args), + "ban_ip" => ip_ban::execute_ban_ip(&self.pool, &self.ip_ban_config, args), + "list_containers" => { + let postures = self.postures.read().unwrap(); + docker::execute_list_containers(&postures) + } + "get_container_posture" => { + let postures = self.postures.read().unwrap(); + docker::execute_get_container_posture(&postures, args) + } + "recent_alerts" => alerts::execute_recent_alerts(&self.pool, args).await, + "run_detectors" => detectors::execute_run_detectors(&self.detectors, args), + unknown => ToolResult::error(unknown, &format!("Unknown tool: {}", unknown)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::connection::{create_pool, init_database}; + + fn make_registry() -> ToolRegistry { + let pool = create_pool(":memory:").unwrap(); + init_database(&pool).unwrap(); + ToolRegistry::new(pool, IpBanConfig::from_env(), DetectorRegistry::default()) + } + + #[test] + fn test_definitions_include_all_tools() { + let registry = make_registry(); + let defs = registry.definitions(); + let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect(); + assert!(names.contains(&"check_ip_status")); + assert!(names.contains(&"ban_ip")); + assert!(names.contains(&"list_containers")); + assert!(names.contains(&"get_container_posture")); + assert!(names.contains(&"recent_alerts")); + assert!(names.contains(&"run_detectors")); + } + + #[actix_rt::test] + async fn test_execute_unknown_tool_returns_error() { + let registry = make_registry(); + let call = ToolCall { + id: "call_1".into(), + call_type: "function".into(), + function: types::FunctionCall { + name: "nonexistent_tool".into(), + arguments: "{}".into(), + }, + }; + let result = registry.execute(&call).await; + assert!(result.content.contains("Unknown tool")); + assert_eq!(result.tool_call_id, "call_1"); + } + + #[actix_rt::test] + async fn test_execute_returns_call_id_not_tool_name() { + let registry = make_registry(); + let call = ToolCall { + id: "call_abc123".into(), + call_type: "function".into(), + function: types::FunctionCall { + name: "check_ip_status".into(), + arguments: r#"{"ip_address":"203.0.113.10"}"#.into(), + }, + }; + + // The API rejects the request when a tool message references anything + // other than the id of the call it answers. + let result = registry.execute(&call).await; + assert_eq!(result.tool_call_id, "call_abc123"); + } + + #[actix_rt::test] + async fn test_execute_check_ip_status() { + let registry = make_registry(); + let call = ToolCall { + id: "call_2".into(), + call_type: "function".into(), + function: types::FunctionCall { + name: "check_ip_status".into(), + arguments: r#"{"ip_address": "1.2.3.4"}"#.into(), + }, + }; + let result = registry.execute(&call).await; + assert!(result.content.contains("1.2.3.4")); + assert!(result.content.contains("banned")); + } +} diff --git a/src/tools/types.rs b/src/tools/types.rs new file mode 100644 index 0000000..ab94025 --- /dev/null +++ b/src/tools/types.rs @@ -0,0 +1,111 @@ +//! Tool-use types for AI function calling + +use serde::{Deserialize, Serialize}; + +/// Definition of a tool the AI can call (maps to OpenAI function schema) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolDef { + #[serde(rename = "type")] + pub tool_type: String, + pub function: FunctionDef, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionDef { + pub name: String, + pub description: String, + pub parameters: serde_json::Value, +} + +/// A tool call requested by the AI +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub call_type: String, + pub function: FunctionCall, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionCall { + pub name: String, + pub arguments: String, +} + +/// Result of executing a tool +#[derive(Debug, Clone, Serialize)] +pub struct ToolResult { + pub tool_call_id: String, + pub role: String, + pub content: String, +} + +impl ToolResult { + pub fn success(tool_call_id: impl Into, content: impl Serialize) -> Self { + Self { + tool_call_id: tool_call_id.into(), + role: "tool".into(), + content: serde_json::to_string(&content).unwrap_or_else(|_| "{}".into()), + } + } + + pub fn error(tool_call_id: impl Into, message: &str) -> Self { + Self { + tool_call_id: tool_call_id.into(), + role: "tool".into(), + content: serde_json::json!({ "error": message }).to_string(), + } + } +} + +/// Helper to build a ToolDef with JSON Schema parameters +pub fn tool_def(name: &str, description: &str, parameters: serde_json::Value) -> ToolDef { + ToolDef { + tool_type: "function".into(), + function: FunctionDef { + name: name.into(), + description: description.into(), + parameters, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tool_def_serializes_to_openai_format() { + let def = tool_def( + "check_ip", + "Check IP status", + serde_json::json!({ + "type": "object", + "properties": { + "ip": { "type": "string" } + }, + "required": ["ip"] + }), + ); + + let json = serde_json::to_value(&def).unwrap(); + assert_eq!(json["type"], "function"); + assert_eq!(json["function"]["name"], "check_ip"); + assert_eq!(json["function"]["parameters"]["type"], "object"); + } + + #[test] + fn test_tool_result_success_serialization() { + let result = ToolResult::success("call_1", serde_json::json!({"banned": false})); + assert_eq!(result.tool_call_id, "call_1"); + assert_eq!(result.role, "tool"); + assert!(result.content.contains("banned")); + } + + #[test] + fn test_tool_result_error_serialization() { + let result = ToolResult::error("call_2", "IP not found"); + assert!(result.content.contains("error")); + assert!(result.content.contains("IP not found")); + } +} diff --git a/website/.dockerignore b/website/.dockerignore new file mode 100644 index 0000000..1d6a6ea --- /dev/null +++ b/website/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +.git +.gitignore +*.md +.env* +!.env.local.example +Dockerfile +.dockerignore diff --git a/website/.env.local.example b/website/.env.local.example new file mode 100644 index 0000000..79812b1 --- /dev/null +++ b/website/.env.local.example @@ -0,0 +1,2 @@ +SITE_URL=https://stackdog.stacker.my +STACKER_PIPE_WEBHOOK_URL=https://your-stacker-pipe-webhook-url diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..1a07a1c --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,18 @@ +# Dependencies +node_modules/ + +# Next.js build output +.next/ +out/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Misc +.DS_Store +*.pem +npm-debug.log* diff --git a/website/Dockerfile b/website/Dockerfile new file mode 100644 index 0000000..655f71f --- /dev/null +++ b/website/Dockerfile @@ -0,0 +1,53 @@ +######################################## +# Stage 1 – Install dependencies +######################################## +FROM node:20-alpine AS deps +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +######################################## +# Stage 2 – Build +######################################## +FROM node:20-alpine AS builder +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +ARG SITE_URL +ENV NEXT_PUBLIC_SITE_URL=$SITE_URL + +RUN npm run build + +######################################## +# Stage 3 – Runtime (nginx + Node standalone) +######################################## +FROM node:20-alpine AS runner + +RUN apk add --no-cache nginx supervisor \ + && mkdir -p /run/nginx /var/log/nginx /var/lib/nginx/tmp \ + && addgroup -S app && adduser -S app -G app + +WORKDIR /app + +# Next.js standalone server +COPY --from=builder --chown=app:app /app/.next/standalone ./ +# Static assets served directly by nginx +COPY --from=builder --chown=app:app /app/.next/static ./.next/static +# Public folder +COPY --from=builder --chown=app:app /app/public ./public + +COPY nginx.conf /etc/nginx/nginx.conf +COPY supervisord.conf /etc/supervisord.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost/api/contact || exit 1 + +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] diff --git a/website/app/api/contact/route.ts b/website/app/api/contact/route.ts new file mode 100644 index 0000000..ab76063 --- /dev/null +++ b/website/app/api/contact/route.ts @@ -0,0 +1,125 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getSiteUrl } from '@/lib/config'; + +const topics = [ + 'General Inquiry', + 'Enterprise', + 'Security Report', + 'Bug Report', + 'Feature Request', + 'Other' +] as const; + +const contactSchema = z.object({ + name: z.string().trim().min(2).max(80), + email: z.string().trim().email().max(120), + company: z.string().trim().max(120).optional(), + topic: z.enum(topics), + message: z.string().trim().min(20).max(4000) +}); + +function escapeSlackText(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +export async function POST(request: Request) { + try { + const webhookUrl = process.env.STACKER_PIPE_WEBHOOK_URL; + + if (!webhookUrl) { + return NextResponse.json( + { error: 'Contact form webhook is not configured.' }, + { status: 500 } + ); + } + + const payload = contactSchema.parse(await request.json()); + + const company = payload.company?.trim() ? payload.company.trim() : 'Not provided'; + const timestamp = new Date().toISOString(); + + // Production note: add IP-based rate limiting and bot protection before exposing this route publicly. + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + text: '📧 New contact from Stackdog website', + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: '📧 New Contact Form Submission' + } + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Name:*\n${escapeSlackText(payload.name)}` + }, + { + type: 'mrkdwn', + text: `*Email:*\n${escapeSlackText(payload.email)}` + }, + { + type: 'mrkdwn', + text: `*Company:*\n${escapeSlackText(company)}` + }, + { + type: 'mrkdwn', + text: `*Topic:*\n${escapeSlackText(payload.topic)}` + } + ] + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Message:*\n${escapeSlackText(payload.message)}` + } + }, + { + type: 'divider' + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Submitted at ${timestamp} from ${getSiteUrl()}` + } + ] + } + ] + }), + cache: 'no-store' + }); + + if (!response.ok) { + return NextResponse.json( + { error: 'Unable to forward your message right now.' }, + { status: 502 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + const issue = error.issues[0]; + return NextResponse.json( + { error: issue?.message ?? 'Please check the submitted fields.' }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'Unexpected error while submitting the contact form.' }, + { status: 500 } + ); + } +} diff --git a/website/app/contact/page.tsx b/website/app/contact/page.tsx new file mode 100644 index 0000000..36cd4ad --- /dev/null +++ b/website/app/contact/page.tsx @@ -0,0 +1,126 @@ +import type { Metadata } from 'next'; +import { Code2, Mail, ShieldCheck } from 'lucide-react'; +import { getSiteUrl } from '@/lib/config'; + +function DiscordIcon({ className }: { className?: string }) { + return ( + + ); +} +import ContactForm from '@/components/ContactForm'; + +export const metadata: Metadata = { + title: 'Contact', + description: + 'Contact Stackdog Security for enterprise questions, security reports, partnership conversations, or product feedback.', + keywords: ['contact Stackdog', 'Stackdog enterprise', 'security disclosure', 'Stackdog support'], + alternates: { + canonical: '/contact' + }, + openGraph: { + title: 'Contact Stackdog Security', + description: + 'Reach the Stackdog team for demos, enterprise evaluations, and responsible security disclosures.', + url: getSiteUrl() + '/contact' + }, + twitter: { + title: 'Contact Stackdog Security', + description: + 'Reach the Stackdog team for demos, enterprise evaluations, and responsible security disclosures.' + } +}; + +const contactLinks = [ + { + title: 'Email', + value: 'info@try.direct', + href: 'mailto:info@try.direct', + icon: Mail + }, + { + title: 'GitHub', + value: 'trydirect/stackdog', + href: 'https://github.com/trydirect/stackdog', + icon: Code2 + }, + { + title: 'Discord', + value: 'Join our community', + href: 'https://discord.gg/RVCcA8QZ9m', + icon: DiscordIcon + } +]; + +export default function ContactPage() { + return ( +
+
+
+ Talk to the Stackdog team +

+ Bring runtime security to your infrastructure without extra drag +

+

+ Reach out for enterprise deployments, responsible disclosure, roadmap conversations, or + implementation guidance around Docker and Linux server security. +

+
+ +
+
+
+
+ +
+

What can we help with?

+
    +
  • Enterprise security evaluations and architecture reviews
  • +
  • Security reports and responsible disclosure follow-up
  • +
  • Feature requests, roadmap discussions, and integrations
  • +
  • Hands-on support with alert routing and response automation
  • +
+
+ +
+ {contactLinks.map((entry) => { + const Icon = entry.icon; + return ( + + + + + + {entry.title} + {entry.value} + + + ); + })} +
+
+ +
+
+

+ Send a message +

+

+ Messages are delivered through a secure webhook flow so the team can pick them up in + Slack quickly. +

+
+ +
+
+
+
+ ); +} diff --git a/website/app/docs/page.tsx b/website/app/docs/page.tsx new file mode 100644 index 0000000..6ce07f1 --- /dev/null +++ b/website/app/docs/page.tsx @@ -0,0 +1,356 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowUpRight, ExternalLink } from 'lucide-react'; +import CopyButton from '@/components/CopyButton'; +import { getSiteUrl } from '@/lib/config'; +import { buildDocsStructuredData, toJsonLd } from '@/lib/structured-data'; + +const installCurl = + 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash'; +const installPinned = + 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.4'; +const installDocker = `docker run --rm -it \\ + --name stackdog \\ + --network host \\ + --cap-add=NET_ADMIN \\ + -e APP_HOST=0.0.0.0 \\ + -e APP_PORT=5000 \\ + -e DATABASE_URL=/data/stackdog.db \\ + -v stackdog-data:/data \\ + -v /var/run/docker.sock:/var/run/docker.sock \\ + trydirect/stackdog:latest`; +const installSource = `git clone https://github.com/trydirect/stackdog\ncd stackdog\ncargo run -- serve`; + +const sidebarItems = [ + { href: '#getting-started', label: 'Getting Started' }, + { href: '#cli-reference', label: 'CLI Reference' }, + { href: '#rest-api', label: 'REST API Reference' }, + { href: '#configuration', label: 'Configuration' }, + { href: '#contributing', label: 'Contributing' } +]; + +const sniffOptions = [ + ['--once', 'Run a single scan or analysis pass, then exit.'], + ['--consume', 'Archive logs to zstd and purge originals after processing.'], + ['--output ', 'Output directory for consumed logs. Defaults to ./stackdog-logs/.'], + ['--sources ', 'Additional comma-separated log paths to monitor.'], + ['--interval ', 'Polling interval in seconds. Defaults to 30.'], + ['--ai-provider ', 'AI backend: openai, ollama, or candle.'], + ['--ai-model ', 'AI model name such as gpt-4o-mini or llama3.'], + ['--ai-api-url ', 'OpenAI-compatible API endpoint, including local Ollama.'], + ['--slack-webhook ', 'Slack incoming webhook for alert delivery.'], + ['--webhook-url ', 'Generic webhook target for alerts.'], + ['--smtp-host ', 'SMTP host for email notifications.'], + ['--smtp-port ', 'SMTP port for email notifications.'], + ['--smtp-user ', 'SMTP username or sender address.'], + ['--smtp-password ', 'SMTP password.'], + ['--email-recipients ', 'Comma-separated email recipients.'] +] as const; + +const apiRows = [ + ['GET', '/api/security/status', 'Live security posture and service health.'], + ['GET', '/api/threats', 'Threat inventory and current detections.'], + ['GET', '/api/alerts', 'Alert list for analysts and dashboards.'], + ['GET', '/api/containers', 'Container inventory and runtime state.'], + ['GET', '/api/logs/sources', 'Registered log sources for sniffing.'], + ['GET', '/api/logs/summaries', 'AI-generated log summaries and findings.'], + ['GET', '/api/security/bans', 'IP ban offenses. Filter with ?status=active|blocked|released and ?limit=.'], + ['DELETE', '/api/security/bans/{ip}', 'Release a ban ahead of its expiry.'], + ['WS', '/ws', 'Real-time event stream over WebSocket.'] +] as const; + +const envRows = [ + ['APP_HOST', '0.0.0.0', 'HTTP host binding for stackdog serve.'], + ['APP_PORT', '5000', 'HTTP API port.'], + ['DATABASE_URL', 'stackdog.db', 'SQLite database file path.'], + ['RUST_BACKTRACE', 'full', 'Verbose Rust backtraces for diagnostics.'], + ['STACKDOG_SERVE_SNIFF_ENABLED', 'true', 'Enable background sniffing while the API server is running.'], + ['STACKDOG_LOG_SOURCES', '/var/log/syslog,/var/log/auth.log', 'Additional log files to include in sniff mode.'], + ['STACKDOG_SNIFF_INTERVAL', '30', 'Sniff polling interval in seconds.'], + ['STACKDOG_AI_PROVIDER', 'openai', 'AI provider selection for log analysis.'], + ['STACKDOG_AI_API_URL', 'http://localhost:11434/v1', 'API URL for OpenAI-compatible providers such as Ollama.'], + ['STACKDOG_AI_MODEL', 'llama3', 'Model name for AI-assisted summarization.'], + ['STACKDOG_SLACK_WEBHOOK_URL', 'https://hooks.slack.com/...', 'Slack alert destination.'], + ['STACKDOG_WEBHOOK_URL', 'https://example.com/webhook', 'Generic webhook target.'], + ['STACKDOG_IP_BAN_ENABLED', 'true', 'Enable automatic IP banning via iptables/nftables.'], + ['STACKDOG_IP_BAN_MAX_RETRIES', '5', 'Offense count before an IP is banned.'], + ['STACKDOG_IP_BAN_BAN_TIME_SECS', '1800', 'How long (seconds) an IP stays banned. Default 30 min.'], + ['STACKDOG_IP_BAN_FIND_TIME_SECS', '300', 'Lookback window (seconds) for counting offenses.'], + ['STACKDOG_IP_BAN_ALLOWLIST', '167.233.9.19,10.0.0.0/8', 'Addresses or CIDRs that are never banned. Use it for load balancers and health checkers.'], + ['STACKDOG_NOTIFICATION_MIN_SEVERITY', 'info', 'Minimum severity for alert notifications. Options: info, low, medium, high, critical.'], + ['STACKDOG_NOTIFY_IP_BAN_ACTIONS', 'true', 'Send notifications when an IP is banned or released.'], + ['STACKDOG_NOTIFY_QUARANTINE_ACTIONS', 'true', 'Send notifications when a container is quarantined or released.'], + ['STACKDOG_ALERT_DEDUP_WINDOW_SECS', '21600', 'How long the same finding stays suppressed before alerting again. Default 6 hours.'], + ['STACKDOG_NOTIFICATION_LABEL', 'prod-eu-1', 'Instance label added to every alert so you can tell hosts apart.'], + ['STACKDOG_SLACK_USERNAME', 'Stackdog', 'Display name on Slack messages (legacy webhooks or chat:write.customize).'], + ['STACKDOG_SLACK_ICON_URL', 'https://stackdog.stacker.my/stackdog-mark.png', 'Avatar image for Slack messages.'], + ['STACKDOG_AI_TIMEOUT_SECS', '300', 'Timeout for AI requests. 0 disables it for very slow local models.'], + ['STACKDOG_AI_MAX_TOKENS', '2048', 'Response token ceiling. 0 lets the provider decide.'], + ['STACKDOG_AI_COOLDOWN_SECS', '300', 'Minimum gap between AI calls for the same source, to limit token spend.'], + ['STACKDOG_AI_TOOLS_ENABLED', 'true', 'Let the analyzer call Stackdog tools (check IPs, list containers, ban) during analysis.'], + ['STACKDOG_FIM_PATHS', '/etc/passwd,/etc/ssh', 'Files or directories tracked for integrity drift against a SQLite baseline.'], + ['STACKDOG_SCA_PATHS', '/etc/ssh/sshd_config,/etc/sudoers', 'Config files audited for insecure settings.'], + ['STACKDOG_PACKAGE_INVENTORY_PATHS', '/var/lib/dpkg/status', 'Package inventories scanned for legacy versions.'], + ['STACKDOG_TRUSTED_CONTAINERS', 'redis,postgres', 'Container names skipped during Docker posture checks.'], + ['STACKDOG_TRUSTED_PROXY_RANGES', '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16', 'CIDRs treated as proxies, so the real client IP is taken from X-Forwarded-For.'], + ['STACKDOG_IP_BAN_UNBAN_CHECK_INTERVAL_SECS', '60', 'How often expired bans are checked and released.'], + ['STACKDOG_MAIL_GUARD_ENABLED', 'true', 'Watch web containers for outbound spam bursts and quarantine offenders.'], + ['STACKDOG_MAIL_GUARD_TARGETS', 'wordpress,php,apache', 'Container name patterns the mail guard watches.'], + ['STACKDOG_MAIL_GUARD_ALLOWLIST', 'postfix,mailu', 'Container name patterns exempt from the mail guard.'] +] as const; + +export const metadata: Metadata = { + title: 'Docs', + description: + 'Read the Stackdog Security documentation for installation, CLI usage, REST API endpoints, and runtime configuration.', + keywords: ['Stackdog docs', 'stackdog CLI', 'stackdog API', 'container security docs'], + alternates: { + canonical: '/docs' + }, + openGraph: { + title: 'Stackdog Security Docs', + description: + 'Install Stackdog, run the CLI, integrate the API, and configure alerting and AI analysis.', + url: getSiteUrl() + '/docs' + }, + twitter: { + title: 'Stackdog Security Docs', + description: + 'Install Stackdog, run the CLI, integrate the API, and configure alerting and AI analysis.' + } +}; + +function DocsTable({ + headers, + rows +}: { + headers: readonly string[]; + rows: readonly (readonly string[])[]; +}) { + return ( +
+
+ + + + {headers.map((header) => ( + + ))} + + + + {rows.map((row) => ( + + {row.map((cell) => ( + + ))} + + ))} + +
+ {header} +
+ {cell} +
+
+
+ ); +} + +function CodeSample({ code, label }: { code: string; label: string }) { + return ( +
+
+

{label}

+ +
+
+        {code}
+      
+
+ ); +} + +export default function DocsPage() { + return ( +
+ + + ); + } + + if (consent === 'denied') { + return null; + } + + return ( +
+
+

+ We use Google Analytics to understand how the site is used. It sets cookies in your + browser. Nothing is loaded until you agree. +

+
+ + +
+
+
+ ); +} diff --git a/website/components/CopyButton.tsx b/website/components/CopyButton.tsx new file mode 100644 index 0000000..2814aea --- /dev/null +++ b/website/components/CopyButton.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Check, Copy } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +interface CopyButtonProps { + text: string; + label?: string; +} + +export default function CopyButton({ text, label = 'Copy command' }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } + }; + + return ( + + ); +} diff --git a/website/components/Footer.tsx b/website/components/Footer.tsx new file mode 100644 index 0000000..0595260 --- /dev/null +++ b/website/components/Footer.tsx @@ -0,0 +1,108 @@ +import Link from 'next/link'; +import { Code2, ExternalLink, Mail, Shield } from 'lucide-react'; + +function DiscordIcon({ className }: { className?: string }) { + return ( + + ); +} + +interface FooterProps {} + +export default function Footer(_: FooterProps) { + const year = new Date().getFullYear(); + + return ( +
+
+
+
+
+ + + + Stackdog Security +
+

+ Rust-native security for Docker containers and Linux servers with eBPF visibility, + AI-assisted analysis, and automated response pipelines. +

+ +
+ +
+

Explore

+
    +
  • + + Home + +
  • +
  • + + Docs + +
  • +
  • + + Contact + +
  • +
+
+ +
+

Release

+ +
+
+ +
+ © {year} try.direct. Stackdog Security protects containers without slowing delivery. +
+
+
+ ); +} diff --git a/website/components/Navbar.tsx b/website/components/Navbar.tsx new file mode 100644 index 0000000..58c719c --- /dev/null +++ b/website/components/Navbar.tsx @@ -0,0 +1,116 @@ +"use client"; + +import Link from 'next/link'; +import Image from 'next/image'; +import { usePathname } from 'next/navigation'; +import { ExternalLink, Menu, X } from 'lucide-react'; +import { useMemo, useState } from 'react'; + +interface NavbarProps {} + +interface NavItem { + href: string; + label: string; +} + +const navItems: NavItem[] = [ + { href: '/', label: 'Home' }, + { href: '/docs', label: 'Docs' }, + { href: '/contact', label: 'Contact' } +]; + +export default function Navbar(_: NavbarProps) { + const pathname = usePathname(); + const [isOpen, setIsOpen] = useState(false); + + const activePath = useMemo(() => pathname ?? '/', [pathname]); + + const linkClassName = (href: string) => { + const isActive = href === '/' ? activePath === href : activePath.startsWith(href); + + return [ + 'rounded-full px-4 py-2 text-sm font-medium transition-colors', + isActive ? 'bg-cyan-500/10 text-cyan-300' : 'text-slate-300 hover:text-white' + ].join(' '); + }; + + return ( +
+
+ + + {isOpen ? ( +
+
+ {navItems.map((item) => ( + setIsOpen(false)} + > + {item.label} + + ))} + + GitHub + + +
+
+ ) : null} +
+
+ ); +} diff --git a/website/lib/config.ts b/website/lib/config.ts new file mode 100644 index 0000000..a5592aa --- /dev/null +++ b/website/lib/config.ts @@ -0,0 +1,11 @@ +const DEFAULT_SITE_URL = 'https://stackdog.stacker.my'; +const DEFAULT_GA_MEASUREMENT_ID = 'G-1ERSVH1L4D'; + +export function getSiteUrl(): string { + return process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL || DEFAULT_SITE_URL; +} + +/** Google Analytics measurement ID. Set to an empty string to disable the tag. */ +export function getGaMeasurementId(): string { + return process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID ?? DEFAULT_GA_MEASUREMENT_ID; +} diff --git a/website/lib/structured-data.ts b/website/lib/structured-data.ts new file mode 100644 index 0000000..029f28b --- /dev/null +++ b/website/lib/structured-data.ts @@ -0,0 +1,96 @@ +import { getSiteUrl } from '@/lib/config'; + +export interface FaqEntry { + question: string; + answer: string; +} + +interface TechArticleInput { + title: string; + description: string; + url: string; +} + +const SITE_URL = getSiteUrl(); +const GITHUB_URL = 'https://github.com/trydirect/stackdog'; + +export function buildHomeStructuredData(faqEntries: FaqEntry[]) { + return { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'SoftwareApplication', + name: 'Stackdog Security', + applicationCategory: 'SecurityApplication', + operatingSystem: 'Linux', + softwareVersion: '0.2.4', + url: SITE_URL, + downloadUrl: GITHUB_URL, + description: + 'Rust-native security platform for Docker containers and Linux servers with eBPF monitoring, AI log analysis, anomaly detection, and automated firewall response.', + creator: { + '@type': 'Person', + name: 'Vasili Pascal' + }, + publisher: { + '@type': 'Organization', + name: 'try.direct', + email: 'info@try.direct' + }, + featureList: [ + 'eBPF-based syscall monitoring with <5% CPU overhead', + 'AI-assisted log sniffing via OpenAI, Ollama, and Candle workflows', + 'AI tool use: the analyzer can check IPs, inspect containers, and ban attackers mid-investigation', + 'Threat scoring with 25+ built-in detectors', + 'ML behavioral drift detection with Isolation Forest', + 'File integrity monitoring, configuration audits, and Docker posture checks', + 'Automated nftables or iptables response with automatic unban', + 'Container quarantine and outbound spam containment', + 'Slack, email, and webhook alerts with severity filtering and deduplication' + ], + offers: { + '@type': 'Offer', + price: '0', + priceCurrency: 'USD' + }, + sameAs: [GITHUB_URL, 'https://twitter.com/VasiliiPascal'] + }, + { + '@type': 'FAQPage', + mainEntity: faqEntries.map((entry) => ({ + '@type': 'Question', + name: entry.question, + acceptedAnswer: { + '@type': 'Answer', + text: entry.answer + } + })) + } + ] + }; +} + +export function buildDocsStructuredData({ title, description, url }: TechArticleInput) { + return { + '@context': 'https://schema.org', + '@type': 'TechArticle', + headline: title, + description, + url, + author: { + '@type': 'Person', + name: 'Vasili Pascal' + }, + publisher: { + '@type': 'Organization', + name: 'try.direct' + }, + about: ['Stackdog CLI', 'Container security', 'eBPF monitoring', 'Threat detection'], + articleSection: ['Getting Started', 'CLI Reference', 'REST API', 'Configuration'], + proficiencyLevel: 'Intermediate' + }; +} + +export function toJsonLd(value: unknown): string { + return JSON.stringify(value); +} diff --git a/website/next-env.d.ts b/website/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/website/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/website/next.config.ts b/website/next.config.ts new file mode 100644 index 0000000..58c9040 --- /dev/null +++ b/website/next.config.ts @@ -0,0 +1,28 @@ +import type { NextConfig } from 'next'; + +const securityHeaders = [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()' + } +]; + +const nextConfig: NextConfig = { + reactStrictMode: true, + poweredByHeader: false, + output: 'standalone', + outputFileTracingRoot: process.cwd(), + async headers() { + return [ + { + source: '/(.*)', + headers: securityHeaders + } + ]; + } +}; + +export default nextConfig; diff --git a/website/nginx.conf b/website/nginx.conf new file mode 100644 index 0000000..227ab9c --- /dev/null +++ b/website/nginx.conf @@ -0,0 +1,67 @@ +worker_processes auto; +error_log /dev/stderr warn; +pid /run/nginx/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /dev/stdout; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + image/svg+xml; + + server { + listen 80; + server_name _; + + # Immutable static assets — cache forever + location /_next/static/ { + alias /app/.next/static/; + expires 1y; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options "nosniff"; + access_log off; + } + + # Public folder assets + location ~* ^/(favicon\.ico|robots\.txt|sitemap\.xml|.*\.(png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|otf))$ { + root /app/public; + expires 7d; + add_header Cache-Control "public"; + access_log off; + } + + # Proxy all other requests (pages + API routes) to Next.js + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 60s; + } + } +} diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 0000000..5040409 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,2182 @@ +{ + "name": "stackdog-website", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stackdog-website", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^1.17.0", + "next": "^15.5.18", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.18.tgz", + "integrity": "sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.18.tgz", + "integrity": "sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.18.tgz", + "integrity": "sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.18.tgz", + "integrity": "sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.18.tgz", + "integrity": "sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.18.tgz", + "integrity": "sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.18.tgz", + "integrity": "sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.18.tgz", + "integrity": "sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.18.tgz", + "integrity": "sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-react": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz", + "integrity": "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.18", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.18", + "@next/swc-darwin-x64": "15.5.18", + "@next/swc-linux-arm64-gnu": "15.5.18", + "@next/swc-linux-arm64-musl": "15.5.18", + "@next/swc-linux-x64-gnu": "15.5.18", + "@next/swc-linux-x64-musl": "15.5.18", + "@next/swc-win32-arm64-msvc": "15.5.18", + "@next/swc-win32-x64-msvc": "15.5.18", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..6cbf2c2 --- /dev/null +++ b/website/package.json @@ -0,0 +1,29 @@ +{ + "name": "stackdog-website", + "version": "0.1.0", + "private": true, + "engines": { + "node": ">=18.18.0" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "lucide-react": "^1.17.0", + "next": "^15.5.18", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3" + } +} diff --git a/website/postcss.config.js b/website/postcss.config.js new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/website/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/website/public/stackdog-mark.png b/website/public/stackdog-mark.png new file mode 100644 index 0000000..aeabb36 Binary files /dev/null and b/website/public/stackdog-mark.png differ diff --git a/website/supervisord.conf b/website/supervisord.conf new file mode 100644 index 0000000..a0d34b0 --- /dev/null +++ b/website/supervisord.conf @@ -0,0 +1,25 @@ +[supervisord] +nodaemon=true +logfile=/dev/null +logfile_maxbytes=0 +loglevel=info + +[program:nextjs] +command=node /app/server.js +environment=NODE_ENV=production,PORT=3000,HOSTNAME=127.0.0.1 +user=app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=nginx -g "daemon off;" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/website/tailwind.config.ts b/website/tailwind.config.ts new file mode 100644 index 0000000..a33bc45 --- /dev/null +++ b/website/tailwind.config.ts @@ -0,0 +1,18 @@ +import type { Config } from 'tailwindcss'; + +const config: Config = { + content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './lib/**/*.{ts,tsx}'], + theme: { + extend: { + fontFamily: { + sans: ['var(--font-inter)', 'sans-serif'] + }, + boxShadow: { + glow: '0 0 0 1px rgba(6, 182, 212, 0.15), 0 18px 60px rgba(2, 6, 23, 0.45)' + } + } + }, + plugins: [] +}; + +export default config; diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 0000000..a1703db --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/website/tsconfig.tsbuildinfo b/website/tsconfig.tsbuildinfo new file mode 100644 index 0000000..45b6d4d --- /dev/null +++ b/website/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.esnext.error.d.ts","./node_modules/typescript/lib/lib.esnext.sharedmemory.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./.next/types/routes.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/shared/lib/amp.d.ts","./node_modules/next/amp.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/@types/node/globals.typedarray.d.ts","./node_modules/@types/node/buffer.buffer.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/@types/node/web-globals/crypto.d.ts","./node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/@types/node/web-globals/events.d.ts","./node_modules/undici-types/utility.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client-stats.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/h2c-client.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-call-history.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/snapshot-agent.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/cache-interceptor.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/@types/node/web-globals/storage.d.ts","./node_modules/@types/node/web-globals/streams.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/inspector.generated.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/sqlite.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/index.d.ts","./node_modules/@types/react/canary.d.ts","./node_modules/@types/react/experimental.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/react-dom/canary.d.ts","./node_modules/@types/react-dom/experimental.d.ts","./node_modules/next/dist/lib/fallback.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/lib/cache-control.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/worker.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/build/rendering-mode.d.ts","./node_modules/next/dist/server/lib/router-utils/build-prefetch-segment-data-route.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/lib/experimental/ppr.d.ts","./node_modules/next/dist/build/webpack/plugins/app-build-manifest-plugin.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/segment-config/app/app-segment-config.d.ts","./node_modules/next/dist/build/segment-config/pages/pages-segment-config.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/server/node-environment-baseline.d.ts","./node_modules/next/dist/server/node-environment-extensions/error-inspect.d.ts","./node_modules/next/dist/server/node-environment-extensions/random.d.ts","./node_modules/next/dist/server/node-environment-extensions/date.d.ts","./node_modules/next/dist/server/node-environment-extensions/web-crypto.d.ts","./node_modules/next/dist/server/node-environment-extensions/node-crypto.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/server/instrumentation/types.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/build/webpack/plugins/telemetry-plugin/use-cache-tracker-utils.d.ts","./node_modules/next/dist/build/webpack/plugins/telemetry-plugin/telemetry-plugin.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/build/build-context.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/server/route-kind.d.ts","./node_modules/next/dist/server/route-definitions/route-definition.d.ts","./node_modules/next/dist/build/swc/generated-native.d.ts","./node_modules/next/dist/build/swc/types.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/next-devtools/shared/types.d.ts","./node_modules/next/dist/server/dev/dev-indicator-server-state.d.ts","./node_modules/next/dist/server/lib/parse-stack.d.ts","./node_modules/next/dist/next-devtools/server/shared.d.ts","./node_modules/next/dist/next-devtools/shared/stack-frame.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/utils/get-error-by-type.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/container/runtime-error/render-error.d.ts","./node_modules/next/dist/next-devtools/dev-overlay/shared.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/server/lib/cache-handlers/types.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/resume-data-cache/cache-store.d.ts","./node_modules/next/dist/server/resume-data-cache/resume-data-cache.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/server/lib/i18n-provider.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/server/after/builtin-request-context.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/build/segment-config/middleware/middleware-config.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/server/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/server/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader/index.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/server/app-render/cache-signal.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/server/request/fallback-params.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage-instance.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/next/dist/server/lib/lazy-result.d.ts","./node_modules/next/dist/server/lib/implicit-tags.d.ts","./node_modules/next/dist/server/app-render/work-unit-async-storage.external.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-relative-url.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.compiled.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/server/app-render/action-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/action-async-storage.external.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/client-segment.d.ts","./node_modules/next/dist/server/request/search-params.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/client/components/http-access-fallback/error-boundary.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/dist/lib/metadata/types/resolvers.d.ts","./node_modules/next/dist/lib/metadata/types/icons.d.ts","./node_modules/next/dist/lib/metadata/resolve-metadata.d.ts","./node_modules/next/dist/lib/metadata/metadata.d.ts","./node_modules/next/dist/lib/framework/boundary-components.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/shared/lib/segment-cache/segment-value-encoding.d.ts","./node_modules/next/dist/server/app-render/collect-segment-data.d.ts","./node_modules/next/dist/next-devtools/userspace/app/segment-explorer-node.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/@types/react/jsx-dev-runtime.d.ts","./node_modules/@types/react/compiler-runtime.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/rsc/entrypoints.d.ts","./node_modules/@types/react-dom/client.d.ts","./node_modules/@types/react-dom/static.d.ts","./node_modules/@types/react-dom/server.d.ts","./node_modules/next/dist/server/route-modules/app-page/vendored/ssr/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/web/adapter.d.ts","./node_modules/next/dist/server/use-cache/cache-life.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/client/flight-data-helpers.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/server/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/route-modules/pages/module.d.ts","./node_modules/next/dist/next-devtools/userspace/pages/pages-dev-overlay-setup.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/server/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/normalizers/normalizer.d.ts","./node_modules/next/dist/server/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/normalizers/request/prefetch-rsc.d.ts","./node_modules/next/dist/server/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/normalizers/request/segment-prefix-rsc.d.ts","./node_modules/next/dist/build/static-paths/types.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/lib/async-callback-set.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/sharp/lib/index.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/lru-cache.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/router-utils/router-server-context.d.ts","./node_modules/next/dist/server/route-modules/route-module.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/server/route-definitions/app-route-route-definition.d.ts","./node_modules/next/dist/server/async-storage/work-store.d.ts","./node_modules/next/dist/server/web/http.d.ts","./node_modules/next/dist/server/route-modules/app-route/shared-modules.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect-error.d.ts","./node_modules/next/dist/build/templates/app-route.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.d.ts","./node_modules/next/dist/server/route-modules/app-route/module.compiled.d.ts","./node_modules/next/dist/build/segment-config/app/app-segments.d.ts","./node_modules/next/dist/build/utils.d.ts","./node_modules/next/dist/build/turborepo-access-trace/types.d.ts","./node_modules/next/dist/build/turborepo-access-trace/result.d.ts","./node_modules/next/dist/build/turborepo-access-trace/helpers.d.ts","./node_modules/next/dist/build/turborepo-access-trace/index.d.ts","./node_modules/next/dist/export/routes/types.d.ts","./node_modules/next/dist/export/types.d.ts","./node_modules/next/dist/export/worker.d.ts","./node_modules/next/dist/build/worker.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/server/after/after.d.ts","./node_modules/next/dist/server/after/after-context.d.ts","./node_modules/next/dist/server/app-render/work-async-storage-instance.d.ts","./node_modules/next/dist/server/app-render/work-async-storage.external.d.ts","./node_modules/next/dist/server/request/params.d.ts","./node_modules/next/dist/server/route-matches/route-match.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/cli/next-test.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/types.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/pages/_app.d.ts","./node_modules/next/app.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/dist/server/use-cache/cache-tag.d.ts","./node_modules/next/cache.d.ts","./node_modules/next/dist/shared/lib/runtime-config.external.d.ts","./node_modules/next/config.d.ts","./node_modules/next/dist/pages/_document.d.ts","./node_modules/next/document.d.ts","./node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/next/dynamic.d.ts","./node_modules/next/dist/pages/_error.d.ts","./node_modules/next/error.d.ts","./node_modules/next/dist/shared/lib/head.d.ts","./node_modules/next/head.d.ts","./node_modules/next/dist/server/request/cookies.d.ts","./node_modules/next/dist/server/request/headers.d.ts","./node_modules/next/dist/server/request/draft-mode.d.ts","./node_modules/next/headers.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image.d.ts","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/forbidden.d.ts","./node_modules/next/dist/client/components/unauthorized.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.server.d.ts","./node_modules/next/dist/client/components/unstable-rethrow.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/unrecognized-action-error.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./node_modules/next/router.d.ts","./node_modules/next/dist/client/script.d.ts","./node_modules/next/script.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/next/dist/server/after/index.d.ts","./node_modules/next/dist/server/request/root-params.d.ts","./node_modules/next/dist/server/request/connection.d.ts","./node_modules/next/server.d.ts","./node_modules/next/types/global.d.ts","./node_modules/next/types/compiled.d.ts","./node_modules/next/types.d.ts","./node_modules/next/index.d.ts","./node_modules/next/image-types/global.d.ts","./next-env.d.ts","./next.config.ts","./node_modules/source-map-js/source-map.d.ts","./node_modules/postcss/lib/previous-map.d.ts","./node_modules/postcss/lib/input.d.ts","./node_modules/postcss/lib/css-syntax-error.d.ts","./node_modules/postcss/lib/declaration.d.ts","./node_modules/postcss/lib/root.d.ts","./node_modules/postcss/lib/warning.d.ts","./node_modules/postcss/lib/lazy-result.d.ts","./node_modules/postcss/lib/no-work-result.d.ts","./node_modules/postcss/lib/processor.d.ts","./node_modules/postcss/lib/result.d.ts","./node_modules/postcss/lib/document.d.ts","./node_modules/postcss/lib/rule.d.ts","./node_modules/postcss/lib/node.d.ts","./node_modules/postcss/lib/comment.d.ts","./node_modules/postcss/lib/container.d.ts","./node_modules/postcss/lib/at-rule.d.ts","./node_modules/postcss/lib/list.d.ts","./node_modules/postcss/lib/postcss.d.ts","./node_modules/postcss/lib/postcss.d.mts","./node_modules/tailwindcss/types/generated/corepluginlist.d.ts","./node_modules/tailwindcss/types/generated/colors.d.ts","./node_modules/tailwindcss/types/config.d.ts","./node_modules/tailwindcss/types/index.d.ts","./tailwind.config.ts","./lib/config.ts","./app/robots.ts","./app/sitemap.ts","./node_modules/zod/v4/core/json-schema.d.cts","./node_modules/zod/v4/core/standard-schema.d.cts","./node_modules/zod/v4/core/registries.d.cts","./node_modules/zod/v4/core/to-json-schema.d.cts","./node_modules/zod/v4/core/util.d.cts","./node_modules/zod/v4/core/versions.d.cts","./node_modules/zod/v4/core/schemas.d.cts","./node_modules/zod/v4/core/checks.d.cts","./node_modules/zod/v4/core/errors.d.cts","./node_modules/zod/v4/core/core.d.cts","./node_modules/zod/v4/core/parse.d.cts","./node_modules/zod/v4/core/regexes.d.cts","./node_modules/zod/v4/locales/ar.d.cts","./node_modules/zod/v4/locales/az.d.cts","./node_modules/zod/v4/locales/be.d.cts","./node_modules/zod/v4/locales/bg.d.cts","./node_modules/zod/v4/locales/ca.d.cts","./node_modules/zod/v4/locales/cs.d.cts","./node_modules/zod/v4/locales/da.d.cts","./node_modules/zod/v4/locales/de.d.cts","./node_modules/zod/v4/locales/el.d.cts","./node_modules/zod/v4/locales/en.d.cts","./node_modules/zod/v4/locales/eo.d.cts","./node_modules/zod/v4/locales/es.d.cts","./node_modules/zod/v4/locales/fa.d.cts","./node_modules/zod/v4/locales/fi.d.cts","./node_modules/zod/v4/locales/fr.d.cts","./node_modules/zod/v4/locales/fr-ca.d.cts","./node_modules/zod/v4/locales/he.d.cts","./node_modules/zod/v4/locales/hr.d.cts","./node_modules/zod/v4/locales/hu.d.cts","./node_modules/zod/v4/locales/hy.d.cts","./node_modules/zod/v4/locales/id.d.cts","./node_modules/zod/v4/locales/is.d.cts","./node_modules/zod/v4/locales/it.d.cts","./node_modules/zod/v4/locales/ja.d.cts","./node_modules/zod/v4/locales/ka.d.cts","./node_modules/zod/v4/locales/kh.d.cts","./node_modules/zod/v4/locales/km.d.cts","./node_modules/zod/v4/locales/ko.d.cts","./node_modules/zod/v4/locales/lt.d.cts","./node_modules/zod/v4/locales/mk.d.cts","./node_modules/zod/v4/locales/ms.d.cts","./node_modules/zod/v4/locales/nl.d.cts","./node_modules/zod/v4/locales/no.d.cts","./node_modules/zod/v4/locales/ota.d.cts","./node_modules/zod/v4/locales/ps.d.cts","./node_modules/zod/v4/locales/pl.d.cts","./node_modules/zod/v4/locales/pt.d.cts","./node_modules/zod/v4/locales/ro.d.cts","./node_modules/zod/v4/locales/ru.d.cts","./node_modules/zod/v4/locales/sl.d.cts","./node_modules/zod/v4/locales/sv.d.cts","./node_modules/zod/v4/locales/ta.d.cts","./node_modules/zod/v4/locales/th.d.cts","./node_modules/zod/v4/locales/tr.d.cts","./node_modules/zod/v4/locales/ua.d.cts","./node_modules/zod/v4/locales/uk.d.cts","./node_modules/zod/v4/locales/ur.d.cts","./node_modules/zod/v4/locales/uz.d.cts","./node_modules/zod/v4/locales/vi.d.cts","./node_modules/zod/v4/locales/zh-cn.d.cts","./node_modules/zod/v4/locales/zh-tw.d.cts","./node_modules/zod/v4/locales/yo.d.cts","./node_modules/zod/v4/locales/index.d.cts","./node_modules/zod/v4/core/doc.d.cts","./node_modules/zod/v4/core/api.d.cts","./node_modules/zod/v4/core/json-schema-processors.d.cts","./node_modules/zod/v4/core/json-schema-generator.d.cts","./node_modules/zod/v4/core/index.d.cts","./node_modules/zod/v4/classic/errors.d.cts","./node_modules/zod/v4/classic/parse.d.cts","./node_modules/zod/v4/classic/schemas.d.cts","./node_modules/zod/v4/classic/checks.d.cts","./node_modules/zod/v4/classic/compat.d.cts","./node_modules/zod/v4/classic/from-json-schema.d.cts","./node_modules/zod/v4/classic/iso.d.cts","./node_modules/zod/v4/classic/coerce.d.cts","./node_modules/zod/v4/classic/external.d.cts","./node_modules/zod/index.d.cts","./app/api/contact/route.ts","./lib/structured-data.ts","./node_modules/next/dist/compiled/@next/font/dist/types.d.ts","./node_modules/next/dist/compiled/@next/font/dist/google/index.d.ts","./node_modules/next/font/google/index.d.ts","./components/cookieconsent.tsx","./node_modules/lucide-react/dist/lucide-react.d.ts","./components/footer.tsx","./components/navbar.tsx","./app/layout.tsx","./components/copybutton.tsx","./app/page.tsx","./components/contactform.tsx","./app/contact/page.tsx","./app/docs/page.tsx","./.next/types/cache-life.d.ts","./.next/types/validator.ts","./.next/types/app/page.ts","./.next/types/app/api/contact/route.ts","./.next/types/app/contact/page.ts","./.next/types/app/docs/page.ts"],"fileIdsList":[[97,151,168,169,498,614],[97,151,168,169,344,627],[97,151,168,169,344,628],[97,151,168,169,344,625],[97,151,168,169,451,452,453,454],[97,151,168,169],[83,97,151,168,169,498,501,614,623,625,627,628],[97,151,168,169,498,531,613],[97,151,168,169,502,531,620,626],[97,151,168,169,475,502,531,615,620,624],[86,97,151,168,169,502,531,618,619,621,622],[97,151,168,169,502,531],[86,97,151,168,169,620],[86,97,151,168,169,488],[97,151,168,169,475,620],[86,97,151,168,169,473,475,485,620],[97,151,168,169,531],[83,97,151,168,169,502,503],[97,151,168,169,502],[97,148,149,151,168,169],[97,150,151,168,169],[151,168,169],[97,151,156,168,169,186],[97,151,152,157,162,168,169,171,183,194],[97,151,152,153,162,168,169,171],[97,151,154,168,169,195],[97,151,155,156,163,168,169,172],[97,151,156,168,169,183,191],[97,151,157,159,162,168,169,171],[97,150,151,158,168,169],[97,151,159,160,168,169],[97,151,161,162,168,169],[97,150,151,162,168,169],[97,151,162,163,164,168,169,183,194],[97,151,162,163,164,168,169,178,183,186],[97,143,151,159,162,165,168,169,171,183,194],[97,151,162,163,165,166,168,169,171,183,191,194],[97,151,165,167,168,169,183,191,194],[95,96,97,98,99,100,101,102,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200],[97,151,162,168,169],[97,151,168,169,170,194],[97,151,159,162,168,169,171,183],[97,151,168,169,172],[97,151,168,169,173],[97,150,151,168,169,174],[97,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200],[97,151,168,169,176],[97,151,168,169,177],[97,151,162,168,169,178,179],[97,151,168,169,178,180,195,197],[97,151,163,168,169],[97,151,162,168,169,183,184,186],[97,151,168,169,185,186],[97,151,168,169,183,184],[97,151,168,169,186],[97,151,168,169,187],[97,148,151,168,169,183,188,194],[97,151,162,168,169,189,190],[97,151,168,169,189,190],[97,151,156,168,169,171,183,191],[97,151,168,169,192],[97,151,168,169,171,193],[97,151,165,168,169,177,194],[97,151,156,168,169,195],[97,151,168,169,183,196],[97,151,168,169,170,197],[97,151,168,169,198],[97,151,156,168,169],[97,143,151,168,169],[97,151,168,169,199],[97,143,151,162,164,168,169,174,183,186,194,196,197,199],[97,151,168,169,183,200],[86,90,97,151,168,169,202,203,204,206,446,494],[86,97,151,168,169],[86,90,97,151,168,169,202,203,204,205,361,446,494],[86,90,97,151,168,169,202,203,205,206,446,494],[86,97,151,168,169,206,361,362],[86,97,151,168,169,206,361],[86,90,97,151,168,169,203,204,205,206,446,494],[86,90,97,151,168,169,202,204,205,206,446,494],[84,85,97,151,168,169],[92,97,151,168,169],[97,151,168,169,449],[97,151,168,169,456],[97,151,168,169,210,224,225,226,228,443],[97,151,168,169,210,249,251,253,254,257,443,445],[97,151,168,169,210,214,216,217,218,219,220,432,443,445],[97,151,168,169,443],[97,151,168,169,225,327,413,422,439],[97,151,168,169,210],[97,151,168,169,207,439],[97,151,168,169,261],[97,151,168,169,260,443,445],[97,151,165,168,169,309,327,356,500],[97,151,165,168,169,320,336,422,438],[97,151,165,168,169,374],[97,151,168,169,426],[97,151,168,169,425,426,427],[97,151,168,169,425],[94,97,151,165,168,169,207,210,214,217,221,222,223,225,229,237,238,367,392,423,443,446],[97,151,168,169,210,227,245,249,250,255,256,443,500],[97,151,168,169,227,500],[97,151,168,169,238,245,307,443,500],[97,151,168,169,500],[97,151,168,169,210,227,228,500],[97,151,168,169,252,500],[97,151,168,169,221,424,431],[97,151,168,169,177,269,439],[97,151,168,169,269,439],[86,97,151,168,169,269],[86,97,151,168,169,328],[97,151,168,169,324,372,439,482,483],[97,151,168,169,419,476,477,478,479,481],[97,151,168,169,418],[97,151,168,169,418,419],[97,151,168,169,218,368,369,370],[97,151,168,169,368,371,372],[97,151,168,169,480],[97,151,168,169,368,372],[86,97,151,168,169,211,470],[86,97,151,168,169,194],[86,97,151,168,169,227,297],[86,97,151,168,169,227],[97,151,168,169,295,299],[86,97,151,168,169,296,448],[97,151,168,169,616],[86,90,97,151,165,168,169,201,202,203,204,205,206,446,492,493],[97,151,165,168,169],[97,151,165,168,169,214,276,368,378,393,413,428,429,443,444,500],[97,151,168,169,237,430],[97,151,168,169,446],[97,151,168,169,209],[86,97,151,168,169,309,323,335,345,347,438],[97,151,168,169,177,309,323,344,345,346,438,499],[97,151,168,169,338,339,340,341,342,343],[97,151,168,169,340],[97,151,168,169,344],[97,151,168,169,267,268,269,271],[86,97,151,168,169,262,263,264,270],[97,151,168,169,267,270],[97,151,168,169,265],[97,151,168,169,266],[86,97,151,168,169,269,296,448],[86,97,151,168,169,269,447,448],[86,97,151,168,169,269,448],[97,151,168,169,393,435],[97,151,168,169,435],[97,151,165,168,169,444,448],[97,151,168,169,332],[97,150,151,168,169,331],[97,151,168,169,239,277,315,317,319,320,321,322,365,368,438,441,444],[97,151,168,169,239,353,368,372],[97,151,168,169,320,438],[86,97,151,168,169,320,329,330,332,333,334,335,336,337,348,349,350,351,352,354,355,438,439,500],[97,151,168,169,314],[97,151,165,168,169,177,239,240,276,291,321,365,366,367,372,393,413,434,443,444,445,446,500],[97,151,168,169,438],[97,150,151,168,169,225,318,321,367,434,436,437,444],[97,151,168,169,320],[97,150,151,168,169,276,281,310,311,312,313,314,315,316,317,319,438,439],[97,151,165,168,169,281,282,310,444,445],[97,151,168,169,225,367,368,393,434,438,444],[97,151,165,168,169,443,445],[97,151,165,168,169,183,441,444,445],[97,151,165,168,169,177,194,207,214,227,239,240,242,277,278,283,288,291,317,321,368,378,380,383,385,388,389,390,391,392,413,433,434,439,441,443,444,445],[97,151,165,168,169,183],[97,151,168,169,210,211,212,214,219,222,227,245,433,441,442,446,448,500],[97,151,165,168,169,183,194,257,259,261,262,263,264,271,500],[97,151,168,169,177,194,207,249,259,287,288,289,290,317,368,383,392,393,399,402,403,413,434,439,441],[97,151,168,169,221,222,237,367,392,434,443],[97,151,165,168,169,194,211,214,317,397,441,443],[97,151,168,169,308],[97,151,165,168,169,400,401,410],[97,151,168,169,441,443],[97,151,168,169,315,318],[97,151,168,169,317,321,433,448],[97,151,165,168,169,177,243,249,290,383,393,399,402,405,441],[97,151,165,168,169,221,237,249,406],[97,151,168,169,210,242,408,433,443],[97,151,165,168,169,194,443],[97,151,165,168,169,227,241,242,243,254,272,407,409,433,443],[94,97,151,168,169,239,321,412,446,448],[97,151,165,168,169,177,194,214,221,229,237,240,277,283,287,288,289,290,291,317,368,380,393,394,396,398,413,433,434,439,440,441,448],[97,151,165,168,169,183,221,399,404,410,441],[97,151,168,169,232,233,234,235,236],[97,151,168,169,278,384],[97,151,168,169,386],[97,151,168,169,384],[97,151,168,169,386,387],[97,151,165,168,169,214,217,218,276,444],[97,151,165,168,169,177,209,211,239,277,291,321,376,377,413,441,445,446,448],[97,151,165,168,169,177,194,213,218,317,377,440,444],[97,151,168,169,310],[97,151,168,169,311],[97,151,168,169,312],[97,151,168,169,439],[97,151,168,169,258,274],[97,151,165,168,169,214,258,277],[97,151,168,169,273,274],[97,151,168,169,275],[97,151,168,169,258,259],[97,151,168,169,258,292],[97,151,168,169,258],[97,151,168,169,278,382,440],[97,151,168,169,381],[97,151,168,169,259,439,440],[97,151,168,169,379,440],[97,151,168,169,259,439],[97,151,168,169,365],[97,151,168,169,214,219,277,306,309,315,317,321,323,326,357,360,364,368,412,433,441,444],[97,151,168,169,300,303,304,305,324,325,372],[86,97,151,168,169,204,206,269,358,359],[86,97,151,168,169,204,206,269,358,359,363],[97,151,168,169,421],[97,151,168,169,225,282,320,321,332,336,368,412,414,415,416,417,419,420,423,433,438,443],[97,151,168,169,372],[97,151,168,169,376],[97,151,165,168,169,277,293,373,375,378,412,441,446,448],[97,151,168,169,300,301,302,303,304,305,324,325,372,447],[94,97,151,165,168,169,177,194,240,258,259,291,317,321,410,411,413,433,434,443,444,446],[97,151,168,169,282,284,287,434],[97,151,165,168,169,278,443],[97,151,168,169,281,320],[97,151,168,169,280],[97,151,168,169,282,283],[97,151,168,169,279,281,443],[97,151,165,168,169,213,282,284,285,286,443,444],[86,97,151,168,169,368,369,371],[97,151,168,169,244],[86,97,151,168,169,211],[86,97,151,168,169,439],[86,94,97,151,168,169,291,321,446,448],[97,151,168,169,211,470,471],[86,97,151,168,169,299],[86,97,151,168,169,177,194,209,256,294,296,298,448],[97,151,168,169,227,439,444],[97,151,168,169,395,439],[97,151,168,169,368],[86,97,151,163,165,168,169,177,209,245,251,299,446,447],[86,97,151,168,169,202,203,204,205,206,446,494],[86,87,88,89,90,97,151,168,169],[97,151,168,169,246,247,248],[97,151,168,169,246],[86,90,97,151,165,167,168,169,177,201,202,203,204,205,206,207,209,240,344,405,443,445,448,494],[97,151,168,169,458],[97,151,168,169,460],[97,151,168,169,462],[97,151,168,169,617],[97,151,168,169,464],[97,151,168,169,466,467,468],[97,151,168,169,472],[91,93,97,151,168,169,450,455,457,459,461,463,465,469,473,475,485,486,488,498,499,500,501],[97,151,168,169,474],[97,151,168,169,484],[97,151,168,169,296],[97,151,168,169,487],[97,150,151,168,169,282,284,285,287,335,439,489,490,491,494,495,496,497],[97,151,168,169,201],[97,151,168,169,521],[97,151,168,169,519,521],[97,151,168,169,510,518,519,520,522,524],[97,151,168,169,508],[97,151,168,169,511,516,521,524],[97,151,168,169,507,524],[97,151,168,169,511,512,515,516,517,524],[97,151,168,169,511,512,513,515,516,524],[97,151,168,169,508,509,510,511,512,516,517,518,520,521,522,524],[97,151,168,169,524],[97,151,168,169,506,508,509,510,511,512,513,515,516,517,518,519,520,521,522,523],[97,151,168,169,506,524],[97,151,168,169,511,513,514,516,517,524],[97,151,168,169,515,524],[97,151,168,169,516,517,521,524],[97,151,168,169,509,519],[97,151,168,169,183,201],[97,151,168,169,526,527],[97,151,168,169,525,528],[97,109,112,115,116,151,168,169,194],[97,112,151,168,169,183,194],[97,112,116,151,168,169,194],[97,151,168,169,183],[97,106,151,168,169],[97,110,151,168,169],[97,108,109,112,151,168,169,194],[97,151,168,169,171,191],[97,106,151,168,169,201],[97,108,112,151,168,169,171,194],[97,103,104,105,107,111,151,162,168,169,183,194],[97,112,120,128,151,168,169],[97,104,110,151,168,169],[97,112,137,138,151,168,169],[97,104,107,112,151,168,169,186,194,201],[97,112,151,168,169],[97,108,112,151,168,169,194],[97,103,151,168,169],[97,106,107,108,110,111,112,113,114,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,138,139,140,141,142,151,168,169],[97,112,130,133,151,159,168,169],[97,112,120,121,122,151,168,169],[97,110,112,121,123,151,168,169],[97,111,151,168,169],[97,104,106,112,151,168,169],[97,112,116,121,123,151,168,169],[97,116,151,168,169],[97,110,112,115,151,168,169,194],[97,104,108,112,120,151,168,169],[97,112,130,151,168,169],[97,123,151,168,169],[97,106,112,137,151,168,169,186,199,201],[97,151,168,169,612],[97,151,168,169,603],[97,151,168,169,603,606],[97,151,168,169,538,598,601,603,604,605,606,607,608,609,610,611],[97,151,168,169,534,536,606],[97,151,168,169,603,604],[97,151,168,169,535,603,605],[97,151,168,169,536,538,540,541,542,543],[97,151,168,169,538,540,542,543],[97,151,168,169,538,540,542],[97,151,168,169,535,538,540,541,543],[97,151,168,169,534,536,537,538,539,540,541,542,543,544,545,598,599,600,601,602],[97,151,168,169,534,536,537,540],[97,151,168,169,536,537,540],[97,151,168,169,540,543],[97,151,168,169,534,535,537,538,539,541,542,543],[97,151,168,169,534,535,536,540,603],[97,151,168,169,540,541,542,543],[97,151,168,169,542],[97,151,168,169,546,547,548,549,550,551,552,553,554,555,556,557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597],[97,151,168,169,529]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"2ab096661c711e4a81cc464fa1e6feb929a54f5340b46b0a07ac6bbf857471f0","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"73f78680d4c08509933daf80947902f6ff41b6230f94dd002ae372620adb0f60","affectsGlobalScope":true,"impliedFormat":1},{"version":"c5239f5c01bcfa9cd32f37c496cf19c61d69d37e48be9de612b541aac915805b","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"a7e595f507b1494a05fa45044e38484d0319a176336103f5e4f82e6726ffd821","affectsGlobalScope":true},{"version":"7e29f41b158de217f94cb9676bf9cbd0cd9b5a46e1985141ed36e075c52bf6ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"bd7dee3446a5b94651d58000ddfda40296f073e9372891f65003a524b4620697","impliedFormat":1},{"version":"acd8fd5090ac73902278889c38336ff3f48af6ba03aa665eb34a75e7ba1dccc4","impliedFormat":1},{"version":"d6258883868fb2680d2ca96bc8b1352cab69874581493e6d52680c5ffecdb6cc","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"f258e3960f324a956fc76a3d3d9e964fff2244ff5859dcc6ce5951e5413ca826","impliedFormat":1},{"version":"643f7232d07bf75e15bd8f658f664d6183a0efaca5eb84b48201c7671a266979","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"631eff75b0e35d1b1b31081d55209abc43e16b49426546ab5a9b40bdd40b1f60","impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"378281aa35786c27d5811af7e6bcaa492eebd0c7013d48137c35bbc69a2b9751","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"1b2dd1cbeb0cc6ae20795958ba5950395ebb2849b7c8326853dd15530c77ab0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"387a023d363f755eb63450a66c28b14cdd7bc30a104565e2dbf0a8988bb4a56c","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"cdcf9ea426ad970f96ac930cd176d5c69c6c24eebd9fc580e1572d6c6a88f62c","impliedFormat":1},{"version":"23cd712e2ce083d68afe69224587438e5914b457b8acf87073c22494d706a3d0","impliedFormat":1},{"version":"487b694c3de27ddf4ad107d4007ad304d29effccf9800c8ae23c2093638d906a","impliedFormat":1},{"version":"3a80bc85f38526ca3b08007ee80712e7bb0601df178b23fbf0bf87036fce40ce","impliedFormat":1},{"version":"ccf4552357ce3c159ef75f0f0114e80401702228f1898bdc9402214c9499e8c0","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"68834d631c8838c715f225509cfc3927913b9cc7a4870460b5b60c8dbdb99baf","impliedFormat":1},{"version":"2931540c47ee0ff8a62860e61782eb17b155615db61e36986e54645ec67f67c2","impliedFormat":1},{"version":"ccab02f3920fc75c01174c47fcf67882a11daf16baf9e81701d0a94636e94556","impliedFormat":1},{"version":"f6faf5f74e4c4cc309a6c6a6c4da02dbb840be5d3e92905a23dcd7b2b0bd1986","impliedFormat":1},{"version":"ea6bc8de8b59f90a7a3960005fd01988f98fd0784e14bc6922dde2e93305ec7d","impliedFormat":1},{"version":"36107995674b29284a115e21a0618c4c2751b32a8766dd4cb3ba740308b16d59","impliedFormat":1},{"version":"914a0ae30d96d71915fc519ccb4efbf2b62c0ddfb3a3fc6129151076bc01dc60","impliedFormat":1},{"version":"33e981bf6376e939f99bd7f89abec757c64897d33c005036b9a10d9587d80187","impliedFormat":1},{"version":"7fd1b31fd35876b0aa650811c25ec2c97a3c6387e5473eb18004bed86cdd76b6","impliedFormat":1},{"version":"b41767d372275c154c7ea6c9d5449d9a741b8ce080f640155cc88ba1763e35b3","impliedFormat":1},{"version":"3bacf516d686d08682751a3bd2519ea3b8041a164bfb4f1d35728993e70a2426","impliedFormat":1},{"version":"7fb266686238369442bd1719bc0d7edd0199da4fb8540354e1ff7f16669b4323","impliedFormat":1},{"version":"0a60a292b89ca7218b8616f78e5bbd1c96b87e048849469cccb4355e98af959a","impliedFormat":1},{"version":"0b6e25234b4eec6ed96ab138d96eb70b135690d7dd01f3dd8a8ab291c35a683a","impliedFormat":1},{"version":"9666f2f84b985b62400d2e5ab0adae9ff44de9b2a34803c2c5bd3c8325b17dc0","impliedFormat":1},{"version":"40cd35c95e9cf22cfa5bd84e96408b6fcbca55295f4ff822390abb11afbc3dca","impliedFormat":1},{"version":"b1616b8959bf557feb16369c6124a97a0e74ed6f49d1df73bb4b9ddf68acf3f3","impliedFormat":1},{"version":"5b03a034c72146b61573aab280f295b015b9168470f2df05f6080a2122f9b4df","impliedFormat":1},{"version":"40b463c6766ca1b689bfcc46d26b5e295954f32ad43e37ee6953c0a677e4ae2b","impliedFormat":1},{"version":"249b9cab7f5d628b71308c7d9bb0a808b50b091e640ba3ed6e2d0516f4a8d91d","impliedFormat":1},{"version":"80aae6afc67faa5ac0b32b5b8bc8cc9f7fa299cff15cf09cc2e11fd28c6ae29e","impliedFormat":1},{"version":"f473cd2288991ff3221165dcf73cd5d24da30391f87e85b3dd4d0450c787a391","impliedFormat":1},{"version":"499e5b055a5aba1e1998f7311a6c441a369831c70905cc565ceac93c28083d53","impliedFormat":1},{"version":"54c3e2371e3d016469ad959697fd257e5621e16296fa67082c2575d0bf8eced0","impliedFormat":1},{"version":"beb8233b2c220cfa0feea31fbe9218d89fa02faa81ef744be8dce5acb89bb1fd","impliedFormat":1},{"version":"c183b931b68ad184bc8e8372bf663f3d33304772fb482f29fb91b3c391031f3e","impliedFormat":1},{"version":"5d0375ca7310efb77e3ef18d068d53784faf62705e0ad04569597ae0e755c401","impliedFormat":1},{"version":"59af37caec41ecf7b2e76059c9672a49e682c1a2aa6f9d7dc78878f53aa284d6","impliedFormat":1},{"version":"addf417b9eb3f938fddf8d81e96393a165e4be0d4a8b6402292f9c634b1cb00d","impliedFormat":1},{"version":"48cc3ec153b50985fb95153258a710782b25975b10dd4ac8a4f3920632d10790","impliedFormat":1},{"version":"adf27937dba6af9f08a68c5b1d3fce0ca7d4b960c57e6d6c844e7d1a8e53adae","impliedFormat":1},{"version":"e1528ca65ac90f6fa0e4a247eb656b4263c470bb22d9033e466463e13395e599","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"866078923a56d026e39243b4392e282c1c63159723996fa89243140e1388a98d","impliedFormat":1},{"version":"f724236417941ea77ec8d38c6b7021f5fb7f8521c7f8c1538e87661f2c6a0774","affectsGlobalScope":true,"impliedFormat":1},{"version":"1cf059eaf468efcc649f8cf6075d3cb98e9a35a0fe9c44419ec3d2f5428d7123","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d97fb21da858fb18b8ae72c314e9743fd52f73ebe2764e12af1db32fc03f853f","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ea15fd99b2e34cb25fe8346c955000bb70c8b423ae4377a972ef46bfb37f595","impliedFormat":1},{"version":"7cf69dd5502c41644c9e5106210b5da7144800670cbe861f66726fa209e231c4","impliedFormat":1},{"version":"72c1f5e0a28e473026074817561d1bc9647909cf253c8d56c41d1df8d95b85f7","impliedFormat":1},{"version":"f9b4137a0d285bd77dba2e6e895530112264310ae47e07bf311feae428fb8b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"c06b2652ffeb89afd0f1c52c165ced77032f9cd09bc481153fbd6b5504c69494","impliedFormat":1},{"version":"51aecd2df90a3cffea1eb4696b33b2d78594ea2aa2138e6b9471ec4841c6c2ee","impliedFormat":1},{"version":"9d8f9e63e29a3396285620908e7f14d874d066caea747dc4b2c378f0599166b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"612422d5ba6b4a5c4537f423e9199645468ad80a689801da63ab7edb43f7b835","impliedFormat":1},{"version":"db9ada976f9e52e13f7ae8b9a320f4b67b87685938c5879187d8864b2fbe97f3","impliedFormat":1},{"version":"9f39e70a354d0fba29ac3cdf6eca00b7f9e96f64b2b2780c432e8ea27f133743","impliedFormat":1},{"version":"0dace96cc0f7bc6d0ee2044921bdf19fe42d16284dbcc8ae200800d1c9579335","impliedFormat":1},{"version":"a2e2bbde231b65c53c764c12313897ffdfb6c49183dd31823ee2405f2f7b5378","impliedFormat":1},{"version":"ad1cc0ed328f3f708771272021be61ab146b32ecf2b78f3224959ff1e2cd2a5c","impliedFormat":1},{"version":"c64e1888baaa3253ca4405b455e4bf44f76357868a1bd0a52998ade9a092ad78","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc8c6f5322961b56d9906601b20798725df60baeab45ec014fba9f795d5596fd","impliedFormat":1},{"version":"0904660ae854e6d41f6ff25356db1d654436c6305b0f0aa89d1532df0253486e","impliedFormat":1},{"version":"060d305fe4494d8cb2b99d620928d369d1ee55c1645f5e729a2aca07d0f108cb","impliedFormat":1},{"version":"a016b2a26863dd6acb1f18c4ff37363c2b51e27ca7b3374dc6342aff1ddead2d","impliedFormat":1},{"version":"0c50296ee73dae94efc3f0da4936b1146ca6ce2217acfabb44c19c9a33fa30e5","impliedFormat":1},{"version":"bbf42f98a5819f4f06e18c8b669a994afe9a17fe520ae3454a195e6eabf7700d","impliedFormat":1},{"version":"0e5974dfff7a97181c7c376545f126b20acf2f1341db7d3fccea4977bf3ce19c","impliedFormat":1},{"version":"c7f977ea78a1b060a30554c1c4ec0e2269c6e305a349ca2ada14931ac27ecc0b","affectsGlobalScope":true,"impliedFormat":1},{"version":"3806cdd6b48ba01a9198134e62a384ec217a98f316d4baef74dd46d62c947a63","impliedFormat":1},{"version":"ff65b8a8bd380c6d129becc35de02f7c29ad7ce03300331ca91311fb4044d1a9","impliedFormat":1},{"version":"04bf1aa481d1adfb16d93d76e44ce71c51c8ef68039d849926551199489637f6","impliedFormat":1},{"version":"2c9adcc85574b002c9a6311ff2141055769e0071856ec979d92ff989042b1f1b","affectsGlobalScope":true,"impliedFormat":1},{"version":"b8bf3fe89ec8baa335f6370b9fa36308e1bc7a72e2eb2dad1e94f31e27fa28b5","affectsGlobalScope":true,"impliedFormat":1},{"version":"a58a15da4c5ba3df60c910a043281256fa52d36a0fcdef9b9100c646282e88dd","impliedFormat":1},{"version":"b36beffbf8acdc3ebc58c8bb4b75574b31a2169869c70fc03f82895b93950a12","impliedFormat":1},{"version":"de263f0089aefbfd73c89562fb7254a7468b1f33b61839aafc3f035d60766cb4","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"8c81fd4a110490c43d7c578e8c6f69b3af01717189196899a6a44f93daa57a3a","impliedFormat":1},{"version":"5fb39858b2459864b139950a09adae4f38dad87c25bf572ce414f10e4bd7baab","impliedFormat":1},{"version":"35390d6fa94bdb432c5d0bcb6547bdd11406c2692a6b90b9e47be2105ea19bd6","impliedFormat":1},{"version":"3910dab597c40e173bf0e0d419d3ce9682c54ebf6ae84849f9b829b1451a17ec","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"486c074a5c0f2254345c0d1c9540380f5463999e42d7e1a159305ea823d3c4b1","affectsGlobalScope":true,"impliedFormat":1},{"version":"c119835edf36415081dfd9ed15fc0cd37aaa28d232be029ad073f15f3d88c323","impliedFormat":1},{"version":"8e7c3bed5f19ade8f911677ddc83052e2283e25b0a8654cd89db9079d4b323c7","impliedFormat":1},{"version":"9705cd157ffbb91c5cab48bdd2de5a437a372e63f870f8a8472e72ff634d47c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ae86f30d5d10e4f75ce8dcb6e1bd3a12ecec3d071a21e8f462c5c85c678efb41","impliedFormat":1},{"version":"ccf3afaeebbeee4ca9092101e99fd6abd681116b6e5ec23e381bbb1e1f32262c","impliedFormat":1},{"version":"e03460fe72b259f6d25ad029f085e4bedc3f90477da4401d8fbc1efa9793230e","impliedFormat":1},{"version":"4286a3a6619514fca656089aee160bb6f2e77f4dd53dc5a96b26a0b4fc778055","impliedFormat":1},{"version":"ab7818a9d57a9297b90e456fc68b77f84d74395a9210a3cfa9d87db33aff8b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb08062718a5470cd864c1fae0eb5b3a3adc5bcd05dcf87608d6f60b65eca3f4","affectsGlobalScope":true,"impliedFormat":1},{"version":"3a815b7d1aebc0646b91548eab2fc19dada09ff255d04c71ced00bbd3058c8eb","impliedFormat":1},{"version":"255d948f87f24ffd57bcb2fdf95792fd418a2e1f712a98cf2cce88744d75085c","impliedFormat":1},{"version":"0d5b085f36e6dc55bc6332ecb9c733be3a534958c238fb8d8d18d4a2b6f2a15a","impliedFormat":1},{"version":"836b36913830645ac3b28fe33731aac3fdb3524ee8adbb4cdab9a5c189f41943","affectsGlobalScope":true,"impliedFormat":1},{"version":"bfd3b3c21a56104693183942e221c1896ee23bcb8f8d91ab0b941f7b32985411","impliedFormat":1},{"version":"d7e9ab1b0996639047c61c1e62f85c620e4382206b3abb430d9a21fb7bc23c77","impliedFormat":1},{"version":"2beff543f6e9a9701df88daeee3cdd70a34b4a1c11cb4c734472195a5cb2af54","impliedFormat":1},{"version":"2e07abf27aa06353d46f4448c0bbac73431f6065eef7113128a5cd804d0c384d","impliedFormat":1},{"version":"be1cc4d94ea60cbe567bc29ed479d42587bf1e6cba490f123d329976b0fe4ee5","impliedFormat":1},{"version":"42bc0e1a903408137c3df2b06dfd7e402cdab5bbfa5fcfb871b22ebfdb30bd0b","impliedFormat":1},{"version":"9894dafe342b976d251aac58e616ac6df8db91fb9d98934ff9dd103e9e82578f","impliedFormat":1},{"version":"413df52d4ea14472c2fa5bee62f7a40abd1eb49be0b9722ee01ee4e52e63beb2","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"829b9e6028b29e6a8b1c01ddb713efe59da04d857089298fa79acbdb3cfcfdef","impliedFormat":1},{"version":"24f8562308dd8ba6013120557fa7b44950b619610b2c6cb8784c79f11e3c4f90","impliedFormat":1},{"version":"5f90b8c733a1bda63e42160b15a2301051e83a6f9d5332a59d16eb12f463270d","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"ad0d1d75d129b1c80f911be438d6b61bfa8703930a8ff2be2f0e1f8a91841c64","impliedFormat":1},{"version":"ce75b1aebb33d510ff28af960a9221410a3eaf7f18fc5f21f9404075fba77256","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"496bbf339f3838c41f164238543e9fe5f1f10659cb30b68903851618464b98ba","impliedFormat":1},{"version":"5178eb4415a172c287c711dc60a619e110c3fd0b7de01ed0627e51a5336aa09c","impliedFormat":1},{"version":"ca6e5264278b53345bc1ce95f42fb0a8b733a09e3d6479c6ccfca55cdc45038c","impliedFormat":1},{"version":"9e2739b32f741859263fdba0244c194ca8e96da49b430377930b8f721d77c000","impliedFormat":1},{"version":"fb1d8e814a3eeb5101ca13515e0548e112bd1ff3fb358ece535b93e94adf5a3a","impliedFormat":1},{"version":"ffa495b17a5ef1d0399586b590bd281056cee6ce3583e34f39926f8dcc6ecdb5","impliedFormat":1},{"version":"98b18458acb46072947aabeeeab1e410f047e0cacc972943059ca5500b0a5e95","impliedFormat":1},{"version":"361e2b13c6765d7f85bb7600b48fde782b90c7c41105b7dab1f6e7871071ba20","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"b6db56e4903e9c32e533b78ac85522de734b3d3a8541bf24d256058d464bf04b","impliedFormat":1},{"version":"24daa0366f837d22c94a5c0bad5bf1fd0f6b29e1fae92dc47c3072c3fdb2fbd5","impliedFormat":1},{"version":"570bb5a00836ffad3e4127f6adf581bfc4535737d8ff763a4d6f4cc877e60d98","impliedFormat":1},{"version":"889c00f3d32091841268f0b994beba4dceaa5df7573be12c2c829d7c5fbc232c","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"acf5a2ac47b59ca07afa9abbd2b31d001bf7448b041927befae2ea5b1951d9f9","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"d71291eff1e19d8762a908ba947e891af44749f3a2cbc5bd2ec4b72f72ea795f","impliedFormat":1},{"version":"c0480e03db4b816dff2682b347c95f2177699525c54e7e6f6aa8ded890b76be7","impliedFormat":1},{"version":"27ab780875bcbb65e09da7496f2ca36288b0c541abaa75c311450a077d54ec15","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"380647d8f3b7f852cca6d154a376dbf8ac620a2f12b936594504a8a852e71d2f","impliedFormat":1},{"version":"208c9af9429dd3c76f5927b971263174aaa4bc7621ddec63f163640cbd3c473c","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"a23185bc5ef590c287c28a91baf280367b50ae4ea40327366ad01f6f4a8edbc5","impliedFormat":1},{"version":"bb37588926aba35c9283fe8d46ebf4e79ffe976343105f5c6d45f282793352b2","impliedFormat":1},{"version":"002eae065e6960458bda3cf695e578b0d1e2785523476f8a9170b103c709cd4f","impliedFormat":1},{"version":"c83bb0c9c5645a46c68356c2f73fdc9de339ce77f7f45a954f560c7e0b8d5ebb","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"72179f9dd22a86deaad4cc3490eb0fe69ee084d503b686985965654013f1391b","impliedFormat":1},{"version":"2e6114a7dd6feeef85b2c80120fdbfb59a5529c0dcc5bfa8447b6996c97a69f5","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"c8f004e6036aa1c764ad4ec543cf89a5c1893a9535c80ef3f2b653e370de45e6","impliedFormat":1},{"version":"dd80b1e600d00f5c6a6ba23f455b84a7db121219e68f89f10552c54ba46e4dc9","impliedFormat":1},{"version":"b064c36f35de7387d71c599bfcf28875849a1dbc733e82bd26cae3d1cd060521","impliedFormat":1},{"version":"6a148329edecbda07c21098639ef4254ef7869fb25a69f58e5d6a8b7b69d4236","impliedFormat":1},{"version":"8de9fe97fa9e00ec00666fa77ab6e91b35d25af8ca75dabcb01e14ad3299b150","impliedFormat":1},{"version":"f63ab283a1c8f5c79fabe7ca4ef85f9633339c4f0e822fce6a767f9d59282af2","impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"a54c996c8870ef1728a2c1fa9b8eaec0bf4a8001cd2583c02dd5869289465b10","impliedFormat":1},{"version":"3e7efde639c6a6c3edb9847b3f61e308bf7a69685b92f665048c45132f51c218","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"3754982006a3b32c502cff0867ca83584f7a43b1035989ca73603f400de13c96","impliedFormat":1},{"version":"a30ae9bb8a8fa7b90f24b8a0496702063ae4fe75deb27da731ed4a03b2eb6631","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"50256e9c31318487f3752b7ac12ff365c8949953e04568009c8705db802776fb","impliedFormat":1},{"version":"7d73b24e7bf31dfb8a931ca6c4245f6bb0814dfae17e4b60c9e194a631fe5f7b","impliedFormat":1},{"version":"413586add0cfe7369b64979d4ec2ed56c3f771c0667fbde1bf1f10063ede0b08","impliedFormat":1},{"version":"06472528e998d152375ad3bd8ebcb69ff4694fd8d2effaf60a9d9f25a37a097a","impliedFormat":1},{"version":"50b5bc34ce6b12eccb76214b51aadfa56572aa6cc79c2b9455cdbb3d6c76af1d","impliedFormat":1},{"version":"b7e16ef7f646a50991119b205794ebfd3a4d8f8e0f314981ebbe991639023d0e","impliedFormat":1},{"version":"42c169fb8c2d42f4f668c624a9a11e719d5d07dacbebb63cbcf7ef365b0a75b3","impliedFormat":1},{"version":"a401617604fa1f6ce437b81689563dfdc377069e4c58465dbd8d16069aede0a5","impliedFormat":1},{"version":"e9dd71cf12123419c60dab867d44fbee5c358169f99529121eaef277f5c83531","impliedFormat":1},{"version":"5b6a189ba3a0befa1f5d9cb028eb9eec2af2089c32f04ff50e2411f63d70f25d","impliedFormat":1},{"version":"d6e73f8010935b7b4c7487b6fb13ea197cc610f0965b759bec03a561ccf8423a","impliedFormat":1},{"version":"174f3864e398f3f33f9a446a4f403d55a892aa55328cf6686135dfaf9e171657","impliedFormat":1},{"version":"824c76aec8d8c7e65769688cbee102238c0ef421ed6686f41b2a7d8e7e78a931","impliedFormat":1},{"version":"75b868be3463d5a8cfc0d9396f0a3d973b8c297401d00bfb008a42ab16643f13","impliedFormat":1},{"version":"15a234e5031b19c48a69ccc1607522d6e4b50f57d308ecb7fe863d44cd9f9eb3","impliedFormat":1},{"version":"d682336018141807fb602709e2d95a192828fcb8d5ba06dda3833a8ea98f69e3","impliedFormat":1},{"version":"6124e973eab8c52cabf3c07575204efc1784aca6b0a30c79eb85fe240a857efa","impliedFormat":1},{"version":"0d891735a21edc75df51f3eb995e18149e119d1ce22fd40db2b260c5960b914e","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"4fbd3116e00ed3a6410499924b6403cc9367fdca303e34838129b328058ede40","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"0a437ae178f999b46b6153d79095b60c42c996bc0458c04955f1c996dc68b971","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"4a7baeb6325920044f66c0f8e5e6f1f52e06e6d87588d837bdf44feb6f35c664","impliedFormat":1},{"version":"6dcf60530c25194a9ee0962230e874ff29d34c59605d8e069a49928759a17e0a","impliedFormat":1},{"version":"7274fbffbd7c9589d8d0ffba68157237afd5cecff1e99881ea3399127e60572f","impliedFormat":1},{"version":"1a42d2ec31a1fe62fdc51591768695ed4a2dc64c01be113e7ff22890bebb5e3f","impliedFormat":1},{"version":"1a82deef4c1d39f6882f28d275cad4c01f907b9b39be9cbc472fcf2cf051e05b","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"0c7c947ff881c4274c0800deaa0086971e0bfe51f89a33bd3048eaa3792d4876","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"a8f8e6ab2fa07b45251f403548b78eaf2022f3c2254df3dc186cb2671fe4996d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"15b36126e0089bfef173ab61329e8286ce74af5e809d8a72edcafd0cc049057f","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"a57b1802794433adec9ff3fed12aa79d671faed86c49b09e02e1ac41b4f1d33a","impliedFormat":1},{"version":"ad10d4f0517599cdeca7755b930f148804e3e0e5b5a3847adce0f1f71bbccd74","impliedFormat":1},{"version":"1042064ece5bb47d6aba91648fbe0635c17c600ebdf567588b4ca715602f0a9d","impliedFormat":1},{"version":"c49469a5349b3cc1965710b5b0f98ed6c028686aa8450bcb3796728873eb923e","impliedFormat":1},{"version":"4a889f2c763edb4d55cb624257272ac10d04a1cad2ed2948b10ed4a7fda2a428","impliedFormat":1},{"version":"7bb79aa2fead87d9d56294ef71e056487e848d7b550c9a367523ee5416c44cfa","impliedFormat":1},{"version":"72d63643a657c02d3e51cd99a08b47c9b020a565c55f246907050d3c8a5e77fb","impliedFormat":1},{"version":"1d415445ea58f8033ba199703e55ff7483c52ac6742075b803bd3e7bbe9f5d61","impliedFormat":1},{"version":"d6406c629bb3efc31aedb2de809bef471e475c86c7e67f3ef9b676b5d7e0d6b2","impliedFormat":1},{"version":"27ff4196654e6373c9af16b6165120e2dd2169f9ad6abb5c935af5abd8c7938c","impliedFormat":1},{"version":"71d8ba39a9e024d9e4bb922464d18542ed8d2c25ee78efa7890c27213cc6e5d3","impliedFormat":1},{"version":"8c030e515014c10a2b98f9f48408e3ba18023dfd3f56e3312c6c2f3ae1f55a16","impliedFormat":1},{"version":"dafc31e9e8751f437122eb8582b93d477e002839864410ff782504a12f2a550c","impliedFormat":1},{"version":"754498c5208ce3c5134f6eabd49b25cf5e1a042373515718953581636491f3c3","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"f56bdc6884648806d34bc66d31cdb787c4718d04105ce2cd88535db214631f82","impliedFormat":1},{"version":"633d58a237f4bb25ec7d565e4ffa32cecdcee8660ac12189c4351c52557cee9e","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"ce791f6ea807560f08065d1af6014581eeb54a05abd73294777a281b6dfd73c2","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"49f95e989b4632c6c2a578cc0078ee19a5831832d79cc59abecf5160ea71abad","impliedFormat":1},{"version":"9666533332f26e8995e4d6fe472bdeec9f15d405693723e6497bf94120c566c8","impliedFormat":1},{"version":"ce0df82a9ae6f914ba08409d4d883983cc08e6d59eb2df02d8e4d68309e7848b","impliedFormat":1},{"version":"796273b2edc72e78a04e86d7c58ae94d370ab93a0ddf40b1aa85a37a1c29ecd7","impliedFormat":1},{"version":"5df15a69187d737d6d8d066e189ae4f97e41f4d53712a46b2710ff9f8563ec9f","impliedFormat":1},{"version":"e17cd049a1448de4944800399daa4a64c5db8657cc9be7ef46be66e2a2cd0e7c","impliedFormat":1},{"version":"43fa6ea8714e18adc312b30450b13562949ba2f205a1972a459180fa54471018","impliedFormat":1},{"version":"6e89c2c177347d90916bad67714d0fb473f7e37fb3ce912f4ed521fe2892cd0d","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"4d4927cbee21750904af7acf940c5e3c491b4d5ebc676530211e389dd375607a","impliedFormat":1},{"version":"72105519d0390262cf0abe84cf41c926ade0ff475d35eb21307b2f94de985778","impliedFormat":1},{"version":"8a97e578a9bc40eb4f1b0ca78f476f2e9154ecbbfd5567ee72943bab37fc156a","impliedFormat":1},{"version":"c857e0aae3f5f444abd791ec81206020fbcc1223e187316677e026d1c1d6fe08","impliedFormat":1},{"version":"ccf6dd45b708fb74ba9ed0f2478d4eb9195c9dfef0ff83a6092fa3cf2ff53b4f","impliedFormat":1},{"version":"2d7db1d73456e8c5075387d4240c29a2a900847f9c1bff106a2e490da8fbd457","impliedFormat":1},{"version":"2b15c805f48e4e970f8ec0b1915f22d13ca6212375e8987663e2ef5f0205e832","impliedFormat":1},{"version":"f22d05663d873ee7a600faf78abb67f3f719d32266803440cf11d5db7ac0cab2","impliedFormat":1},{"version":"d93c544ad20197b3976b0716c6d5cd5994e71165985d31dcab6e1f77feb4b8f2","impliedFormat":1},{"version":"35069c2c417bd7443ae7c7cafd1de02f665bf015479fec998985ffbbf500628c","impliedFormat":1},{"version":"a8b1c79a833ee148251e88a2553d02ce1641d71d2921cce28e79678f3d8b96aa","impliedFormat":1},{"version":"126d4f950d2bba0bd45b3a86c76554d4126c16339e257e6d2fabf8b6bf1ce00c","impliedFormat":1},{"version":"7e0b7f91c5ab6e33f511efc640d36e6f933510b11be24f98836a20a2dc914c2d","impliedFormat":1},{"version":"045b752f44bf9bbdcaffd882424ab0e15cb8d11fa94e1448942e338c8ef19fba","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"2d3cc2211f352f46ea6b7cf2c751c141ffcdf514d6e7ae7ee20b7b6742da313f","impliedFormat":1},{"version":"c75445151ff8b77d9923191efed7203985b1a9e09eccf4b054e7be864e27923d","impliedFormat":1},{"version":"0aedb02516baf3e66b2c1db9fef50666d6ed257edac0f866ea32f1aa05aa474f","impliedFormat":1},{"version":"fa8a8fbf91ee2a4779496225f0312aac6635b0f21aa09cdafa4283fe32d519c5","affectsGlobalScope":true,"impliedFormat":1},{"version":"0e8aef93d79b000deb6ec336b5645c87de167168e184e84521886f9ecc69a4b5","impliedFormat":1},{"version":"56ccb49443bfb72e5952f7012f0de1a8679f9f75fc93a5c1ac0bafb28725fc5f","impliedFormat":1},{"version":"20fa37b636fdcc1746ea0738f733d0aed17890d1cd7cb1b2f37010222c23f13e","impliedFormat":1},{"version":"d90b9f1520366d713a73bd30c5a9eb0040d0fb6076aff370796bc776fd705943","impliedFormat":1},{"version":"bc03c3c352f689e38c0ddd50c39b1e65d59273991bfc8858a9e3c0ebb79c023b","impliedFormat":1},{"version":"19df3488557c2fc9b4d8f0bac0fd20fb59aa19dec67c81f93813951a81a867f8","affectsGlobalScope":true,"impliedFormat":1},{"version":"b25350193e103ae90423c5418ddb0ad1168dc9c393c9295ef34980b990030617","affectsGlobalScope":true,"impliedFormat":1},{"version":"bef86adb77316505c6b471da1d9b8c9e428867c2566270e8894d4d773a1c4dc2","impliedFormat":1},{"version":"de7052bfee2981443498239a90c04ea5cc07065d5b9bb61b12cb6c84313ad4ef","impliedFormat":1},{"version":"a3e7d932dc9c09daa99141a8e4800fc6c58c625af0d4bbb017773dc36da75426","impliedFormat":1},{"version":"43e96a3d5d1411ab40ba2f61d6a3192e58177bcf3b133a80ad2a16591611726d","impliedFormat":1},{"version":"4a2edd238d9104eac35b60d727f1123de5062f452b70ed8e0366cb36387dfdfd","impliedFormat":1},{"version":"ca921bf56756cb6fe957f6af693a35251b134fb932dc13f3dfff0bb7106f80b4","impliedFormat":1},{"version":"fee92c97f1aa59eb7098a0cc34ff4df7e6b11bae71526aca84359a2575f313d8","impliedFormat":1},{"version":"0bd0297484aacea217d0b76e55452862da3c5d9e33b24430e0719d1161657225","impliedFormat":1},{"version":"2ab6d334bcbf2aff3acfc4fd8c73ecd82b981d3c3aa47b3f3b89281772286904","impliedFormat":1},{"version":"d07cbc787a997d83f7bde3877fec5fb5b12ce8c1b7047eb792996ed9726b4dde","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"4805f6161c2c8cefb8d3b8bd96a080c0fe8dbc9315f6ad2e53238f9a79e528a6","impliedFormat":1},{"version":"b83cb14474fa60c5f3ec660146b97d122f0735627f80d82dd03e8caa39b4388c","impliedFormat":1},{"version":"f374cb24e93e7798c4d9e83ff872fa52d2cdb36306392b840a6ddf46cb925cb6","impliedFormat":1},{"version":"49179c6a23701c642bd99abe30d996919748014848b738d8e85181fc159685ff","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"20865ac316b8893c1a0cc383ccfc1801443fbcc2a7255be166cf90d03fac88c9","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"461d0ad8ae5f2ff981778af912ba71b37a8426a33301daa00f21c6ccb27f8156","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"fcafff163ca5e66d3b87126e756e1b6dfa8c526aa9cd2a2b0a9da837d81bbd72","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"45490817629431853543adcb91c0673c25af52a456479588b6486daba34f68bb","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"8b4327413e5af38cd8cb97c59f48c3c866015d5d642f28518e3a891c469f240e","impliedFormat":1},{"version":"8514c62ce38e58457d967e9e73f128eedc1378115f712b9eef7127f7c88f82ae","impliedFormat":1},{"version":"f1289e05358c546a5b664fbb35a27738954ec2cc6eb4137350353099d154fc62","impliedFormat":1},{"version":"4b20fcf10a5413680e39f5666464859fc56b1003e7dfe2405ced82371ebd49b6","impliedFormat":1},{"version":"1d17ba45cfbe77a9c7e0df92f7d95f3eefd49ee23d1104d0548b215be56945ad","impliedFormat":1},{"version":"f7d628893c9fa52ba3ab01bcb5e79191636c4331ee5667ecc6373cbccff8ae12","impliedFormat":1},{"version":"1d879125d1ec570bf04bc1f362fdbe0cb538315c7ac4bcfcdf0c1e9670846aa6","impliedFormat":1},{"version":"bd5f641cc4616eee49497a362c4cb401e9346265bc52670448c4452b4d9be401","impliedFormat":1},{"version":"46273e8c29816125d0d0b56ce9a849cc77f60f9a5ba627447501d214466f0ff3","impliedFormat":1},{"version":"d663134457d8d669ae0df34eabd57028bddc04fc444c4bc04bc5215afc91e1f4","impliedFormat":1},{"version":"e91f7b1344577a02f051b9b471f33044fef8334a76dc9e1de003d17595a5219b","impliedFormat":1},{"version":"3af3584f79c57853028ef9421ec172539e1fe01853296dc05a9d615ade4ffaf6","impliedFormat":1},{"version":"f82579d87701d639ff4e3930a9b24f4ee13ca74221a9a3a792feb47f01881a9c","impliedFormat":1},{"version":"d7e5d5245a8ba34a274717d085174b2c9827722778129b0081fefd341cca8f55","impliedFormat":1},{"version":"d9d32f94056181c31f553b32ce41d0ef75004912e27450738d57efcd2409c324","impliedFormat":1},{"version":"752513f35f6cff294ffe02d6027c41373adf7bfa35e593dbfd53d95c203635ee","impliedFormat":1},{"version":"6c800b281b9e89e69165fd11536195488de3ff53004e55905e6c0059a2d8591e","impliedFormat":1},{"version":"7d4254b4c6c67a29d5e7f65e67d72540480ac2cfb041ca484847f5ae70480b62","impliedFormat":1},{"version":"1a7e2ea171726446850ec72f4d1525d547ff7e86724cc9e7eec509725752a758","impliedFormat":1},{"version":"8c901126d73f09ecdea4785e9a187d1ac4e793e07da308009db04a7283ec2f37","impliedFormat":1},{"version":"c1de754ab5f3b0f4036d6893c74a0fc984c7fcb07936086f19bbe2974406775b","impliedFormat":1},{"version":"aab290b8e4b7c399f2c09b957666fc95335eb4522b2dd9ead1bf0cb64da6d6ee","impliedFormat":1},{"version":"94fe3281392e1015b22f39535878610b4fa6f1388dc8d78746be3bc4e4bb8950","impliedFormat":1},{"version":"2652448ac55a2010a1f71dd141f828b682298d39728f9871e1cdf8696ef443fd","impliedFormat":1},{"version":"06c25ddfc2242bd06c19f66c9eae4c46d937349a267810f89783680a1d7b5259","impliedFormat":1},{"version":"120599fd965257b1f4d0ff794bc696162832d9d8467224f4665f713a3119078b","impliedFormat":1},{"version":"5433f33b0a20300cca35d2f229a7fc20b0e8477c44be2affeb21cb464af60c76","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"bd4131091b773973ca5d2326c60b789ab1f5e02d8843b3587effe6e1ea7c9d86","impliedFormat":1},{"version":"c7f6485931085bf010fbaf46880a9b9ec1a285ad9dc8c695a9e936f5a48f34b4","impliedFormat":1},{"version":"14f6b927888a1112d662877a5966b05ac1bf7ed25d6c84386db4c23c95a5363b","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"0427df5c06fafc5fe126d14b9becd24160a288deff40e838bfbd92a35f8d0d00","impliedFormat":1},{"version":"90c54a02432d04e4246c87736e53a6a83084357acfeeba7a489c5422b22f5c7a","impliedFormat":1},{"version":"49c346823ba6d4b12278c12c977fb3a31c06b9ca719015978cb145eb86da1c61","impliedFormat":1},{"version":"bfac6e50eaa7e73bb66b7e052c38fdc8ccfc8dbde2777648642af33cf349f7f1","impliedFormat":1},{"version":"92f7c1a4da7fbfd67a2228d1687d5c2e1faa0ba865a94d3550a3941d7527a45d","impliedFormat":1},{"version":"f53b120213a9289d9a26f5af90c4c686dd71d91487a0aa5451a38366c70dc64b","impliedFormat":1},{"version":"83fe880c090afe485a5c02262c0b7cdd76a299a50c48d9bde02be8e908fb4ae6","impliedFormat":1},{"version":"0a372c2d12a259da78e21b25974d2878502f14d89c6d16b97bd9c5017ab1bc12","impliedFormat":1},{"version":"57d67b72e06059adc5e9454de26bbfe567d412b962a501d263c75c2db430f40e","impliedFormat":1},{"version":"6511e4503cf74c469c60aafd6589e4d14d5eb0a25f9bf043dcbecdf65f261972","impliedFormat":1},{"version":"ec1ca97598eda26b7a5e6c8053623acbd88e43be7c4d29c77ccd57abc4c43999","impliedFormat":1},{"version":"6e2261cd9836b2c25eecb13940d92c024ebed7f8efe23c4b084145cd3a13b8a6","impliedFormat":1},{"version":"a67b87d0281c97dfc1197ef28dfe397fc2c865ccd41f7e32b53f647184cc7307","impliedFormat":1},{"version":"771ffb773f1ddd562492a6b9aaca648192ac3f056f0e1d997678ff97dbb6bf9b","impliedFormat":1},{"version":"232f70c0cf2b432f3a6e56a8dc3417103eb162292a9fd376d51a3a9ea5fbbf6f","impliedFormat":1},{"version":"a47e6d954d22dd9ebb802e7e431b560ed7c581e79fb885e44dc92ed4f60d4c07","impliedFormat":1},{"version":"f019e57d2491c159d47a107fd90219a1734bdd2e25cd8d1db3c8fae5c6b414c4","impliedFormat":1},{"version":"8a0e762ceb20c7e72504feef83d709468a70af4abccb304f32d6b9bac1129b2c","impliedFormat":1},{"version":"d1c9bf292a54312888a77bb19dba5e2503ad803f5393beafd45d78d2f4fe9b48","impliedFormat":1},{"version":"9252d498a77517aab5d8d4b5eb9d71e4b225bbc7123df9713e08181de63180f6","impliedFormat":1},{"version":"cb8d8ef7b9ce8ed3e6f1c814fcbf3f90dab0cb8863079236784fc350746e27c4","impliedFormat":1},{"version":"35e6379c3f7cb27b111ad4c1aa69538fd8e788ab737b8ff7596a1b40e96f4f90","impliedFormat":1},{"version":"1fffe726740f9787f15b532e1dc870af3cd964dbe29e191e76121aa3dd8693f2","impliedFormat":1},{"version":"3be035da7bee86b4c3abf392e0edaa44fc6e45092995eefe36b39118c8a84068","affectsGlobalScope":true,"impliedFormat":1},{"version":"8f828825d077c2fa0ea606649faeb122749273a353daab23924fe674e98ba44c","impliedFormat":1},{"version":"2896c2e673a5d3bd9b4246811f79486a073cbb03950c3d252fba10003c57411a","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"407a06ba04eede4074eec470ecba2784cbb3bf4e7de56833b097dd90a2aa0651","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"98a787be42bd92f8c2a37d7df5f13e5992da0d967fab794adbb7ee18370f9849","impliedFormat":1},{"version":"5c96bad5f78466785cdad664c056e9e2802d5482ca5f862ed19ba34ffbb7b3a4","impliedFormat":1},{"version":"81d8603ac527e75cfec72bb9391228b58f161c2b33514a9d814c7f3ebd3ef466","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb0cd7862b72f5eba39909c9889d566e198fcaddf7207c16737d0c2246112678","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"320f4091e33548b554d2214ce5fc31c96631b513dffa806e2e3a60766c8c49d9","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"d90d5f524de38889d1e1dbc2aeef00060d779f8688c02766ddb9ca195e4a713d","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"bad68fd0401eb90fe7da408565c8aee9c7a7021c2577aec92fa1382e8876071a","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"fec01479923e169fb52bd4f668dbeef1d7a7ea6e6d491e15617b46f2cacfa37d","impliedFormat":1},{"version":"8a8fb3097ba52f0ae6530ec6ab34e43e316506eb1d9aa29420a4b1e92a81442d","impliedFormat":1},{"version":"44e09c831fefb6fe59b8e65ad8f68a7ecc0e708d152cfcbe7ba6d6080c31c61e","impliedFormat":1},{"version":"1c0a98de1323051010ce5b958ad47bc1c007f7921973123c999300e2b7b0ecc0","impliedFormat":1},{"version":"4655709c9cb3fd6db2b866cab7c418c40ed9533ce8ea4b66b5f17ec2feea46a9","impliedFormat":1},{"version":"87affad8e2243635d3a191fa72ef896842748d812e973b7510a55c6200b3c2a4","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"3eecb25bb467a948c04874d70452b14ae7edb707660aac17dc053e42f2088b00","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"330896c1a2b9693edd617be24fbf9e5895d6e18c7955d6c08f028f272b37314d","impliedFormat":1},{"version":"1d9c0a9a6df4e8f29dc84c25c5aa0bb1da5456ebede7a03e03df08bb8b27bae6","impliedFormat":1},{"version":"84380af21da938a567c65ef95aefb5354f676368ee1a1cbb4cae81604a4c7d17","impliedFormat":1},{"version":"1af3e1f2a5d1332e136f8b0b95c0e6c0a02aaabd5092b36b64f3042a03debf28","impliedFormat":1},{"version":"30d8da250766efa99490fc02801047c2c6d72dd0da1bba6581c7e80d1d8842a4","impliedFormat":1},{"version":"03566202f5553bd2d9de22dfab0c61aa163cabb64f0223c08431fb3fc8f70280","impliedFormat":1},{"version":"5f0292a40df210ab94b9fb44c8b775c51e96777e14e073900e392b295ca1061b","impliedFormat":1},{"version":"bc9ee0192f056b3d5527bcd78dc3f9e527a9ba2bdc0a2c296fbc9027147df4b2","impliedFormat":1},{"version":"8627ad129bcf56e82adff0ab5951627c993937aa99f5949c33240d690088b803","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"5bf5c7a44e779790d1eb54c234b668b15e34affa95e78eada73e5757f61ed76a","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"5c634644d45a1b6bc7b05e71e05e52ec04f3d73d9ac85d5927f647a5f965181a","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"a68d4b3182e8d776cdede7ac9630c209a7bfbb59191f99a52479151816ef9f9e","impliedFormat":99},{"version":"39644b343e4e3d748344af8182111e3bbc594930fff0170256567e13bbdbebb0","impliedFormat":99},{"version":"ed7fd5160b47b0de3b1571c5c5578e8e7e3314e33ae0b8ea85a895774ee64749","impliedFormat":99},{"version":"63a7595a5015e65262557f883463f934904959da563b4f788306f699411e9bac","impliedFormat":1},{"version":"ecbaf0da125974be39c0aac869e403f72f033a4e7fd0d8cd821a8349b4159628","impliedFormat":1},{"version":"4ba137d6553965703b6b55fd2000b4e07ba365f8caeb0359162ad7247f9707a6","impliedFormat":1},{"version":"ceec3c81b2d81f5e3b855d9367c1d4c664ab5046dff8fd56552df015b7ccbe8f","affectsGlobalScope":true,"impliedFormat":1},{"version":"8fac4a15690b27612d8474fb2fc7cc00388df52d169791b78d1a3645d60b4c8b","affectsGlobalScope":true,"impliedFormat":1},{"version":"064ac1c2ac4b2867c2ceaa74bbdce0cb6a4c16e7c31a6497097159c18f74aa7c","impliedFormat":1},{"version":"3dc14e1ab45e497e5d5e4295271d54ff689aeae00b4277979fdd10fa563540ae","impliedFormat":1},{"version":"1d63055b690a582006435ddd3aa9c03aac16a696fac77ce2ed808f3e5a06efab","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"85ae5aee75f011967cf2d25cbc342f62d69314e9d925f7f4aa3456fc2cffcca6","93168789b45a4161105a0d8fc81078dd0f5881625ef312042d801af4de4da7c9",{"version":"402e5c534fb2b85fa771170595db3ac0dd532112c8fa44fc23f233bc6967488b","impliedFormat":1},{"version":"52dcc257df5119fb66d864625112ce5033ac51a4c2afe376a0b299d2f7f76e4a","impliedFormat":1},{"version":"e5bab5f871ef708d52d47b3e5d0aa72a08ee7a152f33931d9a60809711a2a9a3","impliedFormat":1},{"version":"e16dc2a81595736024a206c7d5c8a39bfe2e6039208ef29981d0d95434ba8fcf","impliedFormat":1},{"version":"cc4a4903fb698ca1d961d4c10dce658aa3a479faf40509d526f122b044eaf6a4","impliedFormat":1},{"version":"19ee8416e6473ed6c7adb868fa796b5653cf0fa2a337658e677eaa0d134388c3","impliedFormat":1},{"version":"1328ab4e442614b28cdb3d4b414cf68325c0da0dca07287a338d0654b7a00261","impliedFormat":1},{"version":"a039dc21f045919f3cbee2ec13812cc6cc3eebc99dae4be00973230f468d19a6","impliedFormat":1},{"version":"3fbe57af01460e49dcd29df55d6931e1672bc6f1be0fb073d11410bc16f9037d","impliedFormat":1},{"version":"f760be449e8562ec5c09bb5187e8e1eabf3c113c0c58cddda53ef8c69f3e2131","impliedFormat":1},{"version":"44325ed13294fce6ab825b82947bbeed2611db7dad9d9135260192f375e5a189","impliedFormat":1},{"version":"e392e8fb5b514eafc585601c1d781485aa6dd6a320e75daf1064a4c6918a1b45","impliedFormat":1},{"version":"46e4a36e8ddbdfb4e7330e11c81c970dc8b218611df9183d39c41c5f8c653b55","impliedFormat":1},{"version":"370bde134aa8c2abc926d0e99d3a4d5d5dba65c6ee65459137e4f02670cbf841","impliedFormat":1},{"version":"6332f565867cf4a740a70e30f31cefba37ef7cebcf74f22eab8d744fde6d193e","impliedFormat":1},{"version":"2977b7884aedc895a1d0c9c210c7cf3272c29d6959a08a6fa3ff71e0aff08175","impliedFormat":1},{"version":"17f2922d41ddd032830a91371c948cd9ce903b35c95adca72271a54584f19b0b","impliedFormat":1},{"version":"3eed76ede2a1a14d7c9bb0a642041282dcc264811139d3dd275c9fe14efc9840","impliedFormat":1},{"version":"e3cf0611709328b449ec13f8c436712d62003620ce480139fae46ce001c2ee9f","impliedFormat":1},{"version":"8d369483f0c2b9ee388129cfdb6a43bc8112b377e86a41884bd06e19ce04f4c1","impliedFormat":99},{"version":"b558c9a18ea4e6e4157124465c3ef1063e64640da139e67be5edb22f534f2f08","impliedFormat":1},{"version":"01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","impliedFormat":1},{"version":"b0dee183d4e65cf938242efaf3d833c6b645afb35039d058496965014f158141","impliedFormat":1},{"version":"c0bbbf84d3fbd85dd60d040c81e8964cc00e38124a52e9c5dcdedf45fea3f213","impliedFormat":1},"4ba6cdcf95fb6408de07376ca04954a064c7416931528df27c689a88b20be9e2",{"version":"d94e1d2983db635164d0463a7f70a4d6bc107a2c01fe74a9d63b0d4834a25c49","signature":"48a5c5a2391d912df45ce7db30035736d77adc67ca3a153f7b1b7a4fa1a7f0f0"},"0d0ef6c2b13e302920ca7f3e4c238f2dad906247f167c4ae29afbb1f1f74494e","243a9a79fa3234a1512d157942595c5e5d3e1c9b7cf6ff91264309e12e149ec4",{"version":"c1a2e05eb6d7ca8d7e4a7f4c93ccf0c2857e842a64c98eaee4d85841ee9855e6","impliedFormat":1},{"version":"835fb2909ce458740fb4a49fc61709896c6864f5ce3db7f0a88f06c720d74d02","impliedFormat":1},{"version":"6e5857f38aa297a859cab4ec891408659218a5a2610cd317b6dcbef9979459cc","impliedFormat":1},{"version":"ead8e39c2e11891f286b06ae2aa71f208b1802661fcdb2425cffa4f494a68854","impliedFormat":1},{"version":"40ba6c32eb732a09e4446ade5cb6ad0c147f186f9c9dc6878b90b4418ad9f6ea","impliedFormat":1},{"version":"fdd814741843f85c98281522c58f5a646590ba9019fad2efaa95987655e0611b","impliedFormat":1},{"version":"c78aff4fb58b28b8f642d5095fc7eeb79f00e652a67caa19693af1adabb833c9","impliedFormat":1},{"version":"f80a08ced8818dc99359c0acd5b3f12762e1ce53758007759b0d4e503cbf4a5e","impliedFormat":1},{"version":"37935fa7564bcc6e0bc845b766a24391098d26f7c8245d6e8ab37bc016816e94","impliedFormat":1},{"version":"68add36d9632bc096d7245d24d6b0b8ad5f125183016102a3dad4c9c2438ccb0","impliedFormat":1},{"version":"3a819c2928ee06bbcc84e2797fd3558ae2ebb7e0ed8d87f71732fb2e2acc87b4","impliedFormat":1},{"version":"0f8a263f4c8595c8a07de52e3f3927640c44386c1aa2984de9eae50d75e613b2","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"346fffde7c32da87c2196eb7494422449dc2ca82d3b4e6bf55be1d1a33ffc2b0","impliedFormat":1},{"version":"add0ce7b77ba5b308492fa68f77f24d1ed1d9148534bdf05ac17c30763fc1a79","impliedFormat":1},{"version":"8b5875e4958528042103fdd775e106a7f76bafc29709f0690df9a7d2241d52a7","impliedFormat":1},{"version":"2f67911e4bf4e0717dc2ded248ce2d5e4398d945ee13889a6852c1233ea41508","impliedFormat":1},{"version":"d8430c275b0f59417ea8e173cfb888a4477b430ec35b595bf734f3ec7a7d729f","impliedFormat":1},{"version":"69364df1c776372d7df1fb46a6cb3a6bf7f55e700f533a104e3f9d70a32bec18","impliedFormat":1},{"version":"6042774c61ece4ba77b3bf375f15942eb054675b7957882a00c22c0e4fe5865c","impliedFormat":1},{"version":"5a3bd57ed7a9d9afef74c75f77fce79ba3c786401af9810cdf45907c4e93f30e","impliedFormat":1},{"version":"aef26cf95593c8ace1c62c4724f9afac77bdfa756fb8a00613cd152117cb2f43","impliedFormat":1},{"version":"30db853bb2e60170ba11e39ab48bacecb32d06d4def89eedf17e58ebab762a65","impliedFormat":1},{"version":"e27451b24234dfed45f6cf22112a04955183a99c42a2691fb4936d63cfe42761","impliedFormat":1},{"version":"2316301dd223d31962d917999acf8e543e0119c5d24ec984c9f22cb23247160c","impliedFormat":1},{"version":"58d65a2803c3b6629b0e18c8bf1bc883a686fcf0333230dd0151ab6e85b74307","impliedFormat":1},{"version":"e818471014c77c103330aee11f00a7a00b37b35500b53ea6f337aefacd6174c9","impliedFormat":1},{"version":"268fd6d9f2e807a39a6c5aa654b00f949feb63d3faa7dd0f9bba7dde9172159c","impliedFormat":1},{"version":"29f823cbe0166e10e7176a94afe609a24b9e5af3858628c541ff8ce1727023cd","impliedFormat":1},"b9706698e4c596b315551e1a277c96a59c0777d12d380d694f8f573d92dab79e","59f94b15b30984cd7f09e57d7b924aea0ff00b8cda21ffc74b336cbd399c2f76",{"version":"fe93c474ab38ac02e30e3af073412b4f92b740152cf3a751fdaee8cbea982341","impliedFormat":1},{"version":"476e83e2c9e398265eed2c38773ae9081932b08ea5597b579a7d2e0c690ead56","impliedFormat":1},{"version":"1e00b8bf9e3766c958218cd6144ffe08418286f89ff44ba5a2cc830c03dd22c7","impliedFormat":1},{"version":"446e17ebd404910d4191e09e876a377e82be56aee9660169b1f4abe7bc55a2ee","signature":"bcfa3ef213bd1439b9cef806bad5ca6b298c4c9d87d444cff4e4727a88f897ca"},{"version":"166c048927c78e0ca166de2df67186fd3bd3fa9c28092d332370122a54a4b66f","impliedFormat":1},"1ee263eec5baf90eac20fd9e8cf4673a434b7569e4031494048269589267b144","d7aa6e393e12691c2be12aefeb5568318ab66053a67861c230b58473ce817fd0",{"version":"3cdcf79a43b08f35c7d636b348d85be505913dc705e680b131dc48b0c0dd38fc","signature":"8309d399ef80e084782c5ac1a27e8e315d880a38257599f71b9ef8aeb8a0d208"},"df9c376c322539253364925324f8458d366ae51d425034921952e6b1aba6430c","ba7bb248ab60f5fe974067dcdd55d1769db6f64769cfe43a67504ed5c8caa6b7","a25c7affa1a946287979aea094c40dc0419654d9d3ea79a1d886e556600d96d6","3fe2980cfc2867556f6a28286af8470677adee74897ae7a4854610d1c6de48ae",{"version":"5d0c141362f9577d6d2a853a61ec65574364a9cca4caf75b7329eae3538a4c0e","signature":"8aa1e2861c3e1d533f9e9c69fd2c4e6c3929992f56d95256a5e538f41468dba1"},"2552a31fad45a9ed1bde87e51b038dc0e786cd364b597162263abbf57018949b","c1c7372716fee889b299da69773837a6cc6f9676a32a47f6ae305c54fc3d9ae5","4f88620b901250c96ddea949d32f93ed08887c5adcfca5625334ae433bcbcaab",{"version":"25c46e8c49f06417e909e261f366643e0d1a8376c94e33807c5f81eaa29cefe1","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"7711e232df8bade0d37fc37e2ddf1d58e0ef220d4f9651a1ce04a6acc01a5cb4","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"},{"version":"d7243f62d8eed460574b53464b59be410d645dfa3147a07263880e507894fac7","signature":"89b0f68f8f0b901f9dfff2b9e7255520283a783d6af7f2bc2953d771232317a2"}],"root":[83,504,505,[530,533],614,615,619,[621,634]],"options":{"allowJs":false,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"strict":true,"target":4},"referencedMap":[[632,1],[633,2],[634,3],[631,4],[629,5],[83,6],[630,7],[614,8],[627,9],[628,10],[623,11],[625,10],[532,12],[533,12],[626,13],[619,14],[624,13],[621,15],[622,16],[531,6],[615,17],[504,18],[505,19],[251,6],[148,20],[149,20],[150,21],[97,22],[151,23],[152,24],[153,25],[95,6],[154,26],[155,27],[156,28],[157,29],[158,30],[159,31],[160,31],[161,32],[162,33],[163,34],[164,35],[98,6],[96,6],[165,36],[166,37],[167,38],[201,39],[168,40],[169,6],[170,41],[171,42],[172,43],[173,44],[174,45],[175,46],[176,47],[177,48],[178,49],[179,49],[180,50],[181,6],[182,51],[183,52],[185,53],[184,54],[186,55],[187,56],[188,57],[189,58],[190,59],[191,60],[192,61],[193,62],[194,63],[195,64],[196,65],[197,66],[198,67],[99,6],[100,68],[101,6],[102,6],[144,69],[145,70],[146,6],[147,55],[199,71],[200,72],[205,73],[361,74],[206,75],[204,76],[363,77],[362,78],[202,79],[359,6],[203,80],[84,6],[86,81],[358,74],[269,74],[85,6],[620,74],[93,82],[450,83],[455,5],[457,84],[227,85],[255,86],[433,87],[250,88],[238,6],[219,6],[225,6],[423,89],[286,90],[226,6],[392,91],[260,92],[261,93],[357,94],[420,95],[375,96],[427,97],[428,98],[426,99],[425,6],[424,100],[257,101],[228,102],[307,6],[308,103],[223,6],[239,104],[229,105],[291,104],[288,104],[212,104],[253,106],[252,6],[432,107],[442,6],[218,6],[333,108],[334,109],[328,74],[478,6],[336,6],[337,110],[329,111],[484,112],[482,113],[477,6],[419,114],[418,6],[476,115],[330,74],[371,116],[369,117],[479,6],[483,6],[481,118],[480,6],[370,119],[471,120],[474,121],[298,122],[297,123],[296,124],[487,74],[295,125],[280,6],[490,6],[617,126],[616,6],[493,6],[492,74],[494,127],[208,6],[429,128],[430,129],[431,130],[241,6],[217,131],[207,6],[349,74],[210,132],[348,133],[347,134],[338,6],[339,6],[346,6],[341,6],[344,135],[340,6],[342,136],[345,137],[343,136],[224,6],[215,6],[216,104],[270,138],[271,139],[268,140],[266,141],[267,142],[263,6],[355,110],[377,110],[449,143],[458,144],[462,145],[436,146],[435,6],[283,6],[495,147],[445,148],[331,149],[332,150],[323,151],[313,6],[354,152],[314,153],[356,154],[351,155],[350,6],[352,6],[368,156],[437,157],[438,158],[316,159],[320,160],[311,161],[415,162],[444,163],[290,164],[393,165],[213,166],[443,167],[209,88],[264,6],[272,168],[404,169],[262,6],[403,170],[94,6],[398,171],[240,6],[309,172],[394,6],[214,6],[273,6],[402,173],[222,6],[278,174],[319,175],[434,176],[318,6],[401,6],[265,6],[406,177],[407,178],[220,6],[409,179],[411,180],[410,181],[243,6],[400,166],[413,182],[399,183],[405,184],[231,6],[234,6],[232,6],[236,6],[233,6],[235,6],[237,185],[230,6],[385,186],[384,6],[390,187],[386,188],[389,189],[388,189],[391,187],[387,188],[277,190],[378,191],[441,192],[497,6],[466,193],[468,194],[315,6],[467,195],[439,157],[496,196],[335,157],[221,6],[317,197],[274,198],[275,199],[276,200],[306,201],[414,201],[292,201],[379,202],[293,202],[259,203],[258,6],[383,204],[382,205],[381,206],[380,207],[440,208],[327,209],[365,210],[326,211],[360,212],[364,213],[422,214],[421,215],[417,216],[374,217],[376,218],[373,219],[412,220],[367,6],[454,6],[366,221],[416,6],[279,222],[312,128],[310,223],[281,224],[284,225],[491,6],[282,226],[285,226],[452,6],[451,6],[453,6],[489,6],[287,227],[325,74],[92,6],[372,228],[256,6],[245,229],[321,6],[460,74],[470,230],[305,74],[464,110],[304,231],[447,232],[303,230],[211,6],[472,233],[301,74],[302,74],[294,6],[244,6],[300,234],[299,235],[242,236],[322,48],[289,48],[408,6],[396,237],[395,6],[456,6],[353,238],[324,74],[448,239],[87,74],[90,240],[91,241],[88,74],[89,6],[254,68],[249,242],[248,6],[247,243],[246,6],[446,244],[459,245],[461,246],[463,247],[618,248],[465,249],[469,250],[503,251],[473,251],[502,252],[475,253],[485,254],[486,255],[488,256],[498,257],[501,131],[500,6],[499,258],[522,259],[520,260],[521,261],[509,262],[510,260],[517,263],[508,264],[513,265],[523,6],[514,266],[519,267],[525,268],[524,269],[507,270],[515,271],[516,272],[511,273],[518,259],[512,274],[397,275],[506,6],[528,276],[527,6],[526,6],[529,277],[81,6],[82,6],[13,6],[14,6],[16,6],[15,6],[2,6],[17,6],[18,6],[19,6],[20,6],[21,6],[22,6],[23,6],[24,6],[3,6],[25,6],[26,6],[4,6],[27,6],[31,6],[28,6],[29,6],[30,6],[32,6],[33,6],[34,6],[5,6],[35,6],[36,6],[37,6],[38,6],[6,6],[42,6],[39,6],[40,6],[41,6],[43,6],[7,6],[44,6],[49,6],[50,6],[45,6],[46,6],[47,6],[48,6],[8,6],[54,6],[51,6],[52,6],[53,6],[55,6],[9,6],[56,6],[57,6],[58,6],[60,6],[59,6],[61,6],[62,6],[10,6],[63,6],[64,6],[65,6],[11,6],[66,6],[67,6],[68,6],[69,6],[70,6],[1,6],[71,6],[72,6],[12,6],[76,6],[74,6],[79,6],[78,6],[73,6],[77,6],[75,6],[80,6],[120,278],[132,279],[118,280],[133,281],[142,282],[109,283],[110,284],[108,285],[141,258],[136,286],[140,287],[112,288],[129,289],[111,290],[139,291],[106,292],[107,286],[113,293],[114,6],[119,294],[117,293],[104,295],[143,296],[134,297],[123,298],[122,293],[124,299],[127,300],[121,301],[125,302],[137,258],[115,303],[116,304],[128,305],[105,281],[131,306],[130,293],[126,307],[135,6],[103,6],[138,308],[613,309],[607,310],[611,311],[608,311],[604,310],[612,312],[609,313],[610,311],[605,314],[606,315],[600,316],[541,317],[543,318],[599,6],[542,319],[603,320],[602,321],[601,322],[534,6],[544,317],[545,6],[536,323],[540,324],[535,6],[537,325],[538,326],[539,6],[546,327],[547,327],[548,327],[549,327],[550,327],[551,327],[552,327],[553,327],[554,327],[555,327],[556,327],[557,327],[558,327],[559,327],[561,327],[560,327],[562,327],[563,327],[564,327],[565,327],[566,327],[598,328],[567,327],[568,327],[569,327],[570,327],[571,327],[572,327],[573,327],[574,327],[575,327],[576,327],[577,327],[578,327],[579,327],[581,327],[580,327],[582,327],[583,327],[584,327],[585,327],[586,327],[587,327],[588,327],[589,327],[590,327],[591,327],[592,327],[593,327],[594,327],[597,327],[595,327],[596,327],[530,329]],"affectedFilesPendingEmit":[632,633,634,631,630,614,627,628,623,625,532,533,626,619,624,621,622,531,615,505,530],"version":"5.9.3"} \ No newline at end of file