diff --git a/opencti-platform/opencti-front/lang/back/de.json b/opencti-platform/opencti-front/lang/back/de.json index ecb9440ea1b5..9bcbe3d0152a 100644 --- a/opencti-platform/opencti-front/lang/back/de.json +++ b/opencti-platform/opencti-front/lang/back/de.json @@ -108,6 +108,10 @@ "Card number": "Kartennummer", "Carousel order": "Karussell-Reihenfolge", "Case template": "Fallvorlage", + "Catalog Contract content hash": "Hash des Inhalts des Katalogvertrags", + "Catalog Contract ID": "ID des Katalogvertrags", + "Catalog ID": "Katalog-ID", + "Catalog product version": "Produktversion des Katalogs", "Category": "Kategorie", "CC": "CC", "Certifiate policies": "Zertifizierungsrichtlinien", @@ -130,11 +134,13 @@ "Confidence level": "Konfidenzniveau", "Confidentiality impact": "Auswirkungen auf die Vertraulichkeit", "Configuration": "Konfiguration", + "Configuration schema": "Konfigurationsschema", "Configuration version": "Version der Konfiguration", "Connector catalog": "Stecker-Katalog", "Connector ID": "Anschluss-ID", "Connector information": "Informationen zum Anschluss", "Connector manager": "Stecker-Manager", + "Connector manager contract": "Vertrag des Connector-Managers", "Connector manager current status": "Aktueller Status des Verbindungsmanagers", "Connector manager image": "Bild des Verbindungsmanagers", "Connector manager requested status": "Verbindungsmanager angeforderter Status", @@ -143,10 +149,12 @@ "Connector state": "Zustand des Verbinders", "Connector trigger filters": "Anschluss Triggerfilter", "Connector type": "Steckertyp", + "Connector upgrade strategy": "Upgrade-Strategie für den Connector", "Connector user id": "Connector Benutzer-ID", "Connectors": "Verbinder", "Consent confirm text": "Text zur Bestätigung der Zustimmung", "Consent message": "Zustimmungsnachricht", + "Contact": "Kontakt", "Contact information": "Kontaktinformationen", "Contains": "Enthält", "Contains observable": "Enthält beobachtbar", @@ -160,6 +168,7 @@ "Context": "Kontext", "Context data": "Kontextdaten", "Contextual only": "Nur kontextabhängig", + "Contract version": "Vertragsversion", "Copy confidence level to OpenCTI scores for indicators": "Kopieren des Konfidenzniveaus in OpenCTI-Bewertungen für Indikatoren", "Copy OpenCTI scores to confidence level for indicators": "Kopieren der OpenCTI-Ergebnisse in das Konfidenzniveau der Indikatoren", "Count": "Zählung", @@ -562,12 +571,14 @@ "Last score evolution": "Entwicklung des letzten Punktestandes", "Last seen": "Zuletzt gesehen", "Last update": "Letzte Aktualisierung", + "Last verified date": "Datum der letzten Überprüfung", "Lastname": "Nachname", "Latest containers": "Letzte Container", "Latest created relationships": "Zuletzt erstellte Beziehungen", "Latitude": "Breitengrad", "Layers": "Ebenen", "LdapStrategy": "LDAP-Strategie", + "License type": "Lizenztyp", "Lifetime": "Lebenszeit", "Light accent": "Hell akzentuiert", "Light background": "Heller Hintergrund", @@ -592,6 +603,7 @@ "Locations propagation in reports": "Ortsausbreitung in Berichten", "Log sources": "Protokollquellen", "Login message": "Login-Nachricht", + "Logo URI in file storage": "Logo-URI im Dateispeicher", "Logout remote": "Fernabmeldung", "Longitude": "Längengrad", "LZJD": "LZJD", @@ -607,8 +619,11 @@ "Manager contracts": "Manager-Verträge", "Manager ID": "Manager-ID", "Manager running": "Manager läuft", + "Manager supported": "Unterstützter Manager", "Mandatory": "Erforderlich", "Manifest": "Manifest", + "Manifest schema version": "Manifest-Schema-Version", + "Manifest version": "Manifest-Version", "Mappings": "Mappings", "Marital status": "Familienstand", "Marked for deletion at": "Zur Löschung vorgemerkt unter", @@ -626,6 +641,7 @@ "Match Pir with source of relationship": "Pirat mit Quelle der Beziehung abgleichen", "Max concurrent sessions (0 equals no maximum)": "Max. gleichzeitige Sitzungen (0 entspricht keinem Maximum)", "Max Confidence": "Maximale Konfidenz", + "Max confidence level": "Maximales Konfidenzniveau", "Max policy length": "Max policy length", "Max shareable markings": "Maximal teilbare Markierungen", "Max value": "Maximalwert", @@ -807,6 +823,7 @@ "Platforms": "Plattformen", "Playbook definition": "Spielbuch-Definition", "Playbook running": "Spielbuch läuft", + "Playbook supported": "Unterstütztes Playbook", "Policy constraints": "Policy-Beschränkungen", "Policy mappings": "Richtlinien-Zuordnungen", "Port": "Port", @@ -914,6 +931,7 @@ "Result": "Ergebnis", "Result name": "Name des Ergebnisses", "Retention manager": "Leiter der Personalabteilung", + "Revision": "Revision", "Revoke score": "Widerrufener Punktestand", "Revoked": "Widerrufen", "RIR": "RIR", @@ -959,6 +977,7 @@ "SHA3-256": "SHA3-256", "SHA3-512": "SHA3-512", "Shared with": "Freigegeben für", + "Short description": "Kurzbeschreibung", "Show submenu icons": "Symbole für Untermenüs anzeigen", "Sightings of observables via observed data": "Sichtungen von Observablen über beobachtete Daten", "Sightings propagation from indicator": "Fortpflanzung von Beobachtungen über einen Indikator", @@ -970,12 +989,15 @@ "SMTP configuration": "SMTP-Konfiguration", "SMTP enabled": "SMTP aktiviert", "Software Vulnerability propagation to Infrastructure": "Ausbreitung von Software-Sicherheitslücken auf die Infrastruktur", + "Solution categories": "Lösungskategorien", "Sophistication": "Raffinesse", "Source": "Quelle", + "Source code": "Quellcode", "Source connector": "Quellverbinder", "Source entity": "Quelle Entität", "Source name": "Name der Quelle", "Source type": "Quellentyp", + "Source URI": "Quell-URI", "SRC": "SRC", "SRC byte count": "SRC-Bytezahl", "SRC packets": "SRC-Pakete", @@ -1015,6 +1037,8 @@ "Subject public key exponent": "Thema Exponent des öffentlichen Schlüssels", "Subject public key modulus": "Betreff öffentlicher Schlüsselmodul", "Submission date": "Datum der Einreichung", + "Subscription link": "Abonnement-Link", + "Support version": "Unterstützte Version", "SWID": "SWID", "Sync workflow statuses by name": "Workflow-Status nach Namen synchronisieren", "Synchronized": "Synchronisiert", @@ -1104,6 +1128,7 @@ "URL": "URL", "Usage propagation of parent techniques": "Verwendungspropagierung von übergeordneten Techniken", "Usage propagation via attribution": "Weitergabe der Nutzung über Attribution", + "Use cases": "Anwendungsfälle", "Use DB configuration": "DB-Konfiguration verwenden", "Use SSL": "SSL verwenden", "User": "Benutzer", @@ -1136,6 +1161,7 @@ "Variables": "Variablen", "Vendor": "Anbieter", "verb": "verb", + "Verified": "Verifiziert", "Verify SSL certificate": "SSL-Zertifikat verifizieren", "Version": "Version", "Version History": "Versionsgeschichte", @@ -1181,4 +1207,5 @@ "XTM One intent": "XTM Eine Absicht", "XTM One registration manager": "XTM One Registrierungsmanager", "XTM1 CGU acceptance status": "XTM1 CGU-Annahmestatus" -} \ No newline at end of file +} + diff --git a/opencti-platform/opencti-front/lang/back/en.json b/opencti-platform/opencti-front/lang/back/en.json index f9d39eda4774..cb33c4af0384 100644 --- a/opencti-platform/opencti-front/lang/back/en.json +++ b/opencti-platform/opencti-front/lang/back/en.json @@ -108,6 +108,10 @@ "Card number": "Card number", "Carousel order": "Carousel order", "Case template": "Case template", + "Catalog Contract content hash": "Catalog Contract content hash", + "Catalog Contract ID": "Catalog Contract ID", + "Catalog ID": "Catalog ID", + "Catalog product version": "Catalog product version", "Category": "Category", "CC": "CC", "Certifiate policies": "Certifiate policies", @@ -130,11 +134,13 @@ "Confidence level": "Confidence level", "Confidentiality impact": "Confidentiality impact", "Configuration": "Configuration", + "Configuration schema": "Configuration schema", "Configuration version": "Configuration version", "Connector catalog": "Connector catalog", "Connector ID": "Connector ID", "Connector information": "Connector information", "Connector manager": "Connector manager", + "Connector manager contract": "Connector manager contract", "Connector manager current status": "Connector manager current status", "Connector manager image": "Connector manager image", "Connector manager requested status": "Connector manager requested status", @@ -143,10 +149,12 @@ "Connector state": "Connector state", "Connector trigger filters": "Connector trigger filters", "Connector type": "Connector type", + "Connector upgrade strategy": "Connector upgrade strategy", "Connector user id": "Connector user id", "Connectors": "Connectors", "Consent confirm text": "Consent confirm text", "Consent message": "Consent message", + "Contact": "Contact", "Contact information": "Contact information", "Contains": "Contains", "Contains observable": "Contains observable", @@ -160,6 +168,7 @@ "Context": "Context", "Context data": "Context data", "Contextual only": "Contextual only", + "Contract version": "Contract version", "Copy confidence level to OpenCTI scores for indicators": "Copy confidence level to OpenCTI scores for indicators", "Copy OpenCTI scores to confidence level for indicators": "Copy OpenCTI scores to confidence level for indicators", "Count": "Count", @@ -562,12 +571,14 @@ "Last score evolution": "Last score evolution", "Last seen": "Last seen", "Last update": "Last update", + "Last verified date": "Last verified date", "Lastname": "Lastname", "Latest containers": "Latest containers", "Latest created relationships": "Latest created relationships", "Latitude": "Latitude", "Layers": "Layers", "LdapStrategy": "LDAP Strategy", + "License type": "License type", "Lifetime": "Lifetime", "Light accent": "Light accent", "Light background": "Light background", @@ -592,6 +603,7 @@ "Locations propagation in reports": "Locations propagation in reports", "Log sources": "Log sources", "Login message": "Login message", + "Logo URI in file storage": "Logo URI in file storage", "Logout remote": "Logout remote", "Longitude": "Longitude", "LZJD": "LZJD", @@ -607,8 +619,11 @@ "Manager contracts": "Manager contracts", "Manager ID": "Manager ID", "Manager running": "Manager running", + "Manager supported": "Manager supported", "Mandatory": "Mandatory", "Manifest": "Manifest", + "Manifest schema version": "Manifest schema version", + "Manifest version": "Manifest version", "Mappings": "Mappings", "Marital status": "Marital status", "Marked for deletion at": "Marked for deletion at", @@ -626,6 +641,7 @@ "Match Pir with source of relationship": "Match Pir with source of relationship", "Max concurrent sessions (0 equals no maximum)": "Max concurrent sessions (0 equals no maximum)", "Max Confidence": "Max Confidence", + "Max confidence level": "Max confidence level", "Max policy length": "Max policy length", "Max shareable markings": "Max shareable markings", "Max value": "Max value", @@ -807,6 +823,7 @@ "Platforms": "Platforms", "Playbook definition": "Playbook definition", "Playbook running": "Playbook running", + "Playbook supported": "Playbook supported", "Policy constraints": "Policy constraints", "Policy mappings": "Policy mappings", "Port": "Port", @@ -914,6 +931,7 @@ "Result": "Result", "Result name": "Result name", "Retention manager": "Retention manager", + "Revision": "Revision", "Revoke score": "Revoke score", "Revoked": "Revoked", "RIR": "RIR", @@ -959,6 +977,7 @@ "SHA3-256": "SHA3-256", "SHA3-512": "SHA3-512", "Shared with": "Shared with", + "Short description": "Short description", "Show submenu icons": "Show submenu icons", "Sightings of observables via observed data": "Sightings of observables via observed data", "Sightings propagation from indicator": "Sightings propagation from indicator", @@ -970,12 +989,15 @@ "SMTP configuration": "SMTP configuration", "SMTP enabled": "SMTP enabled", "Software Vulnerability propagation to Infrastructure": "Software Vulnerability propagation to Infrastructure", + "Solution categories": "Solution categories", "Sophistication": "Sophistication", "Source": "Source", + "Source code": "Source code", "Source connector": "Source connector", "Source entity": "Source entity", "Source name": "Source name", "Source type": "Source type", + "Source URI": "Source URI", "SRC": "SRC", "SRC byte count": "SRC byte count", "SRC packets": "SRC packets", @@ -1015,6 +1037,8 @@ "Subject public key exponent": "Subject public key exponent", "Subject public key modulus": "Subject public key modulus", "Submission date": "Submission date", + "Subscription link": "Subscription link", + "Support version": "Support version", "SWID": "SWID", "Sync workflow statuses by name": "Sync workflow statuses by name", "Synchronized": "Synchronized", @@ -1104,6 +1128,7 @@ "URL": "URL", "Usage propagation of parent techniques": "Usage propagation of parent techniques", "Usage propagation via attribution": "Usage propagation via attribution", + "Use cases": "Use cases", "Use DB configuration": "Use DB configuration", "Use SSL": "Use SSL", "User": "User", @@ -1136,6 +1161,7 @@ "Variables": "Variables", "Vendor": "Vendor", "verb": "verb", + "Verified": "Verified", "Verify SSL certificate": "Verify SSL certificate", "Version": "Version", "Version History": "Version History", @@ -1181,4 +1207,5 @@ "XTM One intent": "XTM One intent", "XTM One registration manager": "XTM One registration manager", "XTM1 CGU acceptance status": "XTM1 CGU acceptance status" -} \ No newline at end of file +} + diff --git a/opencti-platform/opencti-front/lang/back/es.json b/opencti-platform/opencti-front/lang/back/es.json index 333202b9d697..cb5f9c042208 100644 --- a/opencti-platform/opencti-front/lang/back/es.json +++ b/opencti-platform/opencti-front/lang/back/es.json @@ -108,6 +108,10 @@ "Card number": "Número de tarjeta", "Carousel order": "Orden del carrusel", "Case template": "Plantilla de casos", + "Catalog Contract content hash": "Hash del contenido del contrato del catálogo", + "Catalog Contract ID": "ID del contrato del catálogo", + "Catalog ID": "ID del catálogo", + "Catalog product version": "Versión del producto del catálogo", "Category": "Categoría", "CC": "CC", "Certifiate policies": "Políticas de certificación", @@ -130,11 +134,13 @@ "Confidence level": "Nivel de confianza", "Confidentiality impact": "Impacto en la confidencialidad", "Configuration": "Configuración", + "Configuration schema": "Esquema de configuración", "Configuration version": "Versión de configuración", "Connector catalog": "Catálogo de conectores", "Connector ID": "ID del conector", "Connector information": "Información sobre el conector", "Connector manager": "Gestor de conectores", + "Connector manager contract": "Contrato del gestor de conectores", "Connector manager current status": "Estado actual del gestor de conectores", "Connector manager image": "Imagen del gestor de conectores", "Connector manager requested status": "Estado solicitado del gestor de conectores", @@ -143,10 +149,12 @@ "Connector state": "Estado del conector", "Connector trigger filters": "Filtros de activación de conectores", "Connector type": "Tipo de conector", + "Connector upgrade strategy": "Estrategia de actualización de conectores", "Connector user id": "ID de usuario del conector", "Connectors": "Conectores", "Consent confirm text": "Texto de confirmación del consentimiento", "Consent message": "Mensaje de consentimiento", + "Contact": "Contacto", "Contact information": "Información de contacto", "Contains": "Contiene", "Contains observable": "Contiene observable", @@ -160,6 +168,7 @@ "Context": "Contexto", "Context data": "Datos contextuales", "Contextual only": "Sólo contextual", + "Contract version": "Versión del contrato", "Copy confidence level to OpenCTI scores for indicators": "Copiar el nivel de confianza a las puntuaciones OpenCTI para los indicadores", "Copy OpenCTI scores to confidence level for indicators": "Copiar las puntuaciones de OpenCTI en el nivel de confianza de los indicadores", "Count": "Cuenta", @@ -562,12 +571,14 @@ "Last score evolution": "Evolución de la última puntuación", "Last seen": "Visto por última vez", "Last update": "Última actualización", + "Last verified date": "Fecha de la última verificación", "Lastname": "Apellido", "Latest containers": "Últimos contenedores", "Latest created relationships": "Últimas relaciones creadas", "Latitude": "Latitud", "Layers": "Capas", "LdapStrategy": "Estrategia LDAP", + "License type": "Tipo de licencia", "Lifetime": "De por vida", "Light accent": "Acento claro", "Light background": "Fondo claro", @@ -592,6 +603,7 @@ "Locations propagation in reports": "Propagación de localizaciones en informes", "Log sources": "Fuentes de registro", "Login message": "Mensaje de inicio de sesión", + "Logo URI in file storage": "URI del logotipo en el almacenamiento de archivos", "Logout remote": "Cerrar sesión remota", "Longitude": "Longitud", "LZJD": "LZJD", @@ -607,8 +619,11 @@ "Manager contracts": "Gestor contratos", "Manager ID": "ID Gestor", "Manager running": "Gestor en funcionamiento", + "Manager supported": "Gestor compatible", "Mandatory": "Obligatorio", "Manifest": "Manifiesto", + "Manifest schema version": "Versión del esquema del manifiesto", + "Manifest version": "Versión del manifiesto", "Mappings": "Asignaciones", "Marital status": "Estado civil", "Marked for deletion at": "Marcado para eliminación en", @@ -626,6 +641,7 @@ "Match Pir with source of relationship": "Emparejar Pir con la fuente de relación", "Max concurrent sessions (0 equals no maximum)": "Máximo de sesiones simultáneas (0 equivale a ningún máximo)", "Max Confidence": "Confianza máxima", + "Max confidence level": "Nivel máximo de confianza", "Max policy length": "Longitud máxima de la política", "Max shareable markings": "Marcas máximas compartibles", "Max value": "Valor máximo", @@ -807,6 +823,7 @@ "Platforms": "Plataformas", "Playbook definition": "Definición de Playbook", "Playbook running": "Ejecución de Playbook", + "Playbook supported": "Guía de procedimientos compatible", "Policy constraints": "Restricciones políticas", "Policy mappings": "Asignación de políticas", "Port": "Puerto", @@ -914,6 +931,7 @@ "Result": "Resultado", "Result name": "Nombre del resultado", "Retention manager": "Responsable de retención", + "Revision": "Revisión", "Revoke score": "Puntuación de revocación", "Revoked": "Revocado", "RIR": "RIR", @@ -959,6 +977,7 @@ "SHA3-256": "SHA3-256", "SHA3-512": "SHA3-512", "Shared with": "Compartido con", + "Short description": "Breve descripción", "Show submenu icons": "Mostrar iconos de submenú", "Sightings of observables via observed data": "Avistamientos de observables a través de datos observados", "Sightings propagation from indicator": "Propagación de avistamientos a partir de un indicador", @@ -970,12 +989,15 @@ "SMTP configuration": "Configuración SMTP", "SMTP enabled": "SMTP habilitado", "Software Vulnerability propagation to Infrastructure": "Propagación de vulnerabilidades de software a la infraestructura", + "Solution categories": "Categorías de la solución", "Sophistication": "Sofisticación", "Source": "Fuente", + "Source code": "Código fuente", "Source connector": "Conector fuente", "Source entity": "Entidad de origen", "Source name": "Nombre de la fuente", "Source type": "Tipo de fuente", + "Source URI": "URI de origen", "SRC": "SRC", "SRC byte count": "Recuento de bytes SRC", "SRC packets": "Paquetes SRC", @@ -1015,6 +1037,8 @@ "Subject public key exponent": "Tema exponente de clave pública", "Subject public key modulus": "Sujeto módulo de clave pública", "Submission date": "Fecha de envío", + "Subscription link": "Enlace de suscripción", + "Support version": "Versión compatible", "SWID": "SWID", "Sync workflow statuses by name": "Sincronizar los estados del flujo de trabajo por nombre", "Synchronized": "Sincronizado", @@ -1104,6 +1128,7 @@ "URL": "URL", "Usage propagation of parent techniques": "Propagación de uso de técnicas parentales", "Usage propagation via attribution": "Propagación del uso mediante atribución", + "Use cases": "Casos de uso", "Use DB configuration": "Usar configuración de la base de datos", "Use SSL": "Usar SSL", "User": "Usuario", @@ -1136,6 +1161,7 @@ "Variables": "Variables", "Vendor": "Proveedor", "verb": "verbo", + "Verified": "Verificado", "Verify SSL certificate": "Verificar certificado SSL", "Version": "Versión", "Version History": "Historial de versiones", @@ -1181,4 +1207,5 @@ "XTM One intent": "Intento de XTM One", "XTM One registration manager": "Gestor de inscripciones XTM One", "XTM1 CGU acceptance status": "XTM1 Estado de aceptación de la UGC" -} \ No newline at end of file +} + diff --git a/opencti-platform/opencti-front/lang/back/fr.json b/opencti-platform/opencti-front/lang/back/fr.json index e8f2339a5074..a703b821655a 100644 --- a/opencti-platform/opencti-front/lang/back/fr.json +++ b/opencti-platform/opencti-front/lang/back/fr.json @@ -108,6 +108,10 @@ "Card number": "Numéro de carte", "Carousel order": "Ordre du carrousel", "Case template": "Modèle de cas", + "Catalog Contract content hash": "Hachage du contenu du contrat de catalogue", + "Catalog Contract ID": "ID du contrat de catalogue", + "Catalog ID": "ID du catalogue", + "Catalog product version": "Version du produit du catalogue", "Category": "Catégorie", "CC": "CC", "Certifiate policies": "Politiques de certification", @@ -130,11 +134,13 @@ "Confidence level": "Niveau de confiance", "Confidentiality impact": "Impact sur la confidentialité", "Configuration": "Configuration", + "Configuration schema": "Schéma de configuration", "Configuration version": "Version de la configuration", "Connector catalog": "Catalogue des connecteurs", "Connector ID": "ID du connecteur", "Connector information": "Informations sur le connecteur", "Connector manager": "Gestionnaire du connecteur", + "Connector manager contract": "Contrat du gestionnaire de connecteurs", "Connector manager current status": "Statut actuel du gestionnaire de connecteur", "Connector manager image": "Image du gestionnaire de connecteur", "Connector manager requested status": "Statut demandé par le gestionnaire de connecteur", @@ -143,10 +149,12 @@ "Connector state": "État du connecteur", "Connector trigger filters": "Filtres de déclenchement du connecteur", "Connector type": "Type de connecteur", + "Connector upgrade strategy": "Stratégie de mise à niveau des connecteurs", "Connector user id": "Identifiant de l'utilisateur du connecteur", "Connectors": "Connecteurs", "Consent confirm text": "Texte de confirmation du consentement", "Consent message": "Message de consentement", + "Contact": "Contact", "Contact information": "Informations de contact", "Contains": "Contient", "Contains observable": "Contient des observables", @@ -160,6 +168,7 @@ "Context": "Contexte", "Context data": "Données contextuelles", "Contextual only": "Contextuel uniquement", + "Contract version": "Version du contrat", "Copy confidence level to OpenCTI scores for indicators": "Copier le niveau de confiance dans les scores OpenCTI pour les indicateurs", "Copy OpenCTI scores to confidence level for indicators": "Copier les scores OpenCTI vers le niveau de confiance pour les indicateurs", "Count": "Nombre", @@ -562,12 +571,14 @@ "Last score evolution": "Evolution du dernier score", "Last seen": "Dernière fois qu'il a été vu", "Last update": "Dernière mise à jour", + "Last verified date": "Date de la dernière vérification", "Lastname": "Nom", "Latest containers": "Derniers conteneurs", "Latest created relationships": "Dernières relations créées", "Latitude": "Latitude", "Layers": "Couches", "LdapStrategy": "Stratégie LDAP", + "License type": "Type de licence", "Lifetime": "Durée de vie", "Light accent": "Accentuation de la lumière", "Light background": "Fond clair", @@ -592,6 +603,7 @@ "Locations propagation in reports": "Propagation de l'emplacement dans les rapports", "Log sources": "Sources de logs", "Login message": "Message de connexion", + "Logo URI in file storage": "URI du logo dans le stockage de fichiers", "Logout remote": "Déconnexion à distance", "Longitude": "Longitude", "LZJD": "LZJD", @@ -607,8 +619,11 @@ "Manager contracts": "Contrats du gestionnaire", "Manager ID": "Manager ID", "Manager running": "Gestionnaire en cours d'exécution", + "Manager supported": "Gestionnaire pris en charge", "Mandatory": "Obligatoire", "Manifest": "Manifeste", + "Manifest schema version": "Version du schéma du manifeste", + "Manifest version": "Version du manifeste", "Mappings": "Mappages", "Marital status": "État civil", "Marked for deletion at": "Marqué pour suppression à", @@ -626,6 +641,7 @@ "Match Pir with source of relationship": "Faire correspondre Pir à la source de la relation", "Max concurrent sessions (0 equals no maximum)": "Nombre maximal de sessions simultanées (0 signifie qu'il n'y a pas de maximum)", "Max Confidence": "Confiance maximale", + "Max confidence level": "Niveau de confiance maximal", "Max policy length": "Longueur maximale de la politique", "Max shareable markings": "Marques maximales partageables", "Max value": "Valeur maximale", @@ -807,6 +823,7 @@ "Platforms": "Plateformes", "Playbook definition": "Définition du Playbook", "Playbook running": "Playbook en cours d'exécution", + "Playbook supported": "Guide de procédures pris en charge", "Policy constraints": "Contraintes de politique", "Policy mappings": "Mappages de politiques", "Port": "Port", @@ -914,6 +931,7 @@ "Result": "Résultat", "Result name": "Nom du résultat", "Retention manager": "Responsable de la rétention", + "Revision": "Révision", "Revoke score": "Score de révocation", "Revoked": "Révoqué", "RIR": "RIR", @@ -959,6 +977,7 @@ "SHA3-256": "SHA3-256", "SHA3-512": "SHA3-512", "Shared with": "Partagé avec", + "Short description": "Brève description", "Show submenu icons": "Afficher les icônes des sous-menus", "Sightings of observables via observed data": "Observations d'observables via des données observées", "Sightings propagation from indicator": "Propagation des observations à partir d'un indicateur", @@ -970,12 +989,15 @@ "SMTP configuration": "Configuration SMTP", "SMTP enabled": "SMTP activé", "Software Vulnerability propagation to Infrastructure": "Propagation d'une vulnérabilité d'un logiciel à une infrastructure", + "Solution categories": "Catégories de solutions", "Sophistication": "Sophistication", "Source": "Source", + "Source code": "Code source", "Source connector": "Connecteur source", "Source entity": "Entité source", "Source name": "Nom de la source", "Source type": "Type de source", + "Source URI": "URI de la source", "SRC": "SRC", "SRC byte count": "Nombre d'octets SRC", "SRC packets": "Paquets SRC", @@ -1015,6 +1037,8 @@ "Subject public key exponent": "Sujet clé publique exposant", "Subject public key modulus": "Sujet modulus de clé publique", "Submission date": "Date de soumission", + "Subscription link": "Lien d’abonnement", + "Support version": "Version prise en charge", "SWID": "SWID", "Sync workflow statuses by name": "Synchroniser les statuts des flux de travail par nom", "Synchronized": "Synchronisé", @@ -1104,6 +1128,7 @@ "URL": "URL", "Usage propagation of parent techniques": "Propagation de l'usage des techniques parentales", "Usage propagation via attribution": "Propagation de l'usage par attribution", + "Use cases": "Cas d’utilisation", "Use DB configuration": "Utiliser la configuration en base", "Use SSL": "Utiliser SSL", "User": "Utilisateur", @@ -1136,6 +1161,7 @@ "Variables": "Variables", "Vendor": "Vendeur", "verb": "verbe", + "Verified": "Vérifié", "Verify SSL certificate": "Vérifier le certificat SSL", "Version": "Version", "Version History": "Historique de la version", @@ -1181,4 +1207,5 @@ "XTM One intent": "XTM One intent", "XTM One registration manager": "Gestionnaire d'enregistrement XTM One", "XTM1 CGU acceptance status": "Statut d'acceptation des CGU XTM1" -} \ No newline at end of file +} + diff --git a/opencti-platform/opencti-front/lang/back/it.json b/opencti-platform/opencti-front/lang/back/it.json index 1a0b8e3acd8e..fc52545ab595 100644 --- a/opencti-platform/opencti-front/lang/back/it.json +++ b/opencti-platform/opencti-front/lang/back/it.json @@ -108,6 +108,10 @@ "Card number": "Numero di carta", "Carousel order": "Ordine del carosello", "Case template": "Modello di caso", + "Catalog Contract content hash": "Hash del contenuto del contratto del catalogo", + "Catalog Contract ID": "ID del contratto del catalogo", + "Catalog ID": "ID del catalogo", + "Catalog product version": "Versione del prodotto del catalogo", "Category": "Categoria", "CC": "CC", "Certifiate policies": "Politiche del certificato", @@ -130,11 +134,13 @@ "Confidence level": "Livello di fiducia", "Confidentiality impact": "Impatto sulla riservatezza", "Configuration": "Configurazione", + "Configuration schema": "Schema di configurazione", "Configuration version": "Versione della configurazione", "Connector catalog": "Catalogo dei connettori", "Connector ID": "ID connettore", "Connector information": "Informazioni sul connettore", "Connector manager": "Gestore del connettore", + "Connector manager contract": "Contratto del gestore dei connettori", "Connector manager current status": "Stato attuale del gestore di connettori", "Connector manager image": "Immagine del gestore del connettore", "Connector manager requested status": "Stato richiesto dal gestore del connettore", @@ -143,10 +149,12 @@ "Connector state": "Stato del connettore", "Connector trigger filters": "Filtri di attivazione del connettore", "Connector type": "Tipo di connettore", + "Connector upgrade strategy": "Strategia di aggiornamento dei connettori", "Connector user id": "ID utente del connettore", "Connectors": "Connettori", "Consent confirm text": "Testo di conferma del consenso", "Consent message": "Messaggio di consenso", + "Contact": "Contatti", "Contact information": "Informazioni di contatto", "Contains": "Contiene", "Contains observable": "Contiene osservabile", @@ -160,6 +168,7 @@ "Context": "Contesto", "Context data": "Dati di contesto", "Contextual only": "Solo contestuale", + "Contract version": "Versione del contratto", "Copy confidence level to OpenCTI scores for indicators": "Copia il livello di fiducia nei punteggi OpenCTI per gli indicatori", "Copy OpenCTI scores to confidence level for indicators": "Copia i punteggi OpenCTI nel livello di fiducia per gli indicatori", "Count": "Conteggio", @@ -562,12 +571,14 @@ "Last score evolution": "Evoluzione dell'ultimo punteggio", "Last seen": "Visto per l'ultima volta", "Last update": "Ultimo aggiornamento", + "Last verified date": "Data dell’ultima verifica", "Lastname": "Cognome", "Latest containers": "Ultimi contenitori", "Latest created relationships": "Ultime relazioni create", "Latitude": "Latitudine", "Layers": "Livelli", "LdapStrategy": "Strategia LDAP", + "License type": "Tipo di licenza", "Lifetime": "Durata", "Light accent": "Accento chiaro", "Light background": "Sfondo chiaro", @@ -592,6 +603,7 @@ "Locations propagation in reports": "Propagazione della posizione nei rapporti", "Log sources": "Fonti di log", "Login message": "Messaggio di login", + "Logo URI in file storage": "URI del logo nell’archivio file", "Logout remote": "Logout remoto", "Longitude": "Longitudine", "LZJD": "LZJD", @@ -607,8 +619,11 @@ "Manager contracts": "Contratti del gestore", "Manager ID": "ID manager", "Manager running": "Manager in esecuzione", + "Manager supported": "Manager supportato", "Mandatory": "Obbligatorio", "Manifest": "Manifesto", + "Manifest schema version": "Versione dello schema del manifesto", + "Manifest version": "Versione del manifesto", "Mappings": "Mappature", "Marital status": "Stato civile", "Marked for deletion at": "Contrassegnato per la cancellazione in", @@ -626,6 +641,7 @@ "Match Pir with source of relationship": "Abbinare il Pir alla fonte della relazione", "Max concurrent sessions (0 equals no maximum)": "Sessioni massime contemporanee (0 equivale a nessun massimo)", "Max Confidence": "Massima fiducia", + "Max confidence level": "Livello massimo di affidabilità", "Max policy length": "Lunghezza massima della politica", "Max shareable markings": "Marcature massime condivisibili", "Max value": "Valore massimo", @@ -807,6 +823,7 @@ "Platforms": "Piattaforme", "Playbook definition": "Definizione del playbook", "Playbook running": "Playbook in esecuzione", + "Playbook supported": "Playbook supportato", "Policy constraints": "Vincoli della policy", "Policy mappings": "Mappature della policy", "Port": "Porta", @@ -914,6 +931,7 @@ "Result": "Risultato", "Result name": "Nome del risultato", "Retention manager": "Gestore della conservazione", + "Revision": "Revisione", "Revoke score": "Revoca punteggio", "Revoked": "Revocato", "RIR": "RIR", @@ -959,6 +977,7 @@ "SHA3-256": "SHA3-256", "SHA3-512": "SHA3-512", "Shared with": "Condiviso con", + "Short description": "Breve descrizione", "Show submenu icons": "Mostra icone del sottomenu", "Sightings of observables via observed data": "Avvistamenti di osservabili tramite dati osservati", "Sightings propagation from indicator": "Propagazione degli osservabili da un indicatore", @@ -970,12 +989,15 @@ "SMTP configuration": "Configurazione SMTP", "SMTP enabled": "SMTP abilitato", "Software Vulnerability propagation to Infrastructure": "Propagazione delle vulnerabilità del software all'infrastruttura", + "Solution categories": "Categorie di soluzioni", "Sophistication": "Sofisticazione", "Source": "Fonte", + "Source code": "Codice sorgente", "Source connector": "Connettore di origine", "Source entity": "Entità di origine", "Source name": "Nome della fonte", "Source type": "Tipo di fonte", + "Source URI": "URI sorgente", "SRC": "SRC", "SRC byte count": "Conteggio byte SRC", "SRC packets": "Pacchetti SRC", @@ -1015,6 +1037,8 @@ "Subject public key exponent": "Esponente della chiave pubblica del soggetto", "Subject public key modulus": "Modulo della chiave pubblica del soggetto", "Submission date": "Data di invio", + "Subscription link": "Link di abbonamento", + "Support version": "Versione supportata", "SWID": "SWID", "Sync workflow statuses by name": "Sincronizza gli stati del flusso di lavoro in base al nome", "Synchronized": "Sincronizzato", @@ -1104,6 +1128,7 @@ "URL": "URL", "Usage propagation of parent techniques": "Propagazione d'uso delle tecniche parentali", "Usage propagation via attribution": "Propagazione dell'uso tramite attribuzione", + "Use cases": "Casi d'uso", "Use DB configuration": "Usa configurazione DB", "Use SSL": "Usa SSL", "User": "Utente", @@ -1136,6 +1161,7 @@ "Variables": "Variabili", "Vendor": "Fornitore", "verb": "verbo", + "Verified": "Verificato", "Verify SSL certificate": "Verifica del certificato SSL", "Version": "Versione", "Version History": "Storia della versione", @@ -1181,4 +1207,5 @@ "XTM One intent": "Intento di XTM One", "XTM One registration manager": "Gestore delle registrazioni XTM One", "XTM1 CGU acceptance status": "XTM1 Stato di accettazione della CGU" -} \ No newline at end of file +} + diff --git a/opencti-platform/opencti-front/lang/back/ja.json b/opencti-platform/opencti-front/lang/back/ja.json index 2439d55c6f57..b5731cf27476 100644 --- a/opencti-platform/opencti-front/lang/back/ja.json +++ b/opencti-platform/opencti-front/lang/back/ja.json @@ -108,6 +108,10 @@ "Card number": "カード番号", "Carousel order": "インデックスの日付", "Case template": "評価", + "Catalog Contract content hash": "カタログ契約の内容ハッシュ", + "Catalog Contract ID": "カタログ契約ID", + "Catalog ID": "カタログID", + "Catalog product version": "カタログ製品のバージョン", "Category": "マニフェスト", "CC": "CC", "Certifiate policies": "認証ポリシー", @@ -130,11 +134,13 @@ "Confidence level": "信頼度", "Confidentiality impact": "インシデントタイプ", "Configuration": "組織タイプ", + "Configuration schema": "構成スキーマ", "Configuration version": "モジュール", "Connector catalog": "コネクタカタログ", "Connector ID": "コネクタID", "Connector information": "コネクタ情報", "Connector manager": "コネクター・マネージャー", + "Connector manager contract": "コネクタマネージャー契約", "Connector manager current status": "コネクタマネージャの現在のステータス", "Connector manager image": "コネクタマネージャイメージ", "Connector manager requested status": "コネクタマネージャ要求ステータス", @@ -143,10 +149,12 @@ "Connector state": "コネクタの状態", "Connector trigger filters": "コネクター・トリガー・フィルター", "Connector type": "コネクタタイプ", + "Connector upgrade strategy": "コネクタのアップグレード戦略", "Connector user id": "コネクター・ユーザーID", "Connectors": "コネクタ", "Consent confirm text": "同意確認テキスト", "Consent message": "同意メッセージ", + "Contact": "連絡先", "Contact information": "連絡先情報", "Contains": "含む", "Contains observable": "観測値を含む", @@ -160,6 +168,7 @@ "Context": "コンテキスト", "Context data": "コンテキストデータ", "Contextual only": "コンテキストのみ", + "Contract version": "契約バージョン", "Copy confidence level to OpenCTI scores for indicators": "指標の信頼度をOpenCTIのスコアにコピーする", "Copy OpenCTI scores to confidence level for indicators": "OpenCTIのスコアを指標の信頼度にコピーする", "Count": "偽陽性", @@ -562,12 +571,14 @@ "Last score evolution": "最終スコアの推移", "Last seen": "目的", "Last update": "最終更新日", + "Last verified date": "最終検証日", "Lastname": "姓", "Latest containers": "最新コンテナ", "Latest created relationships": "最新作成リレーションシップ", "Latitude": "緯度", "Layers": "6 - 真実は判断できない", "LdapStrategy": "LDAP戦略", + "License type": "ライセンスタイプ", "Lifetime": "生涯", "Light accent": "ライトアクセント", "Light background": "明るい背景", @@ -592,6 +603,7 @@ "Locations propagation in reports": "レポートにおける位置情報の伝播", "Log sources": "インフラの種類", "Login message": "ログインメッセージ", + "Logo URI in file storage": "ファイルストレージ内のロゴ URI", "Logout remote": "ログアウト・リモート", "Longitude": "経度", "LZJD": "LZJD", @@ -607,8 +619,11 @@ "Manager contracts": "マネージャー契約", "Manager ID": "設定", "Manager running": "マネージャー実行中", + "Manager supported": "対応マネージャー", "Mandatory": "必須", "Manifest": "タグ", + "Manifest schema version": "マニフェストスキーマバージョン", + "Manifest version": "マニフェストバージョン", "Mappings": "マッピング", "Marital status": "目の色", "Marked for deletion at": "削除対象としてマークされました", @@ -626,6 +641,7 @@ "Match Pir with source of relationship": "海賊と関係元を一致させる", "Max concurrent sessions (0 equals no maximum)": "最大同時セッション数(0は最大なし)", "Max Confidence": "最大信頼度", + "Max confidence level": "最大信頼度", "Max policy length": "最大ポリシー長", "Max shareable markings": "共有可能な最大マーキング", "Max value": "最大値", @@ -807,6 +823,7 @@ "Platforms": "必要なパーミッション", "Playbook definition": "プレイブックの定義", "Playbook running": "プレイブックの実行", + "Playbook supported": "対応プレイブック", "Policy constraints": "ポリシー制約", "Policy mappings": "ポリシーのマッピング", "Port": "ポート", @@ -914,6 +931,7 @@ "Result": "VMホスト", "Result name": "分析結果", "Retention manager": "リテンション・マネージャー", + "Revision": "リビジョン", "Revoke score": "失効点数", "Revoked": "ラング", "RIR": "RIR", @@ -959,6 +977,7 @@ "SHA3-256": "SHA3-256", "SHA3-512": "SHA3-512", "Shared with": "と共有", + "Short description": "概要", "Show submenu icons": "サブメニューアイコンを表示する", "Sightings of observables via observed data": "観測データを介した観測可能性の目撃", "Sightings propagation from indicator": "指標からの目撃伝播", @@ -970,12 +989,15 @@ "SMTP configuration": "SMTP設定", "SMTP enabled": "SMTPを有効にする", "Software Vulnerability propagation to Infrastructure": "ソフトウェアの脆弱性がインフラストラクチャに波及する", + "Solution categories": "ソリューションカテゴリ", "Sophistication": "個人的な動機", "Source": "有効", + "Source code": "ソースコード", "Source connector": "ソースコネクター", "Source entity": "ソースエンティティ", "Source name": "ハッシュ", "Source type": "ソースタイプ", + "Source URI": "ソースURI", "SRC": "SRC", "SRC byte count": "SRCバイト数", "SRC packets": "SRCパケット数", @@ -1015,6 +1037,8 @@ "Subject public key exponent": "主題の公開鍵の指数", "Subject public key modulus": "対象公開鍵モジュラス", "Submission date": "分析開始日", + "Subscription link": "サブスクリプションリンク", + "Support version": "サポート対象バージョン", "SWID": "SWID", "Sync workflow statuses by name": "ワークフローのステータスを名前で同期する", "Synchronized": "同期", @@ -1104,6 +1128,7 @@ "URL": "URL", "Usage propagation of parent techniques": "親テクニックの利用伝播", "Usage propagation via attribution": "アトリビューションによる利用伝播", + "Use cases": "ユースケース", "Use DB configuration": "DB設定を使用する", "Use SSL": "SSLを使用する", "User": "内蔵", @@ -1136,6 +1161,7 @@ "Variables": "変数", "Vendor": "ベンダー", "verb": "動詞", + "Verified": "検証済み", "Verify SSL certificate": "SSL証明書の検証", "Version": "バージョン", "Version History": "バージョン履歴", @@ -1181,4 +1207,5 @@ "XTM One intent": "XTMワンの意図", "XTM One registration manager": "XTM One登録マネージャー", "XTM1 CGU acceptance status": "XTM1 CGU受入状況" -} \ No newline at end of file +} + diff --git a/opencti-platform/opencti-front/lang/back/ko.json b/opencti-platform/opencti-front/lang/back/ko.json index 816ca35c4723..bbc321d3c5db 100644 --- a/opencti-platform/opencti-front/lang/back/ko.json +++ b/opencti-platform/opencti-front/lang/back/ko.json @@ -108,6 +108,10 @@ "Card number": "카드 번호", "Carousel order": "캐러셀 순서", "Case template": "사례 템플릿", + "Catalog Contract content hash": "카탈로그 계약 내용 해시", + "Catalog Contract ID": "카탈로그 계약 ID", + "Catalog ID": "카탈로그 ID", + "Catalog product version": "카탈로그 제품 버전", "Category": "카테고리", "CC": "CC", "Certifiate policies": "인증서 정책", @@ -130,11 +134,13 @@ "Confidence level": "신뢰 수준", "Confidentiality impact": "기밀성 영향", "Configuration": "구성", + "Configuration schema": "구성 스키마", "Configuration version": "구성 버전", "Connector catalog": "커넥터 카탈로그", "Connector ID": "커넥터 ID", "Connector information": "커넥터 정보", "Connector manager": "커넥터 관리자", + "Connector manager contract": "커넥터 관리자 계약", "Connector manager current status": "커넥터 관리자 현재 상태", "Connector manager image": "커넥터 관리자 이미지", "Connector manager requested status": "커넥터 관리자 요청 상태", @@ -143,10 +149,12 @@ "Connector state": "커넥터 상태", "Connector trigger filters": "커넥터 트리거 필터", "Connector type": "커넥터 유형", + "Connector upgrade strategy": "커넥터 업그레이드 전략", "Connector user id": "커넥터 사용자 ID", "Connectors": "커넥터", "Consent confirm text": "동의 확인 텍스트", "Consent message": "동의 메시지", + "Contact": "연락처", "Contact information": "연락처 정보", "Contains": "포함", "Contains observable": "관찰 가능 항목 포함", @@ -160,6 +168,7 @@ "Context": "컨텍스트", "Context data": "컨텍스트 데이터", "Contextual only": "컨텍스트 전용", + "Contract version": "계약 버전", "Copy confidence level to OpenCTI scores for indicators": "지표에 대한 신뢰 수준을 OpenCTI 점수로 복사합니다", "Copy OpenCTI scores to confidence level for indicators": "지표의 신뢰 수준에 OpenCTI 점수를 복사합니다", "Count": "개수", @@ -562,12 +571,14 @@ "Last score evolution": "마지막 점수 진화", "Last seen": "마지막 목격", "Last update": "마지막 업데이트", + "Last verified date": "최종 검증 날짜", "Lastname": "성", "Latest containers": "최신 컨테이너", "Latest created relationships": "최근에 생성된 관계", "Latitude": "위도", "Layers": "레이어", "LdapStrategy": "LDAP 전략", + "License type": "라이선스 유형", "Lifetime": "수명", "Light accent": "밝은 강조", "Light background": "밝은 배경", @@ -592,6 +603,7 @@ "Locations propagation in reports": "보고서 내 위치 전파", "Log sources": "로그 소스", "Login message": "로그인 메시지", + "Logo URI in file storage": "파일 저장소의 로고 URI", "Logout remote": "원격 로그아웃", "Longitude": "경도", "LZJD": "LZJD", @@ -607,8 +619,11 @@ "Manager contracts": "관리자 컨트랙트", "Manager ID": "관리자 ID", "Manager running": "관리자 실행 중", + "Manager supported": "지원되는 관리자", "Mandatory": "필수", "Manifest": "매니페스트", + "Manifest schema version": "매니페스트 스키마 버전", + "Manifest version": "매니페스트 버전", "Mappings": "매핑", "Marital status": "결혼 상태", "Marked for deletion at": "다음 위치에서 삭제 대상으로 표시됨", @@ -626,6 +641,7 @@ "Match Pir with source of relationship": "Pir와 관계 소스 일치", "Max concurrent sessions (0 equals no maximum)": "최대 동시 세션(0은 최대값 없음)", "Max Confidence": "최대 신뢰도", + "Max confidence level": "최대 신뢰 수준", "Max policy length": "최대 정책 길이", "Max shareable markings": "최대 공유 가능 마킹", "Max value": "최대값", @@ -807,6 +823,7 @@ "Platforms": "플랫폼", "Playbook definition": "플레이북 정의", "Playbook running": "플레이북 실행 중", + "Playbook supported": "지원되는 플레이북", "Policy constraints": "정책 제약 조건", "Policy mappings": "정책 매핑", "Port": "포트", @@ -914,6 +931,7 @@ "Result": "결과", "Result name": "결과 이름", "Retention manager": "리텐션 관리자", + "Revision": "개정판", "Revoke score": "점수 취소", "Revoked": "취소됨", "RIR": "RIR", @@ -959,6 +977,7 @@ "SHA3-256": "SHA3-256", "SHA3-512": "SHA3-512", "Shared with": "공유됨", + "Short description": "간략한 설명", "Show submenu icons": "서브메뉴 아이콘 표시", "Sightings of observables via observed data": "관측 데이터를 통한 관측 가능 항목 발견", "Sightings propagation from indicator": "인디케이터를 통한 관측값 전파", @@ -970,12 +989,15 @@ "SMTP configuration": "SMTP 구성", "SMTP enabled": "SMTP 사용", "Software Vulnerability propagation to Infrastructure": "인프라로 전파되는 소프트웨어 취약점", + "Solution categories": "솔루션 카테고리", "Sophistication": "정교함", "Source": "출처", + "Source code": "소스 코드", "Source connector": "출처 커넥터", "Source entity": "출처 엔터티", "Source name": "출처 이름", "Source type": "출처 유형", + "Source URI": "소스 URI", "SRC": "SRC", "SRC byte count": "SRC 바이트 수", "SRC packets": "SRC 패킷", @@ -1015,6 +1037,8 @@ "Subject public key exponent": "주제 공개 키 지수", "Subject public key modulus": "주제 공개 키 모듈러스", "Submission date": "제출 날짜", + "Subscription link": "구독 링크", + "Support version": "지원 버전", "SWID": "SWID", "Sync workflow statuses by name": "이름별로 워크플로 상태 동기화", "Synchronized": "동기화됨", @@ -1104,6 +1128,7 @@ "URL": "URL", "Usage propagation of parent techniques": "상위 기술의 사용 전파", "Usage propagation via attribution": "어트리뷰션을 통한 사용량 전파", + "Use cases": "사용 사례", "Use DB configuration": "DB 구성 사용", "Use SSL": "SSL 사용", "User": "사용자", @@ -1136,6 +1161,7 @@ "Variables": "변수", "Vendor": "공급업체", "verb": "동사", + "Verified": "검증됨", "Verify SSL certificate": "SSL 인증서 확인", "Version": "버전", "Version History": "버전 기록", @@ -1181,4 +1207,5 @@ "XTM One intent": "XTM One 의도", "XTM One registration manager": "XTM One 등록 관리자", "XTM1 CGU acceptance status": "XTM1 CGU 승인 상태" -} \ No newline at end of file +} + diff --git a/opencti-platform/opencti-front/lang/back/ru.json b/opencti-platform/opencti-front/lang/back/ru.json index dde2ed666f5a..721e4a465d7f 100644 --- a/opencti-platform/opencti-front/lang/back/ru.json +++ b/opencti-platform/opencti-front/lang/back/ru.json @@ -108,6 +108,10 @@ "Card number": "Номер карты", "Carousel order": "Порядок в карусели", "Case template": "Шаблон кейса", + "Catalog Contract content hash": "Хеш содержимого контракта каталога", + "Catalog Contract ID": "Идентификатор контракта каталога", + "Catalog ID": "Идентификатор каталога", + "Catalog product version": "Версия продукта в каталоге", "Category": "Категория", "CC": "CC", "Certifiate policies": "Политики сертификации", @@ -130,11 +134,13 @@ "Confidence level": "Уровень доверия", "Confidentiality impact": "Влияние на конфиденциальность", "Configuration": "Конфигурация", + "Configuration schema": "Схема конфигурации", "Configuration version": "Версия конфигурации", "Connector catalog": "Каталог коннекторов", "Connector ID": "Идентификатор коннектора", "Connector information": "Информация о коннекторе", "Connector manager": "Менеджер коннектора", + "Connector manager contract": "Контракт диспетчера коннекторов", "Connector manager current status": "Текущее состояние менеджера коннектора", "Connector manager image": "Изображение менеджера коннекторов", "Connector manager requested status": "Запрошенное состояние менеджера коннектора", @@ -143,10 +149,12 @@ "Connector state": "Состояние коннектора", "Connector trigger filters": "Триггерные фильтры коннектора", "Connector type": "Тип коннектора", + "Connector upgrade strategy": "Стратегия обновления коннекторов", "Connector user id": "Идентификатор пользователя коннектора", "Connectors": "Коннекторы", "Consent confirm text": "Согласие подтвердить текст", "Consent message": "Сообщение о согласии", + "Contact": "Контактные данные", "Contact information": "Контактная информация", "Contains": "Содержит", "Contains observable": "Содержит наблюдаемые", @@ -160,6 +168,7 @@ "Context": "Контекст", "Context data": "Контекстные данные", "Contextual only": "Только контекст", + "Contract version": "Версия контракта", "Copy confidence level to OpenCTI scores for indicators": "Копирование уровня доверия к показателям OpenCTI", "Copy OpenCTI scores to confidence level for indicators": "Скопируйте оценки OpenCTI в уровень доверия для индикаторов", "Count": "Количество", @@ -562,12 +571,14 @@ "Last score evolution": "Эволюция последней оценки", "Last seen": "Последний раз видели", "Last update": "Последнее обновление", + "Last verified date": "Дата последней проверки", "Lastname": "Фамилия", "Latest containers": "Последние контейнеры", "Latest created relationships": "Последние созданные отношения", "Latitude": "Широта", "Layers": "Слои", "LdapStrategy": "LdapStrategy", + "License type": "Тип лицензии", "Lifetime": "Пожизненный", "Light accent": "Светлый акцент", "Light background": "Светлый фон", @@ -592,6 +603,7 @@ "Locations propagation in reports": "Распространение местоположений в отчетах", "Log sources": "Источники журналов", "Login message": "Сообщение о входе в систему", + "Logo URI in file storage": "URI логотипа в хранилище файлов", "Logout remote": "Удаленный выход из системы", "Longitude": "Долгота", "LZJD": "LZJD", @@ -607,8 +619,11 @@ "Manager contracts": "Договоры с менеджерами", "Manager ID": "Идентификатор менеджера", "Manager running": "Менеджер работает", + "Manager supported": "Поддерживаемый менеджер", "Mandatory": "Обязательное поле", "Manifest": "Манифест", + "Manifest schema version": "Версия схемы манифеста", + "Manifest version": "Версия манифеста", "Mappings": "Отображения", "Marital status": "Семейное положение", "Marked for deletion at": "Отмечено для удаления в", @@ -626,6 +641,7 @@ "Match Pir with source of relationship": "Сопоставьте Пир с источником отношений", "Max concurrent sessions (0 equals no maximum)": "Максимальное количество одновременных сеансов (0 означает отсутствие максимума)", "Max Confidence": "Максимальная уверенность", + "Max confidence level": "Максимальный уровень достоверности", "Max policy length": "Максимальная длина полиса", "Max shareable markings": "Максимально разделяемые метки", "Max value": "Максимальное значение", @@ -807,6 +823,7 @@ "Platforms": "Платформы", "Playbook definition": "Определение игровой книги", "Playbook running": "Работа с игровыми книгами", + "Playbook supported": "Поддерживаемые руководства", "Policy constraints": "Политические ограничения", "Policy mappings": "Сопоставление политик", "Port": "Порт", @@ -914,6 +931,7 @@ "Result": "Результат", "Result name": "Название результата", "Retention manager": "Менеджер по удержанию персонала", + "Revision": "Ревизия", "Revoke score": "Отменить оценку", "Revoked": "Отозвано", "RIR": "RIR", @@ -959,6 +977,7 @@ "SHA3-256": "SHA3-256", "SHA3-512": "SHA3-512", "Shared with": "Разделена с", + "Short description": "Краткое описание", "Show submenu icons": "Показать значки подменю", "Sightings of observables via observed data": "Наблюдения наблюдаемых через наблюдаемые данные", "Sightings propagation from indicator": "Распространение наблюдений через индикатор", @@ -970,12 +989,15 @@ "SMTP configuration": "Конфигурация SMTP", "SMTP enabled": "SMTP включен", "Software Vulnerability propagation to Infrastructure": "Распространение уязвимостей программного обеспечения на инфраструктуру", + "Solution categories": "Категории решений", "Sophistication": "Изысканность", "Source": "Источник", + "Source code": "Исходный код", "Source connector": "Исходный коннектор", "Source entity": "Источник", "Source name": "Название источника", "Source type": "Тип источника", + "Source URI": "URI источника", "SRC": "SRC", "SRC byte count": "Счетчик байтов SRC", "SRC packets": "Пакеты SRC", @@ -1015,6 +1037,8 @@ "Subject public key exponent": "Экспонента открытого ключа субъекта", "Subject public key modulus": "Модуль открытого ключа субъекта", "Submission date": "Дата представления", + "Subscription link": "Ссылка на подписку", + "Support version": "Поддерживаемая версия", "SWID": "SWID", "Sync workflow statuses by name": "Синхронизация статусов рабочих процессов по названию", "Synchronized": "Синхронизированный", @@ -1104,6 +1128,7 @@ "URL": "URL", "Usage propagation of parent techniques": "Распространение использования родительских техник", "Usage propagation via attribution": "Распространение использования через атрибуцию", + "Use cases": "Варианты использования", "Use DB configuration": "Использовать настройки БД", "Use SSL": "Использовать SSL", "User": "Пользователь", @@ -1136,6 +1161,7 @@ "Variables": "Переменные", "Vendor": "Поставщик", "verb": "глагол", + "Verified": "Проверено", "Verify SSL certificate": "Проверка сертификата SSL", "Version": "Версия", "Version History": "История версий", @@ -1181,4 +1207,5 @@ "XTM One intent": "Намерение XTM One", "XTM One registration manager": "Менеджер регистрации XTM One", "XTM1 CGU acceptance status": "Статус принятия XTM1 CGU" -} \ No newline at end of file +} + diff --git a/opencti-platform/opencti-front/lang/back/zh.json b/opencti-platform/opencti-front/lang/back/zh.json index a1011d4c0fa8..56780f82d937 100644 --- a/opencti-platform/opencti-front/lang/back/zh.json +++ b/opencti-platform/opencti-front/lang/back/zh.json @@ -108,6 +108,10 @@ "Card number": "卡号", "Carousel order": "传送带顺序", "Case template": "案例模板", + "Catalog Contract content hash": "目录合同内容哈希", + "Catalog Contract ID": "目录合同 ID", + "Catalog ID": "目录 ID", + "Catalog product version": "目录产品版本", "Category": "类别", "CC": "抄送", "Certifiate policies": "认证策略", @@ -130,11 +134,13 @@ "Confidence level": "置信度", "Confidentiality impact": "保密性影响", "Configuration": "配置", + "Configuration schema": "配置模式", "Configuration version": "配置版本", "Connector catalog": "连接器目录", "Connector ID": "连接器 ID", "Connector information": "连接器信息", "Connector manager": "连接器管理器", + "Connector manager contract": "连接器管理器合同", "Connector manager current status": "连接器管理器当前状态", "Connector manager image": "连接器管理器图像", "Connector manager requested status": "连接器管理器请求状态", @@ -143,10 +149,12 @@ "Connector state": "连接器状态", "Connector trigger filters": "连接器触发滤波器", "Connector type": "连接器类型", + "Connector upgrade strategy": "连接器升级策略", "Connector user id": "连接器用户 ID", "Connectors": "连接器", "Consent confirm text": "同意确认文本", "Consent message": "同意信息", + "Contact": "联系方式", "Contact information": "联系信息", "Contains": "包含", "Contains observable": "包含观测值", @@ -160,6 +168,7 @@ "Context": "上下文", "Context data": "上下文数据", "Contextual only": "仅根据上下文", + "Contract version": "合同版本", "Copy confidence level to OpenCTI scores for indicators": "将置信度复制到指标的 OpenCTI 分数", "Copy OpenCTI scores to confidence level for indicators": "将 OpenCTI 分数复制到指标的置信度", "Count": "计数", @@ -562,12 +571,14 @@ "Last score evolution": "最后分数演变", "Last seen": "最近出现", "Last update": "最后更新", + "Last verified date": "最后验证日期", "Lastname": "姓氏", "Latest containers": "最新容器", "Latest created relationships": "最近创建的关系", "Latitude": "纬度", "Layers": "层级", "LdapStrategy": "LDAP Strategy", + "License type": "许可证类型", "Lifetime": "生命周期", "Light accent": "淡色调", "Light background": "淡色背景", @@ -592,6 +603,7 @@ "Locations propagation in reports": "报告中的位置传播", "Log sources": "日志来源", "Login message": "登录信息", + "Logo URI in file storage": "文件存储中的徽标 URI", "Logout remote": "远程注销", "Longitude": "经度", "LZJD": "LZJD", @@ -607,8 +619,11 @@ "Manager contracts": "管理器合同", "Manager ID": "管理者 ID", "Manager running": "管理器运行", + "Manager supported": "支持的管理器", "Mandatory": "必填", "Manifest": "内容", + "Manifest schema version": "清单架构版本", + "Manifest version": "清单版本", "Mappings": "映射", "Marital status": "婚姻状况", "Marked for deletion at": "已标记为删除,位置在", @@ -626,6 +641,7 @@ "Match Pir with source of relationship": "将 Pir 与关系源匹配", "Max concurrent sessions (0 equals no maximum)": "最大并发会话数(0 表示无最大值)", "Max Confidence": "最大置信度", + "Max confidence level": "最大置信度", "Max policy length": "最多长度要求", "Max shareable markings": "最大可共享标记", "Max value": "最大值", @@ -807,6 +823,7 @@ "Platforms": "平台", "Playbook definition": "游戏本定义", "Playbook running": "运行游戏本", + "Playbook supported": "支持的剧本", "Policy constraints": "策略约束", "Policy mappings": "策略映射", "Port": "端口", @@ -914,6 +931,7 @@ "Result": "结果", "Result name": "结果名称", "Retention manager": "留用经理", + "Revision": "修订版", "Revoke score": "撤销得分", "Revoked": "撤销", "RIR": "RIR", @@ -959,6 +977,7 @@ "SHA3-256": "SHA3-256", "SHA3-512": "SHA3-512", "Shared with": "共享对象", + "Short description": "简要描述", "Show submenu icons": "显示子菜单图标", "Sightings of observables via observed data": "通过观察数据看到可观察数据", "Sightings propagation from indicator": "从指标传播观察结果", @@ -970,12 +989,15 @@ "SMTP configuration": "SMTP配置", "SMTP enabled": "已启用 SMTP", "Software Vulnerability propagation to Infrastructure": "软件漏洞向基础设施的传播", + "Solution categories": "解决方案类别", "Sophistication": "复杂性", "Source": "来源", + "Source code": "源代码", "Source connector": "源连接器", "Source entity": "来源实体", "Source name": "源名称", "Source type": "源类型", + "Source URI": "源代码 URI", "SRC": "SRC", "SRC byte count": "SRC 字节数", "SRC packets": "SRC 数据包", @@ -1015,6 +1037,8 @@ "Subject public key exponent": "主体公钥指数", "Subject public key modulus": "主体公钥模数", "Submission date": "提交日期", + "Subscription link": "订阅链接", + "Support version": "支持版本", "SWID": "SWID", "Sync workflow statuses by name": "按名称同步工作流状态", "Synchronized": "同步", @@ -1104,6 +1128,7 @@ "URL": "URL", "Usage propagation of parent techniques": "父技术的使用传播", "Usage propagation via attribution": "通过归属进行使用传播", + "Use cases": "用例", "Use DB configuration": "使用数据库配置", "Use SSL": "使用 SSL", "User": "用户", @@ -1136,6 +1161,7 @@ "Variables": "变量", "Vendor": "供应商", "verb": "动词", + "Verified": "已验证", "Verify SSL certificate": "验证 SSL 证书", "Version": "版本", "Version History": "版本历史", @@ -1181,4 +1207,5 @@ "XTM One intent": "XTM 一个意图", "XTM One registration manager": "XTM One 注册管理器", "XTM1 CGU acceptance status": "XTM1 CGU 验收状态" -} \ No newline at end of file +} + diff --git a/opencti-platform/opencti-front/lang/front/de.json b/opencti-platform/opencti-front/lang/front/de.json index 6456ef123f42..28a38a584d80 100644 --- a/opencti-platform/opencti-front/lang/front/de.json +++ b/opencti-platform/opencti-front/lang/front/de.json @@ -639,6 +639,7 @@ "Cases": "Fälle", "Cases & Analyses": "Fälle & Analysen", "CaseTemplate": "CaseTemplate", + "CATALOG_MANAGER": "Katalogmanager", "Category": "Kategorie", "category": "Kategorie", "Center latitude": "Mittlere geographische Breite", diff --git a/opencti-platform/opencti-front/lang/front/en.json b/opencti-platform/opencti-front/lang/front/en.json index 82ee7bfaa1b9..8615ef712106 100644 --- a/opencti-platform/opencti-front/lang/front/en.json +++ b/opencti-platform/opencti-front/lang/front/en.json @@ -639,6 +639,7 @@ "Cases": "Cases", "Cases & Analyses": "Cases & Analyses", "CaseTemplate": "CaseTemplate", + "CATALOG_MANAGER": "Catalog manager", "Category": "Category", "category": "Category", "Center latitude": "Center latitude", diff --git a/opencti-platform/opencti-front/lang/front/es.json b/opencti-platform/opencti-front/lang/front/es.json index fdc3db47522a..30ac9dabf0a4 100644 --- a/opencti-platform/opencti-front/lang/front/es.json +++ b/opencti-platform/opencti-front/lang/front/es.json @@ -639,6 +639,7 @@ "Cases": "Casos", "Cases & Analyses": "Casos y Análisis", "CaseTemplate": "CaseTemplate", + "CATALOG_MANAGER": "Gestor del catálogo", "Category": "Catégorie", "category": "Categoría", "Center latitude": "Latitud central", diff --git a/opencti-platform/opencti-front/lang/front/fr.json b/opencti-platform/opencti-front/lang/front/fr.json index 9922f5673617..cb48ad9eeddc 100644 --- a/opencti-platform/opencti-front/lang/front/fr.json +++ b/opencti-platform/opencti-front/lang/front/fr.json @@ -639,6 +639,7 @@ "Cases": "Cases", "Cases & Analyses": "Cases et analyse", "CaseTemplate": "Modèle de cas", + "CATALOG_MANAGER": "Manager du catalogue", "Category": "Catégorie", "category": "Catégorie", "Center latitude": "Latitude centrale", diff --git a/opencti-platform/opencti-front/lang/front/it.json b/opencti-platform/opencti-front/lang/front/it.json index fbb4c8409053..0afd9f5b8e86 100644 --- a/opencti-platform/opencti-front/lang/front/it.json +++ b/opencti-platform/opencti-front/lang/front/it.json @@ -639,6 +639,7 @@ "Cases": "Casi", "Cases & Analyses": "Casi & Analisi", "CaseTemplate": "Modello di caso", + "CATALOG_MANAGER": "Gestione del catalogo", "Category": "Categoria", "category": "categoria", "Center latitude": "Latitudine centrale", diff --git a/opencti-platform/opencti-front/lang/front/ja.json b/opencti-platform/opencti-front/lang/front/ja.json index 3c24758d64b8..3271d829325c 100644 --- a/opencti-platform/opencti-front/lang/front/ja.json +++ b/opencti-platform/opencti-front/lang/front/ja.json @@ -639,6 +639,7 @@ "Cases": "事例", "Cases & Analyses": "事例と分析", "CaseTemplate": "ケーステンプレート", + "CATALOG_MANAGER": "カタログマネージャー", "Category": "カテゴリー", "category": "カテゴリー", "Center latitude": "中心緯度", diff --git a/opencti-platform/opencti-front/lang/front/ko.json b/opencti-platform/opencti-front/lang/front/ko.json index 56186620ae2d..869a0396e50d 100644 --- a/opencti-platform/opencti-front/lang/front/ko.json +++ b/opencti-platform/opencti-front/lang/front/ko.json @@ -639,6 +639,7 @@ "Cases": "사례", "Cases & Analyses": "사례 및 분석", "CaseTemplate": "케이스 템플릿", + "CATALOG_MANAGER": "카탈로그 관리자", "Category": "카테고리", "category": "카테고리", "Center latitude": "중심 위도", diff --git a/opencti-platform/opencti-front/lang/front/ru.json b/opencti-platform/opencti-front/lang/front/ru.json index 79b98d6c202c..a4664f1f96a1 100644 --- a/opencti-platform/opencti-front/lang/front/ru.json +++ b/opencti-platform/opencti-front/lang/front/ru.json @@ -639,6 +639,7 @@ "Cases": "Кейсы", "Cases & Analyses": "Кейсы и анализы", "CaseTemplate": "CaseTemplate", + "CATALOG_MANAGER": "Менеджер каталога", "Category": "Категория", "category": "категория", "Center latitude": "Центральная широта", diff --git a/opencti-platform/opencti-front/lang/front/zh.json b/opencti-platform/opencti-front/lang/front/zh.json index f92dff994ee7..1205a233f77d 100644 --- a/opencti-platform/opencti-front/lang/front/zh.json +++ b/opencti-platform/opencti-front/lang/front/zh.json @@ -639,6 +639,7 @@ "Cases": "案例", "Cases & Analyses": "案例与分析", "CaseTemplate": "案例模板", + "CATALOG_MANAGER": "目录管理器", "Category": "类别", "category": "类别", "Center latitude": "中心纬度", diff --git a/opencti-platform/opencti-front/src/private/components/integrations/Integrations.tsx b/opencti-platform/opencti-front/src/private/components/integrations/Integrations.tsx index b67a44642ea2..2d484011e5df 100644 --- a/opencti-platform/opencti-front/src/private/components/integrations/Integrations.tsx +++ b/opencti-platform/opencti-front/src/private/components/integrations/Integrations.tsx @@ -1,5 +1,5 @@ -import React, { Suspense, useEffect, useMemo } from 'react'; -import { Link, Navigate, useParams } from 'react-router'; +import React, { Suspense, useCallback, useEffect, useMemo } from 'react'; +import { Link, Navigate, useParams } from 'react-router'; import { Box, Stack, Typography } from '@mui/material'; import { alpha, useTheme } from '@mui/material/styles'; import { useQueryLoader } from 'react-relay'; @@ -42,6 +42,7 @@ export interface IntegrationsData { deploymentData: IngestionConnectorsQuery['response'] | null; feedsData: IngestionFeedsData | null; formsData: IngestionFeedsFormsData | null; + refetchCatalogs: () => void; refetchFeeds: () => void; refetchForms: () => void; } @@ -78,16 +79,21 @@ const IntegrationsDataProvider = ({ children }: IntegrationsDataProviderProps) = // store-and-network: the previous data keeps rendering while the refresh // happens in the background, so refetching never suspends the whole page. - const refetchFeeds = () => { + const refetchFeeds = useCallback(() => { if (isIngestionReader) { loadFeeds({ first: FEEDS_PAGE_SIZE }, { fetchPolicy: 'store-and-network' }); } - }; - const refetchForms = () => { + }, [isIngestionReader, loadFeeds]); + const refetchForms = useCallback(() => { if (isFormReader) { loadForms({ first: FEEDS_PAGE_SIZE }, { fetchPolicy: 'store-and-network' }); } - }; + }, [isFormReader, loadForms]); + const refetchCatalogs = useCallback(() => { + if (isConnectorReader) { + loadCatalogs({}, { fetchPolicy: 'store-and-network' }); + } + }, [isConnectorReader, loadCatalogs]); const renderWithForms = ( catalogsData: IngestionConnectorsCatalogsQuery['response'] | null, @@ -97,11 +103,27 @@ const IntegrationsDataProvider = ({ children }: IntegrationsDataProviderProps) = if (formsRef) { return ( - {({ data: formsData }) => children({ catalogsData, deploymentData, feedsData, formsData, refetchFeeds, refetchForms })} + {({ data: formsData }) => children({ + catalogsData, + deploymentData, + feedsData, + formsData, + refetchCatalogs, + refetchFeeds, + refetchForms, + })} ); } - return children({ catalogsData, deploymentData, feedsData, formsData: null, refetchFeeds, refetchForms }); + return children({ + catalogsData, + deploymentData, + feedsData, + formsData: null, + refetchCatalogs, + refetchFeeds, + refetchForms, + }); }; const renderWithFeeds = ( diff --git a/opencti-platform/opencti-front/src/private/components/integrations/available/IntegrationsAvailable.tsx b/opencti-platform/opencti-front/src/private/components/integrations/available/IntegrationsAvailable.tsx index 5cd197461fcd..1397f8fb2935 100644 --- a/opencti-platform/opencti-front/src/private/components/integrations/available/IntegrationsAvailable.tsx +++ b/opencti-platform/opencti-front/src/private/components/integrations/available/IntegrationsAvailable.tsx @@ -10,6 +10,7 @@ import IngestionCatalogCard from '@components/integrations/catalog/IngestionCata import IngestionCatalogConnectorCreation from '@components/integrations/catalog/IngestionCatalogConnectorCreation'; import IngestionCatalogFacetSidebar from '@components/integrations/catalog/IngestionCatalogFacetSidebar'; import useConnectorDeployDialog from '@components/integrations/catalog/hooks/useConnectorDeployDialog'; +import useCatalogPolling from '@components/integrations/catalog/hooks/useCatalogPolling'; import useIngestionCatalogFilters, { BUILT_IN_SECTION_KEY, BuiltInCatalogInput, @@ -46,7 +47,20 @@ const IntegrationsAvailable = ({ data }: IntegrationsAvailableProps) => { const theme = useTheme(); const [searchParams] = useSearchParams(); const { hasActiveManagers } = useConnectorManagerStatus(); - const { catalogsData, deploymentData, feedsData, formsData, refetchFeeds, refetchForms } = data; + const { + catalogsData, + deploymentData, + feedsData, + formsData, + refetchCatalogs, + refetchFeeds, + refetchForms, + } = data; + + useCatalogPolling({ + enabled: catalogsData !== null, + onCatalogRevisionsChanged: refetchCatalogs, + }); const { catalogState, handleOpenDeployDialog, handleCloseDeployDialog, handleCreate } = useConnectorDeployDialog(); const [builtInCreationKind, setBuiltInCreationKind] = useState(null); diff --git a/opencti-platform/opencti-front/src/private/components/integrations/catalog/IngestionConnectorsCatalog.tsx b/opencti-platform/opencti-front/src/private/components/integrations/catalog/IngestionConnectorsCatalog.tsx index 82261c51e4f3..7fc8f1a48b7a 100644 --- a/opencti-platform/opencti-front/src/private/components/integrations/catalog/IngestionConnectorsCatalog.tsx +++ b/opencti-platform/opencti-front/src/private/components/integrations/catalog/IngestionConnectorsCatalog.tsx @@ -3,6 +3,15 @@ import type { PreloadedQuery } from 'react-relay'; import React from 'react'; import { IngestionConnectorsCatalogsQuery } from '@components/integrations/catalog/__generated__/IngestionConnectorsCatalogsQuery.graphql'; +export const ingestionConnectorsCatalogRevisionsQuery = graphql` + query IngestionConnectorsCatalogRevisionsQuery { + catalogsRevisions { + catalog_id + revision + } + } +`; + export const ingestionConnectorsCatalogsQuery = graphql` query IngestionConnectorsCatalogsQuery { catalogs { diff --git a/opencti-platform/opencti-front/src/private/components/integrations/catalog/catalog-constants.ts b/opencti-platform/opencti-front/src/private/components/integrations/catalog/catalog-constants.ts new file mode 100644 index 000000000000..9b441047a314 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/integrations/catalog/catalog-constants.ts @@ -0,0 +1 @@ +export const CATALOG_POLLING_INTERVAL_MS = 60000; diff --git a/opencti-platform/opencti-front/src/private/components/integrations/catalog/hooks/useCatalogPolling.test.ts b/opencti-platform/opencti-front/src/private/components/integrations/catalog/hooks/useCatalogPolling.test.ts new file mode 100644 index 000000000000..07dd1a4b4d9c --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/integrations/catalog/hooks/useCatalogPolling.test.ts @@ -0,0 +1,181 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CATALOG_POLLING_INTERVAL_MS } from '../catalog-constants'; +import useCatalogPolling from './useCatalogPolling'; + +const mocks = vi.hoisted(() => ({ + fetchQuery: vi.fn(), +})); + +vi.mock('../../../../../relay/environment', () => ({ + fetchQuery: mocks.fetchQuery, +})); + +type RevisionsPayload = { + catalogsRevisions: Array<{ catalog_id: string; revision: string | null }>; +}; + +const setDocumentHidden = (hidden: boolean) => { + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => hidden, + }); +}; + +const mockFetchSequence = (steps: Array) => { + let index = 0; + mocks.fetchQuery.mockImplementation(() => ({ + toPromise: () => { + const current = steps[Math.min(index, steps.length - 1)]; + index += 1; + if (current instanceof Error) { + return Promise.reject(current); + } + return Promise.resolve(current); + }, + })); +}; + +const flushPromises = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe('useCatalogPolling', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + setDocumentHidden(false); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('seeds baseline without triggering a full catalogs refresh', async () => { + mockFetchSequence([ + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + ]); + const onChanged = vi.fn(); + + renderHook(() => useCatalogPolling({ enabled: true, onCatalogRevisionsChanged: onChanged })); + await act(async () => { + await flushPromises(); + }); + + expect(mocks.fetchQuery).toHaveBeenCalledWith( + expect.anything(), + {}, + { fetchPolicy: 'network-only' }, + ); + + expect(onChanged).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(CATALOG_POLLING_INTERVAL_MS); + await flushPromises(); + }); + + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('refreshes catalogs when a revision changes and updates baseline', async () => { + mockFetchSequence([ + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-2' }] }, + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-2' }] }, + ]); + const onChanged = vi.fn(); + + renderHook(() => useCatalogPolling({ enabled: true, onCatalogRevisionsChanged: onChanged })); + await act(async () => { + await flushPromises(); + }); + + await act(async () => { + vi.advanceTimersByTime(CATALOG_POLLING_INTERVAL_MS); + await flushPromises(); + }); + expect(onChanged).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(CATALOG_POLLING_INTERVAL_MS); + await flushPromises(); + }); + expect(onChanged).toHaveBeenCalledTimes(1); + }); + + it('retries after a failed seed without triggering a refresh', async () => { + mockFetchSequence([ + new Error('network down'), + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + ]); + const onChanged = vi.fn(); + + renderHook(() => useCatalogPolling({ enabled: true, onCatalogRevisionsChanged: onChanged })); + await act(async () => { + await flushPromises(); + }); + + await act(async () => { + vi.advanceTimersByTime(CATALOG_POLLING_INTERVAL_MS); + await flushPromises(); + }); + + expect(onChanged).not.toHaveBeenCalled(); + expect(mocks.fetchQuery).toHaveBeenCalledTimes(2); + }); + + it('pauses polling while hidden and performs an immediate check when visible again', async () => { + mockFetchSequence([ + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + ]); + + renderHook(() => useCatalogPolling({ enabled: true, onCatalogRevisionsChanged: vi.fn() })); + await act(async () => { + await flushPromises(); + }); + expect(mocks.fetchQuery).toHaveBeenCalledTimes(1); + + await act(async () => { + setDocumentHidden(true); + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(CATALOG_POLLING_INTERVAL_MS * 2); + await flushPromises(); + }); + expect(mocks.fetchQuery).toHaveBeenCalledTimes(1); + + await act(async () => { + setDocumentHidden(false); + document.dispatchEvent(new Event('visibilitychange')); + await flushPromises(); + }); + expect(mocks.fetchQuery).toHaveBeenCalledTimes(2); + }); + + it('stops polling on unmount', async () => { + mockFetchSequence([ + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + { catalogsRevisions: [{ catalog_id: 'catalog-1', revision: 'rev-1' }] }, + ]); + + const { unmount } = renderHook(() => useCatalogPolling({ enabled: true, onCatalogRevisionsChanged: vi.fn() })); + await act(async () => { + await flushPromises(); + }); + + unmount(); + + await act(async () => { + vi.advanceTimersByTime(CATALOG_POLLING_INTERVAL_MS * 2); + await flushPromises(); + }); + + expect(mocks.fetchQuery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/opencti-platform/opencti-front/src/private/components/integrations/catalog/hooks/useCatalogPolling.ts b/opencti-platform/opencti-front/src/private/components/integrations/catalog/hooks/useCatalogPolling.ts new file mode 100644 index 000000000000..012fd9b78ecc --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/integrations/catalog/hooks/useCatalogPolling.ts @@ -0,0 +1,121 @@ +import { useEffect, useRef } from 'react'; +import { fetchQuery } from '../../../../../relay/environment'; +import { ingestionConnectorsCatalogRevisionsQuery } from '../IngestionConnectorsCatalog'; +import { CATALOG_POLLING_INTERVAL_MS } from '../catalog-constants'; +import type { IngestionConnectorsCatalogRevisionsQuery } from '../__generated__/IngestionConnectorsCatalogRevisionsQuery.graphql'; + +type UseCatalogPollingProps = { + enabled: boolean; + onCatalogRevisionsChanged: () => Promise | void; +}; + +type RevisionByCatalogId = Map; + +const toRevisionMap = ( + revisions: ReadonlyArray<{ catalog_id: string; revision: string | null | undefined }> | null | undefined, +): RevisionByCatalogId => { + return new Map((revisions ?? []).map((entry) => [entry.catalog_id, entry.revision ?? null])); +}; + +const haveRevisionsChanged = (baseline: RevisionByCatalogId, next: RevisionByCatalogId): boolean => { + if (baseline.size !== next.size) { + return true; + } + for (const [catalogId, revision] of next) { + if (!baseline.has(catalogId) || baseline.get(catalogId) !== revision) { + return true; + } + } + return false; +}; + +const useCatalogPolling = ({ enabled, onCatalogRevisionsChanged }: UseCatalogPollingProps) => { + const baselineRef = useRef(null); + const timeoutRef = useRef | null>(null); + const isUnmountedRef = useRef(false); + const isCheckInFlightRef = useRef(false); + const wasPausedRef = useRef(false); + + useEffect(() => { + if (!enabled) { + return undefined; + } + + isUnmountedRef.current = false; + + const clearScheduledCheck = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + const scheduleNextCheck = () => { + if (isUnmountedRef.current || document.hidden) { + return; + } + clearScheduledCheck(); + timeoutRef.current = setTimeout(() => { + void checkCatalogRevisions(); + }, CATALOG_POLLING_INTERVAL_MS); + }; + + const checkCatalogRevisions = async () => { + if (isCheckInFlightRef.current || isUnmountedRef.current || document.hidden) { + return; + } + isCheckInFlightRef.current = true; + try { + const result = await fetchQuery( + ingestionConnectorsCatalogRevisionsQuery, + {}, + { fetchPolicy: 'network-only' }, + ).toPromise().catch(() => null); + + if (!result) { + return; + } + + const nextBaseline = toRevisionMap(result.catalogsRevisions ?? []); + + if (!baselineRef.current) { + baselineRef.current = nextBaseline; + return; + } + + if (!haveRevisionsChanged(baselineRef.current, nextBaseline)) { + return; + } + + await onCatalogRevisionsChanged(); + baselineRef.current = nextBaseline; + } finally { + isCheckInFlightRef.current = false; + scheduleNextCheck(); + } + }; + + const onVisibilityChange = () => { + if (document.hidden) { + wasPausedRef.current = true; + clearScheduledCheck(); + return; + } + if (wasPausedRef.current) { + wasPausedRef.current = false; + void checkCatalogRevisions(); + } + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + void checkCatalogRevisions(); + + return () => { + isUnmountedRef.current = true; + clearScheduledCheck(); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [enabled, onCatalogRevisionsChanged]); +}; + +export default useCatalogPolling; diff --git a/opencti-platform/opencti-front/src/relay/environment.tsx b/opencti-platform/opencti-front/src/relay/environment.tsx index d49a8a1ddf67..a0c2efdc4bba 100644 --- a/opencti-platform/opencti-front/src/relay/environment.tsx +++ b/opencti-platform/opencti-front/src/relay/environment.tsx @@ -121,6 +121,8 @@ interface QueryRendererProps { fetchPolicy?: FetchPolicy; } +type FetchQueryOptions = NonNullable[3]>; + export const QueryRenderer = ({ variables, query, @@ -227,7 +229,8 @@ export const requestSubscription = (args) => RS(environment, args); export const fetchQuery = ( query: GraphQLTaggedNode, args: T['variables'] = {}, -) => FQ(environment, query, args); + options?: FetchQueryOptions, +) => FQ(environment, query, args, options); export const commitLocalUpdate = (updater: SelectorStoreUpdater) => CLU(environment, updater); diff --git a/opencti-platform/opencti-front/src/schema/relay.schema.graphql b/opencti-platform/opencti-front/src/schema/relay.schema.graphql index 3edda2c6b229..65caf4c5d5f6 100644 --- a/opencti-platform/opencti-front/src/schema/relay.schema.graphql +++ b/opencti-platform/opencti-front/src/schema/relay.schema.graphql @@ -9469,6 +9469,7 @@ type Query { channels(first: Int, after: ID, orderBy: ChannelsOrdering, orderMode: OrderingMode, filters: FilterGroup, search: String): ChannelConnection catalog(id: String!): Catalog catalogs: [Catalog!]! + catalogsRevisions: [CatalogRevision!]! contract(slug: String!): ExtendedContract language(id: String!): Language languages(first: Int, after: ID, orderBy: LanguagesOrdering, orderMode: OrderingMode, filters: FilterGroup, search: String): LanguageConnection @@ -10968,6 +10969,11 @@ type ExtendedContract { contract: String! } +type CatalogRevision { + catalog_id: String! + revision: String +} + enum CatalogsOrdering { name _score diff --git a/opencti-platform/opencti-front/vite.config.ts b/opencti-platform/opencti-front/vite.config.ts index 2902a648bfb1..e0bef487e167 100644 --- a/opencti-platform/opencti-front/vite.config.ts +++ b/opencti-platform/opencti-front/vite.config.ts @@ -77,6 +77,7 @@ export default defineConfig(({ mode, command }) => { [`${basePath}/logout`]: backProxy(), [`${basePath}/stream`]: backProxy(), [`${basePath}/storage`]: backProxy(), + [`${basePath}/catalog`]: backProxy(), [`${basePath}/schema`]: backProxy(), '^/.*/embedded/.*': backProxy(), [`${basePath}/taxii2`]: backProxy(), diff --git a/opencti-platform/opencti-graphql/config/default.json b/opencti-platform/opencti-graphql/config/default.json index ad7032cd39fa..a7a9463c6952 100644 --- a/opencti-platform/opencti-graphql/config/default.json +++ b/opencti-platform/opencti-graphql/config/default.json @@ -373,6 +373,13 @@ "lock_key": "exclusion_list_cache_sync_manager_lock", "interval": 10000 }, + "catalog_manager": { + "enabled": true, + "lock_key": "catalog_manager_lock", + "interval": 60000, + "custom_catalog_refresh_endpoint_uri": null, + "catalog_sync_remote_timeout": 30000 + }, "redis": { "mode": "single", "namespace": "", diff --git a/opencti-platform/opencti-graphql/config/schema/opencti.graphql b/opencti-platform/opencti-graphql/config/schema/opencti.graphql index e0c94f1a27c0..f3ae008f553d 100644 --- a/opencti-platform/opencti-graphql/config/schema/opencti.graphql +++ b/opencti-platform/opencti-graphql/config/schema/opencti.graphql @@ -2249,7 +2249,7 @@ type Connector implements BasicObject & InternalObject { manager_current_status: String manager_requested_status: String manager_contract_image: String - manager_contract_definition: String + manager_contract_definition: String # JSON.Stringified GraphqlCatalogContract item manager_contract_excerpt: ManagerContractExcerpt manager_contract_configuration: [ManagerContractConfiguration!] manager_connector_logs: [String!] diff --git a/opencti-platform/opencti-graphql/src/config/conf.js b/opencti-platform/opencti-graphql/src/config/conf.js index bbd80d288578..f047b2746318 100644 --- a/opencti-platform/opencti-graphql/src/config/conf.js +++ b/opencti-platform/opencti-graphql/src/config/conf.js @@ -542,6 +542,7 @@ export const ENABLED_TRACING = booleanConf('app:telemetry:tracing:enabled', fals export const ENABLED_METRICS = booleanConf('app:telemetry:metrics:enabled', false); export const ENABLED_NOTIFICATION_MANAGER = booleanConf('notification_manager:enabled', true); export const ENABLED_PUBLISHER_MANAGER = booleanConf('publisher_manager:enabled', true); +export const ENABLED_CATALOG_MANAGER = booleanConf('catalog_manager:enabled', true); export const ENABLED_CONNECTOR_MANAGER = booleanConf('connector_manager:enabled', true); export const ENABLED_FILE_INDEX_MANAGER = booleanConf('file_index_manager:enabled', true); diff --git a/opencti-platform/opencti-graphql/src/database/engine-mapping-generator.ts b/opencti-platform/opencti-graphql/src/database/engine-mapping-generator.ts index 32b318e70529..3c2c745aba04 100644 --- a/opencti-platform/opencti-graphql/src/database/engine-mapping-generator.ts +++ b/opencti-platform/opencti-graphql/src/database/engine-mapping-generator.ts @@ -48,6 +48,10 @@ const attributeMappingGenerator = (entityAttribute: AttributeDefinition, engine: if (entityAttribute.format === 'flat') { return { type: engine instanceof ElkClient ? 'flattened' : 'flat_object' }; } + // For non-indexed objects + if (entityAttribute.format === 'raw') { + return { type: 'object', enabled: false }; + } // For standard object const properties: Record = {}; for (let i = 0; i < entityAttribute.mappings.length; i += 1) { diff --git a/opencti-platform/opencti-graphql/src/database/engine.ts b/opencti-platform/opencti-graphql/src/database/engine.ts index 2a6c968f18a3..0bc61817bfbd 100644 --- a/opencti-platform/opencti-graphql/src/database/engine.ts +++ b/opencti-platform/opencti-graphql/src/database/engine.ts @@ -131,6 +131,7 @@ import { isDateNumericOrBooleanAttribute, isNumericAttribute, isObjectFlatAttribute, + isObjectRawAttribute, schemaAttributesDefinition, validateDataBeforeIndexing, } from '../schema/schema-attributes'; @@ -3204,7 +3205,7 @@ export const elLoadBy = async ( user: AuthUser, field: string, value: any, - type = null, + type: string | null = null, indices: string[] = READ_DATA_INDICES, ) => { const filters = { @@ -4493,7 +4494,7 @@ export const prepareElementForIndexing = async (element: Record) => thing[key] = typeof value === 'boolean' ? value : value?.toLowerCase() === 'true'; } else if (isNumericAttribute(key)) { thing[key] = isNotEmptyField(value) ? Number(value) : undefined; - } else if (R.is(Object, value) && Object.keys(value).length > 0) { // For complex object, prepare inner elements + } else if (R.is(Object, value) && Object.keys(value).length > 0 && !isObjectRawAttribute(key)) { // For complex object, prepare inner elements thing[key] = await prepareElementForIndexing(value); } else if (R.is(String, value)) { // For string, trim by default thing[key] = value.trim(); diff --git a/opencti-platform/opencti-graphql/src/database/repository.js b/opencti-platform/opencti-graphql/src/database/repository.js index 603446259b26..f30a7853ed3c 100644 --- a/opencti-platform/opencti-graphql/src/database/repository.js +++ b/opencti-platform/opencti-graphql/src/database/repository.js @@ -9,11 +9,11 @@ import { BUILTIN_NOTIFIERS_CONNECTORS } from '../modules/notifier/notifier-stati import { builtInConnector, builtInConnectorsRuntime } from '../connector/connector-domain'; import { ENTITY_TYPE_PLAYBOOK } from '../modules/playbook/playbook-types'; import { shortHash } from '../schema/schemaUtils'; -import { encryptValue, getSupportedContractsByImage } from '../modules/catalog/catalog-domain'; +import { encryptValue, mapContractEntityFieldsToGraphqlCatalogContract } from '../modules/catalog/catalog-domain'; import { ENTITY_TYPE_PIR } from '../modules/pir/pir-types'; import { getEntitiesMapFromCache } from './cache'; import { SYSTEM_USER } from '../utils/access'; -import conf, { booleanConf, logApp } from '../config/conf'; +import conf, { booleanConf } from '../config/conf'; import { ConnectorPriorityGroup } from '../generated/graphql'; import { injectProxyConfiguration } from '../config/proxy-config'; import { getPlatformCrypto } from '../utils/platformCrypto'; @@ -22,7 +22,7 @@ import { memoize } from '../utils/memoize'; import { addUserTokenByAdmin, revokeUserTokenByAdmin } from '../modules/user/user-domain'; import { getClientBase } from './redis'; import { lockResources } from '../lock/master-lock'; -import { LockTimeoutError, TYPE_LOCK_ERROR } from '../config/errors'; +import { FunctionalError, LockTimeoutError, TYPE_LOCK_ERROR } from '../config/errors'; const getJWTKeyPair = memoize(async () => { const factory = await getPlatformCrypto(); @@ -76,28 +76,22 @@ export const connector = async (context, user, id) => { }; export const computeManagerConnectorContract = async (_context, _user, cn) => { - const contracts = await getSupportedContractsByImage(); - const contract = contracts.get(cn.manager_contract_image); - return contract ? JSON.stringify(contract) : contract; -}; - -export const computeManagerConnectorExcerpt = async (_context, _user, cn) => { - if (!cn.manager_contract_image) { + if (!cn.manager_contract) { return null; } + return JSON.stringify(mapContractEntityFieldsToGraphqlCatalogContract(cn.manager_contract, { excludeRuntimeConfigVars: true })); +}; - const contracts = await getSupportedContractsByImage(); - const contract = contracts.get(cn.manager_contract_image); - - if (!contract) { - logApp.warn('No contract found for', { connectorName: cn.name }); +export const computeManagerConnectorExcerpt = async (_context, _user, cn) => { + if (!cn.manager_contract) { return null; } + const managerContract = cn.manager_contract; return { - title: contract.title, - slug: contract.slug, - logo: contract.logo, + title: managerContract.title, + slug: managerContract.slug, + logo: managerContract.logo_uri ?? '', }; }; @@ -171,10 +165,18 @@ export const computeManagerConnectorConfiguration = async (context, _, cn, { wit }; export const computeManagerConnectorImage = async (cn) => { - const contracts = await getSupportedContractsByImage(); - const contract = contracts.get(cn.manager_contract_image); - if (!contract) return ''; - return isNotEmptyField(cn.manager_contract_image) ? `${cn.manager_contract_image}:${contract.container_version}` : null; + if (!cn.manager_contract) { + return ''; + } + const managerContract = cn.manager_contract; + if (!managerContract.image || !managerContract.contract_version) { + throw FunctionalError('Invalid manager contract snapshot', { + connectorId: cn.id ?? cn.internal_id, + image: managerContract.image, + version: managerContract.contract_version, + }); + } + return `${managerContract.image}:${managerContract.contract_version}`; }; export const computeManagerContractHash = async (context, user, cn) => { diff --git a/opencti-platform/opencti-graphql/src/domain/connector-migration.ts b/opencti-platform/opencti-graphql/src/domain/connector-migration.ts index 45b1bcaf5683..81144ee9df50 100644 --- a/opencti-platform/opencti-graphql/src/domain/connector-migration.ts +++ b/opencti-platform/opencti-graphql/src/domain/connector-migration.ts @@ -7,13 +7,19 @@ import { completeConnector, connector, connectors } from '../database/repository import type { Connector, ConnectorContractConfiguration, ContractConfigInput } from '../generated/graphql'; import { publishUserAction } from '../listener/UserActionListener'; import { addConnectorDeployedCount } from '../manager/telemetryManager'; -import { computeConnectorTargetContract, findContractByContainerImage } from '../modules/catalog/catalog-domain'; +import { + computeConnectorTargetContract, + mapContractEntityFieldsToEmbeddedConnectorManagerContract, + mapContractEntityFieldsToGraphqlCatalogContract, +} from '../modules/catalog/catalog-domain'; import { ABSTRACT_INTERNAL_OBJECT } from '../schema/general'; import { ENTITY_TYPE_CONNECTOR, ENTITY_TYPE_CONNECTOR_MANAGER } from '../schema/internalObject'; import type { BasicStoreEntityConnectorManager } from '../types/connector'; import type { AuthContext, AuthUser } from '../types/user'; import { isServiceAccountUser } from '../utils/access'; import { resolveUserByIdFromCache, userEditField } from './user'; +import { now } from '../utils/format'; +import { findLatestCompatibleCatalogContractByImageName } from '../modules/catalog/catalog-repository'; type ConfigInput = { key: string; @@ -22,6 +28,7 @@ type ConfigInput = { type MappedKey = { key: string; + sourceKey: string; value: string; type: string; required: boolean; @@ -89,6 +96,7 @@ const categorizeKeys = ( if (value !== null && value !== undefined) { mapped.push({ key: schemaKey, + sourceKey: keyUpper, value: String(value), type: propSchema.type, required: requiredKeys.includes(schemaKey), @@ -139,17 +147,12 @@ export const assessConnectorMigration = async (context: AuthContext, user: AuthU throw FunctionalError('Connector is already managed', { id: connectorId }); } - const contractData = await findContractByContainerImage(context, user, containerImage); + const contractData = await findLatestCompatibleCatalogContractByImageName(context, user, containerImage); if (!contractData) { throw FunctionalError('Contract not found', { container_image: containerImage }); } - let contract; - try { - contract = JSON.parse(contractData.contract); - } catch { - throw FunctionalError('Cannot parse contract found'); - } + const contract = mapContractEntityFieldsToGraphqlCatalogContract(contractData, { excludeRuntimeConfigVars: true }); // Check type are correct if (existingConnector.connector_type !== contract.container_type) { @@ -185,7 +188,7 @@ export const assessConnectorMigration = async (context: AuthContext, user: AuthU connector_id: connectorId, connector_name: existingConnector.name, connector_type: existingConnector.connector_type, - contract_slug: contract.contract_slug, + contract_slug: contract.slug, contract_title: contract.title, contract_image: contract.container_image, summary: { @@ -193,6 +196,7 @@ export const assessConnectorMigration = async (context: AuthContext, user: AuthU mapped_keys: mapped.length, ignored_keys: ignored.length, missing_mandatory_keys: missingMandatory.length, + assessment_date: now(), missing_optional_keys: missing.length - missingMandatory.length, can_migrate: missingMandatory.length === 0, configuration_provided: configuration !== null, @@ -251,17 +255,12 @@ export const migrateConnectorToManaged = async ( convertUserToServiceAccount: boolean = true, resetConnectorState: boolean = false, ) => { - const contractData = await findContractByContainerImage(context, user, containerImage); + const contractData = await findLatestCompatibleCatalogContractByImageName(context, user, containerImage); if (!contractData) { throw FunctionalError('Contract not found', { container_image: containerImage }); } - let contract; - try { - contract = JSON.parse(contractData.contract); - } catch { - throw FunctionalError('Cannot parse contract'); - } + const contract = mapContractEntityFieldsToGraphqlCatalogContract(contractData, { excludeRuntimeConfigVars: true }); if (!contract.manager_supported) { throw FunctionalError('Connector is not managed by composer'); @@ -304,7 +303,7 @@ export const migrateConnectorToManaged = async ( ); const invalidKeys: string[] = []; - userConfig.forEach((value: string, key: string) => { + userConfig.forEach((_value: string, key: string) => { if (!schemaKeysUpper.has(key)) { invalidKeys.push(key); } @@ -362,6 +361,7 @@ export const migrateConnectorToManaged = async ( title: existingConnector.name, catalog_id: contractData.catalog_id, manager_contract_image: contract.container_image, + manager_contract: mapContractEntityFieldsToEmbeddedConnectorManagerContract(contractData), manager_contract_configuration: filteredConfigurations, manager_requested_status: 'stopped', }; diff --git a/opencti-platform/opencti-graphql/src/domain/connector.ts b/opencti-platform/opencti-graphql/src/domain/connector.ts index 550025a56234..ef515924ae0c 100644 --- a/opencti-platform/opencti-graphql/src/domain/connector.ts +++ b/opencti-platform/opencti-graphql/src/domain/connector.ts @@ -55,7 +55,7 @@ import { isCompatibleVersionWithMinimal } from '../utils/version'; import { extractEntityRepresentativeName } from '../database/entity-representative'; import type { BasicStoreCommon, StoreEntity } from '../types/store'; import { addConnectorDeployedCount, addWorkbenchDraftConvertionCount, addWorkbenchValidationCount } from '../manager/telemetryManager'; -import { computeConnectorTargetContract, getSupportedContractsByImage } from '../modules/catalog/catalog-domain'; +import { computeConnectorTargetContract, mapContractEntityFieldsToEmbeddedConnectorManagerContract } from '../modules/catalog/catalog-domain'; import { getEntitiesMapFromCache } from '../database/cache'; import { createOnTheFlyUser } from '../modules/user/user-domain'; @@ -67,6 +67,7 @@ import { extractContentFrom } from '../utils/fileToContent'; import type { FileHandle } from 'fs/promises'; import { encryptSynchronizerCredential } from './connector-sync-crypto'; import { verifyIngestionUri } from '../modules/ingestion/ingestion-common'; +import { findLatestCompatibleCatalogContractByImageName } from '../modules/catalog/catalog-repository'; const MINIMAL_SYNCHRONIZER_COMPATIBLE_VERSION = '6.9.6'; // Sanitize name for K8s/Docker @@ -202,13 +203,12 @@ export const managedConnectorEdit = async ( user: AuthUser, input: EditManagedConnectorInput, ) => { - const conn: any = await storeLoadById(context, user, input.id, ENTITY_TYPE_CONNECTOR); + const conn = await storeLoadById(context, user, input.id, ENTITY_TYPE_CONNECTOR); if (isEmptyField(conn)) { throw UnsupportedError('Connector not found', { id: input.id }); } - const contractsMap = await getSupportedContractsByImage(); - const targetContract: any = contractsMap.get(conn.manager_contract_image); - if (isEmptyField(targetContract)) { + const targetContract = conn.manager_contract; + if (!targetContract) { throw UnsupportedError('Target contract not found'); } const connectorManagers = await fullEntitiesList(context, user, [ENTITY_TYPE_CONNECTOR_MANAGER]); @@ -225,7 +225,7 @@ export const managedConnectorEdit = async ( const patch: any = { name: input.name, title: input.title, - connector_type: targetContract.container_type, + connector_type: targetContract.connector_type, connector_user_id: input.connector_user_id, manager_contract_configuration: contractConfigurations, }; @@ -258,8 +258,7 @@ export const managedConnectorAdd = async ( input: AddManagedConnectorInput, ) => { // Get contract - const contractsMap = await getSupportedContractsByImage(); - const targetContract: any = contractsMap.get(input.manager_contract_image); + const targetContract = await findLatestCompatibleCatalogContractByImageName(context, user, input.manager_contract_image); if (isEmptyField(targetContract)) { throw UnsupportedError('Target contract not found'); } @@ -306,11 +305,12 @@ export const managedConnectorAdd = async ( const connectorToCreate: any = { title: input.name, name: sanitizedName, - connector_type: targetContract.container_type, + connector_type: targetContract.connector_type, catalog_id: input.catalog_id, connector_user_id: connectorUser.id, manager_contract_image: input.manager_contract_image, manager_contract_configuration: contractConfigurations, + manager_contract: mapContractEntityFieldsToEmbeddedConnectorManagerContract(targetContract), manager_requested_status: 'stopped', connector_state_timestamp: now(), built_in: false, diff --git a/opencti-platform/opencti-graphql/src/domain/filterKeysSchema.ts b/opencti-platform/opencti-graphql/src/domain/filterKeysSchema.ts index 772bc777e99f..0ce3416e6808 100644 --- a/opencti-platform/opencti-graphql/src/domain/filterKeysSchema.ts +++ b/opencti-platform/opencti-graphql/src/domain/filterKeysSchema.ts @@ -63,7 +63,7 @@ import { CUSTOM_FIELDS_FEATURE_FLAG, isFeatureEnabled } from '../config/conf'; export type FilterDefinition = { filterKey: string; - type: string; // possible values: boolean, date, integer, float, string, id, vocabulary, text, enum, object, nested + type: string; // possible values: boolean, date, integer, float, version, string, id, vocabulary, text, enum, object, nested label: string; // filter key translation in English multiple: boolean; // if the field can have multiple values subEntityTypes: string[]; // entity types that have the given type as parent and have this filter key in their schema diff --git a/opencti-platform/opencti-graphql/src/generated/graphql.ts b/opencti-platform/opencti-graphql/src/generated/graphql.ts index 8beaa3bc28ac..e796597d9a55 100644 --- a/opencti-platform/opencti-graphql/src/generated/graphql.ts +++ b/opencti-platform/opencti-graphql/src/generated/graphql.ts @@ -3880,6 +3880,12 @@ export type CatalogEdge = { node: Catalog; }; +export type CatalogRevision = { + __typename?: 'CatalogRevision'; + catalog_id: Scalars['String']['output']; + revision?: Maybe; +}; + export enum CatalogsOrdering { Score = '_score', Name = 'name' @@ -24752,6 +24758,7 @@ export type Query = { cases?: Maybe; catalog?: Maybe; catalogs: Array; + catalogsRevisions: Array; channel?: Maybe; channels?: Maybe; cities?: Maybe; @@ -40617,6 +40624,7 @@ export type ResolversTypes = ResolversObject<{ Catalog: ResolverTypeWrapper; CatalogConnection: ResolverTypeWrapper & { edges: Array }>; CatalogEdge: ResolverTypeWrapper & { node: ResolversTypes['Catalog'] }>; + CatalogRevision: ResolverTypeWrapper; CatalogsOrdering: CatalogsOrdering; CertAuthConfig: ResolverTypeWrapper; CertAuthConfigInput: CertAuthConfigInput; @@ -41788,6 +41796,7 @@ export type ResolversParentTypes = ResolversObject<{ Catalog: GraphqlCatalog; CatalogConnection: Omit & { edges: Array }; CatalogEdge: Omit & { node: ResolversParentTypes['Catalog'] }; + CatalogRevision: CatalogRevision; CertAuthConfig: CertAuthConfig; CertAuthConfigInput: CertAuthConfigInput; ChangePasswordInput: ChangePasswordInput; @@ -43954,6 +43963,11 @@ export type CatalogEdgeResolvers; }>; +export type CatalogRevisionResolvers = ResolversObject<{ + catalog_id?: Resolver; + revision?: Resolver, ParentType, ContextType>; +}>; + export type CertAuthConfigResolvers = ResolversObject<{ button_label_override?: Resolver, ParentType, ContextType>; description?: Resolver, ParentType, ContextType>; @@ -50597,6 +50611,7 @@ export type QueryResolvers, ParentType, ContextType, Partial>; catalog?: Resolver, ParentType, ContextType, RequireFields>; catalogs?: Resolver, ParentType, ContextType>; + catalogsRevisions?: Resolver, ParentType, ContextType>; channel?: Resolver, ParentType, ContextType, RequireFields>; channels?: Resolver, ParentType, ContextType, Partial>; cities?: Resolver, ParentType, ContextType, Partial>; @@ -54873,6 +54888,7 @@ export type Resolvers = ResolversObject<{ Catalog?: CatalogResolvers; CatalogConnection?: CatalogConnectionResolvers; CatalogEdge?: CatalogEdgeResolvers; + CatalogRevision?: CatalogRevisionResolvers; CertAuthConfig?: CertAuthConfigResolvers; Channel?: ChannelResolvers; ChannelConnection?: ChannelConnectionResolvers; diff --git a/opencti-platform/opencti-graphql/src/http/httpPlatform.js b/opencti-platform/opencti-graphql/src/http/httpPlatform.js index c589cb16c712..d2dda29cf020 100644 --- a/opencti-platform/opencti-graphql/src/http/httpPlatform.js +++ b/opencti-platform/opencti-graphql/src/http/httpPlatform.js @@ -52,6 +52,7 @@ import { CERT_PROVIDER } from '../modules/authenticationProvider/provider-cert'; import { HEADERS_PROVIDER } from '../modules/authenticationProvider/provider-headers'; import { AuthenticationProviderError } from '../modules/authenticationProvider/providers-logger'; import { buildDefaultHelmetParameters, buildPublicHelmetParameters } from './httpUtils'; +import { CATALOG_LOGO_VIEW_PATH, handleCatalogLogoViewRequest } from '../modules/catalog/catalog-http'; const publicDir = DEV_MODE ? '../opencti-front/dist' : 'public'; @@ -300,6 +301,9 @@ const createApp = async (app, schema) => { } }); + // -- Catalog logo view + app.get(`${basePath}${CATALOG_LOGO_VIEW_PATH}`, handleCatalogLogoViewRequest); + // -- embedded loader const uuidPattern = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; const embeddedFileGetPath = new RegExp(`${basePath}/(.*)/(${uuidPattern})/(.*)embedded/(.*)$`, 'i'); diff --git a/opencti-platform/opencti-graphql/src/manager/index.ts b/opencti-platform/opencti-graphql/src/manager/index.ts index 66e1d53ff9a6..ca490ba82c32 100644 --- a/opencti-platform/opencti-graphql/src/manager/index.ts +++ b/opencti-platform/opencti-graphql/src/manager/index.ts @@ -9,3 +9,5 @@ import './retentionManager'; import './telemetryManager'; import './workflowStatusCleanupManager'; import './xtmOneRegistrationManager'; +import './dataSanityManager'; +import '../modules/catalog/catalog-manager'; diff --git a/opencti-platform/opencti-graphql/src/manager/managerModule.ts b/opencti-platform/opencti-graphql/src/manager/managerModule.ts index ff7e21d909ba..ee8183cbf1e1 100644 --- a/opencti-platform/opencti-graphql/src/manager/managerModule.ts +++ b/opencti-platform/opencti-graphql/src/manager/managerModule.ts @@ -63,6 +63,7 @@ const initManager = (manager: ManagerDefinition) => { let streamProcessor: StreamProcessor; let running = false; let shutdown = false; + let enterpriseBlockedLogged = false; const cronTimer = new InterruptibleTimer(); const streamTimer = new InterruptibleTimer(); @@ -70,8 +71,13 @@ const initManager = (manager: ManagerDefinition) => { const cronHandler = async (cronInputFn?: () => Promise) => { if (manager.cronSchedulerHandler) { if (!(await isEnterpriseEditionAuthorized(manager))) { + if (!enterpriseBlockedLogged) { + logApp.info(`[OPENCTI-MODULE] ${manager.label} not running (enterprise edition license required)`); + enterpriseBlockedLogged = true; + } return; } + enterpriseBlockedLogged = false; let lock; let cronInput; const startDate = utcDate(); @@ -113,8 +119,13 @@ const initManager = (manager: ManagerDefinition) => { const streamHandler = async () => { if (manager.streamSchedulerHandler) { if (!(await isEnterpriseEditionAuthorized(manager))) { + if (!enterpriseBlockedLogged) { + logApp.info(`[OPENCTI-MODULE] ${manager.label} not running (enterprise edition license required)`); + enterpriseBlockedLogged = true; + } return; } + enterpriseBlockedLogged = false; let lock; try { // Lock the manager diff --git a/opencti-platform/opencti-graphql/src/manager/telemetryManager.ts b/opencti-platform/opencti-graphql/src/manager/telemetryManager.ts index 33f04de1ba5f..a93f2de1cafb 100644 --- a/opencti-platform/opencti-graphql/src/manager/telemetryManager.ts +++ b/opencti-platform/opencti-graphql/src/manager/telemetryManager.ts @@ -57,7 +57,6 @@ import { ENTITY_TYPE_INGESTION_TAXII_COLLECTION, } from '../modules/ingestion/ingestion-types'; import { ENTITY_TYPE_MANAGER_CONFIGURATION } from '../modules/managerConfiguration/managerConfiguration-types'; -import { getSupportedContractsByImage } from '../modules/catalog/catalog-domain'; import { FilterMode } from '../generated/graphql'; import { redisClearTelemetry, redisGetTelemetry, redisSetTelemetryAdd } from '../database/redis'; import { countOffloadedStreamEvents, rawFetchStreamInfo } from '../database/redis-stream'; @@ -496,8 +495,7 @@ export const fetchTelemetryData = async (manager: TelemetryMeterManager) => { // composer-managed connectors resolve to the catalog contract slug // through their stored container image; manually registered connectors // fall back to their registered name, flagged managed=false. - const contractsByImage = await getSupportedContractsByImage(); - manager.setActiveConnectorsByIdentity(computeActiveConnectorsByIdentity(activeConnectors, contractsByImage)); + manager.setActiveConnectorsByIdentity(computeActiveConnectorsByIdentity(activeConnectors)); // endregion // region Roles with draft capability information diff --git a/opencti-platform/opencti-graphql/src/migrations/1789637887081-embed-catalog-contract-in-connector-entity.ts b/opencti-platform/opencti-graphql/src/migrations/1789637887081-embed-catalog-contract-in-connector-entity.ts new file mode 100644 index 000000000000..af61cb5356b7 --- /dev/null +++ b/opencti-platform/opencti-graphql/src/migrations/1789637887081-embed-catalog-contract-in-connector-entity.ts @@ -0,0 +1,84 @@ +import { logMigration } from '../config/conf'; +import { READ_INDEX_INTERNAL_OBJECTS } from '../database/utils'; +import { FilterMode } from '../generated/graphql'; +import { fullEntitiesList } from '../database/middleware-loader'; +import { executionContext, SYSTEM_USER } from '../utils/access'; +import { ENTITY_TYPE_CONNECTOR } from '../schema/internalObject'; +import { patchAttribute } from '../database/middleware'; +import type { BasicStoreEntityConnector } from '../types/connector'; +import openCtiManifest from '../__generated__/opencti-manifest.json'; +import type { CatalogContract, CatalogDefinition } from '../modules/catalog/catalog-types'; +import { mapContractDtoV0ToContractEntityFields } from '../modules/catalog/catalog-domain'; +import { listCatalogContractLogos, storeCatalogContractLogo } from '../modules/catalog/catalog-logo-storage'; +import { computeContractContentHash } from '../modules/catalog/sync/catalog-sync-domain'; +import { mapCatalogContractDtoToCatalogContractSyncSource } from '../modules/catalog/sync/catalog-sync-source-gateway'; + +const message = '[MIGRATION] managed connectors contract snapshot'; + +export const up = async (next: (error?: Error) => void) => { + const start = new Date().getTime(); + logMigration.info(`${message} > started`); + const context = executionContext('migration'); + + const filigranCatalog = openCtiManifest as unknown as CatalogDefinition; + + // Load all managed connectors which have their contract definition in the embedded catalog manifest + const managedConnectorsArgs = { + indices: [READ_INDEX_INTERNAL_OBJECTS], + filters: { mode: FilterMode.And, filters: [{ key: ['catalog_id'], values: [filigranCatalog.id] }], filterGroups: [] }, + }; + const managedConnectors = await fullEntitiesList(context, SYSTEM_USER, [ENTITY_TYPE_CONNECTOR], managedConnectorsArgs); + logMigration.info(`${message} > ${managedConnectors.length} managed connector(s) to evaluate`); + + // Read the contracts from the embedded catalog file + const contractsByImage: Record = {}; + filigranCatalog.contracts.forEach((contract: CatalogContract) => { + contractsByImage[contract.container_image] = contract; + }); + + const storedLogos = managedConnectors.length > 0 ? (await listCatalogContractLogos()) : new Set(); + for (let i = 0; i < managedConnectors.length; i += 1) { + const connector = managedConnectors[i]; + logMigration.info(`${message} > processing connector ${connector.id} (${i + 1}/${managedConnectors.length})`); + const patch: Pick = {}; + if (!connector.manager_contract && connector.manager_contract_image) { + const contractDto = contractsByImage[connector.manager_contract_image]; + if (!contractDto) { + logMigration.info(`${message} > connector ${connector.id} missing catalog contract ${connector.manager_contract_image}`); + } else { + const result = await storeCatalogContractLogo(contractDto, storedLogos); + if (result.result === 'failed') { + logMigration.info(`${message} > failed to store logo into file storage for connector ${connector.id} (${result.error?.message ?? 'Unknown error'})`); + } else if (result.result === 'success') { + if (result.existed) { + logMigration.info(`${message} > deduped logo file for connector ${connector.id}`); + } else { + storedLogos.add(result.filename); + } + } + const contractContentHash = computeContractContentHash( + mapCatalogContractDtoToCatalogContractSyncSource(contractDto), + ); + patch.manager_contract = mapContractDtoV0ToContractEntityFields({ + catalogId: filigranCatalog.id, + contractDto, + contractContentHash, + logoUri: result.logoUri, + }); + } + } + if (!connector.manager_upgrade_strategy) { + patch.manager_upgrade_strategy = 'latest'; + } + if (Object.keys(patch).length > 0) { + await patchAttribute(context, SYSTEM_USER, connector.id, ENTITY_TYPE_CONNECTOR, patch); + logMigration.info(`${message} > connector ${connector.id} patched`); + } + } + logMigration.info(`${message} > done in ${new Date().getTime() - start} ms`); + next(); +}; + +export const down = async (next: (error?: Error) => void) => { + next(); +}; diff --git a/opencti-platform/opencti-graphql/src/modules/attributes/internalObject-registrationAttributes.ts b/opencti-platform/opencti-graphql/src/modules/attributes/internalObject-registrationAttributes.ts index 0cfd596a4fba..03c55002ab75 100644 --- a/opencti-platform/opencti-graphql/src/modules/attributes/internalObject-registrationAttributes.ts +++ b/opencti-platform/opencti-graphql/src/modules/attributes/internalObject-registrationAttributes.ts @@ -41,6 +41,7 @@ import { schemaAttributesDefinition } from '../../schema/schema-attributes'; import { ENTITY_TYPE_MARKING_DEFINITION } from '../../schema/stixMetaObject'; import { ENTITY_TYPE_IDENTITY_ORGANIZATION } from '../organization/organization-types'; import { ENTITY_TYPE_PIR } from '../pir/pir-types'; +import { CATALOG_CONTRACT_MAPPINGS } from '../catalog/catalog'; const HistoryDefinition: AttributeDefinition[] = [ { name: 'event_type', label: 'Event type', type: 'string', format: 'enum', values: EVENT_TYPE_VALUES, editDefault: false, mandatoryType: 'internal', multiple: false, upsert: false, isFilterable: true }, @@ -464,6 +465,9 @@ const internalObjectsAttributes: { [k: string]: Array> { name: 'manager_requested_status', label: 'Connector manager requested status', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: true }, { name: 'manager_contract_image', label: 'Connector manager image', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: true }, { name: 'manager_contract_configuration', label: 'Connector manager', type: 'object', format: 'flat', mandatoryType: 'no', editDefault: false, multiple: true, upsert: false, isFilterable: true }, + { name: 'manager_contract', label: 'Connector manager contract', type: 'object', format: 'standard', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: false, mappings: CATALOG_CONTRACT_MAPPINGS }, + { name: 'manager_upgrade_strategy', label: 'Connector upgrade strategy', type: 'string', format: 'enum', values: ['latest'], mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + // endregion ], [ENTITY_TYPE_CONNECTOR_MANAGER]: [ diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-domain.ts b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-domain.ts index ed6215dab673..beeb3e9d6b87 100644 --- a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-domain.ts +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-domain.ts @@ -2,24 +2,67 @@ import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import crypto from 'crypto'; import type { AuthContext, AuthUser } from '../../types/user'; -import { type CatalogContract, type CatalogDefinition, type CatalogType } from './catalog-types'; +import { + type BasicStoreEntityCatalogContract, + type BasicStoreEntityCatalog, + type CatalogContract, + type CatalogContractEntityFields, + type GraphqlCatalog, + type GraphqlCatalogRevision, + type GraphqlCatalogContract, +} from './catalog-types'; import { isEmptyField } from '../../database/utils'; import { UnsupportedError } from '../../config/errors'; -import { idGenFromData } from '../../schema/identifier'; -import filigranCatalog from '../../__generated__/opencti-manifest.json'; -import conf, { logApp } from '../../config/conf'; import type { ConnectorContractConfiguration, ContractConfigInput } from '../../generated/graphql'; -import { readFile } from 'node:fs/promises'; import type { ValidateFunction } from 'ajv'; +import { + findAllCatalogs, + findCatalogByCatalogId, + findCatalogsRevisions, + findLatestCompatibleCatalogContractBySlug, + findLatestCompatibleCatalogContractsByCatalogId, +} from './catalog-repository'; +import { logApp } from '../../config/conf'; const validatorCache = new Map(); +const EXCLUDED_CONFIG_VARS = ['OPENCTI_TOKEN', 'OPENCTI_URL', 'CONNECTOR_TYPE', 'CONNECTOR_RUN_AND_TERMINATE']; + +const normalizeContractConfigSchema = ( + configSchema: CatalogContract['config_schema'] | null | undefined, +): CatalogContract['config_schema'] => { + const schema = (configSchema && typeof configSchema === 'object') ? configSchema : {} as CatalogContract['config_schema']; + const properties = (schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)) + ? schema.properties + : {}; + const required = Array.isArray(schema.required) ? schema.required.filter((property) => typeof property === 'string') : []; + return { + $schema: typeof schema.$schema === 'string' ? schema.$schema : 'https://json-schema.org/draft/2020-12/schema', + $id: typeof schema.$id === 'string' ? schema.$id : '', + type: typeof schema.type === 'string' ? schema.type : 'object', + properties, + required, + additionalProperties: typeof schema.additionalProperties === 'boolean' ? schema.additionalProperties : true, + }; +}; + +const getContractConfigSchemaWithoutExcludedRuntimeVars = (configSchema: CatalogContract['config_schema'] | null | undefined) => { + const normalizedConfigSchema = normalizeContractConfigSchema(configSchema); + const filteredProperties = Object.fromEntries( + Object.entries(normalizedConfigSchema.properties).filter(([property]) => !EXCLUDED_CONFIG_VARS.includes(property)), + ); + return { + ...normalizedConfigSchema, + properties: filteredProperties, + required: normalizedConfigSchema.required.filter((property) => !EXCLUDED_CONFIG_VARS.includes(property)), + }; +}; /** * Compiles (or retrieves from cache) an AJV validator for a given schema. * The cacheKey must accurately reflect the exact shape of the schema (properties + required) * to avoid cache false positives. */ -const getOrCompileValidator = (cacheKey: string, jsonValidation: object): ValidateFunction => { +export const getOrCompileValidator = (cacheKey: string, jsonValidation: object): ValidateFunction => { let validate = validatorCache.get(cacheKey); if (!validate) { validate = ajv.compile(jsonValidation); @@ -28,111 +71,9 @@ const getOrCompileValidator = (cacheKey: string, jsonValidation: object): Valida return validate; }; -const CUSTOM_CATALOGS: string[] = conf.get('app:custom_catalogs') ?? []; const ajv = new Ajv({ coerceTypes: true }); addFormats(ajv, ['password', 'uri', 'duration', 'email', 'date-time', 'date']); -// Cache of catalog to read on disk and parse only once -let catalogMap: Promise> | undefined; -// cache for contracts by image map -let contractsByImageCache: Promise> | undefined; - -// Build catalog map from files -const buildCatalogMap = async (): Promise> => { - const newCatalogMap: Record = {}; - const catalogs = []; - catalogs.push(JSON.stringify(filigranCatalog)); - // Add custom catalogs - for (let index = 0; index < CUSTOM_CATALOGS.length; index += 1) { - const customCatalog = CUSTOM_CATALOGS[index]; - const catalog = await readFile(customCatalog, { encoding: 'utf8', flag: 'r' }); - catalogs.push(catalog); - } - // Prepare catalogs map - for (let index = 0; index < catalogs.length; index += 1) { - const catalogRaw = catalogs[index]; - const catalog = JSON.parse(catalogRaw) as CatalogDefinition; - // Validate each contract - for (let contractIndex = 0; contractIndex < catalog.contracts.length; contractIndex += 1) { - const contract = catalog.contracts[contractIndex]; - if (contract.manager_supported) { - if (!contract.config_schema) { - logApp.warn('A contract has manager_supported=true but is missing config_schema', { contractTitle: contract.title }); - } else { - if (isEmptyField(contract.container_image)) { - throw UnsupportedError('Contract must define container_image field', { contractTitle: contract.title }); - } - if (isEmptyField(contract.container_type)) { - throw UnsupportedError('Contract must define container_type field', { contractTitle: contract.title }); - } - - if (contract.config_schema) { - const jsonValidation = { - type: contract.config_schema.type, - properties: contract.config_schema.properties, - required: contract.config_schema.required, - additionalProperties: contract.config_schema.additionalProperties, - }; - try { - getOrCompileValidator(`catalog-contract:${catalog.id}:${contract.slug}`, jsonValidation); - } catch (err) { - throw UnsupportedError('Contract must be a valid json schema definition', { cause: err }); - } - } - } - } - } - newCatalogMap[catalog.id] = { - definition: catalog, - graphql: { - id: catalog.id, - entity_type: 'Catalog', - parent_types: ['Internal'], - standard_id: idGenFromData('catalog', { id: catalog.id }), - name: catalog.name, - description: catalog.description, - contracts: catalog.contracts.map((c) => { - const finalContract = c; - if (finalContract.manager_supported) { - if (!finalContract.config_schema) { - logApp.warn('A contract has manager_supported=true but is missing config_schema', { contractTitle: finalContract.title }); - } else { - const EXCLUDED_CONFIG_VARS = ['OPENCTI_TOKEN', 'OPENCTI_URL', 'CONNECTOR_TYPE', 'CONNECTOR_RUN_AND_TERMINATE']; - EXCLUDED_CONFIG_VARS.forEach((property) => { - delete finalContract.config_schema.properties[property]; - }); - finalContract.config_schema.required = c.config_schema.required.filter((item) => !EXCLUDED_CONFIG_VARS.includes(item)); - } - } - return JSON.stringify(finalContract); - }), - }, - }; - } - return newCatalogMap; -}; - -// Enable custom catalogs - clears cache and enables live catalog loading -export const resetCatalogs = () => { - catalogMap = undefined; - contractsByImageCache = undefined; - validatorCache.clear(); -}; - -const getCatalogs = async (): Promise> => { - if (!catalogMap) { - // We memoize the Promise immediately (not after it resolves) so that - // all concurrent calls share the same in-flight execution. - catalogMap = buildCatalogMap().catch((err) => { - // On failure, we reset the cache to allow a retry on the next call, - // rather than staying stuck with a rejected Promise cached forever. - catalogMap = undefined; - throw err; - }); - } - return catalogMap; -}; - const aesEncrypt = (text: string, key: Buffer, aesIv: Buffer) => { const cipher = crypto.createCipheriv('aes-256-gcm', key, aesIv); const ciphertext = Buffer.concat([ @@ -319,9 +260,9 @@ const formatValidationErrors = (errors: any[] | null | undefined, contractTitle: export const validateContractConfigurations = ( contractConfigurations: ConnectorContractConfiguration[], - targetContract: CatalogContract, + targetContract: Pick, ) => { - const targetConfig = targetContract.config_schema; + const targetConfig = getContractConfigSchemaWithoutExcludedRuntimeVars(targetContract.config_schema); // Build validation object from configurations // For AJV validation, arrays need to be actual arrays, not comma-separated strings @@ -376,9 +317,25 @@ export const validateContractConfigurations = ( ].join('|'); const validate = getOrCompileValidator(cacheKey, jsonValidation); + logApp.debug('[OPENCTI-MODULE] Validating connector contract configuration', { + module: 'catalog', + contractSlug: targetContract.slug, + contractTitle: targetContract.title, + requiredCount: filteredRequired.length, + providedCount: contractConfigurations.length, + validatedFieldsCount: Object.keys(validationProperties).length, + }); const validContractObject = validate(contractObject); if (!validContractObject) { + logApp.warn('[OPENCTI-MODULE] Invalid connector contract configuration', { + module: 'catalog', + contractSlug: targetContract.slug, + contractTitle: targetContract.title, + requiredCount: filteredRequired.length, + providedCount: contractConfigurations.length, + errorsCount: validate.errors?.length ?? 0, + }); const formattedError = formatValidationErrors(validate.errors, targetContract.title); throw UnsupportedError(formattedError, { errors: validate.errors }); } @@ -386,11 +343,14 @@ export const validateContractConfigurations = ( export const computeConnectorTargetContract = ( configurations: ContractConfigInput[], - targetContract: CatalogContract, + targetContract: Pick, publicKey: string, currentManagerContractConfiguration?: ConnectorContractConfiguration[], ): ConnectorContractConfiguration[] => { - const targetConfig = targetContract.config_schema; + const targetConfig = getContractConfigSchemaWithoutExcludedRuntimeVars(targetContract.config_schema); + let passwordCount = 0; + let defaultedCount = 0; + let reusedCount = 0; // Create maps for efficient lookups const configMap = new Map(configurations.map((c) => [c.key, c])); @@ -404,6 +364,7 @@ export const computeConnectorTargetContract = ( Object.entries(targetConfig.properties).forEach(([propKey, propSchema]) => { const inputConfig = configMap.get(propKey); const existingConfig = currentConfigMap.get(propKey); + const isPassword = propSchema.format === 'password'; // Only process fields that are: // 1. Required (will use default if available) @@ -423,6 +384,16 @@ export const computeConnectorTargetContract = ( return; // Skip this field entirely } + if (!hasInput && !hasExisting && (propSchema.default !== undefined && propSchema.default !== null)) { + defaultedCount += 1; + } + if (hasExisting && !hasInput) { + reusedCount += 1; + } + if (isPassword && hasInput && inputConfig?.value !== existingConfig?.value) { + passwordCount += 1; + } + const finalConfig = resolveConfigurationValue( propKey, propSchema, @@ -438,58 +409,237 @@ export const computeConnectorTargetContract = ( // Validate the configurations validateContractConfigurations(contractConfigurations, targetContract); + logApp.debug('[OPENCTI-MODULE] Computed connector contract configuration', { + module: 'catalog', + contractSlug: targetContract.slug, + contractTitle: targetContract.title, + inputCount: configurations.length, + resolvedCount: contractConfigurations.length, + passwordCount, + defaultedCount, + reusedCount, + }); return contractConfigurations; }; -export const getSupportedContractsByImage = async (): Promise> => { - if (!contractsByImageCache) { - contractsByImageCache = (async () => { - const catalogDefinitions = await getCatalogs(); - const contracts = Object.values(catalogDefinitions).map((catalog) => catalog.definition.contracts).flat(); - return new Map(contracts.map((contract) => [contract.container_image, contract])); - })().catch((err) => { - contractsByImageCache = undefined; - throw err; +const mapCatalogToGraphqlCatalog = ( + catalog: BasicStoreEntityCatalog, + contracts: BasicStoreEntityCatalogContract[], +): GraphqlCatalog => { + return { + id: catalog.catalog_id, + name: catalog.name, + description: catalog.description, + entity_type: catalog.entity_type, + parent_types: catalog.parent_types, + standard_id: catalog.standard_id, + contracts: contracts.map((c) => + JSON.stringify(mapContractEntityFieldsToGraphqlCatalogContract(c, { excludeRuntimeConfigVars: true })), + ), + }; +}; + +export const queryCatalogById = async (context: AuthContext, user: AuthUser, catalogId: string) => { + const catalog = await findCatalogByCatalogId(context, user, catalogId); + if (!catalog) { + logApp.debug('[OPENCTI-MODULE] Catalog query by id returned no catalog', { + module: 'catalog', + catalogId, }); + return null; } - return contractsByImageCache; + const contracts = await findLatestCompatibleCatalogContractsByCatalogId(context, user, catalogId); + logApp.debug('[OPENCTI-MODULE] Catalog query by id resolved', { + module: 'catalog', + catalogId, + contractsCount: contracts.size, + }); + return mapCatalogToGraphqlCatalog(catalog, [...contracts.values()]); }; -export const findById = async (_context: AuthContext, _user: AuthUser, catalogId: string) => { - const catalogDefinitions = await getCatalogs(); - return catalogDefinitions[catalogId].graphql; +export const queryCatalogs = async (context: AuthContext, user: AuthUser) => { + const catalogs = await findAllCatalogs(context, user); + const contracts = await Promise.all(catalogs.map((catalog) => findLatestCompatibleCatalogContractsByCatalogId(context, user, catalog.catalog_id))); + const contractsTotalCount = contracts.reduce((total, contractsByCatalog) => total + contractsByCatalog.size, 0); + logApp.debug('[OPENCTI-MODULE] Catalogs query resolved', { + module: 'catalog', + catalogsCount: catalogs.length, + contractsTotalCount, + }); + const ret = catalogs.map((catalog, idx) => { + return mapCatalogToGraphqlCatalog(catalog, [...contracts[idx].values()]); + }); + return ret; }; -export const findCatalog = async (_context: AuthContext, _user: AuthUser) => { - const catalogDefinitions = await getCatalogs(); - return Object.values(catalogDefinitions).map((catalog) => catalog.graphql); +export const findCatalogRevisions = async (context: AuthContext, user: AuthUser): Promise => { + try { + const catalogsRevisions = await findCatalogsRevisions(context, user); + const revisions = catalogsRevisions.map((catalog) => ({ + catalog_id: catalog.catalog_id, + revision: catalog.revision ?? null, + })); + logApp.debug('[OPENCTI-MODULE] [catalog] Catalog revisions query resolved', { + module: 'catalog', + catalogsCount: revisions.length, + }); + return revisions; + } catch (error) { + logApp.error('[OPENCTI-MODULE] [catalog] Catalog revisions query failed', { module: 'catalog', error }); + throw error; + } }; -const findContract = async (_context: AuthContext, _user: AuthUser, predicate: (contract: any) => boolean) => { - const catalogDefinitions = await getCatalogs(); - if (!catalogDefinitions) { +export const queryContractBySlug = async (context: AuthContext, user: AuthUser, contractSlug: string) => { + const contract = await findLatestCompatibleCatalogContractBySlug(context, user, contractSlug); + if (!contract) { + logApp.debug('[OPENCTI-MODULE] Contract query by slug returned no contract', { + module: 'catalog', + contractSlug, + }); return null; } + logApp.debug('[OPENCTI-MODULE] Contract query by slug resolved', { + module: 'catalog', + contractSlug, + catalogId: contract.catalog_id, + contractVersion: contract.contract_version, + }); + return { + catalog_id: contract.catalog_id, + contract: JSON.stringify(mapContractEntityFieldsToGraphqlCatalogContract(contract, { excludeRuntimeConfigVars: true })), + }; +}; - const catalogs = Object.values(catalogDefinitions).map((catalog) => catalog.graphql); - const foundContract = catalogs - .map((catalog) => { - const contract = catalog.contracts.find((contractStr: string) => { - const parsedContract = JSON.parse(contractStr); - return predicate(parsedContract); - }); - return contract ? { catalog_id: catalog.id, contract } : null; - }) - .find((contract) => contract !== null); - - return foundContract || null; +export const mapContractDtoV0ToContractEntityFields = (params: { + catalogId: string; + contractDto: CatalogContract; + contractContentHash: string; + logoUri: string | null; +}): CatalogContractEntityFields => { + const { catalogId, contractDto, contractContentHash, logoUri } = params; + const supportVersionValue = contractDto.support_version + ? contractDto.support_version.replace(/^\s*>=\s*/, '').trim() + : null; + const normalizedSupportVersion = supportVersionValue && supportVersionValue.length > 0 + ? supportVersionValue + : null; + return { + catalog_id: catalogId, + contract_id: `${contractDto.slug}-${contractDto.container_version}`, + content_hash: contractContentHash, + title: contractDto.title, + slug: contractDto.slug, + description: contractDto.description, + short_description: contractDto.short_description, + logo_uri: logoUri ?? undefined, + use_cases: contractDto.use_cases, + verified: contractDto.verified, + last_verified_date: contractDto.last_verified_date ?? undefined, + playbook_supported: contractDto.playbook_supported, + max_confidence_level: contractDto.max_confidence_level, + support_version: normalizedSupportVersion ?? undefined, + subscription_link: contractDto.subscription_link ?? undefined, + source_code: contractDto.source_code ?? undefined, + manager_supported: contractDto.manager_supported, + contract_version: contractDto.container_version, + image: contractDto.container_image, + connector_type: contractDto.container_type, + config_schema: contractDto.config_schema, + license_type: contractDto.license_type ?? undefined, + solution_categories: contractDto.solution_categories ?? undefined, + contact: contractDto.contact ?? undefined, + }; }; -export const findContractBySlug = (context: AuthContext, user: AuthUser, contractSlug: string) => { - return findContract(context, user, (contract) => contract.slug === contractSlug); +export const mapContractEntityFieldsToGraphqlCatalogContract = ( + contract: CatalogContractEntityFields, + options: { excludeRuntimeConfigVars?: boolean } = {}, +): GraphqlCatalogContract => { + const { excludeRuntimeConfigVars = false } = options; + const normalizedConfigSchema = normalizeContractConfigSchema(contract.config_schema); + const configSchema = excludeRuntimeConfigVars + ? getContractConfigSchemaWithoutExcludedRuntimeVars(normalizedConfigSchema) + : normalizedConfigSchema; + return { + title: contract.title, + slug: contract.slug, + description: contract.description, + short_description: contract.short_description, + logo: contract.logo_uri ?? null, + use_cases: contract.use_cases ?? [], + verified: contract.verified, + last_verified_date: contract.last_verified_date ?? '', + playbook_supported: contract.playbook_supported, + max_confidence_level: contract.max_confidence_level, + support_version: contract.support_version ?? null, + subscription_link: contract.subscription_link ?? null, + source_code: contract.source_code ?? '', + manager_supported: contract.manager_supported, + container_version: contract.contract_version, + container_image: contract.image, + container_type: contract.connector_type, + config_schema: configSchema, + license_type: contract.license_type ?? null, + solution_categories: contract.solution_categories ?? [], + contact: contract.contact ?? null, + }; }; -export const findContractByContainerImage = (context: AuthContext, user: AuthUser, containerImage: string) => { - return findContract(context, user, (contract) => contract.container_image === containerImage); +export const mapContractEntityFieldsToEmbeddedConnectorManagerContract = ( + contract: CatalogContractEntityFields, +): CatalogContractEntityFields => { + const { + catalog_id, + contract_id, + content_hash, + title, + slug, + description, + short_description, + logo_uri, + use_cases, + verified, + last_verified_date, + playbook_supported, + max_confidence_level, + support_version, + subscription_link, + source_code, + manager_supported, + contract_version, + image, + connector_type, + config_schema, + license_type, + solution_categories, + contact, + } = contract; + return { + catalog_id, + contract_id, + content_hash, + title, + slug, + description, + short_description, + logo_uri, + use_cases, + verified, + last_verified_date, + playbook_supported, + max_confidence_level, + support_version, + subscription_link, + source_code, + manager_supported, + contract_version, + image, + connector_type, + config_schema, + license_type, + solution_categories, + contact, + }; }; diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-http.ts b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-http.ts new file mode 100644 index 000000000000..595a63773e45 --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-http.ts @@ -0,0 +1,51 @@ +import type { RequestHandler } from 'express'; +import { createAuthenticatedContext } from '../../http/httpAuthenticatedContext'; +import { downloadFile } from '../../database/raw-file-storage'; +import { logApp } from '../../config/conf'; +import { setCookieError } from '../../http/httpUtils'; +import { CATALOG_CONTRACT_LOGOS_DIR, getMimeTypeFromImageExtension } from './catalog-logo-storage'; +import { ResourceNotFoundError, UnsupportedError, UnknownError } from '../../config/errors'; + +export const CATALOG_LOGO_VIEW_PATH = '/catalog/logo/*file'; + +const CONTEXT_SOURCE = 'catalog_logo_view'; + +export const handleCatalogLogoViewRequest: RequestHandler = async (req, res) => { + try { + const context = await createAuthenticatedContext(req, res, CONTEXT_SOURCE); + if (!context.user) { + res.sendStatus(403); + return; + } + const fileParam = req.params.file; + const file = Array.isArray(fileParam) ? fileParam[0] : fileParam; + logApp.debug('Catalog logo view handler', { file }); + if (typeof file !== 'string' || file.includes('..') || file.includes('/') || file.includes('\\')) { + throw UnsupportedError('Invalid URL format'); + } + const s3Key = `${CATALOG_CONTRACT_LOGOS_DIR}/${file}`; + const stream = await downloadFile(s3Key); + if (!stream) { + const error = ResourceNotFoundError('Catalog logo not found'); + logApp.error('Failed to download catalog logo', { cause: error }); + res.status(404).send({ status: 'error', error: error.message }); + return; + } + const extension = file.substring(file.lastIndexOf('.')); + const mimeType = getMimeTypeFromImageExtension(extension); + res.set({ 'Content-Security-Policy': 'sandbox' }); + res.set('Cache-Control', 'private, no-cache, no-store, must-revalidate'); + res.set({ Pragma: 'no-cache' }); + if (mimeType) { + res.set('Content-type', mimeType); + } else { + logApp.warn('Catalog logo: unable to deduce mimeType from extension', { file, extension }); + } + stream.pipe(res); + } catch (exception) { + const error = exception instanceof Error ? exception : UnknownError('Unknown error'); + setCookieError(res, error.message); + logApp.error('Error viewing catalog logo', { cause: error }); + res.status(503).send({ status: 'error', error: error.message }); + } +}; diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-logo-storage.ts b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-logo-storage.ts new file mode 100644 index 000000000000..c9022bfe962b --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-logo-storage.ts @@ -0,0 +1,243 @@ +import crypto from 'crypto'; +import { rawListObjects, rawUpload } from '../../database/raw-file-storage'; +import { parseDataUrl } from '../../utils/data-url'; +import type { CatalogContract } from './catalog-types'; +import { CATALOG_LOGO_VIEW_PATH } from './catalog-http'; +import { FilesystemError, UnknownError, UnsupportedError } from '../../config/errors'; + +export const CATALOG_CONTRACT_LOGOS_DIR = 'catalog-logos'; + +export type CatalogContractLogoUploadOperation = { + filename: string; + s3Key: string; + body: Buffer; + logoUri: string; +}; + +export const listCatalogContractLogos = async () => { + const result = await rawListObjects(CATALOG_CONTRACT_LOGOS_DIR, false); + if (!result.Contents) { + return new Set(); + } + return new Set(result.Contents.map((object) => { + return object?.Key?.substring(CATALOG_CONTRACT_LOGOS_DIR.length + 1) ?? ''; + }).filter(Boolean)); +}; + +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types +// List of image mime types commonly supported by browsers +const IMAGE_MIME_TYPES_TO_EXTENSION = { + 'image/apng': '.apng', + 'image/avif': '.avif', + 'image/bmp': '.bmp', + 'image/gif': '.gif', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/svg+xml': '.svg', + 'image/tiff': '.tiff', + 'image/webp': '.webp', +}; + +const EXTENSION_TO_MIME_TYPES = Object.entries(IMAGE_MIME_TYPES_TO_EXTENSION).reduce((acc, [mimeType, extension]) => { + acc[extension] = mimeType; + return acc; +}, {} as Record); + +const isImageMimeType = (mimeType: string): mimeType is keyof typeof IMAGE_MIME_TYPES_TO_EXTENSION => { + return Object.keys(IMAGE_MIME_TYPES_TO_EXTENSION).includes(mimeType); +}; + +const getExtensionFromImageMimeType = (mimeType: keyof typeof IMAGE_MIME_TYPES_TO_EXTENSION) => { + return IMAGE_MIME_TYPES_TO_EXTENSION[mimeType]; +}; + +export const getMimeTypeFromImageExtension = (extension: string) => { + return EXTENSION_TO_MIME_TYPES[extension]; +}; + +const CATALOG_CONTRACT_LOGO_MAX_SIZE_BYTES = 20 * 1024 * 1024; // 20MB + +const isLikelyBase64 = (payload: string) => { + const sanitizedPayload = payload.replace(/\s+/g, ''); + if (sanitizedPayload.length === 0 || sanitizedPayload.length % 4 !== 0) { + return false; + } + return /^[A-Za-z0-9+/]*={0,2}$/.test(sanitizedPayload); +}; + +const computeLogoHash = (logoContent: Buffer) => { + return crypto.createHash('md5').update(logoContent).digest('hex'); +}; + +export const storeCatalogContractLogo = async ( + contractDto: CatalogContract, + existingLogos: Set, +) => { + const operationResult = computeCatalogContractLogoUploadOperation(contractDto, existingLogos); + if (operationResult.result !== 'success' || operationResult.existed) { + return operationResult; + } + if (!operationResult.operation) { + const error = UnknownError('Missing logo upload operation', { + contractTitle: contractDto.title, + contractSlug: contractDto.slug, + }); + return { + result: 'failed' as const, + logoUri: null, + error, + }; + } + + const uploadResult = await uploadCatalogContractLogoOperation(operationResult.operation); + if (uploadResult.result === 'failed') { + return { + result: 'failed' as const, + logoUri: null, + error: uploadResult.error, + }; + } + return { + result: 'success' as const, + existed: false, + logoUri: operationResult.logoUri, + filename: operationResult.filename, + }; +}; + +export const computeCatalogContractLogoUploadOperation = ( + contractDto: CatalogContract, + existingLogos: Set, +) => { + const { logo } = contractDto; + if (!logo) { + return { + result: 'no-logo' as const, + logoUri: null, + }; + } + if (!logo.startsWith('data:')) { + const error = UnsupportedError('Unsupported logo data format: not a data URL', { + contractTitle: contractDto.title, + contractSlug: contractDto.slug, + }); + return { + result: 'failed' as const, + logoUri: null, + error, + }; + } + const { mimeType, base64Encoded, data } = parseDataUrl(logo); + if (!isImageMimeType(mimeType)) { + const error = UnsupportedError('Unsupported logo mime type', { + contractTitle: contractDto.title, + contractSlug: contractDto.slug, + mimeType, + }); + return { + result: 'failed' as const, + logoUri: null, + error, + }; + } + let decodedData: Buffer; + if (base64Encoded) { + if (!isLikelyBase64(data)) { + const error = UnsupportedError('Unsupported logo data format: invalid base64 payload', { + contractTitle: contractDto.title, + contractSlug: contractDto.slug, + }); + return { + result: 'failed' as const, + logoUri: null, + error, + }; + } + decodedData = Buffer.from(data, 'base64'); + } else { + try { + decodedData = Buffer.from(decodeURIComponent(data), 'utf8'); + } catch (err: unknown) { + const error = UnsupportedError('Unsupported logo data format: invalid URL-encoded payload', { + contractTitle: contractDto.title, + contractSlug: contractDto.slug, + cause: err, + }); + return { + result: 'failed' as const, + logoUri: null, + error, + }; + } + } + if (decodedData.byteLength === 0) { + const error = UnsupportedError('Unexpected zero-length logo data', { + contractTitle: contractDto.title, + contractSlug: contractDto.slug, + }); + return { + result: 'failed' as const, + logoUri: null, + error, + }; + } + if (decodedData.byteLength > CATALOG_CONTRACT_LOGO_MAX_SIZE_BYTES) { + const error = UnsupportedError('Unsupported logo data format: image exceeds max size', { + contractTitle: contractDto.title, + contractSlug: contractDto.slug, + maxSizeBytes: CATALOG_CONTRACT_LOGO_MAX_SIZE_BYTES, + actualSizeBytes: decodedData.byteLength, + }); + return { + result: 'failed' as const, + logoUri: null, + error, + }; + } + const hash = computeLogoHash(decodedData); + const extension = getExtensionFromImageMimeType(mimeType); + const filename = `${hash}${extension}`; + const s3Key = `${CATALOG_CONTRACT_LOGOS_DIR}/${filename}`; + const logoUri = CATALOG_LOGO_VIEW_PATH.replace('*file', filename); + if (existingLogos.has(filename)) { + return { + result: 'success' as const, + existed: true, + logoUri, + filename, + }; + } + const operation: CatalogContractLogoUploadOperation = { + filename, + s3Key, + body: decodedData, + logoUri, + }; + return { + result: 'success' as const, + existed: false, + logoUri, + filename, + operation, + }; +}; + +export const uploadCatalogContractLogoOperation = async (operation: CatalogContractLogoUploadOperation) => { + try { + await rawUpload(operation.s3Key, operation.body); + } catch (err: unknown) { + const error = FilesystemError('Failed to upload logo content to file storage', { + s3Key: operation.s3Key, + filename: operation.filename, + logoUri: operation.logoUri, + cause: err, + }); + return { + result: 'failed' as const, + error, + }; + } + return { + result: 'success' as const, + }; +}; diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-manager.ts b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-manager.ts new file mode 100644 index 000000000000..0b2b0be16dd8 --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-manager.ts @@ -0,0 +1,57 @@ +import conf, { booleanConf } from '../../config/conf'; +import { executionContext, SYSTEM_USER } from '../../utils/access'; +import { autoUpgradeManagedConnectors } from '../connector/connector-domain'; +import { synchronizeCatalogs } from './sync/catalog-sync-domain'; +import { registerManager, type ManagerDefinition } from '../../manager/managerModule'; + +const CATALOG_MANAGER_ID = 'CATALOG_MANAGER'; +const CATALOG_MANAGER_LABEL = 'Catalog Manager'; +const CATALOG_MANAGER_CONTEXT = 'catalog_manager'; +const CATALOG_MANAGER_ENABLED = booleanConf('catalog_manager:enabled', true); +const CATALOG_MANAGER_LOCK_KEY = conf.get('catalog_manager:lock_key') ?? 'catalog_manager_lock'; +const CATALOG_MANAGER_INTERVAL = conf.get('catalog_manager:interval') ?? 60_000; +const DEFAULT_CATALOG_SYNC_REMOTE_TIMEOUT_MS = 30_000; + +const resolveCatalogSyncRemoteTimeoutMs = () => { + const configuredTimeout = Number(conf.get('catalog_manager:catalog_sync_remote_timeout') ?? conf.get('app:request_timeout')); + if (!Number.isFinite(configuredTimeout) || configuredTimeout <= 0) { + return DEFAULT_CATALOG_SYNC_REMOTE_TIMEOUT_MS; + } + return configuredTimeout; +}; + +export const isCatalogManagerEnabled = () => CATALOG_MANAGER_ENABLED; + +const catalogManagerHandler = async () => { + const context = executionContext('catalog_manager'); + // Sync catalogs to ES + const syncedCatalogsWithChanges = await synchronizeCatalogs(context, SYSTEM_USER, { + remoteCatalogTimeoutMs: resolveCatalogSyncRemoteTimeoutMs(), + }); + // Apply upgrade strategy. + // Should be moved another manager maybe (connectorManager). + // Consider using an event to invert the dependency. + await autoUpgradeManagedConnectors(context, SYSTEM_USER, syncedCatalogsWithChanges); +}; + +const CATALOG_MANAGER_DEFINITION: ManagerDefinition = { + id: CATALOG_MANAGER_ID, + label: CATALOG_MANAGER_LABEL, + executionContext: CATALOG_MANAGER_CONTEXT, + enabledByConfig: CATALOG_MANAGER_ENABLED, + enabled(): boolean { + return this.enabledByConfig && !!CATALOG_MANAGER_LOCK_KEY; + }, + enabledToStart(): boolean { + return this.enabledByConfig && !!CATALOG_MANAGER_LOCK_KEY; + }, + enterpriseEditionOnly: false, + cronSchedulerHandler: { + handler: catalogManagerHandler, + interval: CATALOG_MANAGER_INTERVAL, + lockKey: CATALOG_MANAGER_LOCK_KEY, + }, +}; + +// Automatically register manager on start. +registerManager(CATALOG_MANAGER_DEFINITION); diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-repository.ts b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-repository.ts new file mode 100644 index 000000000000..a9bb1dff67a8 --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-repository.ts @@ -0,0 +1,371 @@ +import { logApp, PLATFORM_VERSION } from '../../config/conf'; +import { FunctionalError, UnknownError } from '../../config/errors'; +import { elDeleteInstances, elIndex, elIndexElements, elLoadBy } from '../../database/engine'; +import { fullEntitiesList, internalFindByIdsMapped, type FilterGroupWithNested } from '../../database/middleware-loader'; +import { INDEX_INTERNAL_OBJECTS, READ_INDEX_INTERNAL_OBJECTS } from '../../database/utils'; +import { FilterMode, FilterOperator } from '../../generated/graphql'; +import type { AuthContext, AuthUser } from '../../types/user'; +import { + type BasicStoreEntityCatalogContract, + ENTITY_TYPE_CATALOG_CONTRACT, + type BasicStoreEntityCatalog, + type CatalogContractCreation, + type CatalogContractUpdate, + type CatalogContractDeletion, + type CatalogUpsert, + ENTITY_TYPE_CATALOG, +} from './catalog-types'; +import { filterAndSortLatestCompatibleContracts } from './catalog-version-utils'; + +/** + * Catalog data accessors & mutators + */ + +export const findCatalogByCatalogId = async ( + context: AuthContext, + user: AuthUser, + catalogId: string, +) => { + const catalog = await elLoadBy( + context, + user, + 'catalog_id', + catalogId, + ENTITY_TYPE_CATALOG, + [READ_INDEX_INTERNAL_OBJECTS], + ); + return catalog; +}; + +export const findCatalogBySourceUri = async ( + context: AuthContext, + user: AuthUser, + sourceUri: string, +) => { + const catalog = await elLoadBy( + context, + user, + 'source_uri', + sourceUri, + ENTITY_TYPE_CATALOG, + [READ_INDEX_INTERNAL_OBJECTS], + ); + return catalog; +}; + +export const upsertCatalog = async ( + _context: AuthContext, + _user: AuthUser, + update: CatalogUpsert, + currentCatalog?: BasicStoreEntityCatalog, +) => { + // Reuse the existing document's physical index when updating so the write lands + // on the same index as the current document instead of the write alias, which + // could resolve to a different physical index (e.g. after an ILM rollover). + await elIndex(currentCatalog?._index ?? INDEX_INTERNAL_OBJECTS, { + ...update, + entity_type: ENTITY_TYPE_CATALOG, + }); +}; + +export const findAllCatalogs = async ( + context: AuthContext, + user: AuthUser, + filters?: FilterGroupWithNested, +) => { + return fullEntitiesList( + context, + user, + [ENTITY_TYPE_CATALOG], + { indices: [READ_INDEX_INTERNAL_OBJECTS], filters }, + ); +}; + +export const findAllCatalogsExcluding = async ( + context: AuthContext, + user: AuthUser, + excludedIds: string[], +) => { + const filters: FilterGroupWithNested = { + filters: excludedIds.map((catalogId) => ({ + key: ['catalog_id'], + values: [catalogId], + operator: FilterOperator.NotEq, + })), + filterGroups: [], + mode: FilterMode.And, + }; + return findAllCatalogs(context, user, filters); +}; + +export const findCatalogsRevisions = async (context: AuthContext, user: AuthUser, excludedIds?: string[]) => { + const filters: FilterGroupWithNested | undefined = excludedIds?.length + ? { + filters: excludedIds.map((catalogId) => ({ + key: ['catalog_id'], + values: [catalogId], + operator: FilterOperator.NotEq, + })), + filterGroups: [], + mode: FilterMode.And, + } + : undefined; + + const catalogs = await fullEntitiesList( + context, + user, + [ENTITY_TYPE_CATALOG], + { + indices: [READ_INDEX_INTERNAL_OBJECTS], + filters, + baseData: true, + baseFields: ['catalog_id', 'revision'], + }, + ); + + return catalogs.map((catalog) => ({ + catalog_id: catalog.catalog_id, + revision: catalog.revision, + })); +}; + +export const deleteCatalogs = async (context: AuthContext, catalogEntities: BasicStoreEntityCatalog[]) => { + for (let idx = 0; idx < catalogEntities.length; ++idx) { + const catalogEntity = catalogEntities[idx]; + if (catalogEntity.entity_type !== ENTITY_TYPE_CATALOG) { + throw UnknownError(`Expected the entity for deletion to be of type ${ENTITY_TYPE_CATALOG}`, { + actualType: catalogEntity.entity_type, + }); + } + } + await elDeleteInstances(context, catalogEntities); +}; + +/** + * Catalog contracts data accessors & mutators + */ + +const resolveCatalogContractsByIds = async ( + context: AuthContext, + user: AuthUser, + ids: string[], +) => { + const contractsById = await internalFindByIdsMapped( + context, + user, + ids, + { + type: ENTITY_TYPE_CATALOG_CONTRACT, + indices: [READ_INDEX_INTERNAL_OBJECTS], + baseData: true, + mapWithAllIds: true, + }, + ); + const missingIds = ids.filter((id) => !contractsById[id]); + if (missingIds.length > 0) { + throw FunctionalError('Catalog contracts not found', { ids: missingIds }); + } + return ids.map((id) => contractsById[id]); +}; + +export const findCatalogContractsByCatalogId = async ( + context: AuthContext, + user: AuthUser, + catalogId: string, +) => { + const contracts = await fullEntitiesList( + context, + user, + [ENTITY_TYPE_CATALOG_CONTRACT], + { + indices: [READ_INDEX_INTERNAL_OBJECTS], + filters: { + filters: [{ + key: ['catalog_id'], + values: [catalogId], + }], + filterGroups: [], + mode: FilterMode.And, + }, + }, + ); + return contracts.reduce((map, contract) => { + map.set(contract.contract_id, contract); + return map; + }, new Map()); +}; + +export const findLatestCompatibleCatalogContractsByCatalogId = async ( + context: AuthContext, + user: AuthUser, + catalogId: string, +) => { + const contracts = await fullEntitiesList( + context, + user, + [ENTITY_TYPE_CATALOG_CONTRACT], + { + indices: [READ_INDEX_INTERNAL_OBJECTS], + filters: { + filters: [{ + key: ['catalog_id'], + values: [catalogId], + }], + filterGroups: [], + mode: FilterMode.And, + }, + }, + ); + const compatibleContracts = filterAndSortLatestCompatibleContracts(contracts); + logApp.debug('[OPENCTI-MODULE] Loaded compatible catalog contracts', { + module: 'catalog', + catalogId, + platformVersion: PLATFORM_VERSION, + compatibleContractsCount: compatibleContracts.length, + }); + // Keep latest compatible version by slug (results are sorted by version desc). + return compatibleContracts.reduce((map, contract) => { + if (map.has(contract.slug)) { + return map; + } + map.set(contract.slug, contract); + return map; + }, new Map()); +}; + +export const findLatestCompatibleCatalogContractBySlug = async ( + context: AuthContext, + user: AuthUser, + contractSlug: string, +) => { + const contracts = await fullEntitiesList( + context, + user, + [ENTITY_TYPE_CATALOG_CONTRACT], + { + indices: [READ_INDEX_INTERNAL_OBJECTS], + filters: { + filters: [{ + key: ['slug'], + values: [contractSlug], + }], + filterGroups: [], + mode: FilterMode.And, + }, + }, + ); + return filterAndSortLatestCompatibleContracts(contracts)[0]; +}; + +export const findLatestCompatibleCatalogContractByImageName = async ( + context: AuthContext, + user: AuthUser, + imageName: string, +) => { + const contracts = await fullEntitiesList( + context, + user, + [ENTITY_TYPE_CATALOG_CONTRACT], + { + indices: [READ_INDEX_INTERNAL_OBJECTS], + filters: { + filters: [{ + key: ['image'], + values: [imageName], + }], + filterGroups: [], + mode: FilterMode.And, + }, + }, + ); + const selectedContract = filterAndSortLatestCompatibleContracts(contracts)[0]; + if (!selectedContract) { + logApp.debug('[OPENCTI-MODULE] No compatible catalog contract found by image', { + module: 'catalog', + imageName, + platformVersion: PLATFORM_VERSION, + }); + } else { + logApp.debug('[OPENCTI-MODULE] Selected compatible catalog contract by image', { + module: 'catalog', + imageName, + contractId: selectedContract.contract_id, + contractVersion: selectedContract.contract_version, + catalogId: selectedContract.catalog_id, + }); + } + return selectedContract; +}; + +export const insertCatalogContracts = async ( + context: AuthContext, + user: AuthUser, + contracts: CatalogContractCreation[], +) => { + const contractsToIndex = contracts.map((contract) => ({ + ...contract, + _index: INDEX_INTERNAL_OBJECTS, + entity_type: ENTITY_TYPE_CATALOG_CONTRACT, + })); + if (contracts.length > 0) { + logApp.debug('[OPENCTI-MODULE] Inserting catalog contracts', { + module: 'catalog', + count: contracts.length, + catalogIds: [...new Set(contracts.map((contract) => contract.catalog_id))], + }); + } + await elIndexElements(context, user, ENTITY_TYPE_CATALOG_CONTRACT, contractsToIndex); +}; + +export const updateCatalogContracts = async ( + context: AuthContext, + user: AuthUser, + updates: CatalogContractUpdate[], +) => { + const existingContracts = await resolveCatalogContractsByIds( + context, + user, + updates.map((update) => update.internal_id), + ); + // We can use bulk `index` as we provide the entire documents + const contractsToIndex = updates.map((update, index) => ({ + ...update, + _index: existingContracts[index]._index, + entity_type: ENTITY_TYPE_CATALOG_CONTRACT, + })); + if (updates.length > 0) { + if (updates.length > 100) { + logApp.warn('[OPENCTI-MODULE] High volume of catalog contracts updates', { + module: 'catalog', + count: updates.length, + catalogIds: [...new Set(updates.map((update) => update.catalog_id))], + }); + } else { + logApp.debug('[OPENCTI-MODULE] Updating catalog contracts', { + module: 'catalog', + count: updates.length, + catalogIds: [...new Set(updates.map((update) => update.catalog_id))], + }); + } + } + await elIndexElements(context, user, ENTITY_TYPE_CATALOG_CONTRACT, contractsToIndex); +}; + +export const deleteCatalogContracts = async ( + context: AuthContext, + user: AuthUser, + deletions: CatalogContractDeletion[], +) => { + const contracts = await resolveCatalogContractsByIds( + context, + user, + deletions.map((deletion) => deletion.idToDelete), + ); + if (deletions.length > 0) { + logApp.debug('[OPENCTI-MODULE] Deleting catalog contracts', { + module: 'catalog', + count: deletions.length, + }); + } + await elDeleteInstances(context, contracts); +}; diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-resolver.ts b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-resolver.ts index c1b5d15a11c6..a3e3addc0920 100644 --- a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-resolver.ts +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-resolver.ts @@ -1,16 +1,20 @@ -import { findCatalog, findById, findContractBySlug } from './catalog-domain'; import type { Resolvers } from '../../generated/graphql'; +import type { AuthContext } from '../../types/user'; +import { findCatalogRevisions, queryCatalogById, queryCatalogs, queryContractBySlug } from './catalog-domain'; const catalogResolver: Resolvers = { Query: { catalog: (_, { id }, context) => { - return findById(context, context.user, id); + return queryCatalogById(context, context.user, id); }, - catalogs: (_, args, context) => { - return findCatalog(context, context.user); + catalogs: (_, _args, context) => { + return queryCatalogs(context, context.user); + }, + catalogsRevisions: (_: unknown, _args: Record, context: AuthContext) => { + return findCatalogRevisions(context, context.user!); }, contract: (_, { slug }, context) => { - return findContractBySlug(context, context.user, slug); + return queryContractBySlug(context, context.user, slug); }, }, }; diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-types.ts b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-types.ts index c2eaee5eda3f..ca0a19f48caa 100644 --- a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-types.ts +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-types.ts @@ -1,4 +1,7 @@ -export type IngestionConnectorType = 'INTERNAL_ENRICHMENT' | 'EXTERNAL_IMPORT' | 'INTERNAL_EXPORT_FILE' | 'INTERNAL_IMPORT_FILE'; +import type { BasicStoreEntity, StoreEntity } from '../../types/store'; + +// region DTO types +export type IngestionConnectorType = string; type TypeMap = { string: string; @@ -8,10 +11,10 @@ type TypeMap = { boolean: boolean; }; -type TypedProperty = { +export type TypedProperty = { type: K; - default: TypeMap[K]; - description: string; + default?: TypeMap[K]; + description?: string; format?: string; }; @@ -20,14 +23,14 @@ export interface CatalogContract { slug: string; description: string; short_description: string; - logo: string; + logo: string | null; use_cases: string[]; verified: boolean; - last_verified_date: string; + last_verified_date: string | null; playbook_supported: boolean; max_confidence_level: number; - support_version: string; - subscription_link: string; + support_version: string | null; + subscription_link: string | null; source_code: string; manager_supported: boolean; container_version: string; @@ -43,21 +46,27 @@ export interface CatalogContract { required: string[]; additionalProperties: boolean; }; + license_type: 'Free' | 'Commercial' | null; + solution_categories: string[]; + contact: string | null; } export interface CatalogDefinition { id: string; name: string; description: string; + version: string; contracts: Array; } +// endregion export interface CatalogType { definition: CatalogDefinition; graphql: GraphqlCatalog; } -// region Database types +// region Api types +export type GraphqlCatalogContract = CatalogContract; export interface GraphqlCatalog { id: string; entity_type: string; @@ -65,5 +74,88 @@ export interface GraphqlCatalog { parent_types: string[]; name: string; description: string; - contracts: string[]; + contracts: string[]; // JSON.Stringified GraphqlCatalogContract items } + +export interface GraphqlCatalogRevision { + catalog_id: string; + revision: string | null; +} +// endregion + +// region Database types + +export const ENTITY_TYPE_CATALOG_CONTRACT = 'CatalogContract'; +export const ENTITY_TYPE_CATALOG = 'Catalog'; + +/** + * Fields specific to the `CatalogContract` entity. + * Also reused to embed the contract in `Connector` entities. + */ +export interface CatalogContractEntityFields { + catalog_id: string; + contract_id: string; + content_hash: string; + title: string; + slug: string; + description: string; + short_description: string; + logo_uri?: string; + use_cases?: string[]; + verified: boolean; + last_verified_date?: string; + playbook_supported: boolean; + max_confidence_level: number; + support_version?: string; + subscription_link?: string; + source_code?: string; + manager_supported: boolean; + contract_version: string; + image: string; + connector_type: IngestionConnectorType; + config_schema: { + $schema: string; + $id: string; + type: string; + properties: { + [key: string]: TypedProperty; + }; + required: string[]; + additionalProperties: boolean; + }; + license_type?: 'Free' | 'Commercial'; + solution_categories: string[]; + contact?: string; +}; + +export interface BasicStoreEntityCatalogContract extends BasicStoreEntity, CatalogContractEntityFields {} + +export interface StoreEntityCatalogContract extends StoreEntity, CatalogContractEntityFields {} + +interface CatalogEntityFields { + revision: string; + source_uri: string; + catalog_id: string; + name: string; + description: string; + product_version: string; + manifest_version: string | null; + manifest_schema_version: '0' | '1'; +} + +export interface BasicStoreEntityCatalog extends BasicStoreEntity, CatalogEntityFields {} + +export interface StoreEntityCatalog extends StoreEntity, CatalogEntityFields {} + +export interface CatalogContractDeletion { + idToDelete: string; +} + +export type CatalogContractUpdate = Required<{ + [TKey in keyof CatalogContractEntityFields]: CatalogContractEntityFields[TKey] | null; +}> & { internal_id: string; standard_id: string }; + +export type CatalogContractCreation = CatalogContractEntityFields & { internal_id: string; standard_id: string }; + +export type CatalogUpsert = CatalogEntityFields & { internal_id: string; standard_id: string }; +// endregion diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog-version-utils.ts b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-version-utils.ts new file mode 100644 index 000000000000..b613ef08ac80 --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog-version-utils.ts @@ -0,0 +1,75 @@ +import semver from 'semver'; +import { logApp, PLATFORM_VERSION } from '../../config/conf'; +import type { BasicStoreEntityCatalogContract } from './catalog-types'; + +type SupportVersionContract = Pick; +type ContractVersionContract = Pick; + +type CompatibilityOptions = { + platformVersion?: string; + onUnparsableSupportVersion?: (args: { contractId: string; supportVersion: string; platformVersion: string }) => void; +}; + +export const parseCatalogSemver = (version: string | null | undefined) => { + if (!version) { + return null; + } + return semver.coerce(version); +}; + +export const isSupportVersionCompatible = ( + contract: SupportVersionContract, + options: CompatibilityOptions = {}, +) => { + if (!contract.support_version) { + return true; + } + const platformVersion = options.platformVersion ?? PLATFORM_VERSION; + const contractVersion = parseCatalogSemver(contract.support_version); + const parsedPlatformVersion = parseCatalogSemver(platformVersion); + if (!contractVersion || !parsedPlatformVersion) { + if (options.onUnparsableSupportVersion) { + options.onUnparsableSupportVersion({ + contractId: contract.contract_id, + supportVersion: contract.support_version, + platformVersion, + }); + } else { + logApp.warn('[OPENCTI-MODULE] Ignoring catalog contract with unparsable support version', { + module: 'catalog', + contractId: contract.contract_id, + supportVersion: contract.support_version, + platformVersion, + }); + } + return false; + } + return semver.lte(contractVersion, parsedPlatformVersion); +}; + +export const compareContractVersionDesc = ( + left: ContractVersionContract, + right: ContractVersionContract, +) => { + const leftVersion = parseCatalogSemver(left.contract_version); + const rightVersion = parseCatalogSemver(right.contract_version); + if (leftVersion && rightVersion) { + return semver.rcompare(leftVersion, rightVersion); + } + if (leftVersion) { + return -1; + } + if (rightVersion) { + return 1; + } + return right.contract_version.localeCompare(left.contract_version, undefined, { numeric: true, sensitivity: 'base' }); +}; + +export const filterAndSortLatestCompatibleContracts = ( + contracts: BasicStoreEntityCatalogContract[], + options: CompatibilityOptions = {}, +) => { + return contracts + .filter((contract) => isSupportVersionCompatible(contract, options)) + .sort(compareContractVersionDesc); +}; diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog.graphql b/opencti-platform/opencti-graphql/src/modules/catalog/catalog.graphql index c405a4467dc7..71503d051696 100644 --- a/opencti-platform/opencti-graphql/src/modules/catalog/catalog.graphql +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog.graphql @@ -8,12 +8,17 @@ type Catalog implements InternalObject & BasicObject { # Catalog name: String! description: String! - contracts: [String!]! + contracts: [String!]! # JSON.Stringified GraphqlCatalogContract items } type ExtendedContract { catalog_id: String! - contract: String! + contract: String! # JSON.Stringified GraphqlCatalogContract items +} + +type CatalogRevision { + catalog_id: String! + revision: String } # Ordering @@ -34,7 +39,14 @@ type CatalogEdge { # Queries type Query { + # Returns only the latest compatible version of each contract for + # backwards compatibility. catalog(id: String!): Catalog @auth(for: [INGESTION]) + # Returns only the latest compatible version of each contract for + # backwards compatibility. catalogs: [Catalog!]! @auth(for: [INGESTION]) + # Lightweight polling endpoint for catalog refresh detection. + catalogsRevisions: [CatalogRevision!]! @auth(for: [INGESTION]) + # Returns only the latest compatible version for backwards compatibility. contract(slug: String!): ExtendedContract @auth(for: [INGESTION]) } diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/catalog.ts b/opencti-platform/opencti-graphql/src/modules/catalog/catalog.ts new file mode 100644 index 000000000000..d2012a419660 --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/catalog/catalog.ts @@ -0,0 +1,104 @@ +import { type ModuleDefinition, registerDefinition } from '../../schema/module'; +import { ABSTRACT_INTERNAL_OBJECT } from '../../schema/general'; +import { + type BasicStoreEntityCatalogContract, + type BasicStoreEntityCatalog, + ENTITY_TYPE_CATALOG, + ENTITY_TYPE_CATALOG_CONTRACT, + type StoreEntityCatalogContract, + type StoreEntityCatalog, +} from './catalog-types'; +import type { AttributeDefinition } from '../../schema/attribute-definition'; +import { UnsupportedError } from '../../config/errors'; + +export const CATALOG_CONTRACT_MAPPINGS: AttributeDefinition[] = [ + { name: 'catalog_id', label: 'Catalog ID', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'contract_id', label: 'Catalog Contract ID', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'content_hash', label: 'Catalog Contract content hash', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'title', label: 'Title', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'slug', label: 'Slug', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'description', label: 'Description', type: 'string', format: 'text', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'short_description', label: 'Short description', type: 'string', format: 'text', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'logo_uri', label: 'Logo URI in file storage', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'use_cases', label: 'Use cases', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: true, upsert: false, isFilterable: true }, + { name: 'verified', label: 'Verified', type: 'boolean', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'last_verified_date', label: 'Last verified date', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'playbook_supported', label: 'Playbook supported', type: 'boolean', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'max_confidence_level', label: 'Max confidence level', type: 'numeric', precision: 'integer', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'support_version', label: 'Support version', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'subscription_link', label: 'Subscription link', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'source_code', label: 'Source code', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'manager_supported', label: 'Manager supported', type: 'boolean', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'contract_version', label: 'Contract version', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'image', label: 'Image', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'connector_type', label: 'Connector type', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'config_schema', label: 'Configuration schema', type: 'object', format: 'raw', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'license_type', label: 'License type', type: 'string', format: 'enum', values: ['Free', 'Commercial'], mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'solution_categories', label: 'Solution categories', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: true, upsert: false, isFilterable: true }, + { name: 'contact', label: 'Contact', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: true }, +]; + +// CatalogContract = one persisted entry per (slug, contract_version). +// All connector metadata lives here since any field can vary between versions. +const CATALOG_CONTRACT_DEFINITION: ModuleDefinition = { + type: { + id: 'catalogContracts', + name: ENTITY_TYPE_CATALOG_CONTRACT, + category: ABSTRACT_INTERNAL_OBJECT, + aliased: false, + }, + identifier: { + definition: { + [ENTITY_TYPE_CATALOG_CONTRACT]: [{ src: 'catalog_id' }, { src: 'contract_id' }], + }, + resolvers: {}, + }, + attributes: CATALOG_CONTRACT_MAPPINGS, + relations: [], + // Irrelevant as no corresponding Stix object. + // To remove once entity/module definition helper is reworked. + representative: () => { + throw UnsupportedError('Catalog contract representative should not be called'); + }, + converter_2_1: () => { + throw UnsupportedError('Catalog contract converter_2_1 should not be called'); + }, +}; + +registerDefinition(CATALOG_CONTRACT_DEFINITION); + +const CATALOG_DEFINITION: ModuleDefinition = { + type: { + id: 'catalogs', + name: ENTITY_TYPE_CATALOG, + category: ABSTRACT_INTERNAL_OBJECT, + aliased: false, + }, + identifier: { + definition: { + [ENTITY_TYPE_CATALOG]: [{ src: 'catalog_id' }], + }, + resolvers: {}, + }, + attributes: [ + { name: 'source_uri', label: 'Source URI', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'catalog_id', label: 'Catalog ID', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'revision', label: 'Revision', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: true }, + { name: 'product_version', label: 'Catalog product version', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'manifest_version', label: 'Manifest version', type: 'string', format: 'short', mandatoryType: 'no', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + { name: 'manifest_schema_version', label: 'Manifest schema version', type: 'string', format: 'short', mandatoryType: 'external', editDefault: false, multiple: false, upsert: false, isFilterable: false }, + ], + relations: [], + // Irrelevant as no corresponding Stix object. + // To remove once entity/module definition helper is reworked. + representative: () => { + throw UnsupportedError('Catalog representative should not be called'); + }, + converter_2_1: () => { + throw UnsupportedError('Catalog converter_2_1 should not be called'); + }, +}; + +registerDefinition(CATALOG_DEFINITION); + +export type { BasicStoreEntityCatalogContract, BasicStoreEntityCatalog }; diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/sync/catalog-sync-domain.ts b/opencti-platform/opencti-graphql/src/modules/catalog/sync/catalog-sync-domain.ts new file mode 100644 index 000000000000..abd8f80b70de --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/catalog/sync/catalog-sync-domain.ts @@ -0,0 +1,476 @@ +import { createHash } from 'node:crypto'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import conf, { isFeatureEnabled, logApp, PLATFORM_VERSION } from '../../../config/conf'; +import { SYSTEM_USER } from '../../../utils/access'; +import type { AuthContext, AuthUser } from '../../../types/user'; +import { generateStandardId, idGenFromData } from '../../../schema/identifier'; +import { UnsupportedError } from '../../../config/errors'; +import { ENTITY_TYPE_CATALOG_CONTRACT, ENTITY_TYPE_CATALOG } from '../catalog-types'; +import { + type CatalogContractLogoUploadOperation, + computeCatalogContractLogoUploadOperation, + listCatalogContractLogos, + uploadCatalogContractLogoOperation, +} from '../catalog-logo-storage'; +import type { + BasicStoreEntityCatalogContract, + BasicStoreEntityCatalog, + CatalogContractCreation, + CatalogContractDeletion, + CatalogContractUpdate, + CatalogUpsert, +} from '../catalog-types'; +import { + deleteCatalogContracts, + deleteCatalogs, + findCatalogContractsByCatalogId, + findCatalogByCatalogId, + findCatalogBySourceUri, + findAllCatalogsExcluding, + insertCatalogContracts, + updateCatalogContracts, + upsertCatalog, +} from '../catalog-repository'; +import type { CatalogContractSyncSource, CatalogSyncSource, CatalogSyncSourceConfig } from './catalog-sync-types'; +import { type CatalogSyncSourceGatewayOptions, fetchSourceCatalog, fetchSourceCatalogRevisionHint } from './catalog-sync-source-gateway'; + +const DECOUPLING_VERSIONS_FEATURE_FLAG = 'DECOUPLING_VERSIONS'; + +const getFiligranCatalogRemoteUri = (): string | undefined => { + const xtmHubUrl = conf.get('xtm:xtmhub_url'); + if (!isFeatureEnabled(DECOUPLING_VERSIONS_FEATURE_FLAG) || typeof xtmHubUrl !== 'string' || xtmHubUrl.length === 0) { + return undefined; + } + const normalized = xtmHubUrl.endsWith('/') ? xtmHubUrl.slice(0, -1) : xtmHubUrl; + return `${normalized}/opencti/${encodeURIComponent(PLATFORM_VERSION)}/connector/manifests/latest`; +}; + +const computeCatalogRevision = (rawManifest: unknown): string => { + return createHash('sha256').update(JSON.stringify(rawManifest)).digest('hex'); +}; + +const buildCatalogContractIds = (catalogId: string, contractId: string) => { + const keyData = { catalog_id: catalogId, contract_id: contractId }; + return { + internal_id: idGenFromData(ENTITY_TYPE_CATALOG_CONTRACT, keyData), + standard_id: generateStandardId(ENTITY_TYPE_CATALOG_CONTRACT, keyData), + }; +}; + +const buildCatalogIds = (catalogId: string) => { + const keyData = { catalog_id: catalogId }; + return { + internal_id: idGenFromData(ENTITY_TYPE_CATALOG, keyData), + standard_id: generateStandardId(ENTITY_TYPE_CATALOG, keyData), + }; +}; + +export const computeContractContentHash = (contract: CatalogContractSyncSource) => { + const replacer = (_key: string, value: Record) => + value && typeof value === 'object' && !Array.isArray(value) + ? Object.keys(value).sort().reduce((sorted, k) => { + sorted[k] = value[k]; + return sorted; + }, {} as Record) + : value; + return createHash('sha256').update(JSON.stringify(contract, replacer)).digest('hex'); +}; + +const computeCatalogLogosSyncOps = ( + sourceCatalog: CatalogSyncSource, + currentLogos: Set, +) => { + const plannedLogos = new Set(currentLogos); + const sourceCatalogLogosUris = new Map(); + const uploadOperations: CatalogContractLogoUploadOperation[] = []; + for (const sourceContract of sourceCatalog.contracts) { + const logoOperationResult = computeCatalogContractLogoUploadOperation(sourceContract, plannedLogos); + if (logoOperationResult.result === 'failed') { + throw UnsupportedError(`Error while preparing logo for contract ${sourceContract.id}`, { + cause: logoOperationResult.error, + }); + } + if (logoOperationResult.result === 'no-logo') { + continue; + } + sourceCatalogLogosUris.set(sourceContract.id, logoOperationResult.logoUri); + plannedLogos.add(logoOperationResult.filename); + if (!logoOperationResult.existed) { + if (!logoOperationResult.operation) { + throw UnsupportedError(`Missing logo upload operation for contract ${sourceContract.id}`); + } + uploadOperations.push(logoOperationResult.operation); + } + } + return { + sourceCatalogLogosUris, + uploadOperations, + }; +}; + +const uploadCatalogContractLogos = async (operations: CatalogContractLogoUploadOperation[]) => { + for (const operation of operations) { + const uploadResult = await uploadCatalogContractLogoOperation(operation); + if (uploadResult.result === 'failed') { + throw uploadResult.error; + } + } +}; + +const computeCatalogSyncOps = (params: { + revision: string; + sourceConfig: CatalogSyncSourceConfig; + sourceCatalog: CatalogSyncSource; + sourceCatalogContractsHashes: Map; + sourceCatalogLogosUris: Map; + currentCatalog: BasicStoreEntityCatalog | undefined; + currentContracts: Map; +}) => { + const sourceContractsIds = new Set(params.sourceCatalogContractsHashes.keys()); + const contractsCreations: CatalogContractCreation[] = []; + const contractsUpdates: CatalogContractUpdate[] = []; + const contractsDeletions: CatalogContractDeletion[] = []; + + params.sourceCatalog.contracts.forEach((sourceContract) => { + const sourceContractHash = params.sourceCatalogContractsHashes.get(sourceContract.id)!; + const currentContract = params.currentContracts.get(sourceContract.id); + if (!currentContract) { + const ids = buildCatalogContractIds(params.sourceCatalog.id, sourceContract.id); + const contractCreation: CatalogContractCreation = { + ...ids, + catalog_id: params.sourceCatalog.id, + contract_id: sourceContract.id, + content_hash: sourceContractHash, + title: sourceContract.title, + slug: sourceContract.slug, + description: sourceContract.description, + short_description: sourceContract.short_description, + logo_uri: params.sourceCatalogLogosUris.get(sourceContract.id) ?? undefined, + use_cases: [...sourceContract.use_cases], + verified: sourceContract.verified, + last_verified_date: sourceContract.last_verified_date ?? undefined, + playbook_supported: sourceContract.playbook_supported, + max_confidence_level: sourceContract.max_confidence_level, + support_version: sourceContract.support_version ?? undefined, + subscription_link: sourceContract.subscription_link ?? undefined, + source_code: sourceContract.source_code ?? undefined, + manager_supported: sourceContract.manager_supported, + contract_version: sourceContract.container_version, + image: sourceContract.container_image, + connector_type: sourceContract.container_type, + config_schema: sourceContract.config_schema, + license_type: sourceContract.license_type ?? undefined, + solution_categories: sourceContract.solution_categories, + contact: sourceContract.contact ?? undefined, + }; + contractsCreations.push(contractCreation); + return; + } + if (sourceContractHash === currentContract.content_hash) { + return; + } + const ids = buildCatalogContractIds(params.sourceCatalog.id, sourceContract.id); + const contractUpdate: CatalogContractUpdate = { + internal_id: currentContract.internal_id ?? currentContract.id, + standard_id: ids.standard_id, + catalog_id: params.sourceCatalog.id, + contract_id: sourceContract.id, + content_hash: sourceContractHash, + title: sourceContract.title, + slug: sourceContract.slug, + description: sourceContract.description, + short_description: sourceContract.short_description, + logo_uri: params.sourceCatalogLogosUris.get(sourceContract.id) ?? null, + use_cases: [...sourceContract.use_cases], + verified: sourceContract.verified, + last_verified_date: sourceContract.last_verified_date, + playbook_supported: sourceContract.playbook_supported, + max_confidence_level: sourceContract.max_confidence_level, + support_version: sourceContract.support_version, + subscription_link: sourceContract.subscription_link, + manager_supported: sourceContract.manager_supported, + contract_version: sourceContract.container_version, + image: sourceContract.container_image, + connector_type: sourceContract.container_type, + config_schema: sourceContract.config_schema, + solution_categories: sourceContract.solution_categories, + source_code: sourceContract.source_code, + license_type: sourceContract.license_type, + contact: sourceContract.contact, + }; + contractsUpdates.push(contractUpdate); + }); + + params.currentContracts.forEach((currentContract) => { + if (!sourceContractsIds.has(currentContract.contract_id)) { + contractsDeletions.push({ idToDelete: currentContract.id }); + return; + } + }); + const catalogIds = buildCatalogIds(params.sourceCatalog.id); + const catalogUpsert: CatalogUpsert = { + ...catalogIds, + revision: params.revision, + source_uri: params.sourceConfig.uri, + catalog_id: params.sourceCatalog.id, + name: params.sourceCatalog.name, + description: params.sourceCatalog.description, + product_version: params.sourceCatalog.product_version, + manifest_version: params.sourceCatalog.manifest_version, + manifest_schema_version: params.sourceCatalog.manifest_schema_version, + }; + return { + contractsCreations, + contractsUpdates, + contractsDeletions, + catalogUpsert, + }; +}; + +const synchronizeCatalog = async ( + context: AuthContext, + user: AuthUser, + sourceConfig: CatalogSyncSourceConfig, + options?: CatalogSyncSourceGatewayOptions, +) => { + try { + logApp.debug('[OPENCTI-MODULE] Synchronizing catalog', { + sourceKind: sourceConfig.kind, + sourceUri: sourceConfig.uri, + }); + const currentCatalogBySourceUri = sourceConfig.kind === 'remote' + ? await findCatalogBySourceUri(context, user, sourceConfig.uri) + : undefined; + const remoteRevisionHint = sourceConfig.kind === 'remote' + ? await fetchSourceCatalogRevisionHint(sourceConfig, options) + : undefined; + if (sourceConfig.kind === 'remote' && currentCatalogBySourceUri?.revision) { + if (remoteRevisionHint && remoteRevisionHint === currentCatalogBySourceUri.revision) { + logApp.info('[OPENCTI-MODULE] Catalog manager manifest unchanged (remote ETag match)', { + sourceKind: sourceConfig.kind, + catalogId: currentCatalogBySourceUri.catalog_id, + revision: remoteRevisionHint, + module: 'catalog', + }); + return { + synced: false, + catalogId: currentCatalogBySourceUri.catalog_id, + } as const; + } + } + // Fetch source catalog + const sourceCatalog = await fetchSourceCatalog(sourceConfig, options); + // Find existing persisted data in the database corresponding to the catalog id + const currentCatalog = await findCatalogByCatalogId(context, user, sourceCatalog.id); + const currentRevision = currentCatalog?.revision; + if (currentCatalog) { + logApp.debug('[OPENCTI-MODULE] Found existing persisted catalog', { + sourceKind: sourceConfig.kind, + catalogId: currentCatalog.catalog_id, + revision: currentRevision, + module: 'catalog', + }); + } else { + logApp.info('[OPENCTI-MODULE] New catalog source', { + executionContext: context.source, + sourceKind: sourceConfig.kind, + module: 'catalog', + }); + } + if (currentCatalogBySourceUri && currentCatalogBySourceUri.catalog_id !== sourceCatalog.id) { + logApp.warn('[OPENCTI-MODULE] Same catalog source with different ID', { + sourceKind: sourceConfig.kind, + knownId: currentCatalogBySourceUri.catalog_id, + newId: sourceCatalog.id, + module: 'catalog', + }); + } + // Compute revision and compare with persisted, return early if equal. + const revision = remoteRevisionHint ?? computeCatalogRevision(sourceCatalog); + if (currentRevision && revision === currentRevision) { + logApp.info('[OPENCTI-MODULE] Catalog manager manifest unchanged (revision match)', { + sourceKind: sourceConfig.kind, + catalogId: sourceCatalog.id, + revision, + module: 'catalog', + }); + return { + synced: false, + catalogId: sourceCatalog.id, + } as const; + } + // Fetch all current contracts, logos & compute sync diff + const currentContracts = currentCatalog + ? await findCatalogContractsByCatalogId(context, user, currentCatalog.catalog_id) + : new Map(); + const currentLogos = await listCatalogContractLogos(); + const logoSyncOps = computeCatalogLogosSyncOps(sourceCatalog, currentLogos); + const sourceCatalogContractsHashes = sourceCatalog.contracts.reduce((hashesMap, contract) => { + hashesMap.set(contract.id, computeContractContentHash(contract)); + return hashesMap; + }, new Map()); + const catalogSyncDiff = computeCatalogSyncOps({ + revision, + sourceConfig, + sourceCatalog, + sourceCatalogContractsHashes, + sourceCatalogLogosUris: logoSyncOps.sourceCatalogLogosUris, + currentCatalog, + currentContracts, + }); + // Persist refreshed catalog, contracts & logos + // Something's fishy if this occurs too frequently. + logApp.warn('[OPENCTI-MODULE] Persisting catalog & contracts', { + catalogId: sourceCatalog.id, + contractsCreationsCount: catalogSyncDiff.contractsCreations.length, + contractsUpdatesCount: catalogSyncDiff.contractsUpdates.length, + contractsDeletionsCount: catalogSyncDiff.contractsDeletions.length, + module: 'catalog', + }); + await uploadCatalogContractLogos(logoSyncOps.uploadOperations); + await insertCatalogContracts(context, user, catalogSyncDiff.contractsCreations); + if (catalogSyncDiff.contractsUpdates.length > 0) { + // Usually there would be no updates of catalog contracts given a change + // would be treated in a new version. This could be the result of a previous + // failure being compensated or an issue in the release process. + // Something's fishy if this occurs too frequently. + logApp.warn('[OPENCTI-MODULE] Catalog contracts updates', { + catalogId: sourceCatalog.id, + count: catalogSyncDiff.contractsUpdates.length, + module: 'catalog', + }); + await updateCatalogContracts(context, user, catalogSyncDiff.contractsUpdates); + } + await deleteCatalogContracts(context, user, catalogSyncDiff.contractsDeletions); + await upsertCatalog(context, user, catalogSyncDiff.catalogUpsert, currentCatalog); + const isNotNil = (str: string | null | undefined): str is string => Boolean(str); + let usedLogos = catalogSyncDiff.contractsCreations.map(({ logo_uri }) => logo_uri).filter(isNotNil); + usedLogos = usedLogos.concat(catalogSyncDiff.contractsUpdates.map(({ logo_uri }) => logo_uri).filter(isNotNil)); + return { + synced: true as const, + catalogId: sourceCatalog.id, + usedLogos: new Set(usedLogos), + }; + } catch (exception) { + logApp.error('[OPENCTI-MODULE] [catalog] Error while syncing catalog', { + cause: exception, + }); + return { synced: false, error: true } as const; + } +}; + +const EMBEDDED_CATALOG_SYNC_SOURCE: CatalogSyncSourceConfig = { kind: 'embedded', uri: 'embedded' } as const; + +const parseCustomCatalogEndpointUri = (uri: string): CatalogSyncSourceConfig => { + if (uri.startsWith('http://') || uri.startsWith('https://')) { + return { kind: 'remote', uri }; + } + if (uri.startsWith('file://')) { + const filepath = fileURLToPath(uri); + return { + kind: 'local', + filepath, + uri, + }; + } + throw UnsupportedError('Unsupported custom catalog endpoint URI protocol', { + uri, + supportedProtocols: ['file://', 'http://', 'https://'], + }); +}; + +const initSyncSources = () => { + // Build catalog sources configs from env vars/app settings + const sources: CatalogSyncSourceConfig[] = []; + if (isFeatureEnabled(DECOUPLING_VERSIONS_FEATURE_FLAG)) { + const customCatalogRefreshEndpointUri = conf.get('catalog_manager:custom_catalog_refresh_endpoint_uri'); + if (typeof customCatalogRefreshEndpointUri === 'string' && customCatalogRefreshEndpointUri.length > 0) { + // Custom decoupling source overrides XTM hub source when configured. + sources.push(parseCustomCatalogEndpointUri(customCatalogRefreshEndpointUri)); + } else { + const decouplingRemoteUri = getFiligranCatalogRemoteUri(); + if (decouplingRemoteUri) { + sources.push({ kind: 'remote', uri: decouplingRemoteUri }); + } else { + sources.push(EMBEDDED_CATALOG_SYNC_SOURCE); + } + } + } else { + sources.push(EMBEDDED_CATALOG_SYNC_SOURCE); + } + // We will consider removing the support for these CUSTOM_CATALOGS + // as it historically only supported local files and wasn't explicitly + // documented. + const CUSTOM_CATALOGS: string[] = conf.get('app:custom_catalogs') ?? []; + if (CUSTOM_CATALOGS) { + sources.push(...CUSTOM_CATALOGS.map((customCatalog) => { + if (customCatalog.startsWith('http://') || customCatalog.startsWith('https://')) { + return { + kind: 'remote' as const, + uri: customCatalog, + }; + } + return { + kind: 'local' as const, + filepath: customCatalog, + uri: pathToFileURL(customCatalog).toString(), + }; + })); + } + return sources.filter((source, idx, all) => all.findIndex((candidate) => candidate.uri === source.uri) === idx); +}; + +const cleanupObsoleteCatalogs = async (context: AuthContext, syncedCatalogs: string[]) => { + const obsoleteCatalogs = await findAllCatalogsExcluding(context, SYSTEM_USER, syncedCatalogs); + if (obsoleteCatalogs.length) { + // The app config changed resulting in a catalog not being synced anymore + logApp.warn('[OPENCTI-MODULE] Deleting obsolete catalogs', { + module: 'catalog', + deletedCatalogIds: obsoleteCatalogs.map((catalog) => catalog.catalog_id), + }); + await deleteCatalogs(context, obsoleteCatalogs); + } +}; + +export const synchronizeCatalogs = async ( + context: AuthContext, + user: AuthUser, + options?: CatalogSyncSourceGatewayOptions, +) => { + const sources = initSyncSources(); + const filigranCatalogRemoteUri = getFiligranCatalogRemoteUri(); + logApp.debug('[OPENCTI-MODULE] Synchronizing catalogs', { + module: 'catalog', + executionContext: context.source, + count: sources.length, + filigranCatalogRemoteUri, + }); + const syncedCatalogs: string[] = []; + const syncedCatalogsWithChanges: string[] = []; + // Sync catalogs from sources + for (const source of sources) { + let result = await synchronizeCatalog(context, user, source, options); + if (source.kind === 'remote' && result.error && filigranCatalogRemoteUri && source.uri === filigranCatalogRemoteUri) { + const alreadyPersistedRemoteCatalog = await findCatalogBySourceUri(context, user, source.uri); + if (!alreadyPersistedRemoteCatalog) { + logApp.error('[OPENCTI-MODULE] Remote catalog source failed before first successful persistence, falling back to embedded source', { + sourceKind: source.kind, + sourceUri: source.uri, + module: 'catalog', + }); + result = await synchronizeCatalog(context, user, EMBEDDED_CATALOG_SYNC_SOURCE, options); + } + } + if (!result.error) { + syncedCatalogs.push(result.catalogId); + if (result.synced) { + syncedCatalogsWithChanges.push(result.catalogId); + } + } + }; + // Cleanup obsolete catalogs only if no sync failed + if (syncedCatalogs.length === sources.length) { + await cleanupObsoleteCatalogs(context, syncedCatalogs); + } + return syncedCatalogsWithChanges; +}; diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/sync/catalog-sync-source-gateway.ts b/opencti-platform/opencti-graphql/src/modules/catalog/sync/catalog-sync-source-gateway.ts new file mode 100644 index 000000000000..ac98e9758f44 --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/catalog/sync/catalog-sync-source-gateway.ts @@ -0,0 +1,375 @@ +import { readFile } from 'node:fs/promises'; +import { logApp } from '../../../config/conf'; +import { isEmptyField } from '../../../database/utils'; +import { UnsupportedError } from '../../../config/errors'; +import { getOrCompileValidator } from '../catalog-domain'; +import { getHttpClient } from '../../../utils/http-client'; +import type { CatalogContract, CatalogDefinition, TypedProperty } from '../catalog-types'; +import type { CatalogContractSyncSource, CatalogSyncSource, CatalogSyncSourceConfig } from './catalog-sync-types'; + +/** + * Data Transfer Objects (DTOs), in several versions. + * - V0: the format used in the embedded catalog manifest, before the decoupling project + * - V1: the format specced during the decoupling project + */ +const MANIFEST_SCHEMA_VERSION_1 = '1'; + +type CatalogContractDtoV0 = CatalogContract; +type CatalogDtoV0 = CatalogDefinition; +type CatalogContractDtoV1 = { + id: string; + title: string; + slug: string; + description: string; + short_description: string; + logo: string | null; + use_cases: string[]; + verified: boolean; + last_verified_date: string; + subscription_link: string | null; + source_code: string | null; + manager_supported: boolean; + support_version: string | null; + license_type: 'Free' | 'Commercial' | null; + contact: string | null; + solution_categories: string[]; + version: string; + image_name: string; + image_type: string; + additional_properties: Record; + config_schema: Record; +}; +type CatalogDtoV1 = { + id: string; + name: string; + description: string; + manifest_schema_version: typeof MANIFEST_SCHEMA_VERSION_1; + manifest_version: string; + product_version: string; + contracts: Array; +}; + +interface CatalogDtoWithExplicitSchemaVersion { + manifest_schema_version: string; +} + +interface CatalogSyncSourceAdapter { + fetch: () => Promise; + fetchRevisionHint?: () => Promise; +} + +const DEFAULT_REMOTE_CATALOG_TIMEOUT_MS = 30000; +export type CatalogSyncSourceGatewayOptions = { + remoteCatalogTimeoutMs?: number; +}; + +const resolveRemoteCatalogTimeoutMs = (options?: CatalogSyncSourceGatewayOptions) => { + const configuredTimeout = Number(options?.remoteCatalogTimeoutMs); + if (!Number.isFinite(configuredTimeout) || configuredTimeout <= 0) { + return DEFAULT_REMOTE_CATALOG_TIMEOUT_MS; + } + return configuredTimeout; +}; + +const withAbortTimeout = async (timeoutMs: number, call: (signal: AbortSignal) => Promise): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await call(controller.signal); + } finally { + clearTimeout(timer); + } +}; + +class EmbeddedCatalogSyncSource implements CatalogSyncSourceAdapter { + constructor(_sourceConfig: Extract) {} + + async fetch() { + const data = await import('../../../__generated__/opencti-manifest.json'); + return data.default as unknown as CatalogDefinition; + } +} + +class LocalCatalogSyncSource implements CatalogSyncSourceAdapter { + constructor(private sourceConfig: Extract) {} + + async fetch() { + const catalog = await readFile(this.sourceConfig.filepath, { encoding: 'utf8', flag: 'r' }); + return JSON.parse(catalog); + } +} + +class RemoteCatalogSyncSource implements CatalogSyncSourceAdapter { + constructor( + private sourceConfig: Extract, + private options?: CatalogSyncSourceGatewayOptions, + ) {} + + async fetch() { + const timeout = resolveRemoteCatalogTimeoutMs(this.options); + const client = getHttpClient({ responseType: 'text', timeout }); + const response = await withAbortTimeout(timeout, (signal) => client.get(this.sourceConfig.uri, { signal })); + return JSON.parse(response.data); + } + + async fetchRevisionHint() { + const timeout = resolveRemoteCatalogTimeoutMs(this.options); + const client = getHttpClient({ responseType: 'text', timeout }); + const response = await withAbortTimeout(timeout, (signal) => client.head(this.sourceConfig.uri, { signal })); + const etagHeader = response.headers?.etag; + const etag = Array.isArray(etagHeader) ? etagHeader[0] : etagHeader; + if (!etag || typeof etag !== 'string') { + return undefined; + } + return etag; + } +} + +const getCatalogSourceAdapter = ( + config: CatalogSyncSourceConfig, + options?: CatalogSyncSourceGatewayOptions, +): CatalogSyncSourceAdapter => { + switch (config.kind) { + case 'local': { + return new LocalCatalogSyncSource(config); + } + case 'embedded': { + return new EmbeddedCatalogSyncSource(config); + } + case 'remote': { + return new RemoteCatalogSyncSource(config, options); + } + default: + throw UnsupportedError(`Unknown catalog sync source kind (${JSON.stringify(config)})`); + }; +}; + +const validateSyncSource = (syncSource: CatalogSyncSource) => { + // Validate each contract + for (let contractIndex = 0; contractIndex < syncSource.contracts.length; contractIndex += 1) { + const contract = syncSource.contracts[contractIndex]; + if (contract.manager_supported) { + if (!contract.config_schema) { + logApp.warn('A contract has manager_supported=true but is missing config_schema', { contractTitle: contract.title }); + } else { + if (isEmptyField(contract.container_image)) { + throw UnsupportedError('Contract must define container_image field', { contractTitle: contract.title }); + } + if (isEmptyField(contract.container_type)) { + throw UnsupportedError('Contract must define container_type field', { contractTitle: contract.title }); + } + + if (contract.config_schema) { + const jsonValidation = { + type: contract.config_schema.type, + properties: contract.config_schema.properties, + required: contract.config_schema.required, + additionalProperties: contract.config_schema.additionalProperties, + }; + try { + getOrCompileValidator(`catalog-contract:${syncSource.id}:${contract.slug}`, jsonValidation); + } catch (err) { + throw UnsupportedError('Contract must be a valid json schema definition', { cause: err }); + } + } + } + } + } +}; + +const DEFAULT_CONFIG_SCHEMA: CatalogContractDtoV0['config_schema'] = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: '', + type: 'object', + properties: {}, + required: [], + additionalProperties: true, +}; + +const DEFAULT_PLAYBOOK_SUPPORTED = false; + +const DEFAULT_MAX_CONFIDENCE = 100; + +const normalizeConfigSchema = (configSchema: unknown): CatalogContractDtoV0['config_schema'] => { + if (!configSchema || typeof configSchema !== 'object' || Array.isArray(configSchema)) { + return DEFAULT_CONFIG_SCHEMA; + } + const schema = configSchema as Record; + return { + $schema: typeof schema.$schema === 'string' ? schema.$schema : DEFAULT_CONFIG_SCHEMA.$schema, + $id: typeof schema.$id === 'string' ? schema.$id : DEFAULT_CONFIG_SCHEMA.$id, + type: typeof schema.type === 'string' ? schema.type : DEFAULT_CONFIG_SCHEMA.type, + properties: schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties) + ? schema.properties as Record + : DEFAULT_CONFIG_SCHEMA.properties, + required: Array.isArray(schema.required) + ? schema.required.filter((item): item is string => typeof item === 'string') + : DEFAULT_CONFIG_SCHEMA.required, + additionalProperties: typeof schema.additionalProperties === 'boolean' + ? schema.additionalProperties + : DEFAULT_CONFIG_SCHEMA.additionalProperties, + }; +}; + +const normalizeSupportVersion = (supportVersion: string | null | undefined): string | null => { + if (!supportVersion) { + return null; + } + const normalized = supportVersion.replace(/^\s*>=\s*/, '').trim(); + return normalized.length > 0 ? normalized : null; +}; + +const mapCatalogContractDtoV1ToCatalogContractSyncSource = (contractDto: CatalogContractDtoV1): CatalogContractSyncSource => { + const playbook_supported = contractDto.additional_properties['playbook_supported']; + const max_confidence_level = contractDto.additional_properties['max_confidence_level']; + return { + id: contractDto.id, + title: contractDto.title, + slug: contractDto.slug, + description: contractDto.description, + short_description: contractDto.short_description, + logo: contractDto.logo, + use_cases: contractDto.use_cases, + verified: contractDto.verified, + last_verified_date: contractDto.last_verified_date, + playbook_supported: + typeof playbook_supported === 'boolean' + ? playbook_supported + : DEFAULT_PLAYBOOK_SUPPORTED, + max_confidence_level: + typeof max_confidence_level === 'number' + ? max_confidence_level + : DEFAULT_MAX_CONFIDENCE, + support_version: contractDto.support_version, + subscription_link: contractDto.subscription_link, + source_code: contractDto.source_code ?? '', + manager_supported: contractDto.manager_supported, + container_version: contractDto.version, + container_image: contractDto.image_name, + container_type: contractDto.image_type, + config_schema: normalizeConfigSchema(contractDto.config_schema), + license_type: contractDto.license_type, + solution_categories: contractDto.solution_categories, + contact: contractDto.contact, + }; +}; + +const isCatalogDtoV1 = (catalog: CatalogDtoWithExplicitSchemaVersion): catalog is CatalogDtoV1 => { + return catalog.manifest_schema_version === MANIFEST_SCHEMA_VERSION_1; +}; + +const isCatalogDtoV0 = (catalog: unknown): catalog is CatalogDtoV0 => { + return typeof catalog === 'object' + && catalog !== null + && 'id' in catalog + && 'version' in catalog + && typeof catalog.id === 'string' + && typeof catalog.version === 'string'; +}; + +const isCatalogDtoWithExplicitSchemaVersion = ( + catalog: unknown, +): catalog is CatalogDtoWithExplicitSchemaVersion => { + return typeof catalog === 'object' + && catalog !== null + && 'manifest_schema_version' in catalog + && typeof catalog.manifest_schema_version === 'string'; +}; + +export const mapCatalogContractDtoToCatalogContractSyncSource = ( + contractDto: CatalogContractDtoV0, +): CatalogContractSyncSource => { + const normalizedSupportVersion = normalizeSupportVersion(contractDto.support_version); + return { + id: `${contractDto.slug}-${contractDto.container_version}`, + ...contractDto, + support_version: normalizedSupportVersion, + }; +}; + +const mapCatalogDtoToCatalogSyncSource = (catalog: unknown): CatalogSyncSource => { + if (isCatalogDtoV0(catalog)) { + return mapCatalogDtoV0ToCatalogSyncSource(catalog); + } + if (!isCatalogDtoWithExplicitSchemaVersion(catalog)) { + throw UnsupportedError('Unrecognized catalog format: no manifest_schema_version'); + } + if (isCatalogDtoV1(catalog)) { + return mapCatalogDtoV1ToCatalogSyncSource(catalog); + } + throw UnsupportedError('Unsupported catalog schema version', { + cause: { + manifest_schema_version: catalog.manifest_schema_version, + }, + }); +}; + +const mapCatalogDtoV1ToCatalogSyncSource = (catalog: CatalogDtoV1): CatalogSyncSource => { + return { + id: catalog.id, + name: catalog.name, + description: catalog.description, + product_version: catalog.product_version, + manifest_version: catalog.manifest_version, + manifest_schema_version: catalog.manifest_schema_version, + contracts: catalog.contracts.map((contractDto) => { + return mapCatalogContractDtoV1ToCatalogContractSyncSource(contractDto); + }), + }; +}; + +const mapCatalogDtoV0ToCatalogSyncSource = (catalog: CatalogDtoV0): CatalogSyncSource => { + return { + ...catalog, + product_version: catalog.version, + manifest_version: null, + manifest_schema_version: '0' as const, + contracts: catalog.contracts.map(mapCatalogContractDtoToCatalogContractSyncSource), + }; +}; + +export const fetchSourceCatalog = async ( + sourceConfig: CatalogSyncSourceConfig, + options?: CatalogSyncSourceGatewayOptions, +): Promise => { + const adapter = getCatalogSourceAdapter(sourceConfig, options); + const raw = await adapter.fetch(); + const syncSource = mapCatalogDtoToCatalogSyncSource(raw); + validateSyncSource(syncSource); + logApp.debug('[OPENCTI-MODULE] Fetched and validated catalog source', { + module: 'catalog', + sourceKind: sourceConfig.kind, + sourceUri: sourceConfig.uri, + catalogId: syncSource.id, + contractsCount: syncSource.contracts.length, + manifestSchemaVersion: syncSource.manifest_schema_version, + productVersion: syncSource.product_version, + manifestVersion: syncSource.manifest_version, + }); + return syncSource; +}; + +export const fetchSourceCatalogRevisionHint = async ( + sourceConfig: CatalogSyncSourceConfig, + options?: CatalogSyncSourceGatewayOptions, +): Promise => { + const adapter = getCatalogSourceAdapter(sourceConfig, options); + const revisionHint = await adapter.fetchRevisionHint?.(); + if (sourceConfig.kind === 'remote') { + if (revisionHint) { + logApp.debug('[OPENCTI-MODULE] Fetched catalog source revision hint', { + module: 'catalog', + sourceKind: sourceConfig.kind, + sourceUri: sourceConfig.uri, + revisionHint, + }); + } else { + logApp.debug('[OPENCTI-MODULE] Catalog source revision hint unavailable', { + module: 'catalog', + sourceKind: sourceConfig.kind, + sourceUri: sourceConfig.uri, + }); + } + } + return revisionHint; +}; diff --git a/opencti-platform/opencti-graphql/src/modules/catalog/sync/catalog-sync-types.ts b/opencti-platform/opencti-graphql/src/modules/catalog/sync/catalog-sync-types.ts new file mode 100644 index 000000000000..ac6c97013fbc --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/catalog/sync/catalog-sync-types.ts @@ -0,0 +1,27 @@ +import type { CatalogContract } from '../catalog-types'; + +export type CatalogSyncSourceConfig = { + kind: 'embedded'; + uri: 'embedded'; +} | { + kind: 'local'; + filepath: string; + uri: string; +} | { + kind: 'remote'; + uri: string; +}; + +export type CatalogContractSyncSource = CatalogContract & { + id: string; // slug-version +}; + +export interface CatalogSyncSource { + id: string; + name: string; + description: string; + product_version: string; + manifest_version: string | null; + manifest_schema_version: '0' | '1'; + contracts: Array; +} diff --git a/opencti-platform/opencti-graphql/src/modules/connector/connector-domain.ts b/opencti-platform/opencti-graphql/src/modules/connector/connector-domain.ts new file mode 100644 index 000000000000..20901a90837a --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/connector/connector-domain.ts @@ -0,0 +1,155 @@ +import semver from 'semver'; +import { logApp } from '../../config/conf'; +import { publishUserAction } from '../../listener/UserActionListener'; +import type { AuthContext, AuthUser } from '../../types/user'; +import { findLatestCompatibleCatalogContractBySlug } from '../catalog/catalog-repository'; +import { mapContractEntityFieldsToEmbeddedConnectorManagerContract } from '../catalog/catalog-domain'; +import { findManagedConnectorsByCatalogId } from './connector-repository'; +import type { BasicStoreEntityConnector } from '../../types/connector'; +import { patchAttribute } from '../../database/middleware'; +import { ENTITY_TYPE_CONNECTOR } from '../../schema/internalObject'; + +const autoUpgradeManagedConnector = async ( + context: AuthContext, + user: AuthUser, + managedConnector: BasicStoreEntityConnector, +) => { + const { manager_upgrade_strategy, manager_contract } = managedConnector; + // Currently we only support the "upgrade to latest compatible version" strategy + if (manager_upgrade_strategy !== 'latest') { + return; + } + if (!manager_contract) { + logApp.warn('[OPENCTI-MODULE] Inconsistent connector data, unable to find manager_contract on managed connector', { + module: 'connector', + connectorId: managedConnector.id, + }); + return; + } + const { slug, contract_version, content_hash } = manager_contract; + try { + const latestCompatibleContract = await findLatestCompatibleCatalogContractBySlug(context, user, slug); + if (!latestCompatibleContract) { + // Warning: we're running a connector that's not compatible anymore but + // there's no replacement version compatible ! + logApp.warn('[OPENCTI-MODULE] Unable to find a compatible contract when applying auto-upgrade-to-latest-compatible strategy', { + module: 'connector', + connectorId: managedConnector.id, + }); + return; + } + if (semver.eq(contract_version, latestCompatibleContract.contract_version) + && content_hash === latestCompatibleContract.content_hash) { + logApp.debug('[OPENCTI-MODULE] Managed connector already uses latest compatible version', { + module: 'connector', + connectorId: managedConnector.id, + version: contract_version, + }); + return; + } + // Update connector + const patch: Partial = { + manager_contract: mapContractEntityFieldsToEmbeddedConnectorManagerContract(latestCompatibleContract), + manager_contract_image: latestCompatibleContract.image, + }; + await patchAttribute(context, user, managedConnector.id, ENTITY_TYPE_CONNECTOR, patch); + if (semver.lt(contract_version, latestCompatibleContract.contract_version)) { + logApp.info('[OPENCTI-MODULE] Upgraded connector to latest compatible version', { + module: 'connector', + connectorId: managedConnector.id, + contractSlug: slug, + previousVersion: contract_version, + newVersion: latestCompatibleContract.contract_version, + }); + // Activity log + // Unsure how correct this is. Maybe the context_data is too big here. + void publishUserAction({ + event_type: 'mutation', + event_access: 'administration', + event_scope: 'update', + message: 'upgrades connector to latest compatible version', + user, + context_data: { + entity_type: ENTITY_TYPE_CONNECTOR, + id: managedConnector.id, + input: { + slug, + previousVersion: contract_version, + newVersion: latestCompatibleContract.contract_version, + }, + }, + }); + } else if (semver.gt(contract_version, latestCompatibleContract.contract_version)) { + logApp.info('[OPENCTI-MODULE] Downgraded connector to latest compatible version', { + module: 'connector', + connectorId: managedConnector.id, + contractSlug: slug, + previousVersion: contract_version, + newVersion: latestCompatibleContract.contract_version, + }); + // Activity log + void publishUserAction({ + event_type: 'mutation', + event_access: 'administration', + event_scope: 'update', + message: 'downgrades connector to latest compatible version', + user, + context_data: { + entity_type: ENTITY_TYPE_CONNECTOR, + id: managedConnector.id, + input: { + slug, + previousVersion: contract_version, + newVersion: latestCompatibleContract.contract_version, + }, + }, + }); + } else if (semver.eq(contract_version, latestCompatibleContract.contract_version)) { + // Shouldn't happen: either a Release issue or a logic/code error. + logApp.warn('[OPENCTI-MODULE] Inconsistent connector data, same connector version with different contract content hash', { + module: 'connector', + contractSlug: slug, + contractVersion: contract_version, + }); + // Activity log + void publishUserAction({ + event_type: 'mutation', + event_access: 'administration', + event_scope: 'update', + message: 'upgrades connector to latest compatible identical version', + user, + context_data: { + entity_type: ENTITY_TYPE_CONNECTOR, + id: managedConnector.id, + input: { + slug, + previousVersion: contract_version, + newVersion: latestCompatibleContract.contract_version, + }, + }, + }); + } else { + throw new Error('Unexpected case when comparing connector contract versions'); + } + } catch (exception) { + logApp.error('[OPENCTI-MODULE] Failed to auto-upgrade connector to latest compatible version', { + module: 'connector', + contractSlug: slug, + contractVersion: contract_version, + cause: exception, + }); + } +}; + +export const autoUpgradeManagedConnectors = async ( + context: AuthContext, + user: AuthUser, + synchedCatalogs: string[], +) => { + for (const catalogId of synchedCatalogs) { + const managedConnectors = await findManagedConnectorsByCatalogId(context, user, catalogId); + for (const managedConnector of managedConnectors) { + await autoUpgradeManagedConnector(context, user, managedConnector); + }; + }; +}; diff --git a/opencti-platform/opencti-graphql/src/modules/connector/connector-repository.ts b/opencti-platform/opencti-graphql/src/modules/connector/connector-repository.ts new file mode 100644 index 000000000000..b88a6991b32a --- /dev/null +++ b/opencti-platform/opencti-graphql/src/modules/connector/connector-repository.ts @@ -0,0 +1,23 @@ +import { fullEntitiesList } from '../../database/middleware-loader'; +import { FilterMode, FilterOperator } from '../../generated/graphql'; +import { ENTITY_TYPE_CONNECTOR } from '../../schema/internalObject'; +import type { BasicStoreEntityConnector } from '../../types/connector'; +import type { AuthContext, AuthUser } from '../../types/user'; + +export const findManagedConnectorsByCatalogId = async ( + context: AuthContext, + user: AuthUser, + catalogId: string, +) => { + return await fullEntitiesList(context, user, [ENTITY_TYPE_CONNECTOR], { + filters: { + filters: [{ + key: ['catalog_id'], + values: [catalogId], + operator: FilterOperator.Eq, + }], + filterGroups: [], + mode: FilterMode.And, + }, + }); +}; diff --git a/opencti-platform/opencti-graphql/src/schema/attribute-definition.ts b/opencti-platform/opencti-graphql/src/schema/attribute-definition.ts index c82b40665b9c..e38a8e2b14b7 100644 --- a/opencti-platform/opencti-graphql/src/schema/attribute-definition.ts +++ b/opencti-platform/opencti-graphql/src/schema/attribute-definition.ts @@ -82,11 +82,12 @@ export type VocabAttribute = { type: 'string'; format: 'vocabulary'; vocabularyC export type JsonAttribute = { type: 'string'; format: 'json'; attrRawIds?: GetRawIdsFn; representative?: RepresentativeFn; multiple: false; schemaDef?: Record } & BasicDefinition; export type ObjectDefinition = { type: 'object'; attrRawIds?: GetRawIdsFn; representative?: RepresentativeFn } & BasicDefinition; export type FlatObjectAttribute = { type: 'object'; format: 'flat' } & ObjectDefinition; +export type RawObjectAttribute = { type: 'object'; format: 'raw' } & ObjectDefinition; export type ObjectAttribute = { type: 'object'; format: 'standard' } & BasicObjectDefinition; export type NestedObjectAttribute = { type: 'object'; format: 'nested' } & BasicObjectDefinition; export type RefAttribute = { type: 'ref'; attrRawIds?: GetRawIdsFn; databaseName: string; stixName: string; isRefExistingForTypes: Checker; datable?: boolean; toTypes: string[] } & BasicDefinition; export type StringAttribute = IdAttribute | TextAttribute | EnumAttribute | VocabAttribute | JsonAttribute; -export type ComplexAttribute = FlatObjectAttribute | ObjectAttribute | NestedObjectAttribute; +export type ComplexAttribute = FlatObjectAttribute | ObjectAttribute | NestedObjectAttribute | RawObjectAttribute; export type ComplexAttributeWithMappings = ObjectAttribute | NestedObjectAttribute; export type AttributeDefinition = NumericAttribute | DateAttribute | BooleanAttribute diff --git a/opencti-platform/opencti-graphql/src/schema/schema-attributes.ts b/opencti-platform/opencti-graphql/src/schema/schema-attributes.ts index 58cd5ec268e4..d276ada77957 100644 --- a/opencti-platform/opencti-graphql/src/schema/schema-attributes.ts +++ b/opencti-platform/opencti-graphql/src/schema/schema-attributes.ts @@ -2,7 +2,7 @@ import * as R from 'ramda'; import { GraphQLDateTime } from 'graphql-scalars'; import { RULE_PREFIX } from './general'; import { FunctionalError, UnsupportedError } from '../config/errors'; -import type { AttributeDefinition, AttrType, ComplexAttributeWithMappings, MappingDefinition } from './attribute-definition'; +import type { AttributeDefinition, AttrType, ComplexAttribute, ComplexAttributeWithMappings, MappingDefinition } from './attribute-definition'; import { shortStringFormats } from './attribute-definition'; import { getParentTypes } from './schemaUtils'; import { isFeatureEnabled } from '../config/conf'; @@ -22,8 +22,8 @@ export const depsKeysRegister = { const isMandatoryAttributeDefinition = (schemaDef: AttributeDefinition) => schemaDef.mandatoryType === 'external' || schemaDef.mandatoryType === 'internal'; const isDateAttributeDefinition = (schemaDef: AttributeDefinition) => schemaDef.type === 'date'; -const isNonFlatObjectAttributeDefinition = (schemaDef: AttributeDefinition): schemaDef is ComplexAttributeWithMappings => { // handy typeguard - return schemaDef.type === 'object' && schemaDef.format !== 'flat'; +const isObjectAttributeWithMappings = (schemaDef: AttributeDefinition): schemaDef is ComplexAttributeWithMappings => { // handy typeguard + return schemaDef.type === 'object' && (schemaDef.format === 'standard' || schemaDef.format === 'nested'); }; /** @@ -35,7 +35,7 @@ const getAttributeMappingFromPath = (path: string, schemaDef: AttributeDefinitio if (pathTokens.length === 1) { return schemaDef; } - if (!isNonFlatObjectAttributeDefinition(schemaDef)) { + if (!isObjectAttributeWithMappings(schemaDef)) { throw FunctionalError(`Cannot resolve path [${path}], [${schemaDef.name}] is not an object`); } const mapping = schemaDef.mappings.find((m) => m.name === pathTokens[1]); @@ -145,7 +145,7 @@ export const schemaAttributesDefinition = { }); } // Check sortBy on object - if (attribute.type === 'object' && attribute.format !== 'flat' && attribute.sortBy) { + if (isObjectAttributeWithMappings(attribute) && attribute.sortBy) { const correspondingMapping = getAttributeMappingFromPath(attribute.sortBy.path, attribute); if (correspondingMapping.type !== attribute.sortBy.type) { throw UnsupportedError('You can\'t define a sortBy with path and type that do not match the corresponding mapping', { @@ -155,7 +155,7 @@ export const schemaAttributesDefinition = { } } let registeredAttribute: AttributeDefinition = { ...attribute }; - if (registeredAttribute.type === 'object' && registeredAttribute.format !== 'flat' && registeredAttribute.mappings.length > 0) { + if (isObjectAttributeWithMappings(registeredAttribute) && registeredAttribute.mappings.length > 0) { registeredAttribute = { ...registeredAttribute, // filter feature flagged attributes @@ -294,7 +294,11 @@ export const isObjectFlatAttribute = (k: string): boolean => { if (!definition) return false; return definition.type === 'object' && definition.format === 'flat'; }; - +export const isObjectRawAttribute = (k: string): boolean => { + const definition = schemaAttributesDefinition.getAttributeByName(k.split('.')[0]); + if (!definition) return false; + return definition.type === 'object' && definition.format === 'raw'; +}; // -- MULTIPLE -- export const isMultipleAttribute = (entityType: string, k: string): boolean => ( @@ -320,7 +324,7 @@ const validateInputAgainstSchema = (input: any, schemaDef: AttributeDefinition) } } - if (isNonFlatObjectAttributeDefinition(schemaDef)) { + if (isObjectAttributeWithMappings(schemaDef)) { if (!isMandatory && R.isNil(input)) { return; // nothing to check (happens on 'remove' operation for instance } diff --git a/opencti-platform/opencti-graphql/src/telemetry/TelemetryMeterManager.ts b/opencti-platform/opencti-graphql/src/telemetry/TelemetryMeterManager.ts index 2a87246185be..ebb93c05fdc5 100644 --- a/opencti-platform/opencti-graphql/src/telemetry/TelemetryMeterManager.ts +++ b/opencti-platform/opencti-graphql/src/telemetry/TelemetryMeterManager.ts @@ -1,6 +1,7 @@ import { MeterProvider } from '@opentelemetry/sdk-metrics'; import type { ObservableResult } from '@opentelemetry/api'; import { ValueType } from '@opentelemetry/api'; +import type { CatalogContractEntityFields } from '../modules/catalog/catalog-types'; export const TELEMETRY_SERVICE_NAME = 'opencti-telemetry'; @@ -32,6 +33,7 @@ export const normalizeTelemetryTags = (rawTags: string | null | undefined): stri export interface ConnectorIdentitySource { catalog_id?: string | null; manager_contract_image?: string | null; + manager_contract?: CatalogContractEntityFields | null; name?: string | null; connector_type?: string | null; } @@ -75,13 +77,12 @@ export const stripImageToRepositoryPath = (imageReference: string | null | undef // available identity, flagged managed=false. export const computeActiveConnectorsByIdentity = ( activeConnectors: ConnectorIdentitySource[], - contractsByImage: ReadonlyMap, ): DimensionalGaugeItem[] => { const connectorsByIdentity = new Map(); activeConnectors.forEach((connector) => { const isManaged = (connector.catalog_id ?? '').length > 0; const slug = isManaged - ? (contractsByImage.get(connector.manager_contract_image ?? '')?.slug ?? stripImageToRepositoryPath(connector.manager_contract_image)) + ? (connector.manager_contract?.slug ?? stripImageToRepositoryPath(connector.manager_contract_image)) : (connector.name ?? '').trim().toLowerCase(); if (slug.length === 0) { return; diff --git a/opencti-platform/opencti-graphql/src/types/connector.d.ts b/opencti-platform/opencti-graphql/src/types/connector.d.ts index e1f20703e637..0e32a1fd26e0 100644 --- a/opencti-platform/opencti-graphql/src/types/connector.d.ts +++ b/opencti-platform/opencti-graphql/src/types/connector.d.ts @@ -1,3 +1,5 @@ +import type { ConnectorContractConfiguration } from '../generated/graphql'; +import type { CatalogContractEntityFields } from '../modules/catalog/catalog-types'; import type { BasicStoreEntity, StoreEntity } from './store'; export interface ConnectorInfo { @@ -9,6 +11,8 @@ export interface ConnectorInfo { last_run_datetime: DateTime; } +type ConnectorManagerContract = CatalogContractEntityFields; + export interface BasicStoreEntityConnector extends StoreEntity { active: boolean; auto: boolean; @@ -20,13 +24,16 @@ export interface BasicStoreEntityConnector extends StoreEntity { connector_state: string; connector_state_reset: boolean; connector_trigger_filters: string; - connector_user_id; string; + connector_user_id: string; connector_info: ConnectorInfo; playbook_compatible: boolean; xtm_one_intent: string | null; // region composer (set only on composer-managed connectors) catalog_id?: string; manager_contract_image?: string; + manager_contract_configuration?: ConnectorContractConfiguration[]; + manager_contract?: ConnectorManagerContract; + manager_upgrade_strategy?: string; // endregion } export interface BasicStoreEntityConnectorManager extends BasicStoreEntity { diff --git a/opencti-platform/opencti-graphql/src/utils/data-url.ts b/opencti-platform/opencti-graphql/src/utils/data-url.ts new file mode 100644 index 000000000000..863025780a23 --- /dev/null +++ b/opencti-platform/opencti-graphql/src/utils/data-url.ts @@ -0,0 +1,32 @@ +export const parseDataUrl = (dataUrl: string) => { + const defaultValue = { + mimeType: '', + base64Encoded: false, + data: '', + }; + + if (!dataUrl.startsWith('data:')) { + return defaultValue; + } + + const metadataAndData = dataUrl.slice(5); + const separatorIndex = metadataAndData.indexOf(','); + if (separatorIndex < 0) { + return defaultValue; + } + + const metadata = metadataAndData.slice(0, separatorIndex); + const data = metadataAndData.slice(separatorIndex + 1); + const metadataParts = metadata + .split(';') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + const mimeType = metadataParts[0]?.includes('/') ? metadataParts[0].toLowerCase() : ''; + const base64Encoded = metadataParts.some((part) => part.toLowerCase() === 'base64'); + return { + mimeType, + base64Encoded, + data, + }; +}; diff --git a/opencti-platform/opencti-graphql/tests/01-unit/database/migration-embed-catalog-contract-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/database/migration-embed-catalog-contract-test.ts new file mode 100644 index 000000000000..69df7c4cb70d --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/database/migration-embed-catalog-contract-test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockFullEntitiesList, + mockPatchAttribute, + mockListCatalogContractLogos, + mockStoreCatalogContractLogo, + mockComputeContractContentHash, + mockMapCatalogContractDtoToCatalogContractSyncSource, + mockMapContractDtoV0ToContractEntityFields, +} = vi.hoisted(() => ({ + mockFullEntitiesList: vi.fn(), + mockPatchAttribute: vi.fn(), + mockListCatalogContractLogos: vi.fn(), + mockStoreCatalogContractLogo: vi.fn(), + mockComputeContractContentHash: vi.fn(), + mockMapCatalogContractDtoToCatalogContractSyncSource: vi.fn(), + mockMapContractDtoV0ToContractEntityFields: vi.fn(), +})); + +vi.mock('../../../src/config/conf', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual.default, + get: vi.fn((key: string) => { + if (key === 'redis:ca') return []; + if (key === 'redis:use_ssl') return false; + return undefined; + }), + }, + logMigration: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, + }; +}); + +vi.mock('../../../src/database/middleware-loader', () => ({ + fullEntitiesList: mockFullEntitiesList, +})); + +vi.mock('../../../src/database/middleware', () => ({ + patchAttribute: mockPatchAttribute, +})); + +vi.mock('../../../src/utils/access', () => ({ + executionContext: vi.fn(() => ({ source: 'migration' })), + SYSTEM_USER: { id: 'system-user' }, +})); + +vi.mock('../../../src/modules/catalog/catalog-logo-storage', () => ({ + listCatalogContractLogos: mockListCatalogContractLogos, + storeCatalogContractLogo: mockStoreCatalogContractLogo, +})); + +vi.mock('../../../src/modules/catalog/sync/catalog-sync-domain', () => ({ + computeContractContentHash: mockComputeContractContentHash, +})); + +vi.mock('../../../src/modules/catalog/sync/catalog-sync-source-gateway', () => ({ + mapCatalogContractDtoToCatalogContractSyncSource: mockMapCatalogContractDtoToCatalogContractSyncSource, +})); + +vi.mock('../../../src/modules/catalog/catalog-domain', () => ({ + mapContractDtoV0ToContractEntityFields: mockMapContractDtoV0ToContractEntityFields, +})); + +vi.mock('../../../src/__generated__/opencti-manifest.json', () => ({ + default: { + id: 'filigran-catalog', + contracts: [ + { + title: 'IPinfo', + slug: 'ipinfo', + description: 'desc', + short_description: 'short', + logo: null, + use_cases: [], + verified: true, + last_verified_date: '2024-01-01', + playbook_supported: false, + max_confidence_level: 50, + support_version: '6.7.0', + subscription_link: null, + source_code: '', + manager_supported: true, + container_version: '1.2.3', + container_image: 'opencti/connector-ipinfo', + container_type: 'EXTERNAL_IMPORT', + config_schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'schema-id', + type: 'object', + properties: {}, + required: [], + additionalProperties: true, + }, + license_type: null, + solution_categories: [], + contact: null, + }, + ], + }, +})); + +import { up } from '../../../src/migrations/1786264797376-embed-catalog-contract-in-connector-entity'; + +const runUp = async () => { + await new Promise((resolve, reject) => { + up((error?: Error) => { + if (error) reject(error); + else resolve(); + }); + }); +}; + +describe('migration 1786264797376 embed catalog contract', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListCatalogContractLogos.mockResolvedValue(new Set()); + mockStoreCatalogContractLogo.mockResolvedValue({ result: 'success', existed: false, logoUri: '/storage/view/catalog-logos/logo.png', filename: 'logo.png' }); + mockMapCatalogContractDtoToCatalogContractSyncSource.mockReturnValue({ id: 'ipinfo-1.2.3' }); + mockComputeContractContentHash.mockReturnValue('hash-1'); + mockMapContractDtoV0ToContractEntityFields.mockReturnValue({ contract_id: 'ipinfo-1.2.3', image: 'opencti/connector-ipinfo' }); + mockPatchAttribute.mockResolvedValue(undefined); + }); + + it('should patch missing manager_contract and missing manager_upgrade_strategy', async () => { + mockFullEntitiesList.mockResolvedValue([{ + id: 'connector-1', + catalog_id: 'filigran-catalog', + manager_contract_image: 'opencti/connector-ipinfo', + manager_contract: undefined, + manager_upgrade_strategy: undefined, + }]); + await runUp(); + expect(mockPatchAttribute).toHaveBeenCalledWith( + { source: 'migration' }, + { id: 'system-user' }, + 'connector-1', + 'Connector', + expect.objectContaining({ + manager_upgrade_strategy: 'latest', + }), + ); + const patch = mockPatchAttribute.mock.calls[0][4]; + expect(patch.manager_contract).toBeDefined(); + }); + + it('should only patch upgrade strategy when manager contract snapshot already exists', async () => { + mockFullEntitiesList.mockResolvedValue([{ + id: 'connector-2', + catalog_id: 'filigran-catalog', + manager_contract_image: 'opencti/connector-ipinfo', + manager_contract: { contract_id: 'ipinfo-1.2.3' }, + manager_upgrade_strategy: undefined, + }]); + await runUp(); + const patch = mockPatchAttribute.mock.calls[0][4]; + expect(patch.manager_upgrade_strategy).toBe('latest'); + expect(patch.manager_contract).toBeUndefined(); + }); + + it('should not patch connectors that already have manager contract and strategy', async () => { + mockFullEntitiesList.mockResolvedValue([{ + id: 'connector-3', + catalog_id: 'filigran-catalog', + manager_contract_image: 'opencti/connector-ipinfo', + manager_contract: { contract_id: 'ipinfo-1.2.3' }, + manager_upgrade_strategy: 'latest', + }]); + await runUp(); + expect(mockPatchAttribute).not.toHaveBeenCalled(); + }); + + it('should still patch upgrade strategy when contract is missing from manifest', async () => { + mockFullEntitiesList.mockResolvedValue([{ + id: 'connector-4', + catalog_id: 'filigran-catalog', + manager_contract_image: 'opencti/connector-unknown', + manager_contract: undefined, + manager_upgrade_strategy: undefined, + }]); + await runUp(); + expect(mockPatchAttribute).toHaveBeenCalledWith( + { source: 'migration' }, + { id: 'system-user' }, + 'connector-4', + 'Connector', + { manager_upgrade_strategy: 'latest' }, + ); + }); + + it('should patch manager_contract even when logo storage fails', async () => { + mockStoreCatalogContractLogo.mockResolvedValue({ + result: 'failed', + logoUri: null, + error: new Error('logo upload failed'), + }); + mockFullEntitiesList.mockResolvedValue([{ + id: 'connector-5', + catalog_id: 'filigran-catalog', + manager_contract_image: 'opencti/connector-ipinfo', + manager_contract: undefined, + manager_upgrade_strategy: undefined, + }]); + await runUp(); + const patch = mockPatchAttribute.mock.calls[0][4]; + expect(patch.manager_contract).toBeDefined(); + expect(patch.manager_upgrade_strategy).toBe('latest'); + }); +}); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/database/repository-manager-contract-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/database/repository-manager-contract-test.ts new file mode 100644 index 000000000000..7feb9166b1d3 --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/database/repository-manager-contract-test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { FunctionalError } from '../../../src/config/errors'; +import { + computeManagerConnectorContract, + computeManagerConnectorExcerpt, + computeManagerConnectorImage, +} from '../../../src/database/repository'; + +const connectorWithContract = () => ({ + id: 'connector-1', + internal_id: 'connector-1', + manager_contract: { + catalog_id: 'catalog-1', + contract_id: 'ipinfo-1.2.3', + content_hash: 'hash-1', + title: 'IPinfo', + slug: 'ipinfo', + description: 'desc', + short_description: 'short', + logo_uri: '/storage/view/catalog-logos/logo.png', + use_cases: [], + verified: true, + last_verified_date: '2024-01-01', + playbook_supported: false, + max_confidence_level: 50, + support_version: '6.7.0', + subscription_link: null, + source_code: '', + manager_supported: true, + contract_version: '1.2.3', + image: 'opencti/connector-ipinfo', + connector_type: 'EXTERNAL_IMPORT', + config_schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'schema-id', + type: 'object', + properties: {}, + required: [], + additionalProperties: true, + }, + license_type: null, + solution_categories: [], + contact: null, + }, +}); + +describe('repository manager contract helpers', () => { + it('should return null contract and excerpt when manager contract is absent', async () => { + const connector = { id: 'connector-1', internal_id: 'connector-1' }; + await expect(computeManagerConnectorContract({} as any, {} as any, connector as any)).resolves.toBeNull(); + await expect(computeManagerConnectorExcerpt({} as any, {} as any, connector as any)).resolves.toBeNull(); + }); + + it('should compute manager contract JSON from embedded contract snapshot', async () => { + const contract = await computeManagerConnectorContract({} as any, {} as any, connectorWithContract() as any); + expect(contract).not.toBeNull(); + const parsed = JSON.parse(contract!); + expect(parsed.slug).toBe('ipinfo'); + expect(parsed.container_image).toBe('opencti/connector-ipinfo'); + expect(parsed.container_version).toBe('1.2.3'); + }); + + it('should compute manager contract excerpt from embedded contract snapshot', async () => { + const excerpt = await computeManagerConnectorExcerpt({} as any, {} as any, connectorWithContract() as any); + expect(excerpt).toEqual({ + title: 'IPinfo', + slug: 'ipinfo', + logo: '/storage/view/catalog-logos/logo.png', + }); + }); + + it('should compute manager connector image from embedded contract snapshot', async () => { + const image = await computeManagerConnectorImage(connectorWithContract() as any); + expect(image).toBe('opencti/connector-ipinfo:1.2.3'); + }); + + it('should throw FunctionalError on invalid contract snapshot', async () => { + const connector = connectorWithContract(); + delete (connector.manager_contract as any).image; + await expect(computeManagerConnectorImage(connector as any)).rejects.toThrowError(FunctionalError('Invalid manager contract snapshot').message); + }); +}); + diff --git a/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/ajvValidatorCache-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/ajvValidatorCache-test.ts index 0b92925786ba..d85ec9890deb 100644 --- a/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/ajvValidatorCache-test.ts +++ b/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/ajvValidatorCache-test.ts @@ -1,13 +1,10 @@ -import { fileURLToPath } from 'node:url'; import Ajv from 'ajv'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CatalogContract } from '../../../../src/modules/catalog/catalog-types'; import type { ConnectorContractConfiguration } from '../../../../src/generated/graphql'; const compileSpy = vi.spyOn(Ajv.prototype, 'compile'); -const TEST_CATALOG_URL = new URL('../../../utils/opencti-manifest.json', import.meta.url); - let catalogDomain: typeof import('../../../../src/modules/catalog/catalog-domain'); const buildContract = (overrides: Partial = {}): CatalogContract => ({ @@ -33,18 +30,9 @@ const buildContract = (overrides: Partial = {}): CatalogContrac const buildConfig = (entries: Record): ConnectorContractConfiguration[] => Object.entries(entries).map(([key, value]) => ({ key, value })); describe('catalog-domain - AJV validator compilation cache', () => { - beforeAll(async () => { - process.env.APP__CUSTOM_CATALOGS = JSON.stringify([fileURLToPath(TEST_CATALOG_URL)]); + beforeEach(async () => { vi.resetModules(); catalogDomain = await import('../../../../src/modules/catalog/catalog-domain'); - }); - - afterAll(() => { - delete process.env.APP__CUSTOM_CATALOGS; - }); - - beforeEach(() => { - catalogDomain.resetCatalogs(); compileSpy.mockClear(); }); @@ -88,19 +76,6 @@ describe('catalog-domain - AJV validator compilation cache', () => { expect(compileSpy).toHaveBeenCalledTimes(2); }); - it('should invalidate the validator cache on resetCatalogs()', () => { - const contract = buildContract(); - const config = buildConfig({ IPINFO_TOKEN: 'token-value' }); - - catalogDomain.validateContractConfigurations(config, contract); - expect(compileSpy).toHaveBeenCalledTimes(1); - - catalogDomain.resetCatalogs(); - - catalogDomain.validateContractConfigurations(config, contract); - expect(compileSpy).toHaveBeenCalledTimes(2); - }); - it('should throw a formatted validation error without caching a broken state', () => { const contract = buildContract(); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/connector-queue-threshold-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/connector-queue-threshold-test.ts index 961d30a1267e..8748ca5dd4fc 100644 --- a/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/connector-queue-threshold-test.ts +++ b/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/connector-queue-threshold-test.ts @@ -29,23 +29,26 @@ const createMitreContract = (): CatalogContract => ({ MITRE_URL: { type: 'string', description: 'MITRE ATT&CK URL', - default: 'https://attack.mitre.org' + default: 'https://attack.mitre.org', }, CONNECTOR_QUEUE_THRESHOLD: { default: null, description: 'Connector queue max size in Mbytes. Default to 500.', exclusiveMinimum: 0, - type: 'integer' + type: 'integer', } as any, // Use any to bypass TypeScript constraint for testing INTERVAL: { type: 'integer', description: 'Interval in hours', - default: 24 - } + default: 24, + }, }, required: ['MITRE_URL'], // CONNECTOR_QUEUE_THRESHOLD is NOT required - additionalProperties: false - } + additionalProperties: false, + }, + license_type: null, + solution_categories: [], + contact: null, }); describe('CONNECTOR_QUEUE_THRESHOLD bug fix', () => { @@ -71,16 +74,16 @@ mwIDAQAB API_KEY: { type: 'string', format: 'password', - description: 'API key for authentication' - } as any // Use any to bypass TypeScript constraint for testing + description: 'API key for authentication', + } as any, // Use any to bypass TypeScript constraint for testing }, - required: ['MITRE_URL', 'API_KEY'] - } + required: ['MITRE_URL', 'API_KEY'], + }, }; const configurations: ContractConfigInput[] = [ { key: 'MITRE_URL', value: 'https://attack.mitre.org' }, - { key: 'API_KEY', value: 'my-secret-api-key' } + { key: 'API_KEY', value: 'my-secret-api-key' }, ]; const result = computeConnectorTargetContract(configurations, contractWithPassword, publicKey); @@ -107,16 +110,16 @@ mwIDAQAB PROXY_PASSWORD: { type: 'string', format: 'password', - description: 'Optional proxy password' - } as any // Use any to bypass TypeScript constraint for testing - } + description: 'Optional proxy password', + } as any, // Use any to bypass TypeScript constraint for testing + }, // PROXY_PASSWORD is not in required array - } + }, }; // Configuration without the optional password const configurations: ContractConfigInput[] = [ - { key: 'MITRE_URL', value: 'https://attack.mitre.org' } + { key: 'MITRE_URL', value: 'https://attack.mitre.org' }, // PROXY_PASSWORD not provided ]; @@ -135,33 +138,33 @@ mwIDAQAB properties: { USERNAME: { type: 'string', - description: 'Username' + description: 'Username', } as any, // Use any to bypass TypeScript constraint for testing PASSWORD: { type: 'string', format: 'password', - description: 'User password' + description: 'User password', } as any, // Use any to bypass TypeScript constraint for testing API_KEY: { type: 'string', format: 'password', - description: 'API key' + description: 'API key', } as any, // Use any to bypass TypeScript constraint for testing PROXY_PASSWORD: { type: 'string', format: 'password', - description: 'Proxy password' - } as any // Use any to bypass TypeScript constraint for testing + description: 'Proxy password', + } as any, // Use any to bypass TypeScript constraint for testing }, - required: ['USERNAME', 'PASSWORD', 'API_KEY'] - } + required: ['USERNAME', 'PASSWORD', 'API_KEY'], + }, }; const configurations: ContractConfigInput[] = [ { key: 'USERNAME', value: 'admin' }, { key: 'PASSWORD', value: 'user-password' }, { key: 'API_KEY', value: 'api-key-value' }, - { key: 'PROXY_PASSWORD', value: 'proxy-pass' } + { key: 'PROXY_PASSWORD', value: 'proxy-pass' }, ]; const result = computeConnectorTargetContract(configurations, contractWithMultiplePasswords, publicKey); @@ -195,11 +198,11 @@ mwIDAQAB SECRET: { type: 'string', format: 'password', - description: 'Secret key' - } as any // Use any to bypass TypeScript constraint for testing + description: 'Secret key', + } as any, // Use any to bypass TypeScript constraint for testing }, - required: ['MITRE_URL', 'SECRET'] - } + required: ['MITRE_URL', 'SECRET'], + }, }; // Configuration with encrypted password @@ -208,8 +211,8 @@ mwIDAQAB { key: 'SECRET', value: 'AQEAAf8AAABAwOL5+encrypted_value', // Already encrypted - encrypted: true - } + encrypted: true, + }, ]; // Should validate without throwing @@ -228,16 +231,16 @@ mwIDAQAB PASSWORD: { type: 'string', format: 'password', - description: 'Optional password' - } as any // Use any to bypass TypeScript constraint for testing - } + description: 'Optional password', + } as any, // Use any to bypass TypeScript constraint for testing + }, // PASSWORD is NOT in required array, making it optional - } + }, }; const configurations: ContractConfigInput[] = [ { key: 'MITRE_URL', value: 'https://attack.mitre.org' }, - { key: 'PASSWORD', value: '' } // Empty password + { key: 'PASSWORD', value: '' }, // Empty password ]; // Empty password should be handled (returns null) @@ -254,7 +257,7 @@ mwIDAQAB // Only providing required field const configurations: ContractConfigInput[] = [ - { key: 'MITRE_URL', value: 'https://attack.mitre.org' } + { key: 'MITRE_URL', value: 'https://attack.mitre.org' }, // CONNECTOR_QUEUE_THRESHOLD is not provided ]; @@ -274,7 +277,7 @@ mwIDAQAB // Configuration without the optional CONNECTOR_QUEUE_THRESHOLD const configurations: ConnectorContractConfiguration[] = [ - { key: 'MITRE_URL', value: 'https://attack.mitre.org' } + { key: 'MITRE_URL', value: 'https://attack.mitre.org' }, ]; // Should not throw validation error @@ -288,7 +291,7 @@ mwIDAQAB const configurations: ContractConfigInput[] = [ { key: 'MITRE_URL', value: 'https://attack.mitre.org' }, - { key: 'CONNECTOR_QUEUE_THRESHOLD', value: '1000' } + { key: 'CONNECTOR_QUEUE_THRESHOLD', value: '1000' }, ]; const result = computeConnectorTargetContract(configurations, contract, publicKey); @@ -296,7 +299,7 @@ mwIDAQAB expect(result).toHaveLength(3); // MITRE_URL, CONNECTOR_QUEUE_THRESHOLD, and INTERVAL (default) expect(result.find((c) => c.key === 'CONNECTOR_QUEUE_THRESHOLD')).toEqual({ key: 'CONNECTOR_QUEUE_THRESHOLD', - value: '1000' + value: '1000', }); }); @@ -305,7 +308,7 @@ mwIDAQAB const configurations: ContractConfigInput[] = [ { key: 'MITRE_URL', value: 'https://attack.mitre.org' }, - { key: 'CONNECTOR_QUEUE_THRESHOLD', value: 'not_a_number' } + { key: 'CONNECTOR_QUEUE_THRESHOLD', value: 'not_a_number' }, ]; expect(() => { @@ -318,7 +321,7 @@ mwIDAQAB // Test with INTERVAL which has a default value of 24 const configurations: ContractConfigInput[] = [ - { key: 'MITRE_URL', value: 'https://attack.mitre.org' } + { key: 'MITRE_URL', value: 'https://attack.mitre.org' }, // INTERVAL not provided, should use default ]; @@ -328,7 +331,7 @@ mwIDAQAB const intervalConfig = result.find((c) => c.key === 'INTERVAL'); expect(intervalConfig).toEqual({ key: 'INTERVAL', - value: '24' // default value converted to string + value: '24', // default value converted to string }); }); @@ -336,7 +339,7 @@ mwIDAQAB const contract = createMitreContract(); const configurations: ContractConfigInput[] = [ - { key: 'MITRE_URL', value: 'https://attack.mitre.org' } + { key: 'MITRE_URL', value: 'https://attack.mitre.org' }, ]; const result = computeConnectorTargetContract(configurations, contract, publicKey); @@ -355,25 +358,25 @@ mwIDAQAB REQUIRED_FIELD: { type: 'string', description: 'Required field', - default: 'default_value' + default: 'default_value', }, OPTIONAL_WITH_DEFAULT: { type: 'integer', description: 'Optional with default', - default: 100 + default: 100, }, OPTIONAL_NO_DEFAULT: { type: 'integer', - description: 'Optional without default' + description: 'Optional without default', // No default provided intentionally - } as any // Use any to bypass TypeScript constraint for testing + } as any, // Use any to bypass TypeScript constraint for testing }, - required: ['REQUIRED_FIELD'] - } + required: ['REQUIRED_FIELD'], + }, }; const configurations: ContractConfigInput[] = [ - { key: 'REQUIRED_FIELD', value: 'test' } + { key: 'REQUIRED_FIELD', value: 'test' }, // All optional fields omitted ]; @@ -385,7 +388,7 @@ mwIDAQAB // Should include optional field with non-null default expect(result.find((c) => c.key === 'OPTIONAL_WITH_DEFAULT')).toEqual({ key: 'OPTIONAL_WITH_DEFAULT', - value: '100' + value: '100', }); // Should NOT include optional fields without default diff --git a/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/contract-mapping-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/contract-mapping-test.ts new file mode 100644 index 000000000000..26b7bbf85a13 --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/contract-mapping-test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import type { CatalogContract, CatalogContractEntityFields } from '../../../../src/modules/catalog/catalog-types'; +import { + mapContractDtoV0ToContractEntityFields, + mapContractEntityFieldsToEmbeddedConnectorManagerContract, + mapContractEntityFieldsToGraphqlCatalogContract, +} from '../../../../src/modules/catalog/catalog-domain'; + +const buildContractEntityFields = (): CatalogContractEntityFields => ({ + catalog_id: 'catalog-1', + contract_id: 'ipinfo-1.2.3', + content_hash: 'hash-1', + title: 'IPinfo', + slug: 'ipinfo', + description: 'desc', + short_description: 'short', + logo_uri: '/storage/view/catalog-logos/logo.png', + use_cases: ['a'], + verified: true, + last_verified_date: '2024-01-01', + playbook_supported: false, + max_confidence_level: 50, + support_version: '6.7.0', + subscription_link: null as unknown as string, + source_code: '', + manager_supported: true, + contract_version: '1.2.3', + image: 'opencti/connector-ipinfo', + connector_type: 'EXTERNAL_IMPORT', + config_schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'schema-id', + type: 'object', + properties: { + OPENCTI_URL: { type: 'string' }, + OPENCTI_TOKEN: { type: 'string', format: 'password' }, + USERNAME: { type: 'string' }, + }, + required: ['OPENCTI_URL', 'OPENCTI_TOKEN', 'USERNAME'], + additionalProperties: false, + }, + license_type: 'Free', + solution_categories: [], + contact: null as unknown as string, +}); + +describe('catalog contract mappings', () => { + it('should normalize V0 support_version when mapping to entity fields', () => { + const dto: CatalogContract = { + title: 'IPinfo', + slug: 'ipinfo', + description: 'desc', + short_description: 'short', + logo: null, + use_cases: [], + verified: true, + last_verified_date: '2024-01-01', + playbook_supported: false, + max_confidence_level: 50, + support_version: '>= 6.7.0', + subscription_link: null, + source_code: '', + manager_supported: true, + container_version: '1.2.3', + container_image: 'opencti/connector-ipinfo', + container_type: 'EXTERNAL_IMPORT', + config_schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'schema-id', + type: 'object', + properties: {}, + required: [], + additionalProperties: true, + }, + license_type: null, + solution_categories: [], + contact: null, + }; + const mapped = mapContractDtoV0ToContractEntityFields({ + catalogId: 'catalog-1', + contractDto: dto, + contractContentHash: 'hash-1', + logoUri: null, + }); + expect(mapped.contract_id).toBe('ipinfo-1.2.3'); + expect(mapped.support_version).toBe('6.7.0'); + expect(mapped.contract_version).toBe('1.2.3'); + }); + + it('should strip runtime config vars from GraphQL contract mapping when requested', () => { + const mapped = mapContractEntityFieldsToGraphqlCatalogContract(buildContractEntityFields(), { excludeRuntimeConfigVars: true }); + expect(mapped.config_schema.properties).toEqual({ + USERNAME: { type: 'string' }, + }); + expect(mapped.config_schema.required).toEqual(['USERNAME']); + }); + + it('should keep runtime config vars when GraphQL contract mapping does not request stripping', () => { + const mapped = mapContractEntityFieldsToGraphqlCatalogContract(buildContractEntityFields()); + expect(Object.keys(mapped.config_schema.properties)).toContain('OPENCTI_URL'); + expect(Object.keys(mapped.config_schema.properties)).toContain('OPENCTI_TOKEN'); + }); + + it('should normalize malformed config schema when mapping GraphQL contracts', () => { + const contract = { + ...buildContractEntityFields(), + config_schema: { + type: 'object', + }, + } as unknown as CatalogContractEntityFields; + const mapped = mapContractEntityFieldsToGraphqlCatalogContract(contract, { excludeRuntimeConfigVars: true }); + expect(mapped.config_schema).toMatchObject({ + type: 'object', + properties: {}, + required: [], + additionalProperties: true, + }); + }); + + it('should sanitize embedded manager contract snapshot to known fields only', () => { + const raw = { + ...buildContractEntityFields(), + _index: 'internal-objects', + _score: 1, + entity_type: 'CatalogContract', + } as unknown as CatalogContractEntityFields; + const embedded = mapContractEntityFieldsToEmbeddedConnectorManagerContract(raw); + expect(embedded).toMatchObject({ + catalog_id: 'catalog-1', + contract_id: 'ipinfo-1.2.3', + contract_version: '1.2.3', + image: 'opencti/connector-ipinfo', + }); + expect((embedded as any)._index).toBeUndefined(); + expect((embedded as any)._score).toBeUndefined(); + expect((embedded as any).entity_type).toBeUndefined(); + }); +}); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/getCatalogsRaceCondition-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/getCatalogsRaceCondition-test.ts deleted file mode 100644 index 2fb64f285e00..000000000000 --- a/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/getCatalogsRaceCondition-test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import type { AuthContext, AuthUser } from '../../../../src/types/user'; - -const FAKE_CONTEXT = {} as unknown as AuthContext; -const FAKE_USER = {} as unknown as AuthUser; - -const validCatalogContent = (id: string) => JSON.stringify({ - id, - name: 'Race Condition Test Catalog', - description: 'Custom catalog used only for cache-stampede unit tests', - contracts: [ - { - title: 'Race Condition Test Connector', - slug: 'race-condition-test-connector', - manager_supported: false, - }, - ], -}); - -let tmpDir: string; -let catalogFilePath: string; - -const loadFreshCatalogDomain = async (customCatalogPaths: string[]) => { - process.env.APP__CUSTOM_CATALOGS = JSON.stringify(customCatalogPaths); - vi.resetModules(); - return import('../../../../src/modules/catalog/catalog-domain'); -}; - -describe('catalog-domain - getCatalogs() / getSupportedContractsByImage() concurrency (cache stampede)', () => { - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'catalog-race-test-')); - catalogFilePath = path.join(tmpDir, 'custom-catalog.json'); - }); - - afterEach(() => { - delete process.env.APP__CUSTOM_CATALOGS; - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - afterAll(() => { - vi.resetModules(); - }); - - it('should return the exact same in-memory result across 20 concurrent calls mixing findCatalog() and getSupportedContractsByImage() (no duplicate build)', async () => { - fs.writeFileSync(catalogFilePath, validCatalogContent('race-test-catalog-1'), 'utf8'); - const catalogDomain = await loadFreshCatalogDomain([catalogFilePath]); - - const calls = Array.from({ length: 20 }, (_, index) => (index % 2 === 0 - ? catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER) - : catalogDomain.getSupportedContractsByImage())); - - const results = await Promise.all(calls); - - // getSupportedContractsByImage() is memoized as a single Promise, - // so the exact same Map instance must be returned to every caller. - const contractMaps = results.filter((_, index) => index % 2 === 1) as Map[]; - contractMaps.forEach((map) => { - expect(map).toBe(contractMaps[0]); - }); - - const catalogArrays = results.filter((_, index) => index % 2 === 0) as { id: string }[][]; - const firstCatalogRef = catalogArrays[0].find((c) => c.id === 'race-test-catalog-1'); - catalogArrays.forEach((catalogs) => { - const catalogRef = catalogs.find((c) => c.id === 'race-test-catalog-1'); - expect(catalogRef).toBe(firstCatalogRef); - }); - }); - - it('should reuse the same in-flight build when getCatalogs() is triggered indirectly from two different entry points at once', async () => { - fs.writeFileSync(catalogFilePath, validCatalogContent('race-test-catalog-2'), 'utf8'); - const catalogDomain = await loadFreshCatalogDomain([catalogFilePath]); - - // Cold start: both entry points race to trigger the very first build. - const [catalogs, contractsByImage] = await Promise.all([ - catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER), - catalogDomain.getSupportedContractsByImage(), - ]); - - expect(catalogs.find((c) => c.id === 'race-test-catalog-2')).toBeDefined(); - expect(contractsByImage).toBeInstanceOf(Map); - - // A subsequent call from either entry point must still resolve to the - // exact same underlying catalog object, proving there was only one build. - const catalogsAgain = await catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER); - expect(catalogsAgain.find((c) => c.id === 'race-test-catalog-2')) - .toBe(catalogs.find((c) => c.id === 'race-test-catalog-2')); - }); - - it('should not read the catalog file again on a second sequential call (module-level cache)', async () => { - fs.writeFileSync(catalogFilePath, validCatalogContent('race-test-catalog-3'), 'utf8'); - const catalogDomain = await loadFreshCatalogDomain([catalogFilePath]); - - const first = await catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER); - const second = await catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER); - - expect(second.find((c) => c.id === 'race-test-catalog-3')) - .toBe(first.find((c) => c.id === 'race-test-catalog-3')); - }); - - it('should rebuild (and reflect real file changes) after resetCatalogs()', async () => { - fs.writeFileSync(catalogFilePath, validCatalogContent('race-test-catalog-4'), 'utf8'); - const catalogDomain = await loadFreshCatalogDomain([catalogFilePath]); - - const before = await catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER); - const beforeRef = before.find((c) => c.id === 'race-test-catalog-4'); - expect(beforeRef).toBeDefined(); - - catalogDomain.resetCatalogs(); - - const after = await catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER); - const afterRef = after.find((c) => c.id === 'race-test-catalog-4'); - - // Same logical catalog, but a genuinely new object graph -> proves a real rebuild happened. - expect(afterRef).toBeDefined(); - expect(afterRef).not.toBe(beforeRef); - expect(afterRef).toEqual(beforeRef); - }); - - it('should allow a retry after a failed build instead of caching the rejection forever (real file deleted then recreated)', async () => { - fs.writeFileSync(catalogFilePath, validCatalogContent('race-test-catalog-5'), 'utf8'); - const catalogDomain = await loadFreshCatalogDomain([catalogFilePath]); - - fs.unlinkSync(catalogFilePath); - await expect(catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER)).rejects.toThrow(); - - fs.writeFileSync(catalogFilePath, validCatalogContent('race-test-catalog-5'), 'utf8'); - - const catalogs = await catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER); - expect(catalogs.find((c) => c.id === 'race-test-catalog-5')).toBeDefined(); - }); - - it('should not duplicate work when 20 concurrent calls race right after a resetCatalogs()', async () => { - fs.writeFileSync(catalogFilePath, validCatalogContent('race-test-catalog-6'), 'utf8'); - const catalogDomain = await loadFreshCatalogDomain([catalogFilePath]); - - await catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER); - catalogDomain.resetCatalogs(); - - const results = await Promise.all( - Array.from({ length: 20 }, () => catalogDomain.findCatalog(FAKE_CONTEXT, FAKE_USER)), - ); - - const firstRef = results[0].find((c) => c.id === 'race-test-catalog-6'); - results.forEach((catalogs) => { - expect(catalogs.find((c) => c.id === 'race-test-catalog-6')).toBe(firstRef); - }); - }); -}); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/validateContractConfigurations-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/validateContractConfigurations-test.ts index 728a3c47765d..1ee800b02e18 100644 --- a/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/validateContractConfigurations-test.ts +++ b/opencti-platform/opencti-graphql/tests/01-unit/domain/catalog-domain/validateContractConfigurations-test.ts @@ -27,8 +27,11 @@ const createTestContract = (properties: any, required: string[] = []): CatalogCo type: 'object', properties, required, - additionalProperties: false - } + additionalProperties: false, + }, + license_type: null, + solution_categories: [], + contact: null, }); describe('validateContractConfigurations', () => { @@ -37,15 +40,15 @@ describe('validateContractConfigurations', () => { { name: { type: 'string' }, port: { type: 'integer' }, - enabled: { type: 'boolean' } + enabled: { type: 'boolean' }, }, - ['name', 'port', 'enabled'] + ['name', 'port', 'enabled'], ); const configurations: ConnectorContractConfiguration[] = [ { key: 'name', value: 'test-service' }, { key: 'port', value: '8080' }, - { key: 'enabled', value: 'true' } + { key: 'enabled', value: 'true' }, ]; // Should not throw @@ -58,13 +61,13 @@ describe('validateContractConfigurations', () => { const contract = createTestContract( { name: { type: 'string' }, - port: { type: 'integer' } + port: { type: 'integer' }, }, - ['name', 'port'] + ['name', 'port'], ); const configurations: ConnectorContractConfiguration[] = [ - { key: 'name', value: 'test-service' } + { key: 'name', value: 'test-service' }, // port is missing ]; @@ -84,13 +87,13 @@ describe('validateContractConfigurations', () => { const contract = createTestContract( { name: { type: 'string' }, - description: { type: 'string' } + description: { type: 'string' }, }, - ['name'] // only name is required + ['name'], // only name is required ); const configurations: ConnectorContractConfiguration[] = [ - { key: 'name', value: 'test-service' } + { key: 'name', value: 'test-service' }, // description is optional and not provided ]; @@ -103,14 +106,14 @@ describe('validateContractConfigurations', () => { const contract = createTestContract( { port: { type: 'integer' }, - enabled: { type: 'boolean' } + enabled: { type: 'boolean' }, }, - ['port', 'enabled'] + ['port', 'enabled'], ); const configurations: ConnectorContractConfiguration[] = [ { key: 'port', value: '8080' }, // string that can be coerced to integer - { key: 'enabled', value: 'true' } // string that can be coerced to boolean + { key: 'enabled', value: 'true' }, // string that can be coerced to boolean ]; expect(() => { @@ -121,13 +124,13 @@ describe('validateContractConfigurations', () => { it('should throw error for invalid type that cannot be coerced', () => { const contract = createTestContract( { - port: { type: 'integer' } + port: { type: 'integer' }, }, - ['port'] + ['port'], ); const configurations: ConnectorContractConfiguration[] = [ - { key: 'port', value: 'not_a_number' } + { key: 'port', value: 'not_a_number' }, ]; try { @@ -144,13 +147,13 @@ describe('validateContractConfigurations', () => { it('should handle array type in configuration', () => { const contract = createTestContract( { - tags: { type: 'array', items: { type: 'string' } } + tags: { type: 'array', items: { type: 'string' } }, }, - ['tags'] + ['tags'], ); const configurations: ConnectorContractConfiguration[] = [ - { key: 'tags', value: 'tag1,tag2,tag3' } + { key: 'tags', value: 'tag1,tag2,tag3' }, ]; // Arrays passed as comma-separated strings should now work @@ -163,13 +166,13 @@ describe('validateContractConfigurations', () => { it('should handle empty array from comma-separated string', () => { const contract = createTestContract( { - tags: { type: 'array', items: { type: 'string' } } + tags: { type: 'array', items: { type: 'string' } }, }, - [] // tags is optional + [], // tags is optional ); const configurations: ConnectorContractConfiguration[] = [ - { key: 'tags', value: '' } // empty string should become empty array + { key: 'tags', value: '' }, // empty string should become empty array ]; expect(() => { @@ -180,13 +183,13 @@ describe('validateContractConfigurations', () => { it('should handle array with spaces in comma-separated values', () => { const contract = createTestContract( { - scopes: { type: 'array', items: { type: 'string' } } + scopes: { type: 'array', items: { type: 'string' } }, }, - ['scopes'] + ['scopes'], ); const configurations: ConnectorContractConfiguration[] = [ - { key: 'scopes', value: 'greynoisefeed, toto , test ' } // with spaces + { key: 'scopes', value: 'greynoisefeed, toto , test ' }, // with spaces ]; // Should trim spaces and validate correctly @@ -200,15 +203,15 @@ describe('validateContractConfigurations', () => { { name: { type: 'string' }, optional1: { type: 'string' }, - optional2: { type: 'string' } + optional2: { type: 'string' }, }, - ['name'] + ['name'], ); const configurations: ConnectorContractConfiguration[] = [ { key: 'name', value: 'test' }, { key: 'optional1', value: undefined as any }, - { key: 'optional2', value: null as any } + { key: 'optional2', value: null as any }, ]; expect(() => { @@ -222,16 +225,16 @@ describe('validateContractConfigurations', () => { name: { type: 'string' }, port: { type: 'integer' }, enabled: { type: 'boolean' }, - timeout: { type: 'integer' } + timeout: { type: 'integer' }, }, - ['name', 'port', 'enabled'] + ['name', 'port', 'enabled'], ); const configurations: ConnectorContractConfiguration[] = [ { key: 'name', value: 'complex-service' }, { key: 'port', value: '3000' }, { key: 'enabled', value: 'false' }, - { key: 'timeout', value: '30' } + { key: 'timeout', value: '30' }, ]; expect(() => { @@ -242,9 +245,9 @@ describe('validateContractConfigurations', () => { it('should handle empty configuration array for schema with no required fields', () => { const contract = createTestContract( { - optional: { type: 'string' } + optional: { type: 'string' }, }, - [] // no required fields + [], // no required fields ); const configurations: ConnectorContractConfiguration[] = []; @@ -259,9 +262,9 @@ describe('validateContractConfigurations', () => { const contract = createTestContract( { username: { type: 'string' }, - password: { type: 'string', format: 'password' } + password: { type: 'string', format: 'password' }, }, - ['username', 'password'] + ['username', 'password'], ); const configurations: ConnectorContractConfiguration[] = [ @@ -269,8 +272,8 @@ describe('validateContractConfigurations', () => { { key: 'password', value: 'AQEAAf8AAABAwOL5+encrypted_base64_value', // Encrypted value - encrypted: true - } + encrypted: true, + }, ]; // Should validate encrypted passwords successfully @@ -282,16 +285,16 @@ describe('validateContractConfigurations', () => { it('should validate unencrypted password fields', () => { const contract = createTestContract( { - api_key: { type: 'string', format: 'password' } + api_key: { type: 'string', format: 'password' }, }, - ['api_key'] + ['api_key'], ); const configurations: ConnectorContractConfiguration[] = [ { key: 'api_key', - value: 'plain_text_api_key' // Unencrypted password (not tagged as encrypted) - } + value: 'plain_text_api_key', // Unencrypted password (not tagged as encrypted) + }, ]; // Should still validate unencrypted passwords @@ -305,27 +308,27 @@ describe('validateContractConfigurations', () => { { primary_password: { type: 'string', format: 'password' }, secondary_password: { type: 'string', format: 'password' }, - api_token: { type: 'string', format: 'password' } + api_token: { type: 'string', format: 'password' }, }, - ['primary_password', 'secondary_password'] + ['primary_password', 'secondary_password'], ); const configurations: ConnectorContractConfiguration[] = [ { key: 'primary_password', value: 'encrypted_primary', - encrypted: true + encrypted: true, }, { key: 'secondary_password', value: 'encrypted_secondary', - encrypted: true + encrypted: true, }, { key: 'api_token', value: 'optional_encrypted_token', - encrypted: true - } + encrypted: true, + }, ]; expect(() => { @@ -339,9 +342,9 @@ describe('validateContractConfigurations', () => { host: { type: 'string' }, port: { type: 'integer' }, password: { type: 'string', format: 'password' }, - ssl_enabled: { type: 'boolean' } + ssl_enabled: { type: 'boolean' }, }, - ['host', 'port', 'password'] + ['host', 'port', 'password'], ); const configurations: ConnectorContractConfiguration[] = [ @@ -350,9 +353,9 @@ describe('validateContractConfigurations', () => { { key: 'password', value: 'encrypted_password_value', - encrypted: true + encrypted: true, }, - { key: 'ssl_enabled', value: 'true' } + { key: 'ssl_enabled', value: 'true' }, ]; expect(() => { @@ -364,18 +367,18 @@ describe('validateContractConfigurations', () => { const contract = createTestContract( { username: { type: 'string' }, - count: { type: 'integer' } + count: { type: 'integer' }, }, - ['username', 'count'] + ['username', 'count'], ); const configurations: ConnectorContractConfiguration[] = [ { key: 'username', value: 'john_doe', - encrypted: true // Incorrectly marked as encrypted + encrypted: true, // Incorrectly marked as encrypted }, - { key: 'count', value: '42' } + { key: 'count', value: '42' }, ]; // Should validate even with incorrect encrypted flag @@ -387,9 +390,9 @@ describe('validateContractConfigurations', () => { it('should validate empty password field', () => { const contract = createTestContract( { - optional_password: { type: 'string', format: 'password' } + optional_password: { type: 'string', format: 'password' }, }, - [] // Password is optional + [], // Password is optional ); const configurations: ConnectorContractConfiguration[] = [ @@ -404,9 +407,9 @@ describe('validateContractConfigurations', () => { it('should fail validation for missing required password', () => { const contract = createTestContract( { - auth_token: { type: 'string', format: 'password' } + auth_token: { type: 'string', format: 'password' }, }, - ['auth_token'] // Password is required + ['auth_token'], // Password is required ); const configurations: ConnectorContractConfiguration[] = [ diff --git a/opencti-platform/opencti-graphql/tests/01-unit/manager/telemetryTags-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/manager/telemetryTags-test.ts index ece545ad5632..fa91797184ba 100644 --- a/opencti-platform/opencti-graphql/tests/01-unit/manager/telemetryTags-test.ts +++ b/opencti-platform/opencti-graphql/tests/01-unit/manager/telemetryTags-test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { computeActiveConnectorsByIdentity, normalizeTelemetryTags, stripImageToRepositoryPath, type ConnectorIdentitySource } from '../../../src/telemetry/TelemetryMeterManager'; +import type { CatalogContractEntityFields } from '../../../src/modules/catalog/catalog-types'; describe('Telemetry tags normalization', () => { it('should return an empty string when no tags are configured', () => { @@ -67,6 +68,9 @@ describe('Active connectors by catalog identity', () => { const managed = (image: string, type = 'EXTERNAL_IMPORT'): ConnectorIdentitySource => ({ catalog_id: 'catalog-1', manager_contract_image: image, + manager_contract: CONTRACTS.has(image) ? { + slug: CONTRACTS.get(image)?.slug, + } as CatalogContractEntityFields : undefined, name: 'User Renamed Me', connector_type: type, }); @@ -77,11 +81,11 @@ describe('Active connectors by catalog identity', () => { }); it('should return no datapoint for an empty connector list', () => { - expect(computeActiveConnectorsByIdentity([], CONTRACTS)).toEqual([]); + expect(computeActiveConnectorsByIdentity([])).toEqual([]); }); it('should resolve managed connectors to the catalog contract slug, ignoring the user-set name', () => { - const items = computeActiveConnectorsByIdentity([managed('opencti/connector-mitre')], CONTRACTS); + const items = computeActiveConnectorsByIdentity([managed('opencti/connector-mitre')]); expect(items).toEqual([ { value: 1, attributes: { slug: 'mitre-att-ck', managed: 'true', type: 'EXTERNAL_IMPORT' } }, ]); @@ -90,7 +94,6 @@ describe('Active connectors by catalog identity', () => { it('should export the registry-stripped repository path for managed connectors whose image is not in the catalog', () => { const items = computeActiveConnectorsByIdentity( [managed('registry.private.corp:5000/team/custom-connector:1.2.3'), managed('opencti/connector-misp')], - CONTRACTS, ); expect(items).toHaveLength(2); // The datapoint stays visible, but the private registry hostname is stripped. @@ -104,25 +107,24 @@ describe('Active connectors by catalog identity', () => { }); it('should still skip managed connectors without any usable image reference', () => { - expect(computeActiveConnectorsByIdentity([managed(''), managed(' ')], CONTRACTS)).toEqual([]); + expect(computeActiveConnectorsByIdentity([managed(''), managed(' ')])).toEqual([]); }); it('should fall back to the trimmed/lowercased registered name for manual connectors', () => { - const items = computeActiveConnectorsByIdentity([manual(' My Custom Feed ')], CONTRACTS); + const items = computeActiveConnectorsByIdentity([manual(' My Custom Feed ')]); expect(items).toEqual([ { value: 1, attributes: { slug: 'my custom feed', managed: 'false', type: 'EXTERNAL_IMPORT' } }, ]); }); it('should skip manual connectors without a usable name', () => { - expect(computeActiveConnectorsByIdentity([manual(''), manual(' '), manual(null), manual(undefined)], CONTRACTS)).toEqual([]); + expect(computeActiveConnectorsByIdentity([manual(''), manual(' '), manual(null), manual(undefined)])).toEqual([]); }); it('should not collide identities when a freeform manual name contains separator-like characters', () => { // Under a naive 'slug|managed|type' string key these two would merge. const items = computeActiveConnectorsByIdentity( [manual('a|false', 't'), manual('a', 'false|t')], - CONTRACTS, ); expect(items).toHaveLength(2); expect(items).toContainEqual({ value: 1, attributes: { slug: 'a|false', managed: 'false', type: 't' } }); @@ -137,7 +139,6 @@ describe('Active connectors by catalog identity', () => { managed('opencti/connector-mitre', 'INTERNAL_ENRICHMENT'), manual('mitre-att-ck'), ], - CONTRACTS, ); expect(items).toContainEqual({ value: 2, attributes: { slug: 'mitre-att-ck', managed: 'true', type: 'EXTERNAL_IMPORT' } }); expect(items).toContainEqual({ value: 1, attributes: { slug: 'mitre-att-ck', managed: 'true', type: 'INTERNAL_ENRICHMENT' } }); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-http-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-http-test.ts new file mode 100644 index 000000000000..cc65235d8cc7 --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-http-test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockCreateAuthenticatedContext, mockDownloadFile, mockSetCookieError, mockMimeTypeFromExtension } = vi.hoisted(() => ({ + mockCreateAuthenticatedContext: vi.fn(), + mockDownloadFile: vi.fn(), + mockSetCookieError: vi.fn(), + mockMimeTypeFromExtension: vi.fn(), +})); + +vi.mock('../../../../src/http/httpAuthenticatedContext', () => ({ + createAuthenticatedContext: mockCreateAuthenticatedContext, +})); + +vi.mock('../../../../src/database/raw-file-storage', () => ({ + downloadFile: mockDownloadFile, +})); + +vi.mock('../../../../src/http/httpUtils', () => ({ + setCookieError: mockSetCookieError, +})); + +vi.mock('../../../../src/modules/catalog/catalog-logo-storage', () => ({ + CATALOG_CONTRACT_LOGOS_DIR: 'catalog-logos', + getMimeTypeFromImageExtension: mockMimeTypeFromExtension, +})); + +vi.mock('../../../../src/modules/catalog/catalog-logger', () => ({ + logCatalog: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { handleCatalogLogoViewRequest } from '../../../../src/modules/catalog/catalog-http'; + +const buildRes = () => { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.send = vi.fn().mockReturnValue(res); + res.sendStatus = vi.fn().mockReturnValue(res); + res.set = vi.fn().mockReturnValue(res); + return res; +}; + +describe('catalog-http', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMimeTypeFromExtension.mockReturnValue('image/png'); + }); + + it('should return 403 when unauthenticated', async () => { + mockCreateAuthenticatedContext.mockResolvedValue({ user: null, source: 'catalog_logo_view' }); + const req = { params: { file: 'abc.png' } } as any; + const res = buildRes(); + await handleCatalogLogoViewRequest(req, res, vi.fn()); + expect(res.sendStatus).toHaveBeenCalledWith(403); + }); + + it('should return 404 when logo does not exist', async () => { + mockCreateAuthenticatedContext.mockResolvedValue({ user: { id: 'u1' }, source: 'catalog_logo_view' }); + mockDownloadFile.mockResolvedValue(null); + const req = { params: { file: 'abc.png' } } as any; + const res = buildRes(); + await handleCatalogLogoViewRequest(req, res, vi.fn()); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.send).toHaveBeenCalledWith({ status: 'error', error: 'Catalog logo not found' }); + }); + + it('should stream file and set expected headers', async () => { + const pipe = vi.fn(); + mockCreateAuthenticatedContext.mockResolvedValue({ user: { id: 'u1' }, source: 'catalog_logo_view' }); + mockDownloadFile.mockResolvedValue({ pipe }); + const req = { params: { file: 'abc.png' } } as any; + const res = buildRes(); + await handleCatalogLogoViewRequest(req, res, vi.fn()); + expect(res.set).toHaveBeenCalledWith({ 'Content-Security-Policy': 'sandbox' }); + expect(res.set).toHaveBeenCalledWith('Cache-Control', 'private, no-cache, no-store, must-revalidate'); + expect(res.set).toHaveBeenCalledWith({ Pragma: 'no-cache' }); + expect(res.set).toHaveBeenCalledWith('Content-type', 'image/png'); + expect(pipe).toHaveBeenCalledWith(res); + }); + + it('should handle invalid URL parameters', async () => { + mockCreateAuthenticatedContext.mockResolvedValue({ user: { id: 'u1' }, source: 'catalog_logo_view' }); + const req = { params: {} } as any; + const res = buildRes(); + await handleCatalogLogoViewRequest(req, res, vi.fn()); + expect(mockSetCookieError).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.send).toHaveBeenCalledWith({ status: 'error', error: 'Invalid URL format' }); + }); + + it('should handle download errors with 503', async () => { + mockCreateAuthenticatedContext.mockResolvedValue({ user: { id: 'u1' }, source: 'catalog_logo_view' }); + mockDownloadFile.mockRejectedValue(new Error('storage failure')); + const req = { params: { file: 'abc.png' } } as any; + const res = buildRes(); + await handleCatalogLogoViewRequest(req, res, vi.fn()); + expect(mockSetCookieError).toHaveBeenCalledWith(res, 'storage failure'); + expect(res.status).toHaveBeenCalledWith(503); + expect(res.send).toHaveBeenCalledWith({ status: 'error', error: 'storage failure' }); + }); +}); + diff --git a/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-logo-storage-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-logo-storage-test.ts new file mode 100644 index 000000000000..4e83f54ee741 --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-logo-storage-test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import type { CatalogContract } from '../../../../src/modules/catalog/catalog-types'; +import { computeCatalogContractLogoUploadOperation, getMimeTypeFromImageExtension } from '../../../../src/modules/catalog/catalog-logo-storage'; + +const buildContract = (logo: string | null): CatalogContract => ({ + title: 'Contract title', + slug: 'contract-slug', + description: 'description', + short_description: 'short description', + logo, + use_cases: [], + verified: true, + last_verified_date: '2024-01-01', + playbook_supported: false, + max_confidence_level: 50, + support_version: '6.7.0', + subscription_link: null, + source_code: '', + manager_supported: true, + container_version: '1.2.3', + container_image: 'opencti/connector-test', + container_type: 'EXTERNAL_IMPORT', + config_schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'id', + type: 'object', + properties: {}, + required: [], + additionalProperties: true, + }, + license_type: null, + solution_categories: [], + contact: null, +}); + +describe('catalog-logo-storage', () => { + it('should return no-logo when contract has no logo', () => { + const result = computeCatalogContractLogoUploadOperation(buildContract(null), new Set()); + expect(result).toEqual({ result: 'no-logo', logoUri: null }); + }); + + it('should reject non data URL logos', () => { + const result = computeCatalogContractLogoUploadOperation(buildContract('https://example.org/logo.png'), new Set()); + expect(result.result).toBe('failed'); + if (result.result === 'failed') { + expect(result.error.message).toContain('not a data URL'); + } + }); + + it('should reject unsupported mime type', () => { + const result = computeCatalogContractLogoUploadOperation(buildContract('data:text/plain;base64,Zm9v'), new Set()); + expect(result.result).toBe('failed'); + if (result.result === 'failed') { + expect(result.error.message).toContain('Unsupported logo mime type'); + } + }); + + it('should reject invalid base64 payload', () => { + const result = computeCatalogContractLogoUploadOperation(buildContract('data:image/png;base64,not-valid-***'), new Set()); + expect(result.result).toBe('failed'); + if (result.result === 'failed') { + expect(result.error.message).toContain('invalid base64 payload'); + } + }); + + it('should reject invalid URL-encoded payload', () => { + const result = computeCatalogContractLogoUploadOperation(buildContract('data:image/svg+xml,%E0%A4%A'), new Set()); + expect(result.result).toBe('failed'); + if (result.result === 'failed') { + expect(result.error.message).toContain('invalid URL-encoded payload'); + } + }); + + it('should reject zero-length payload', () => { + const result = computeCatalogContractLogoUploadOperation(buildContract('data:image/svg+xml,'), new Set()); + expect(result.result).toBe('failed'); + if (result.result === 'failed') { + expect(result.error.message).toContain('Unexpected zero-length logo data'); + } + }); + + it('should return dedup success when logo already exists', () => { + const first = computeCatalogContractLogoUploadOperation(buildContract('data:image/png;base64,Zm9v'), new Set()); + expect(first.result).toBe('success'); + if (first.result !== 'success') { + return; + } + const second = computeCatalogContractLogoUploadOperation( + buildContract('data:image/png;base64,Zm9v'), + new Set([first.filename]), + ); + expect(second).toEqual({ + result: 'success', + existed: true, + logoUri: first.logoUri, + filename: first.filename, + }); + }); + + it('should build upload operation for base64 payload', () => { + const result = computeCatalogContractLogoUploadOperation(buildContract('data:image/png;base64,Zm9v'), new Set()); + expect(result.result).toBe('success'); + if (result.result !== 'success') { + return; + } + expect(result.existed).toBe(false); + expect(result.filename.endsWith('.png')).toBe(true); + expect(result.logoUri).toContain('/catalog/logo/'); + expect(result.operation?.s3Key).toContain('catalog-logos/'); + expect(result.operation?.body.toString('utf8')).toBe('foo'); + }); + + it('should build upload operation for URL-encoded payload', () => { + const result = computeCatalogContractLogoUploadOperation(buildContract('data:image/svg+xml,%3Csvg%3Eok%3C%2Fsvg%3E'), new Set()); + expect(result.result).toBe('success'); + if (result.result !== 'success') { + return; + } + expect(result.filename.endsWith('.svg')).toBe(true); + expect(result.operation?.body.toString('utf8')).toBe('ok'); + }); + + it('should resolve mime type from extension', () => { + expect(getMimeTypeFromImageExtension('.png')).toBe('image/png'); + expect(getMimeTypeFromImageExtension('.unknown')).toBeUndefined(); + }); +}); + diff --git a/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-repository-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-repository-test.ts new file mode 100644 index 000000000000..f5c11a478dea --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-repository-test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { elDeleteInstances, elIndex, elIndexElements } from '../../../../src/database/engine'; +import { fullEntitiesList, internalFindByIdsMapped } from '../../../../src/database/middleware-loader'; +import { deleteCatalogContracts, findAllCatalogs, findAllCatalogsExcluding, updateCatalogContracts, upsertCatalog } from '../../../../src/modules/catalog/catalog-repository'; +import { + type BasicStoreEntityCatalog, + type BasicStoreEntityCatalogContract, + type CatalogContractUpdate, + type CatalogUpsert, + ENTITY_TYPE_CATALOG, + ENTITY_TYPE_CATALOG_CONTRACT, +} from '../../../../src/modules/catalog/catalog-types'; +import { INDEX_INTERNAL_OBJECTS, READ_INDEX_INTERNAL_OBJECTS } from '../../../../src/database/utils'; +import { FilterMode, FilterOperator } from '../../../../src/generated/graphql'; +import type { AuthContext, AuthUser } from '../../../../src/types/user'; + +vi.mock('../../../../src/database/engine', () => ({ + elDeleteInstances: vi.fn(), + elIndex: vi.fn(), + elIndexElements: vi.fn(), + elLoadBy: vi.fn(), +})); + +vi.mock('../../../../src/database/middleware-loader', () => ({ + fullEntitiesList: vi.fn(), + internalFindByIdsMapped: vi.fn(), +})); + +const context = {} as AuthContext; +const user = {} as AuthUser; +const contractId = 'catalog-contract--1'; +const physicalIndex = 'opencti_internal_objects-000042'; +const existingContract = { + id: contractId, + internal_id: contractId, + _id: contractId, + _index: physicalIndex, + entity_type: ENTITY_TYPE_CATALOG_CONTRACT, +} as BasicStoreEntityCatalogContract; + +describe('catalog repository', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(internalFindByIdsMapped).mockResolvedValue({ + [contractId]: existingContract, + }); + }); + + it('should find all catalogs without filters', async () => { + vi.mocked(fullEntitiesList).mockResolvedValue([]); + + await findAllCatalogs(context, user); + + expect(fullEntitiesList).toHaveBeenCalledWith( + context, + user, + [ENTITY_TYPE_CATALOG], + { + indices: [READ_INDEX_INTERNAL_OBJECTS], + filters: null, + }, + ); + }); + + it('should find catalogs excluding the specified catalog ids', async () => { + vi.mocked(fullEntitiesList).mockResolvedValue([]); + + await findAllCatalogsExcluding(context, user, ['catalog-1', 'catalog-2']); + + expect(fullEntitiesList).toHaveBeenCalledWith( + context, + user, + [ENTITY_TYPE_CATALOG], + { + indices: [READ_INDEX_INTERNAL_OBJECTS], + filters: { + filters: [ + { + key: ['catalog_id'], + values: ['catalog-1'], + operator: FilterOperator.NotEq, + }, + { + key: ['catalog_id'], + values: ['catalog-2'], + operator: FilterOperator.NotEq, + }, + ], + filterGroups: [], + mode: FilterMode.And, + }, + }, + ); + }); + + it('should upsert a new catalog on the write alias when no existing catalog is provided', async () => { + const catalogUpsert = { + internal_id: 'catalog--1', + standard_id: 'catalog--standard', + catalog_id: 'catalog-1', + } as CatalogUpsert; + + await upsertCatalog(context, user, catalogUpsert); + + expect(elIndex).toHaveBeenCalledWith(INDEX_INTERNAL_OBJECTS, { + ...catalogUpsert, + entity_type: ENTITY_TYPE_CATALOG, + }); + }); + + it('should upsert an existing catalog in its existing physical index', async () => { + const catalogPhysicalIndex = 'opencti_internal_objects-000042'; + const currentCatalog = { + id: 'catalog--1', + internal_id: 'catalog--1', + _index: catalogPhysicalIndex, + } as BasicStoreEntityCatalog; + const catalogUpsert = { + internal_id: 'catalog--1', + standard_id: 'catalog--standard', + catalog_id: 'catalog-1', + } as CatalogUpsert; + + await upsertCatalog(context, user, catalogUpsert, currentCatalog); + + expect(elIndex).toHaveBeenCalledWith(catalogPhysicalIndex, { + ...catalogUpsert, + entity_type: ENTITY_TYPE_CATALOG, + }); + }); + + it('should update catalog contracts in their existing physical index', async () => { + const update = { + internal_id: contractId, + standard_id: 'catalog-contract--standard', + title: 'Updated contract', + } as CatalogContractUpdate; + + await updateCatalogContracts(context, user, [update]); + + expect(internalFindByIdsMapped).toHaveBeenCalledWith( + context, + user, + [contractId], + { + type: ENTITY_TYPE_CATALOG_CONTRACT, + indices: [READ_INDEX_INTERNAL_OBJECTS], + baseData: true, + mapWithAllIds: true, + }, + ); + expect(elIndexElements).toHaveBeenCalledWith( + context, + user, + ENTITY_TYPE_CATALOG_CONTRACT, + [{ + ...update, + _index: physicalIndex, + entity_type: ENTITY_TYPE_CATALOG_CONTRACT, + }], + ); + }); + + it('should delete catalog contracts from their existing physical index', async () => { + await deleteCatalogContracts(context, user, [{ idToDelete: contractId }]); + + expect(elDeleteInstances).toHaveBeenCalledWith(context, [existingContract]); + }); + + it('should reject writes when a catalog contract cannot be located', async () => { + vi.mocked(internalFindByIdsMapped).mockResolvedValue({}); + + await expect(deleteCatalogContracts( + context, + user, + [{ idToDelete: contractId }], + )).rejects.toThrow('Catalog contracts not found'); + expect(elDeleteInstances).not.toHaveBeenCalled(); + }); +}); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-sync-domain-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-sync-domain-test.ts new file mode 100644 index 000000000000..662df066d66e --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-sync-domain-test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { pathToFileURL } from 'node:url'; + +const { + mockFetchSourceCatalog, + mockFetchSourceCatalogRevisionHint, + mockFindCatalogBySourceUri, + mockFindCatalogByCatalogId, + mockFindCatalogContractsByCatalogId, + mockFindAllCatalogsExcluding, + mockInsertCatalogContracts, + mockUpdateCatalogContracts, + mockDeleteCatalogContracts, + mockUpsertCatalog, + mockDeleteCatalogs, + mockListCatalogContractLogos, + mockConfGet, + featureFlagState, +} = vi.hoisted(() => ({ + mockFetchSourceCatalog: vi.fn(), + mockFetchSourceCatalogRevisionHint: vi.fn(), + mockFindCatalogBySourceUri: vi.fn(), + mockFindCatalogByCatalogId: vi.fn(), + mockFindCatalogContractsByCatalogId: vi.fn(), + mockFindAllCatalogsExcluding: vi.fn(), + mockInsertCatalogContracts: vi.fn(), + mockUpdateCatalogContracts: vi.fn(), + mockDeleteCatalogContracts: vi.fn(), + mockUpsertCatalog: vi.fn(), + mockDeleteCatalogs: vi.fn(), + mockListCatalogContractLogos: vi.fn(), + mockConfGet: vi.fn((key: string): any => { + if (key === 'redis:ca') return []; + if (key === 'redis:use_ssl') return false; + return undefined; + }), + featureFlagState: { value: false }, +})); + +vi.mock('../../../../src/config/conf', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual.default, + get: mockConfGet, + }, + isFeatureEnabled: vi.fn(() => featureFlagState.value), + PLATFORM_VERSION: '7.2.0', + }; +}); + +vi.mock('../../../../src/utils/access', () => ({ + SYSTEM_USER: { id: 'system-user' }, +})); + +vi.mock('../../../../src/modules/catalog/sync/catalog-sync-source-gateway', () => ({ + fetchSourceCatalog: mockFetchSourceCatalog, + fetchSourceCatalogRevisionHint: mockFetchSourceCatalogRevisionHint, +})); + +vi.mock('../../../../src/modules/catalog/catalog-repository', () => ({ + findCatalogBySourceUri: mockFindCatalogBySourceUri, + findCatalogByCatalogId: mockFindCatalogByCatalogId, + findCatalogContractsByCatalogId: mockFindCatalogContractsByCatalogId, + findAllCatalogsExcluding: mockFindAllCatalogsExcluding, + insertCatalogContracts: mockInsertCatalogContracts, + updateCatalogContracts: mockUpdateCatalogContracts, + deleteCatalogContracts: mockDeleteCatalogContracts, + upsertCatalog: mockUpsertCatalog, + deleteCatalogs: mockDeleteCatalogs, +})); + +vi.mock('../../../../src/modules/catalog/catalog-logo-storage', () => ({ + listCatalogContractLogos: mockListCatalogContractLogos, + computeCatalogContractLogoUploadOperation: vi.fn(() => ({ result: 'no-logo', logoUri: null })), + uploadCatalogContractLogoOperation: vi.fn(async () => ({ result: 'success' })), +})); + +vi.mock('../../../../src/modules/catalog/catalog-logger', () => ({ + logCatalog: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../../../src/schema/identifier', () => ({ + idGenFromData: vi.fn((entityType: string, data: Record) => `${entityType}-${Object.values(data).join('-')}-internal`), + generateStandardId: vi.fn((entityType: string, data: Record) => `${entityType}-${Object.values(data).join('-')}-standard`), +})); + +import { synchronizeCatalogs } from '../../../../src/modules/catalog/sync/catalog-sync-domain'; + +const buildSourceCatalog = (id: string) => ({ + id, + name: `Catalog ${id}`, + description: 'desc', + product_version: '7.2.0', + manifest_version: null, + manifest_schema_version: '0' as const, + contracts: [], +}); + +describe('catalog-sync-domain', () => { + beforeEach(() => { + vi.clearAllMocks(); + featureFlagState.value = false; + mockConfGet.mockImplementation((key: string) => { + if (key === 'redis:ca') return []; + if (key === 'redis:use_ssl') return false; + if (key === 'app:custom_catalogs') return []; + if (key === 'catalog_manager:custom_catalog_refresh_endpoint_uri') return undefined; + if (key === 'xtm:xtmhub_url') return undefined; + return undefined; + }); + mockFetchSourceCatalogRevisionHint.mockResolvedValue(undefined); + mockFindCatalogBySourceUri.mockResolvedValue(undefined); + mockFindCatalogByCatalogId.mockResolvedValue(undefined); + mockFindCatalogContractsByCatalogId.mockResolvedValue(new Map()); + mockFindAllCatalogsExcluding.mockResolvedValue([]); + mockListCatalogContractLogos.mockResolvedValue(new Set()); + mockInsertCatalogContracts.mockResolvedValue(undefined); + mockUpdateCatalogContracts.mockResolvedValue(undefined); + mockDeleteCatalogContracts.mockResolvedValue(undefined); + mockUpsertCatalog.mockResolvedValue(undefined); + mockDeleteCatalogs.mockResolvedValue(undefined); + }); + + it('should use embedded source by default', async () => { + mockFetchSourceCatalog.mockResolvedValue(buildSourceCatalog('embedded-catalog')); + const result = await synchronizeCatalogs({ source: 'test' } as any, { id: 'user-1' } as any); + expect(result).toEqual(['embedded-catalog']); + expect(mockFetchSourceCatalog).toHaveBeenCalledWith({ kind: 'embedded', uri: 'embedded' }, undefined); + expect(mockFindAllCatalogsExcluding).toHaveBeenCalled(); + }); + + it('should include custom sources and deduplicate by uri', async () => { + mockConfGet.mockImplementation((key: string) => { + if (key === 'redis:ca') return []; + if (key === 'redis:use_ssl') return false; + if (key === 'catalog_manager:custom_catalog_refresh_endpoint_uri') return 'https://catalog.example.org/custom.json'; + if (key === 'app:custom_catalogs') return ['https://catalog.example.org/custom.json', '/tmp/custom-catalog.json']; + if (key === 'xtm:xtmhub_url') return undefined; + return undefined; + }); + mockFetchSourceCatalog.mockImplementation(async (source: { uri: string }) => buildSourceCatalog(source.uri)); + const result = await synchronizeCatalogs({ source: 'test' } as any, { id: 'user-1' } as any); + const calledSources = mockFetchSourceCatalog.mock.calls.map((call) => call[0]); + expect(calledSources).toEqual([ + { kind: 'embedded', uri: 'embedded' }, + { kind: 'remote', uri: 'https://catalog.example.org/custom.json' }, + { kind: 'local', filepath: '/tmp/custom-catalog.json', uri: pathToFileURL('/tmp/custom-catalog.json').toString() }, + ]); + expect(result).toHaveLength(3); + }); + + it('should ignore custom catalog manager endpoint when decoupling feature is disabled', async () => { + mockConfGet.mockImplementation((key: string) => { + if (key === 'redis:ca') return []; + if (key === 'redis:use_ssl') return false; + if (key === 'catalog_manager:custom_catalog_refresh_endpoint_uri') return 'https://catalog.example.org/custom.json'; + if (key === 'app:custom_catalogs') return []; + if (key === 'xtm:xtmhub_url') return 'https://xtm.example.org'; + return undefined; + }); + mockFetchSourceCatalog.mockResolvedValue(buildSourceCatalog('embedded-catalog')); + await synchronizeCatalogs({ source: 'test' } as any, { id: 'user-1' } as any); + expect(mockFetchSourceCatalog).toHaveBeenCalledTimes(1); + expect(mockFetchSourceCatalog).toHaveBeenCalledWith({ kind: 'embedded', uri: 'embedded' }, undefined); + }); + + it('should use custom catalog manager endpoint instead of xtm hub source when decoupling feature is enabled', async () => { + featureFlagState.value = true; + mockConfGet.mockImplementation((key: string) => { + if (key === 'redis:ca') return []; + if (key === 'redis:use_ssl') return false; + if (key === 'catalog_manager:custom_catalog_refresh_endpoint_uri') return 'https://catalog.example.org/custom.json'; + if (key === 'app:custom_catalogs') return []; + if (key === 'xtm:xtmhub_url') return 'https://xtm.example.org'; + return undefined; + }); + mockFetchSourceCatalog.mockResolvedValue(buildSourceCatalog('custom-catalog')); + await synchronizeCatalogs({ source: 'test' } as any, { id: 'user-1' } as any); + expect(mockFetchSourceCatalog).toHaveBeenCalledTimes(1); + expect(mockFetchSourceCatalog).toHaveBeenCalledWith({ kind: 'remote', uri: 'https://catalog.example.org/custom.json' }, undefined); + }); + + it('should fallback to embedded when remote decoupling source fails before first persistence', async () => { + featureFlagState.value = true; + mockConfGet.mockImplementation((key: string) => { + if (key === 'redis:ca') return []; + if (key === 'redis:use_ssl') return false; + if (key === 'xtm:xtmhub_url') return 'https://xtm.example.org'; + if (key === 'app:custom_catalogs') return []; + if (key === 'catalog_manager:custom_catalog_refresh_endpoint_uri') return undefined; + return undefined; + }); + mockFetchSourceCatalog.mockImplementation(async (source: { kind: string }) => { + if (source.kind === 'remote') { + throw new Error('remote unavailable'); + } + return buildSourceCatalog('embedded-fallback'); + }); + const result = await synchronizeCatalogs({ source: 'test' } as any, { id: 'user-1' } as any); + expect(result).toEqual(['embedded-fallback']); + expect(mockFetchSourceCatalog.mock.calls[0][0].kind).toBe('remote'); + expect(mockFetchSourceCatalog.mock.calls[1][0]).toEqual({ kind: 'embedded', uri: 'embedded' }); + }); + + it('should skip obsolete catalog cleanup when at least one source sync fails', async () => { + mockConfGet.mockImplementation((key: string) => { + if (key === 'redis:ca') return []; + if (key === 'redis:use_ssl') return false; + if (key === 'catalog_manager:custom_catalog_refresh_endpoint_uri') return undefined; + if (key === 'app:custom_catalogs') return ['/tmp/custom-catalog.json']; + if (key === 'xtm:xtmhub_url') return undefined; + return undefined; + }); + mockFetchSourceCatalog.mockImplementation(async (source: { kind: string }) => { + if (source.kind === 'local') { + throw new Error('local source failure'); + } + return buildSourceCatalog('embedded-catalog'); + }); + const result = await synchronizeCatalogs({ source: 'test' } as any, { id: 'user-1' } as any); + expect(result).toEqual(['embedded-catalog']); + expect(mockFindAllCatalogsExcluding).not.toHaveBeenCalled(); + expect(mockDeleteCatalogs).not.toHaveBeenCalled(); + }); +}); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-sync-source-gateway-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-sync-source-gateway-test.ts new file mode 100644 index 000000000000..786e69bb3572 --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-sync-source-gateway-test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadFile, mockGet, mockHead, mockGetOrCompileValidator } = vi.hoisted(() => ({ + mockReadFile: vi.fn(), + mockGet: vi.fn(), + mockHead: vi.fn(), + mockGetOrCompileValidator: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + readFile: mockReadFile, +})); + +vi.mock('../../../../src/config/conf', () => ({ + default: { + get: vi.fn(() => undefined), + }, + logApp: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + TEST_MODE: true, + ENABLED_METRICS: false, + booleanConf: vi.fn(() => false), + loadCert: vi.fn(() => ''), +})); + +vi.mock('../../../../src/database/utils', () => ({ + isEmptyField: (value: unknown) => value === null || value === undefined || value === '', +})); + +vi.mock('../../../../src/modules/catalog/catalog-domain', () => ({ + getOrCompileValidator: mockGetOrCompileValidator, +})); + +vi.mock('../../../../src/utils/http-client', () => ({ + getHttpClient: vi.fn(() => ({ + get: mockGet, + head: mockHead, + })), +})); + +vi.mock('../../../../src/modules/catalog/catalog-logger', () => ({ + logCatalog: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { + fetchSourceCatalog, + fetchSourceCatalogRevisionHint, + mapCatalogContractDtoToCatalogContractSyncSource, +} from '../../../../src/modules/catalog/sync/catalog-sync-source-gateway'; + +const baseV0Contract = { + title: 'IPinfo', + slug: 'ipinfo', + description: 'desc', + short_description: 'short', + logo: 'data:image/png;base64,Zm9v', + use_cases: [], + verified: true, + last_verified_date: '2024-01-01', + playbook_supported: false, + max_confidence_level: 50, + support_version: '>= 6.7.0', + subscription_link: null, + source_code: '', + manager_supported: true, + container_version: '1.2.3', + container_image: 'opencti/connector-ipinfo', + container_type: 'EXTERNAL_IMPORT', + config_schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'id', + type: 'object', + properties: {}, + required: [], + additionalProperties: true, + }, + license_type: null, + solution_categories: [], + contact: null, +}; + +const baseV1Contract = { + id: 'ipinfo-1.2.3', + title: 'IPinfo', + slug: 'ipinfo', + description: 'desc', + short_description: 'short', + logo: 'data:image/png;base64,Zm9v', + use_cases: [], + verified: true, + last_verified_date: '2024-01-01', + subscription_link: null, + source_code: '', + manager_supported: true, + support_version: '6.7.0', + license_type: null, + contact: null, + solution_categories: [], + version: '1.2.3', + image_name: 'opencti/connector-ipinfo', + image_type: 'EXTERNAL_IMPORT', + additional_properties: { + playbook_supported: true, + max_confidence_level: 80, + }, + config_schema: { + type: 'object', + properties: {}, + required: [], + additionalProperties: true, + }, +}; + +describe('catalog-sync-source-gateway', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetOrCompileValidator.mockReturnValue({}); + }); + + it('should normalize support_version and id from V0 contract DTO', () => { + const mapped = mapCatalogContractDtoToCatalogContractSyncSource(baseV0Contract as any); + expect(mapped.id).toBe('ipinfo-1.2.3'); + expect(mapped.support_version).toBe('6.7.0'); + }); + + it('should map V0 catalog sources', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ + id: 'filigran', + name: 'Filigran catalog', + description: 'catalog', + version: '6.8.0', + contracts: [baseV0Contract], + })); + const source = await fetchSourceCatalog({ + kind: 'local', + filepath: '/tmp/catalog.json', + uri: 'file:///tmp/catalog.json', + }); + expect(source.manifest_schema_version).toBe('0'); + expect(source.manifest_version).toBeNull(); + expect(source.product_version).toBe('6.8.0'); + expect(source.contracts).toHaveLength(1); + expect(source.contracts[0].support_version).toBe('6.7.0'); + }); + + it('should map V1 catalog sources', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ + id: 'filigran', + name: 'Filigran catalog', + description: 'catalog', + manifest_schema_version: '1', + manifest_version: '2026.08', + product_version: '6.8.0', + contracts: [baseV1Contract], + })); + const source = await fetchSourceCatalog({ + kind: 'local', + filepath: '/tmp/catalog-v1.json', + uri: 'file:///tmp/catalog-v1.json', + }); + expect(source.manifest_schema_version).toBe('1'); + expect(source.manifest_version).toBe('2026.08'); + expect(source.product_version).toBe('6.8.0'); + expect(source.contracts[0].id).toBe('ipinfo-1.2.3'); + expect(source.contracts[0].container_image).toBe('opencti/connector-ipinfo'); + }); + + it('should reject unsupported schema versions', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ + id: 'filigran', + name: 'Filigran catalog', + description: 'catalog', + manifest_schema_version: '2', + contracts: [], + })); + await expect(fetchSourceCatalog({ + kind: 'local', + filepath: '/tmp/catalog-invalid.json', + uri: 'file:///tmp/catalog-invalid.json', + })).rejects.toThrowError('Unsupported catalog schema version'); + }); + + it('should reject unrecognized formats', async () => { + mockReadFile.mockResolvedValue(JSON.stringify({ hello: 'world' })); + await expect(fetchSourceCatalog({ + kind: 'local', + filepath: '/tmp/catalog-invalid.json', + uri: 'file:///tmp/catalog-invalid.json', + })).rejects.toThrowError('Unrecognized catalog format'); + }); + + it('should reject manager-supported contracts missing container image', async () => { + const invalidContract = { ...baseV0Contract, container_image: '' }; + mockReadFile.mockResolvedValue(JSON.stringify({ + id: 'filigran', + name: 'Filigran catalog', + description: 'catalog', + version: '6.8.0', + contracts: [invalidContract], + })); + await expect(fetchSourceCatalog({ + kind: 'local', + filepath: '/tmp/catalog-invalid-contract.json', + uri: 'file:///tmp/catalog-invalid-contract.json', + })).rejects.toThrowError('Contract must define container_image field'); + }); + + it('should return revision hint from remote ETag header', async () => { + mockHead.mockResolvedValue({ headers: { etag: '"abc123"' } }); + const hint = await fetchSourceCatalogRevisionHint({ + kind: 'remote', + uri: 'https://catalog.example.org/manifest.json', + }); + expect(hint).toBe('"abc123"'); + }); + + it('should return undefined revision hint when ETag is missing', async () => { + mockHead.mockResolvedValue({ headers: {} }); + const hint = await fetchSourceCatalogRevisionHint({ + kind: 'remote', + uri: 'https://catalog.example.org/manifest.json', + }); + expect(hint).toBeUndefined(); + }); +}); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-version-utils-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-version-utils-test.ts new file mode 100644 index 000000000000..85a78dcb11b7 --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/modules/catalog/catalog-version-utils-test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { BasicStoreEntityCatalogContract } from '../../../../src/modules/catalog/catalog-types'; +import { + compareContractVersionDesc, + filterAndSortLatestCompatibleContracts, + isSupportVersionCompatible, + parseCatalogSemver, +} from '../../../../src/modules/catalog/catalog-version-utils'; + +const buildContract = (args: { + contract_id: string; + contract_version: string; + support_version?: string; +}) => { + return { + contract_id: args.contract_id, + contract_version: args.contract_version, + support_version: args.support_version, + } as unknown as BasicStoreEntityCatalogContract; +}; + +describe('catalog-version-utils', () => { + it('should coerce semantic versions from strings', () => { + expect(parseCatalogSemver('>= 6.5.2')?.version).toBe('6.5.2'); + expect(parseCatalogSemver('7.2.0')?.version).toBe('7.2.0'); + expect(parseCatalogSemver(undefined)).toBeNull(); + }); + + it('should evaluate support version compatibility', () => { + expect(isSupportVersionCompatible( + buildContract({ contract_id: 'c1', contract_version: '1.0.0' }), + { platformVersion: '7.2.0' }, + )).toBe(true); + + expect(isSupportVersionCompatible( + buildContract({ contract_id: 'c2', contract_version: '1.0.0', support_version: '7.1.0' }), + { platformVersion: '7.2.0' }, + )).toBe(true); + + expect(isSupportVersionCompatible( + buildContract({ contract_id: 'c3', contract_version: '1.0.0', support_version: '8.0.0' }), + { platformVersion: '7.2.0' }, + )).toBe(false); + }); + + it('should report unparsable support versions as incompatible', () => { + const onUnparsableSupportVersion = vi.fn(); + const isCompatible = isSupportVersionCompatible( + buildContract({ contract_id: 'c4', contract_version: '1.0.0', support_version: 'not-a-version' }), + { platformVersion: '7.2.0', onUnparsableSupportVersion }, + ); + expect(isCompatible).toBe(false); + expect(onUnparsableSupportVersion).toHaveBeenCalledWith({ + contractId: 'c4', + supportVersion: 'not-a-version', + platformVersion: '7.2.0', + }); + }); + + it('should compare contract versions in descending semantic order', () => { + const contracts = [ + buildContract({ contract_id: 'c1', contract_version: '1.2.0' }), + buildContract({ contract_id: 'c2', contract_version: '2.0.0' }), + buildContract({ contract_id: 'c3', contract_version: '1.10.0' }), + ]; + const sorted = contracts.sort(compareContractVersionDesc); + expect(sorted.map((contract) => contract.contract_id)).toEqual(['c2', 'c3', 'c1']); + }); + + it('should filter incompatible contracts and keep latest versions first', () => { + const onUnparsableSupportVersion = vi.fn(); + const contracts = [ + buildContract({ contract_id: 'latest-compatible', contract_version: '2.0.0', support_version: '7.0.0' }), + buildContract({ contract_id: 'older-compatible', contract_version: '1.5.0', support_version: '>= 6.5.2' }), + buildContract({ contract_id: 'no-support-version', contract_version: '1.0.0' }), + buildContract({ contract_id: 'too-new', contract_version: '3.0.0', support_version: '8.0.0' }), + buildContract({ contract_id: 'invalid-support-version', contract_version: '2.5.0', support_version: 'broken-version' }), + ]; + + const filtered = filterAndSortLatestCompatibleContracts(contracts, { + platformVersion: '7.2.0', + onUnparsableSupportVersion, + }); + + expect(filtered.map((contract) => contract.contract_id)).toEqual([ + 'latest-compatible', + 'older-compatible', + 'no-support-version', + ]); + expect(onUnparsableSupportVersion).toHaveBeenCalledTimes(1); + }); +}); + diff --git a/opencti-platform/opencti-graphql/tests/01-unit/modules/connector/connector-domain-auto-upgrade-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/modules/connector/connector-domain-auto-upgrade-test.ts new file mode 100644 index 000000000000..5891642f08be --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/modules/connector/connector-domain-auto-upgrade-test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BasicStoreEntityConnector } from '../../../../src/types/connector'; + +const { + mockFindManagedConnectorsByCatalogId, + mockFindLatestCompatibleCatalogContractBySlug, + mockMapContractEntityFieldsToEmbeddedConnectorManagerContract, + mockPatchAttribute, + mockPublishUserAction, +} = vi.hoisted(() => ({ + mockFindManagedConnectorsByCatalogId: vi.fn(), + mockFindLatestCompatibleCatalogContractBySlug: vi.fn(), + mockMapContractEntityFieldsToEmbeddedConnectorManagerContract: vi.fn((c) => ({ ...c })), + mockPatchAttribute: vi.fn(), + mockPublishUserAction: vi.fn(), +})); + +vi.mock('../../../../src/modules/connector/connector-repository', () => ({ + findManagedConnectorsByCatalogId: mockFindManagedConnectorsByCatalogId, +})); + +vi.mock('../../../../src/modules/catalog/catalog-repository', () => ({ + findLatestCompatibleCatalogContractBySlug: mockFindLatestCompatibleCatalogContractBySlug, +})); + +vi.mock('../../../../src/modules/catalog/catalog-domain', () => ({ + mapContractEntityFieldsToEmbeddedConnectorManagerContract: mockMapContractEntityFieldsToEmbeddedConnectorManagerContract, +})); + +vi.mock('../../../../src/database/middleware', () => ({ + patchAttribute: mockPatchAttribute, +})); + +vi.mock('../../../../src/listener/UserActionListener', () => ({ + publishUserAction: mockPublishUserAction, +})); + +vi.mock('../../../../src/config/conf', () => ({ + logApp: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { autoUpgradeManagedConnectors } from '../../../../src/modules/connector/connector-domain'; + +const buildManagedConnector = (overrides: Partial = {}): BasicStoreEntityConnector => { + const connector = { + id: 'connector-1', + internal_id: 'connector-1', + name: 'Managed connector', + connector_type: 'EXTERNAL_IMPORT', + active: true, + auto: true, + built_in: false, + only_contextual: false, + connector_scope: '*', + updated_at: new Date(), + connector_user_id: 'user-1', + connector_info: {} as any, + playbook_compatible: false, + xtm_one_intent: null, + connector_state: '', + connector_state_reset: false, + connector_trigger_filters: '', + catalog_id: 'catalog-1', + manager_upgrade_strategy: 'latest', + manager_contract_image: 'opencti/connector-test:1.0.0', + manager_contract: { + slug: 'ipinfo', + contract_version: '1.0.0', + content_hash: 'hash-1', + } as any, + ...overrides, + }; + return connector as BasicStoreEntityConnector; +}; + +const latestCompatibleContract = { + slug: 'ipinfo', + contract_version: '1.1.0', + content_hash: 'hash-2', + image: 'opencti/connector-test', +}; + +describe('connector-domain auto-upgrade', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindManagedConnectorsByCatalogId.mockResolvedValue([]); + mockFindLatestCompatibleCatalogContractBySlug.mockResolvedValue(undefined); + }); + + it('should skip connectors when strategy is not latest', async () => { + mockFindManagedConnectorsByCatalogId.mockResolvedValue([ + buildManagedConnector({ manager_upgrade_strategy: 'manual' as any }), + ]); + await autoUpgradeManagedConnectors({ source: 'test' } as any, { id: 'user-1' } as any, ['catalog-1']); + expect(mockPatchAttribute).not.toHaveBeenCalled(); + }); + + it('should skip connectors without manager_contract snapshot', async () => { + mockFindManagedConnectorsByCatalogId.mockResolvedValue([ + buildManagedConnector({ manager_contract: undefined }), + ]); + await autoUpgradeManagedConnectors({ source: 'test' } as any, { id: 'user-1' } as any, ['catalog-1']); + expect(mockFindLatestCompatibleCatalogContractBySlug).not.toHaveBeenCalled(); + expect(mockPatchAttribute).not.toHaveBeenCalled(); + }); + + it('should skip patch when connector already has latest compatible contract', async () => { + mockFindManagedConnectorsByCatalogId.mockResolvedValue([ + buildManagedConnector({ + manager_contract: { + slug: 'ipinfo', + contract_version: '1.1.0', + content_hash: 'hash-2', + } as any, + }), + ]); + mockFindLatestCompatibleCatalogContractBySlug.mockResolvedValue(latestCompatibleContract); + await autoUpgradeManagedConnectors({ source: 'test' } as any, { id: 'user-1' } as any, ['catalog-1']); + expect(mockPatchAttribute).not.toHaveBeenCalled(); + }); + + it('should patch managed connector when a newer compatible contract exists', async () => { + mockFindManagedConnectorsByCatalogId.mockResolvedValue([buildManagedConnector()]); + mockFindLatestCompatibleCatalogContractBySlug.mockResolvedValue(latestCompatibleContract); + await autoUpgradeManagedConnectors({ source: 'test' } as any, { id: 'user-1' } as any, ['catalog-1']); + expect(mockMapContractEntityFieldsToEmbeddedConnectorManagerContract).toHaveBeenCalledWith(latestCompatibleContract); + expect(mockPatchAttribute).toHaveBeenCalledWith( + { source: 'test' }, + { id: 'user-1' }, + 'connector-1', + 'Connector', + expect.objectContaining({ + manager_contract_image: 'opencti/connector-test', + }), + ); + expect(mockPublishUserAction).toHaveBeenCalled(); + }); + + it('should patch managed connector when latest compatible contract is an older version (downgrade)', async () => { + mockFindManagedConnectorsByCatalogId.mockResolvedValue([ + buildManagedConnector({ + manager_contract: { + slug: 'ipinfo', + contract_version: '2.0.0', + content_hash: 'hash-newer', + } as any, + }), + ]); + mockFindLatestCompatibleCatalogContractBySlug.mockResolvedValue({ + ...latestCompatibleContract, + contract_version: '1.9.0', + content_hash: 'hash-older', + }); + await autoUpgradeManagedConnectors({ source: 'test' } as any, { id: 'user-1' } as any, ['catalog-1']); + expect(mockPatchAttribute).toHaveBeenCalled(); + expect(mockPublishUserAction).toHaveBeenCalled(); + }); + + it('should patch managed connector when version is equal but content hash differs', async () => { + mockFindManagedConnectorsByCatalogId.mockResolvedValue([ + buildManagedConnector({ + manager_contract: { + slug: 'ipinfo', + contract_version: '1.1.0', + content_hash: 'old-hash', + } as any, + }), + ]); + mockFindLatestCompatibleCatalogContractBySlug.mockResolvedValue({ + ...latestCompatibleContract, + contract_version: '1.1.0', + content_hash: 'new-hash', + }); + await autoUpgradeManagedConnectors({ source: 'test' } as any, { id: 'user-1' } as any, ['catalog-1']); + expect(mockPatchAttribute).toHaveBeenCalled(); + expect(mockPublishUserAction).toHaveBeenCalled(); + }); + + it('should skip patch when no compatible contract can be found', async () => { + mockFindManagedConnectorsByCatalogId.mockResolvedValue([buildManagedConnector()]); + mockFindLatestCompatibleCatalogContractBySlug.mockResolvedValue(undefined); + await autoUpgradeManagedConnectors({ source: 'test' } as any, { id: 'user-1' } as any, ['catalog-1']); + expect(mockPatchAttribute).not.toHaveBeenCalled(); + expect(mockPublishUserAction).not.toHaveBeenCalled(); + }); +}); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/utils/data-url-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/utils/data-url-test.ts new file mode 100644 index 000000000000..8b37f956150c --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/01-unit/utils/data-url-test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { parseDataUrl } from '../../../src/utils/data-url'; + +describe('parseDataUrl', () => { + it('should parse mime type and base64 metadata', () => { + const parsed = parseDataUrl('data:image/png;base64,Zm9v'); + expect(parsed).toEqual({ + mimeType: 'image/png', + base64Encoded: true, + data: 'Zm9v', + }); + }); + + it('should parse metadata case-insensitively for base64 flag', () => { + const parsed = parseDataUrl('data:image/png;BASE64,Zm9v'); + expect(parsed).toEqual({ + mimeType: 'image/png', + base64Encoded: true, + data: 'Zm9v', + }); + }); + + it('should parse URL-encoded payload without base64 flag', () => { + const parsed = parseDataUrl('data:text/plain,hello%20world'); + expect(parsed).toEqual({ + mimeType: 'text/plain', + base64Encoded: false, + data: 'hello%20world', + }); + }); + + it('should fallback when no data URL prefix is present', () => { + expect(parseDataUrl('https://example.org/logo.png')).toEqual({ + mimeType: '', + base64Encoded: false, + data: '', + }); + }); + + it('should fallback when metadata/data separator is missing', () => { + expect(parseDataUrl('data:image/png;base64')).toEqual({ + mimeType: '', + base64Encoded: false, + data: '', + }); + }); +}); + diff --git a/opencti-platform/opencti-graphql/tests/01-unit/utils/logger-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/utils/logger-test.ts deleted file mode 100644 index d13ce6b68f62..000000000000 --- a/opencti-platform/opencti-graphql/tests/01-unit/utils/logger-test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import * as R from 'ramda'; -import { describe, expect, it } from 'vitest'; -import { appLogLevelMaxArraySize, appLogLevelMaxStringSize, prepareLogMetadata } from '../../../src/config/conf'; -import { FunctionalError } from '../../../src/config/errors'; - -// region objects definition -const CLASSIC_OBJECT = { - category: 'APP', - errors: [ - { - attributes: { - errors: [ - { - index: 'opencti_stix_domain_objects-000001', - index_uuid: 'ntQ2slJaRmWphjOmcls3lA', - reason: 'reason', - shard: '0', - type: 'version_conflict_engine_exception' - } - ], - genre: 'TECHNICAL', - http_status: 500 - }, - message: 'Bulk indexing fail', - name: 'DATABASE_ERROR', - stack: 'GraphQLError: Bulk indexing fail' - } - ], - id: '3f001108-c42c-4131-b3a3-583a98043c15', - level: 'error', - manager: 'RETENTION_MANAGER', - message: 'Bulk indexing fail', - source: 'backend', - timestamp: '2025-01-09T20:57:05.422Z', - version: '6.4.6' -}; -const CATEGORY_TO_LIMIT = [ - 'Item 1', - 'Item 2', - 'Item 3', - 'Item 4', - 'Item 5', - 'Item 6', - 'Item 7', - 'Item 8', - 'Item 9', - 'Item 10', - 'Item 11', - 'Item 12', - 'Item 13', - 'Item 14', - 'Item 15', - 'Item 16', - 'Item 17', - 'Item 18', - 'Item 19', - 'Item 20', - 'Item 21', - 'Item 22', - 'Item 23', - 'Item 24', - 'Item 25', - 'Item 26', - 'Item 27', - 'Item 28', - 'Item 29', - 'Item 30', - 'Item 31', - 'Item 32', - 'Item 33', - 'Item 34', - 'Item 35', - 'Item 36', - 'Item 37', - 'Item 38', - 'Item 39', - 'Item 40', - 'Item 41', - 'Item 42', - 'Item 43', - 'Item 44', - 'Item 45', - 'Item 46', - 'Item 47', - 'Item 48', - 'Item 49', - 'Item 50', - 'Item 51', - 'Item 52', - 'Item 53', - 'Item 54', - 'Item 55', - 'Item 56' -]; -const TOO_COMPLEX_OBJECT = { - category: 'APP', - cause: FunctionalError('my error', { category_to_limit: CATEGORY_TO_LIMIT }), - errors: [ - { - category_to_limit: CATEGORY_TO_LIMIT - }, - { - category_to_limit: ['2', '1', '3'], - } - ], - id: '3f001108-c42c-4131-b3a3-583a98043c15', - level: 'error', - source: R.range(1, 6000).map(() => 'A').join(''), - timestamp: '2025-01-09T20:57:05.422Z' -}; -const WITH_ERROR_OBJECT = { - level: 'error', - cause: FunctionalError('my error', { cause: new Error('embedded error') }), - timestamp: '2025-01-09T20:57:05.422Z' -}; -// endregion - -describe('Logger test suite', () => { - it('Log object is correctly untouched', () => { - const cleanObject = prepareLogMetadata(CLASSIC_OBJECT); - const classicCompare = R.dissoc('version', CLASSIC_OBJECT); - const cleanCompare = R.dissoc('version', cleanObject); - expect(JSON.stringify(cleanCompare)).toEqual(JSON.stringify(classicCompare)); - }); - - it('Log object with error correctly formatted', () => { - const cleanObject = prepareLogMetadata(WITH_ERROR_OBJECT); - expect(cleanObject.cause.message).toBe('my error'); - expect(cleanObject.cause.attributes.cause.message).toBe('embedded error'); - }); - - it('Log object is correctly limited', () => { - let initialSize = CATEGORY_TO_LIMIT.length; - const start = new Date().getTime(); - const cleanObject = prepareLogMetadata(TOO_COMPLEX_OBJECT); - const parsingTimeMs = new Date().getTime() - start; - expect(parsingTimeMs).to.be.lt(8); - let cleanedSize = cleanObject.cause.attributes.category_to_limit.length; - expect(initialSize).not.toEqual(cleanedSize); - expect(initialSize).to.be.gt(appLogLevelMaxArraySize); - expect(cleanedSize).to.be.eq(appLogLevelMaxArraySize); - // check more inside look - initialSize = TOO_COMPLEX_OBJECT.errors[0].category_to_limit.length; - cleanedSize = cleanObject.errors[0].category_to_limit.length; - expect(initialSize).not.toEqual(cleanedSize); - expect(initialSize).to.be.gt(appLogLevelMaxArraySize); - expect(cleanedSize).to.be.eq(appLogLevelMaxArraySize); - initialSize = TOO_COMPLEX_OBJECT.source.length; - cleanedSize = cleanObject.source.length; - // Check max string - expect(initialSize).not.toEqual(cleanedSize); - expect(initialSize).to.be.gt(appLogLevelMaxStringSize); - expect(cleanedSize).to.be.eq(appLogLevelMaxStringSize); - }); -}); diff --git a/opencti-platform/opencti-graphql/tests/01-unit/utils/sorting-test.ts b/opencti-platform/opencti-graphql/tests/01-unit/utils/sorting-test.ts index 7d176fbe4702..f3c3db3f6290 100644 --- a/opencti-platform/opencti-graphql/tests/01-unit/utils/sorting-test.ts +++ b/opencti-platform/opencti-graphql/tests/01-unit/utils/sorting-test.ts @@ -30,6 +30,14 @@ describe('Sorting utilities', () => { }, }); + sorting = await buildElasticSortingForAttributeCriteria(testContext, SYSTEM_USER, 'support_version', 'asc'); + expect(sorting).toEqual({ + 'support_version.keyword': { + missing: '_last', + order: 'asc', + }, + }); + // complex object with sortBy sorting = await buildElasticSortingForAttributeCriteria(testContext, SYSTEM_USER, 'group_confidence_level', 'asc'); expect(sorting).toEqual({ diff --git a/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/connector-composer-test.ts b/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/connector-composer-test.ts index 32f0e7a7a587..db95ba91e373 100644 --- a/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/connector-composer-test.ts +++ b/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/connector-composer-test.ts @@ -1,15 +1,15 @@ import { expect, it, describe, afterAll, beforeAll, vi } from 'vitest'; import gql from 'graphql-tag'; import { v4 as uuidv4 } from 'uuid'; -import { fileURLToPath } from 'node:url'; +import { FEATURE_FLAG_ALL, ENABLED_FEATURE_FLAGS } from '../../../src/config/conf'; import { queryAsAdminWithError, awaitUntilCondition, queryAsAdminWithSuccess, queryAsUserIsExpectedForbidden, queryAsUserWithSuccess } from '../../utils/testQueryHelper'; -import { USER_CONNECTOR, USER_EDITOR } from '../../utils/testQuery'; +import { ADMIN_USER, USER_CONNECTOR, USER_EDITOR, testContext } from '../../utils/testQuery'; import { wait } from '../../../src/database/utils'; import { XTMComposerMock } from '../../utils/XTMComposerMock'; import type { ApiConnector } from '../../utils/XTMComposerMock'; import { catalogHelper } from '../../utils/catalogHelper'; -import { resetCatalogs } from '../../../src/modules/catalog/catalog-domain'; -import * as UserActionListener from '../../../src/listener/UserActionListener'; +import { synchronizeCatalogs } from '../../../src/modules/catalog/sync/catalog-sync-domain'; +import conf from '../../../src/config/conf'; const TEST_COMPOSER_ID = uuidv4(); const TEST_USER_CONNECTOR_ID: string = USER_CONNECTOR.id; // Initialize with default value @@ -98,6 +98,16 @@ const DELETE_CONNECTOR_MUTATION = gql` } `; +const CREATE_CONNECTOR_MUTATION = gql` + mutation RegisterConnector($input: RegisterConnectorInput) { + registerConnector(input: $input) { + id + name + connector_type + } + } +`; + // Queries const CONNECTOR_MANAGER_QUERY = gql` query ConnectorManager($managerId: ID!) { @@ -168,6 +178,65 @@ const GET_CONNECTOR_EXCERPT_QUERY = gql` } `; +const GET_CONNECTOR_MANAGER_CONTRACT_ENDPOINTS_QUERY = gql` + query GetConnectorManagerContractEndpoints($id: String!) { + connector(id: $id) { + id + manager_contract_image + manager_contract_hash + manager_contract_configuration { + key + value + } + manager_contract_definition + manager_contract_excerpt { + title + slug + logo + } + } + } +`; + +const CONNECTOR_MIGRATION_ASSESSMENT_QUERY = gql` + query ConnectorMigrationAssessment($connectorId: ID!, $containerImage: String!, $configuration: [ContractConfigInput!]) { + connectorMigrationAssessment(connectorId: $connectorId, containerImage: $containerImage, configuration: $configuration) { + connector_id + connector_name + contract_image + summary { + total_source_keys + mapped_keys + ignored_keys + missing_mandatory_keys + can_migrate + assessment_date + } + mapped { + key + sourceKey + value + type + required + } + ignored { + key + value + reason + } + missing { + key + type + description + format + enum + default + required + } + } + } +`; + // Variables const ipinfoProperties = { CONNECTOR_LISTEN_PROTOCOL_API_PORT: 0, @@ -183,15 +252,21 @@ describe('Connector Composer and Managed Connectors', () => { // Track all created resources const createdConnectorIds = new Set(); let xtmComposer: XTMComposerMock; + const previousEnabledFeatureFlags = [...ENABLED_FEATURE_FLAGS]; + const previousCustomCatalogRefreshEndpoint = conf.get('catalog_manager:custom_catalog_refresh_endpoint_uri'); + const previousCustomCatalogs = conf.get('app:custom_catalogs'); // Initialize XTM Composer mock beforeAll(async () => { - // Set up test catalog path in environment - const testCatalogUrl = new URL('../../utils/opencti-manifest.json', import.meta.url); - process.env.APP__CUSTOM_CATALOGS = JSON.stringify([fileURLToPath(testCatalogUrl)]); - - // Reset catalogs to ensure test catalog is loaded - resetCatalogs(); + const testCatalogUri = new URL('../../utils/opencti-manifest.json', import.meta.url).toString(); + ENABLED_FEATURE_FLAGS.splice(0, ENABLED_FEATURE_FLAGS.length); + ENABLED_FEATURE_FLAGS.push(...previousEnabledFeatureFlags.filter((flag) => flag !== FEATURE_FLAG_ALL)); + if (!ENABLED_FEATURE_FLAGS.includes('DECOUPLING_VERSIONS')) { + ENABLED_FEATURE_FLAGS.push('DECOUPLING_VERSIONS'); + } + conf.set('app:custom_catalogs', []); + conf.set('catalog_manager:custom_catalog_refresh_endpoint_uri', testCatalogUri); + await synchronizeCatalogs(testContext, ADMIN_USER); // Validate that we're using the test catalog catalogHelper.validateTestCatalog(); @@ -1195,6 +1270,7 @@ describe('Connector Composer and Managed Connectors', () => { describe('Managed Connector operations', () => { let managedConnectorId: string; + let standaloneConnectorId: string; it('should fail to add managed connector with invalid image', async () => { const catalogId = catalogHelper.getCatalogId(); @@ -1253,7 +1329,70 @@ describe('Connector Composer and Managed Connectors', () => { expect(managedConnectorAdd.manager_contract_configuration.length).toBeGreaterThan(0); }); + it('should return stable manager contract endpoint output formats', async () => { + const testConnector = catalogHelper.getTestSafeConnector(); + const result = await queryAsAdminWithSuccess({ + query: GET_CONNECTOR_MANAGER_CONTRACT_ENDPOINTS_QUERY, + variables: { id: managedConnectorId }, + }); + + const connectorData = result.data?.connector; + expect(connectorData).toBeDefined(); + expect(typeof connectorData.manager_contract_image).toBe('string'); + expect(connectorData.manager_contract_image).toEqual(`${testConnector.container_image}:${testConnector.container_version}`); + expect(typeof connectorData.manager_contract_hash).toBe('string'); + expect(connectorData.manager_contract_hash.length).toBeGreaterThan(0); + expect(Array.isArray(connectorData.manager_contract_configuration)).toBeTruthy(); + expect(connectorData.manager_contract_configuration.length).toBeGreaterThan(0); + connectorData.manager_contract_configuration.forEach((entry: { key: string; value: string }) => { + expect(typeof entry.key).toBe('string'); + expect(typeof entry.value).toBe('string'); + }); + + const definitionRaw = connectorData.manager_contract_definition; + expect(typeof definitionRaw).toBe('string'); + const definition = JSON.parse(definitionRaw); + expect(definition).toMatchObject({ + title: expect.any(String), + slug: expect.any(String), + description: expect.any(String), + short_description: expect.any(String), + use_cases: expect.any(Array), + verified: expect.any(Boolean), + last_verified_date: expect.any(String), + playbook_supported: expect.any(Boolean), + max_confidence_level: expect.any(Number), + support_version: expect.any(String), + subscription_link: expect.any(String), + source_code: expect.any(String), + manager_supported: expect.any(Boolean), + container_version: expect.any(String), + container_image: expect.any(String), + container_type: expect.any(String), + }); + expect(definition.logo === null || typeof definition.logo === 'string').toBeTruthy(); + expect(definition.config_schema).toBeDefined(); + expect(definition.config_schema.properties).toBeDefined(); + const runtimeKeys = ['OPENCTI_TOKEN', 'OPENCTI_URL', 'CONNECTOR_TYPE', 'CONNECTOR_RUN_AND_TERMINATE']; + runtimeKeys.forEach((runtimeKey) => { + expect(definition.config_schema.properties[runtimeKey]).toBeUndefined(); + }); + expect(Array.isArray(definition.config_schema.required)).toBeTruthy(); + runtimeKeys.forEach((runtimeKey) => { + expect(definition.config_schema.required).not.toContain(runtimeKey); + }); + + const excerpt = connectorData.manager_contract_excerpt; + expect(excerpt).toBeDefined(); + expect(excerpt).toMatchObject({ + title: expect.any(String), + slug: expect.any(String), + logo: expect.anything(), + }); + }); + it('should retrieve the manager contract excerpt for a managed connector', async () => { + const testConnector = catalogHelper.getTestSafeConnector(); const result = await queryAsAdminWithSuccess({ query: GET_CONNECTOR_EXCERPT_QUERY, variables: { id: managedConnectorId }, @@ -1262,9 +1401,80 @@ describe('Connector Composer and Managed Connectors', () => { expect(result.data).toBeDefined(); const excerpt = result.data?.connector?.manager_contract_excerpt; expect(excerpt).toBeDefined(); - expect(excerpt.title).toEqual('IPinfo'); - expect(excerpt.slug).toEqual('ipinfo'); - expect(excerpt.logo).toMatch(/^data:image\/.+;base64,[A-Za-z0-9+/]+=*$/); + expect(excerpt.title).toEqual(testConnector.title); + expect(excerpt.slug).toEqual(testConnector.slug); + expect(excerpt.logo === null || typeof excerpt.logo === 'string').toBeTruthy(); + }); + + it('should return connectorMigrationAssessment with stable output format', async () => { + const testConnector = catalogHelper.getTestSafeConnector(); + const registerResult = await queryAsAdminWithSuccess({ + query: CREATE_CONNECTOR_MUTATION, + variables: { + input: { + id: uuidv4(), + name: 'Assessment standalone connector', + type: testConnector.container_type, + scope: ['IPv4-Addr'], + auto: true, + only_contextual: false, + }, + }, + }); + standaloneConnectorId = registerResult.data?.registerConnector?.id; + createdConnectorIds.add(standaloneConnectorId); + + const assessmentResult = await queryAsAdminWithSuccess({ + query: CONNECTOR_MIGRATION_ASSESSMENT_QUERY, + variables: { + connectorId: standaloneConnectorId, + containerImage: testConnector.container_image, + configuration: catalogHelper.getMinimalConfig(testConnector, { + IPINFO_TOKEN: 'assessment-token', + ...ipinfoProperties, + }), + }, + }); + + const assessment = assessmentResult.data?.connectorMigrationAssessment; + expect(assessment).toBeDefined(); + expect(assessment.connector_id).toEqual(standaloneConnectorId); + expect(assessment.connector_name).toEqual('Assessment standalone connector'); + expect(assessment.contract_image).toEqual(testConnector.container_image); + expect(assessment.summary).toMatchObject({ + total_source_keys: expect.any(Number), + mapped_keys: expect.any(Number), + ignored_keys: expect.any(Number), + missing_mandatory_keys: expect.any(Number), + can_migrate: expect.any(Boolean), + }); + expect(assessment.summary.assessment_date).toBeDefined(); + expect(new Date(assessment.summary.assessment_date).toString()).not.toEqual('Invalid Date'); + expect(Array.isArray(assessment.mapped)).toBeTruthy(); + expect(Array.isArray(assessment.ignored)).toBeTruthy(); + expect(Array.isArray(assessment.missing)).toBeTruthy(); + assessment.mapped.forEach((entry: { key: string; sourceKey: string; value: string; type: string; required: boolean }) => { + expect(entry).toMatchObject({ + key: expect.any(String), + sourceKey: expect.any(String), + value: expect.any(String), + type: expect.any(String), + required: expect.any(Boolean), + }); + }); + assessment.ignored.forEach((entry: { key: string; value: string; reason: string }) => { + expect(entry).toMatchObject({ + key: expect.any(String), + value: expect.any(String), + reason: expect.any(String), + }); + }); + assessment.missing.forEach((entry: { key: string; type: string; description: string; required: boolean }) => { + expect(entry.key).toEqual(expect.any(String)); + expect(entry.type).toEqual(expect.any(String)); + expect(entry.description).toEqual(expect.any(String)); + expect(entry.required).toEqual(expect.any(Boolean)); + }); }); it('should edit managed connector', async () => { @@ -1291,6 +1501,16 @@ describe('Connector Composer and Managed Connectors', () => { expect(result.data).toBeDefined(); expect(result.data?.managedConnectorEdit.name).toEqual('Updated IpInfo Connector'); expect(result.data?.managedConnectorEdit.manager_contract_configuration).toBeDefined(); + const runtimeKeys = ['OPENCTI_URL', 'CONNECTOR_RUN_AND_TERMINATE']; + runtimeKeys.forEach((runtimeKey) => { + const found = result.data?.managedConnectorEdit.manager_contract_configuration + .find((c: any) => c.key === runtimeKey); + expect(found).toBeUndefined(); + }); + const openctiTokenConfig = result.data?.managedConnectorEdit.manager_contract_configuration + .find((c: any) => c.key === 'OPENCTI_TOKEN'); + expect(openctiTokenConfig).toBeDefined(); + expect(openctiTokenConfig.value).not.toEqual('updated-token-456'); const autoConfig = result.data?.managedConnectorEdit.manager_contract_configuration .find((c: any) => c.key === 'CONNECTOR_AUTO'); expect(autoConfig.value).toEqual('false'); @@ -1635,5 +1855,9 @@ describe('Connector Composer and Managed Connectors', () => { } }), ); + + conf.set('catalog_manager:custom_catalog_refresh_endpoint_uri', previousCustomCatalogRefreshEndpoint); + conf.set('app:custom_catalogs', previousCustomCatalogs); + ENABLED_FEATURE_FLAGS.splice(0, ENABLED_FEATURE_FLAGS.length, ...previousEnabledFeatureFlags); }); }); diff --git a/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/connector-migration-test.ts b/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/connector-migration-test.ts index 771f3747990b..945f63563922 100644 --- a/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/connector-migration-test.ts +++ b/opencti-platform/opencti-graphql/tests/03-integration/02-resolvers/connector-migration-test.ts @@ -3,7 +3,7 @@ import gql from 'graphql-tag'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { registerConnector } from '../../../src/domain/connector'; import { ConnectorType } from '../../../src/generated/graphql'; -import * as catalogDomain from '../../../src/modules/catalog/catalog-domain'; +import * as catalogRepository from '../../../src/modules/catalog/catalog-repository'; import { ADMIN_USER, testContext } from '../../utils/testQuery'; import { queryAsAdmin } from '../../utils/testQueryHelper'; import { queryAsAdminWithSuccess } from '../../utils/testQueryHelper'; @@ -188,30 +188,22 @@ describe('Check connector migration', () => { }, }); - const contractFound = await catalogDomain.findContractByContainerImage(testContext, ADMIN_USER, 'opencti/connector-cve'); - if (!contractFound?.contract) { + const contractFound = await catalogRepository.findLatestCompatibleCatalogContractByImageName(testContext, ADMIN_USER, 'opencti/connector-cve'); + if (!contractFound) { throw new Error('Connector nist-nvd-cve container-image not found in catalog'); } - let contractParsed; - try { - contractParsed = JSON.parse(contractFound?.contract); - } catch { - throw new Error('Cannot parse nist-nvd-cve catalog'); - } - - if (!contractParsed) { - throw new Error('Contract nist-nvd-cve catalog is undefined'); - } - // same values excluded from catalog-domain const RUNTIME_KEYS = ['OPENCTI_TOKEN', 'CONNECTOR_ID', 'CONNECTOR_TYPE', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'HTTPS_PROXY_REJECT_UNAUTHORIZED']; const managedConnector = managedConnectorResult.data.connectorMigrateToManaged; const rawConfig = managedConnector.manager_contract_configuration; - rawConfig.filter((c: any) => !RUNTIME_KEYS.includes(c.key)); const actualConfig = rawConfig.filter((c: { key: string }) => !RUNTIME_KEYS.includes(c.key)); - const schemaProperties = contractParsed.config_schema.properties; + RUNTIME_KEYS.forEach((runtimeKey) => { + const found = rawConfig.find((c: { key: string }) => c.key === runtimeKey); + expect(found).toBeUndefined(); + }); + const schemaProperties = contractFound.config_schema.properties; const expectedKeys = Object.keys(schemaProperties); const actualKeys = actualConfig.map((c: { key: string }) => c.key); diff --git a/opencti-platform/opencti-graphql/tests/03-integration/10-modules/catalog/catalog-resolver-test.ts b/opencti-platform/opencti-graphql/tests/03-integration/10-modules/catalog/catalog-resolver-test.ts new file mode 100644 index 000000000000..70ef28b5b81d --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/03-integration/10-modules/catalog/catalog-resolver-test.ts @@ -0,0 +1,226 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import gql from 'graphql-tag'; +import { v4 as uuidv4 } from 'uuid'; +import { queryAsAdminWithSuccess } from '../../../utils/testQueryHelper'; +import { ADMIN_USER, testContext } from '../../../utils/testQuery'; +import { synchronizeCatalogs } from '../../../../src/modules/catalog/sync/catalog-sync-domain'; +import conf, { ENABLED_FEATURE_FLAGS, FEATURE_FLAG_ALL } from '../../../../src/config/conf'; + +const LIST_CATALOGS_QUERY = gql` + query Catalogs { + catalogs { + id + name + description + contracts + } + } +`; + +const LIST_CATALOG_REVISIONS_QUERY = gql` + query CatalogsRevisions { + catalogsRevisions { + catalog_id + revision + } + } +`; + +const GET_CATALOG_BY_ID_QUERY = gql` + query CatalogById($id: String!) { + catalog(id: $id) { + id + name + description + contracts + } + } +`; + +const GET_CONTRACT_BY_SLUG_QUERY = gql` + query ContractBySlug($slug: String!) { + contract(slug: $slug) { + catalog_id + contract + } + } +`; + +const assertContractShape = (contract: any) => { + expect(contract.title).toEqual(expect.any(String)); + expect(contract.slug).toEqual(expect.any(String)); + expect(contract.description).toEqual(expect.any(String)); + expect(contract.short_description).toEqual(expect.any(String)); + expect(contract.use_cases).toEqual(expect.any(Array)); + expect(contract.verified).toEqual(expect.any(Boolean)); + expect(contract.playbook_supported).toEqual(expect.any(Boolean)); + expect(contract.max_confidence_level).toEqual(expect.any(Number)); + expect(contract.source_code).toEqual(expect.any(String)); + expect(contract.manager_supported).toEqual(expect.any(Boolean)); + expect(contract.container_version).toEqual(expect.any(String)); + expect(contract.container_image).toEqual(expect.any(String)); + expect(contract.container_type).toEqual(expect.any(String)); + expect(contract.config_schema).toEqual(expect.any(Object)); + expect(contract.logo === null || typeof contract.logo === 'string').toBe(true); + expect(contract.last_verified_date === null || typeof contract.last_verified_date === 'string').toBe(true); + expect(contract.support_version === null || typeof contract.support_version === 'string').toBe(true); + expect(contract.subscription_link === null || typeof contract.subscription_link === 'string').toBe(true); + expect(contract.config_schema).toMatchObject({ + type: expect.any(String), + properties: expect.any(Object), + required: expect.any(Array), + additionalProperties: expect.any(Boolean), + }); +}; + +describe('Catalog resolver integration', () => { + const v0TemplatePath = fileURLToPath(new URL('./fixture/integration-catalog-v0.json', import.meta.url)); + const v1TemplatePath = fileURLToPath(new URL('./fixture/integration-catalog-v1.json', import.meta.url)); + let v0CatalogUri = ''; + let v1CatalogUri = ''; + let catalogV0Id = ''; + let catalogV1Id = ''; + let catalogV0Slug = ''; + let catalogV1Slug = ''; + let tempFixtureDir = ''; + const previousEnabledFeatureFlags = [...ENABLED_FEATURE_FLAGS]; + const previousCustomCatalogRefreshEndpoint = conf.get('catalog_manager:custom_catalog_refresh_endpoint_uri'); + const previousCustomCatalogs = conf.get('app:custom_catalogs'); + const runtimeKeys = ['OPENCTI_TOKEN', 'OPENCTI_URL', 'CONNECTOR_TYPE', 'CONNECTOR_RUN_AND_TERMINATE']; + + const assertCatalogEndpoints = async (expectedCatalogId: string, expectedSlug: string, customCatalogUri: string) => { + conf.set('catalog_manager:custom_catalog_refresh_endpoint_uri', customCatalogUri); + await synchronizeCatalogs(testContext, ADMIN_USER); + + const catalogsResult = await queryAsAdminWithSuccess({ + query: LIST_CATALOGS_QUERY, + variables: {}, + }); + const catalogs = catalogsResult.data?.catalogs ?? []; + const expectedCatalog = catalogs.find((catalog: any) => catalog.id === expectedCatalogId); + expect(expectedCatalog).toBeDefined(); + + for (const catalog of catalogs) { + expect(catalog).toMatchObject({ + id: expect.any(String), + name: expect.any(String), + description: expect.any(String), + contracts: expect.any(Array), + }); + const contracts = catalog.contracts.map((raw: string) => JSON.parse(raw)); + const slugs = contracts.map((contract: { slug: string }) => contract.slug); + expect(new Set(slugs).size).toEqual(slugs.length); + contracts.forEach((contract: any) => { + assertContractShape(contract); + runtimeKeys.forEach((runtimeKey) => { + expect(contract.config_schema.properties[runtimeKey]).toBeUndefined(); + }); + }); + } + + const byIdResult = await queryAsAdminWithSuccess({ + query: GET_CATALOG_BY_ID_QUERY, + variables: { id: expectedCatalogId }, + }); + const byIdCatalog = byIdResult.data?.catalog; + expect(byIdCatalog).toMatchObject({ + id: expectedCatalogId, + name: expect.any(String), + description: expect.any(String), + contracts: expect.any(Array), + }); + expect(byIdCatalog.contracts.length).toBeGreaterThan(0); + const byIdContract = JSON.parse(byIdCatalog.contracts[0]); + assertContractShape(byIdContract); + runtimeKeys.forEach((runtimeKey) => { + expect(byIdContract.config_schema.properties[runtimeKey]).toBeUndefined(); + }); + + const bySlugResult = await queryAsAdminWithSuccess({ + query: GET_CONTRACT_BY_SLUG_QUERY, + variables: { slug: expectedSlug }, + }); + const bySlugPayload = bySlugResult.data?.contract; + expect(bySlugPayload).toMatchObject({ + catalog_id: expectedCatalogId, + contract: expect.any(String), + }); + const bySlugContract = JSON.parse(bySlugPayload.contract); + assertContractShape(bySlugContract); + expect(bySlugContract.slug).toEqual(expectedSlug); + runtimeKeys.forEach((runtimeKey) => { + expect(bySlugContract.config_schema.properties[runtimeKey]).toBeUndefined(); + }); + }; + + beforeAll(async () => { + const runSuffix = uuidv4().slice(0, 8); + tempFixtureDir = await mkdtemp(join(tmpdir(), 'opencti-catalog-integration-')); + const v0Fixture = JSON.parse(await readFile(v0TemplatePath, 'utf8')); + const v1Fixture = JSON.parse(await readFile(v1TemplatePath, 'utf8')); + + catalogV0Id = `integration-catalog-v0-${runSuffix}`; + catalogV1Id = `integration-catalog-v1-${runSuffix}`; + catalogV0Slug = `integration-v0-connector-${runSuffix}`; + catalogV1Slug = `integration-v1-connector-${runSuffix}`; + + v0Fixture.id = catalogV0Id; + v0Fixture.contracts[0].slug = catalogV0Slug; + v1Fixture.id = catalogV1Id; + v1Fixture.contracts[0].slug = catalogV1Slug; + v1Fixture.contracts[0].id = `${catalogV1Slug}-${v1Fixture.contracts[0].version}`; + + const v0TempPath = join(tempFixtureDir, 'integration-catalog-v0.json'); + const v1TempPath = join(tempFixtureDir, 'integration-catalog-v1.json'); + await writeFile(v0TempPath, JSON.stringify(v0Fixture), 'utf8'); + await writeFile(v1TempPath, JSON.stringify(v1Fixture), 'utf8'); + v0CatalogUri = pathToFileURL(v0TempPath).toString(); + v1CatalogUri = pathToFileURL(v1TempPath).toString(); + + ENABLED_FEATURE_FLAGS.splice(0, ENABLED_FEATURE_FLAGS.length); + ENABLED_FEATURE_FLAGS.push(...previousEnabledFeatureFlags.filter((flag) => flag !== FEATURE_FLAG_ALL)); + if (!ENABLED_FEATURE_FLAGS.includes('DECOUPLING_VERSIONS')) { + ENABLED_FEATURE_FLAGS.push('DECOUPLING_VERSIONS'); + } + conf.set('app:custom_catalogs', []); + }); + + afterAll(async () => { + conf.set('catalog_manager:custom_catalog_refresh_endpoint_uri', previousCustomCatalogRefreshEndpoint); + conf.set('app:custom_catalogs', previousCustomCatalogs); + ENABLED_FEATURE_FLAGS.splice(0, ENABLED_FEATURE_FLAGS.length, ...previousEnabledFeatureFlags); + if (tempFixtureDir) { + await rm(tempFixtureDir, { recursive: true, force: true }); + } + }); + + it('should expose expected endpoint shapes with a custom V0 catalog source', async () => { + await assertCatalogEndpoints(catalogV0Id, catalogV0Slug, v0CatalogUri); + }); + + it('should expose expected endpoint shapes with a custom V1 catalog source', async () => { + await assertCatalogEndpoints(catalogV1Id, catalogV1Slug, v1CatalogUri); + }); + + it('should expose lightweight catalogs revisions payload', async () => { + conf.set('catalog_manager:custom_catalog_refresh_endpoint_uri', v1CatalogUri); + await synchronizeCatalogs(testContext, ADMIN_USER); + + const result = await queryAsAdminWithSuccess({ + query: LIST_CATALOG_REVISIONS_QUERY, + variables: {}, + }); + const revisions = result.data?.catalogsRevisions ?? []; + expect(revisions.length).toBeGreaterThan(0); + + for (const revisionEntry of revisions) { + expect(Object.keys(revisionEntry).sort()).toEqual(['catalog_id', 'revision']); + expect(revisionEntry.catalog_id).toEqual(expect.any(String)); + expect(revisionEntry.revision).toEqual(expect.any(String)); + } + }); +}); diff --git a/opencti-platform/opencti-graphql/tests/03-integration/10-modules/catalog/fixture/integration-catalog-v0.json b/opencti-platform/opencti-graphql/tests/03-integration/10-modules/catalog/fixture/integration-catalog-v0.json new file mode 100644 index 000000000000..33fce64bd08d --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/03-integration/10-modules/catalog/fixture/integration-catalog-v0.json @@ -0,0 +1,50 @@ +{ + "id": "integration-catalog-v0", + "name": "Integration Catalog V0", + "description": "Small V0 catalog fixture for resolver integration tests", + "version": "7.2.0", + "contracts": [ + { + "title": "Integration V0 Connector", + "slug": "integration-v0-connector", + "description": "V0 contract fixture", + "short_description": "v0 fixture", + "logo": null, + "use_cases": [], + "verified": true, + "last_verified_date": "2026-01-01", + "playbook_supported": false, + "max_confidence_level": 50, + "support_version": ">= 7.0.0", + "subscription_link": null, + "source_code": "", + "manager_supported": true, + "container_version": "1.0.0", + "container_image": "opencti/connector-integration-v0", + "container_type": "EXTERNAL_IMPORT", + "config_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "integration-v0", + "type": "object", + "properties": { + "INTEGRATION_API_KEY": { + "type": "string", + "description": "fixture key" + }, + "OPENCTI_URL": { + "type": "string", + "description": "runtime key to strip" + } + }, + "required": [ + "INTEGRATION_API_KEY", + "OPENCTI_URL" + ], + "additionalProperties": false + }, + "license_type": null, + "solution_categories": [], + "contact": null + } + ] +} diff --git a/opencti-platform/opencti-graphql/tests/03-integration/10-modules/catalog/fixture/integration-catalog-v1.json b/opencti-platform/opencti-graphql/tests/03-integration/10-modules/catalog/fixture/integration-catalog-v1.json new file mode 100644 index 000000000000..d7edcf0d2af3 --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/03-integration/10-modules/catalog/fixture/integration-catalog-v1.json @@ -0,0 +1,54 @@ +{ + "id": "integration-catalog-v1", + "name": "Integration Catalog V1", + "description": "Small V1 catalog fixture for resolver integration tests", + "manifest_schema_version": "1", + "manifest_version": "2026.08.19", + "product_version": "7.2.0", + "contracts": [ + { + "id": "integration-v1-connector-1.0.0", + "title": "Integration V1 Connector", + "slug": "integration-v1-connector", + "description": "V1 contract fixture", + "short_description": "v1 fixture", + "logo": null, + "use_cases": [], + "verified": true, + "last_verified_date": "2026-01-01", + "subscription_link": null, + "source_code": null, + "manager_supported": true, + "support_version": "7.0.0", + "license_type": null, + "contact": null, + "solution_categories": [], + "version": "1.0.0", + "image_name": "opencti/connector-integration-v1", + "image_type": "EXTERNAL_IMPORT", + "additional_properties": { + "playbook_supported": false, + "max_confidence_level": 75 + }, + "config_schema": { + "type": "object", + "properties": { + "INTEGRATION_API_KEY": { + "type": "string", + "description": "fixture key" + }, + "OPENCTI_TOKEN": { + "type": "string", + "format": "password", + "description": "runtime key to strip" + } + }, + "required": [ + "INTEGRATION_API_KEY", + "OPENCTI_TOKEN" + ], + "additionalProperties": false + } + } + ] +} diff --git a/opencti-platform/opencti-graphql/tests/03-integration/10-modules/http/catalog-logo-http-test.ts b/opencti-platform/opencti-graphql/tests/03-integration/10-modules/http/catalog-logo-http-test.ts new file mode 100644 index 000000000000..565433862b40 --- /dev/null +++ b/opencti-platform/opencti-graphql/tests/03-integration/10-modules/http/catalog-logo-http-test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { API_URI, generateBasicAuth } from '../../../utils/testQuery'; +import { basePath } from '../../../../src/config/conf'; +import { rawUpload } from '../../../../src/database/raw-file-storage'; + +const CATALOG_LOGO_PATH = `${API_URI}${basePath}/catalog/logo`; + +describe('catalog logo HTTP integration', () => { + it('should return 403 when unauthenticated', async () => { + const res = await fetch(`${CATALOG_LOGO_PATH}/does-not-matter.png`); + expect(res.status).toBe(403); + }); + + it('should return 404 when authenticated but file does not exist', async () => { + const res = await fetch(`${CATALOG_LOGO_PATH}/missing-logo.png`, { + headers: { + authorization: generateBasicAuth(), + }, + }); + expect(res.status).toBe(404); + }); + + it('should return 200 and content-type when logo exists', async () => { + await rawUpload('catalog-logos/integration-logo.png', Buffer.from('png-content')); + const res = await fetch(`${CATALOG_LOGO_PATH}/integration-logo.png`, { + headers: { + authorization: generateBasicAuth(), + }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('image/png'); + }); +}); diff --git a/opencti-platform/opencti-graphql/tests/utils/opencti-manifest.json b/opencti-platform/opencti-graphql/tests/utils/opencti-manifest.json index b233b6c9e570..b541cc28f9e5 100644 --- a/opencti-platform/opencti-graphql/tests/utils/opencti-manifest.json +++ b/opencti-platform/opencti-graphql/tests/utils/opencti-manifest.json @@ -9,7 +9,7 @@ "slug": "test-external", "description": "This is a dummy external connector for testing purposes. It simulates an external data import connector with various configuration options.", "short_description": "A test external connector for importing dummy threat intelligence data", - "logo": "https://example.com/logos/test-external.png", + "logo": null, "use_cases": [ "Test Import", "Dummy Data" @@ -120,7 +120,7 @@ "slug": "test-email", "description": "A dummy email connector that simulates importing threat intelligence from email sources. This connector demonstrates complex configuration with arrays and optional fields.", "short_description": "Test connector for email-based threat intelligence import", - "logo": "https://example.com/logos/test-email.png", + "logo": null, "use_cases": [ "Email Import", "Test Processing" @@ -246,7 +246,7 @@ "slug": "test-enrichment", "description": "Dummy enrichment connector for testing IP address and domain enrichment functionality", "short_description": "Test IP and domain enrichment service", - "logo": "https://example.com/logos/test-enrichment.png", + "logo": null, "use_cases": [ "Test Enrichment", "IP Analysis" @@ -361,7 +361,7 @@ "slug": "test-stream", "description": "A dummy stream connector that simulates exporting OpenCTI data to external systems. Used for testing stream connector functionality.", "short_description": "Test connector for streaming data to external systems", - "logo": "https://example.com/logos/test-stream.png", + "logo": null, "use_cases": [ "Data Export", "Stream Testing"