diff --git a/Makefile b/Makefile index 57ba039..c365ccf 100644 --- a/Makefile +++ b/Makefile @@ -117,6 +117,7 @@ TEST_OSINT_SELECTION_CONTEXT := tests/test_osint_selection_context TEST_OSINT_ACTION_CATALOG := tests/test_osint_action_catalog TEST_OSINT_DNS_QUERY := tests/test_osint_dns_query TEST_OSINT_DNS_PROPOSAL := tests/test_osint_dns_proposal +TEST_OSINT_DNS_INTEGRATION := tests/test_osint_dns_integration all: $(TARGET) @@ -554,9 +555,24 @@ $(TEST_OSINT_DNS_QUERY): \ $(TEST_OSINT_DNS_PROPOSAL): \ tests/test_osint_dns_proposal.c \ - src/models/osint_dns_proposal.c + src/models/osint_dns_proposal.c \ + src/models/osint_dns_query.c $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) +$(TEST_OSINT_DNS_INTEGRATION): \ + tests/test_osint_dns_integration.c \ + src/core/osint_dns_integration.c \ + src/models/osint_dns_proposal.c \ + src/models/osint_dns_query.c \ + src/models/entity_record.c \ + src/dao/entity_dao.c \ + src/database/database.c \ + src/database/schema.c \ + src/database/statement.c \ + src/database/transaction.c \ + src/database/error.c + $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 + $(TEST_INVESTIGATION_GRAPH_LOAD_TASK): \ tests/test_investigation_graph_load_task.c \ src/core/investigation_graph_load_task.c \ @@ -628,7 +644,8 @@ test: \ $(TEST_OSINT_SELECTION_CONTEXT) \ $(TEST_OSINT_ACTION_CATALOG) \ $(TEST_OSINT_DNS_QUERY) \ - $(TEST_OSINT_DNS_PROPOSAL) + $(TEST_OSINT_DNS_PROPOSAL) \ + $(TEST_OSINT_DNS_INTEGRATION) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -680,6 +697,7 @@ test: \ @$(TEST_OSINT_ACTION_CATALOG) @$(TEST_OSINT_DNS_QUERY) @$(TEST_OSINT_DNS_PROPOSAL) + @$(TEST_OSINT_DNS_INTEGRATION) @echo "Tous les tests sont valides." %.o: %.c @@ -737,7 +755,8 @@ clean: $(TEST_OSINT_SELECTION_CONTEXT) \ $(TEST_OSINT_ACTION_CATALOG) \ $(TEST_OSINT_DNS_QUERY) \ - $(TEST_OSINT_DNS_PROPOSAL) + $(TEST_OSINT_DNS_PROPOSAL) \ + $(TEST_OSINT_DNS_INTEGRATION) -include $(DEP) diff --git a/README.md b/README.md index 4c028de..b4b69be 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,9 @@ Le socle actuel comprend notamment : - disponibilité des actions OSINT synchronisée avec le registre d’outils ; - résolution DNS asynchrone avec `dig` depuis une entité domaine ; - affichage sélectionnable des sorties standard et d’erreur, sans persistance ; -- révision des réponses DNS sous forme de propositions avant intégration. +- révision des réponses DNS sous forme de propositions avant intégration ; +- sélection explicite et intégration transactionnelle des propositions DNS + compatibles, avec normalisation et détection des doublons. Les outils actuellement présents dans le catalogue initial sont : diff --git a/docs/database/DATABASE_ARCHITECTURE.md b/docs/database/DATABASE_ARCHITECTURE.md index 82cefef..d092b71 100644 --- a/docs/database/DATABASE_ARCHITECTURE.md +++ b/docs/database/DATABASE_ARCHITECTURE.md @@ -1106,6 +1106,27 @@ En revanche, la suppression ou la modification d'un type existant doit être réalisée avec précaution afin de préserver la cohérence des données déjà enregistrées. +### Limite actuelle de la traçabilité OSINT + +L'intégration DNS initiale peut créer des entités `domain_name` et +`ip_address`. La description de l'entité conserve une indication minimale : +outil `dns.dig`, cible interrogée et type d'enregistrement. + +Cette information ne remplace pas une provenance structurée. Une migration +future devra introduire un objet de résultat OSINT capable de conserver au +minimum : + +- l'outil et sa version ; +- la cible et les arguments de l'exécution ; +- la date d'exécution ; +- la sortie brute et son empreinte ; +- les propositions extraites ; +- les entités effectivement créées ou réutilisées. + +Jusqu'à cette migration, l'intégration ne doit pas présenter les entités DNS +comme des faits vérifiés et ne doit pas supprimer la sortie brute affichée à +l'utilisateur. + --- # 6. Tables de liaison diff --git a/include/core/osint_dns_integration.h b/include/core/osint_dns_integration.h new file mode 100644 index 0000000..901484b --- /dev/null +++ b/include/core/osint_dns_integration.h @@ -0,0 +1,59 @@ +/****************************************************************************** + * @file osint_dns_integration.h + * @brief Intégration transactionnelle de propositions DNS validées. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_OSINT_DNS_INTEGRATION_H +#define LABFY_INVESTIGATION_OSINT_DNS_INTEGRATION_H + +#include "database/database.h" + +#include + +G_BEGIN_DECLS + +/** @brief Codes d'erreur de l'intégration DNS. */ +typedef enum +{ + OSINT_DNS_INTEGRATION_ERROR_INVALID_ARGUMENT, + OSINT_DNS_INTEGRATION_ERROR_DATABASE, + OSINT_DNS_INTEGRATION_ERROR_MODEL, + OSINT_DNS_INTEGRATION_ERROR_INSERT +} OsintDnsIntegrationError; + +/** @brief Domaine d'erreur de l'intégration DNS. */ +#define OSINT_DNS_INTEGRATION_ERROR osint_dns_integration_error_quark() + +/** + * @brief Retourne le domaine d'erreur de l'intégration DNS. + * + * @return Quark GLib du domaine d'erreur. + */ +GQuark osint_dns_integration_error_quark(void); + +/** + * @brief Intègre une sélection de propositions dans une transaction unique. + * + * Les propositions doivent être des OsintDnsProposal empruntées. Les valeurs + * incompatibles sont ignorées. Les couples type/valeur déjà présents sont + * comptés comme doublons et ne sont pas réinsérés. + * + * @param database Connexion SQLite active. + * @param selected_proposals Propositions explicitement sélectionnées. + * @param out_inserted_count Nombre d'entités créées. + * @param out_skipped_count Nombre de propositions ignorées ou déjà présentes. + * @param error Emplacement facultatif recevant une erreur. + * + * @return TRUE lorsque la transaction est validée, sinon FALSE. + */ +gboolean osint_dns_integration_apply( + Database *database, + GPtrArray *selected_proposals, + guint *out_inserted_count, + guint *out_skipped_count, + GError **error +); + +G_END_DECLS + +#endif diff --git a/include/models/osint_dns_proposal.h b/include/models/osint_dns_proposal.h index 7d3f3cf..40834cd 100644 --- a/include/models/osint_dns_proposal.h +++ b/include/models/osint_dns_proposal.h @@ -75,6 +75,35 @@ const char *osint_dns_proposal_get_target( const OsintDnsProposal *proposal ); +/** + * @brief Retourne le type d'entité compatible avec la proposition. + * + * Les types actuellement pris en charge sont `ip_address` pour A et AAAA, + * ainsi que `domain_name` pour CNAME, NS et PTR. + * + * @param proposal Proposition consultée. + * + * @return Identifiant statique du type d'entité, ou NULL. + */ +const char *osint_dns_proposal_get_entity_type( + const OsintDnsProposal *proposal +); + +/** + * @brief Normalise la valeur avant recherche ou insertion. + * + * Les adresses IP sont converties dans leur représentation canonique. Les + * noms de domaine sont passés en minuscules et leur point final est retiré. + * + * @param proposal Proposition consultée. + * + * @return Nouvelle chaîne à libérer avec g_free(), ou NULL si la proposition + * n'est pas compatible ou si sa valeur est invalide. + */ +char *osint_dns_proposal_dup_normalized_value( + const OsintDnsProposal *proposal +); + G_END_DECLS #endif diff --git a/include/views/osint_dns_review_dialog.h b/include/views/osint_dns_review_dialog.h new file mode 100644 index 0000000..ad3ce8e --- /dev/null +++ b/include/views/osint_dns_review_dialog.h @@ -0,0 +1,47 @@ +/****************************************************************************** + * @file osint_dns_review_dialog.h + * @brief Dialogue de sélection des propositions issues d'une réponse DNS. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_OSINT_DNS_REVIEW_DIALOG_H +#define LABFY_INVESTIGATION_OSINT_DNS_REVIEW_DIALOG_H + +#include + +G_BEGIN_DECLS + +/** + * @brief Callback appelé après confirmation de la sélection DNS. + * + * Le tableau et les propositions sont empruntés et uniquement valides pendant + * l'appel. Le tableau contient seulement les propositions compatibles cochées. + * + * @param selected_proposals Propositions sélectionnées. + * @param user_data Données empruntées fournies par l'appelant. + */ +typedef void (*OsintDnsReviewDialogConfirmCallback)( + GPtrArray *selected_proposals, + gpointer user_data +); + +/** + * @brief Affiche le dialogue de sélection avant intégration. + * + * Le dialogue conserve une référence sur proposals jusqu'à sa fermeture. + * Fermer ou annuler ne déclenche pas le callback. + * + * @param parent_window Fenêtre parente, ou NULL. + * @param proposals Tableau de OsintDnsProposal à réviser. + * @param callback Callback de confirmation facultatif. + * @param user_data Données empruntées transmises au callback. + */ +void osint_dns_review_dialog_present( + GtkWindow *parent_window, + GPtrArray *proposals, + OsintDnsReviewDialogConfirmCallback callback, + gpointer user_data +); + +G_END_DECLS + +#endif diff --git a/src/core/application.c b/src/core/application.c index 242103b..0e18ec9 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -24,6 +24,7 @@ #include "core/background_task.h" #include "core/tool_initializer.h" #include "core/tool_task.h" +#include "core/osint_dns_integration.h" #include "views/file_dialog.h" #include "core/evidence_import_task.h" #include "models/evidence_record.h" @@ -31,6 +32,7 @@ #include "dao/graph_node_position_dao.h" #include "views/evidence_import_dialog.h" #include "views/create_relation_dialog.h" +#include "views/osint_dns_review_dialog.h" #include "dao/evidence_dao.h" #include "dao/entity_dao.h" #include "models/evidence_type.h" @@ -167,70 +169,99 @@ typedef struct typedef struct { Application *application; - char *proposal_summary; + GPtrArray *proposals; } ApplicationOsintReviewContext; +static void application_start_graph_loading( + Application *application, + const char *database_path +); + +static void application_present_error( + Application *application, + const char *title, + const char *message +); + /** @brief Libère les données de révision possédées par le dialogue. */ static void application_osint_review_context_free(gpointer user_data) { ApplicationOsintReviewContext *context = user_data; if (context == NULL) return; - g_free(context->proposal_summary); + g_clear_pointer(&context->proposals, g_ptr_array_unref); g_free(context); } -/** @brief Affiche les propositions sans les intégrer à l'enquête. */ -static void application_on_osint_prepare_integration(gpointer user_data) +/** @brief Intègre les propositions confirmées puis recharge le graphe. */ +static void application_on_osint_integration_confirmed( + GPtrArray *selected_proposals, + gpointer user_data +) { - ApplicationOsintReviewContext *context = user_data; - if (context == NULL || context->application == NULL || - context->application->main_window == NULL) + Application *application = user_data; + const InvestigationProject *project = NULL; + Database *database = NULL; + const char *database_path = NULL; + guint inserted_count = 0U; + guint skipped_count = 0U; + char *message = NULL; + GError *error = NULL; + + if (application == NULL || application->session == NULL || + application->main_window == NULL) { return; } - application_message_dialog_present_details( - main_window_get_window(context->application->main_window), - APPLICATION_MESSAGE_DIALOG_INFORMATION, - "Propositions DNS à réviser", - "Aucune donnée n'a été enregistrée dans l'enquête.", - context->proposal_summary - ); -} - -/** @brief Construit le résumé lisible des propositions DNS. */ -static char *application_build_dns_proposal_summary(GPtrArray *proposals) -{ - GString *summary = g_string_new(NULL); - - if (summary == NULL) return NULL; - g_string_append_printf( - summary, - "Propositions détectées : %u\n\n", - proposals != NULL ? proposals->len : 0U - ); - - for (guint index = 0; proposals != NULL && index < proposals->len; index++) + database = investigation_session_get_database(application->session); + project = investigation_session_get_project(application->session); + database_path = project != NULL + ? investigation_project_get_database_path(project) : NULL; + if (!osint_dns_integration_apply( + database, selected_proposals, &inserted_count, &skipped_count, + &error + )) { - const OsintDnsProposal *proposal = g_ptr_array_index(proposals, index); - g_string_append_printf( - summary, - "%u. Cible : %s\n Propriétaire : %s\n Type : %s\n" - " Valeur : %s\n\n", - index + 1U, - osint_dns_proposal_get_target(proposal), - osint_dns_proposal_get_owner(proposal), - osint_dns_proposal_get_record_type(proposal), - osint_dns_proposal_get_value(proposal) + application_present_error( + application, + "Intégration DNS impossible", + error != NULL ? error->message + : "La transaction DNS a échoué." ); + g_clear_error(&error); + return; } - g_string_append( - summary, - "Ces propositions sont uniquement préparées pour révision. " - "Elles ne sont pas enregistrées dans SQLite." + message = g_strdup_printf( + "%u entité(s) créée(s), %u proposition(s) ignorée(s).\n\n" + "La provenance complète devra être ajoutée par une future migration " + "du schéma OSINT.", + inserted_count, skipped_count + ); + application_message_dialog_present( + main_window_get_window(application->main_window), + APPLICATION_MESSAGE_DIALOG_INFORMATION, + "Intégration DNS terminée", + message + ); + g_free(message); + if (database_path != NULL && database_path[0] != '\0') + application_start_graph_loading(application, database_path); +} + +/** @brief Affiche les propositions et demande une sélection explicite. */ +static void application_on_osint_prepare_integration(gpointer user_data) +{ + ApplicationOsintReviewContext *context = user_data; + if (context == NULL || context->application == NULL || + context->application->main_window == NULL || context->proposals == NULL) + return; + osint_dns_review_dialog_present( + main_window_get_window(context->application->main_window), + context->proposals, + application_on_osint_integration_confirmed, + context->application ); - return g_string_free(summary, FALSE); } /** @@ -1147,10 +1178,9 @@ static void application_on_dns_lookup_completed( if (review_context != NULL) { review_context->application = application; - review_context->proposal_summary = - application_build_dns_proposal_summary(proposals); + review_context->proposals = g_ptr_array_ref(proposals); } - if (review_context == NULL || review_context->proposal_summary == NULL) + if (review_context == NULL || review_context->proposals == NULL) { application_osint_review_context_free(review_context); review_context = NULL; diff --git a/src/core/osint_dns_integration.c b/src/core/osint_dns_integration.c new file mode 100644 index 0000000..6078eb8 --- /dev/null +++ b/src/core/osint_dns_integration.c @@ -0,0 +1,228 @@ +/****************************************************************************** + * @file osint_dns_integration.c + * @brief Intégration transactionnelle de propositions DNS validées. + ******************************************************************************/ + +#include "core/osint_dns_integration.h" + +#include "dao/entity_dao.h" +#include "database/error.h" +#include "database/transaction.h" +#include "models/entity_record.h" +#include "models/osint_dns_proposal.h" + +#include +#include + +/** @brief Normalise une valeur déjà persistée pour la comparaison. */ +static char *osint_dns_integration_normalize_existing_value( + const char *entity_type, + const char *value +) +{ + if (g_strcmp0(entity_type, "ip_address") == 0) + { + GInetAddress *address = g_inet_address_new_from_string(value); + char *normalized_value = NULL; + if (address == NULL) return g_strdup(value); + normalized_value = g_inet_address_to_string(address); + g_object_unref(address); + return normalized_value; + } + if (g_strcmp0(entity_type, "domain_name") == 0) + { + char *normalized_value = g_ascii_strdown(value, -1); + gsize value_length = normalized_value != NULL + ? strlen(normalized_value) : 0U; + if (value_length > 0U && normalized_value[value_length - 1U] == '.') + normalized_value[value_length - 1U] = '\0'; + return normalized_value; + } + return g_strdup(value); +} + +/** @brief Construit la clé anti-doublon d'une entité. */ +static char *osint_dns_integration_build_key( + const char *entity_type, + const char *value +) +{ + return g_strdup_printf("%s\n%s", entity_type, value); +} + +/** @brief Renseigne une erreur littérale si possible. */ +static void osint_dns_integration_set_error( + GError **error, + OsintDnsIntegrationError code, + const char *message +) +{ + if (error != NULL && *error == NULL) + g_set_error_literal(error, OSINT_DNS_INTEGRATION_ERROR, code, message); +} + +GQuark osint_dns_integration_error_quark(void) +{ + return g_quark_from_static_string("osint-dns-integration-error-quark"); +} + +gboolean osint_dns_integration_apply( + Database *database, + GPtrArray *selected_proposals, + guint *out_inserted_count, + guint *out_skipped_count, + GError **error +) +{ + EntityDao *entity_dao = NULL; + GPtrArray *existing_entities = NULL; + GHashTable *known_entities = NULL; + GDateTime *now = NULL; + char *timestamp = NULL; + guint inserted_count = 0U; + guint skipped_count = 0U; + gboolean success = FALSE; + + g_return_val_if_fail(error == NULL || *error == NULL, FALSE); + if (out_inserted_count != NULL) *out_inserted_count = 0U; + if (out_skipped_count != NULL) *out_skipped_count = 0U; + if (database == NULL || selected_proposals == NULL || + selected_proposals->len == 0U) + { + osint_dns_integration_set_error( + error, OSINT_DNS_INTEGRATION_ERROR_INVALID_ARGUMENT, + "La sélection DNS à intégrer est invalide." + ); + return FALSE; + } + + entity_dao = entity_dao_new(database, error); + if (entity_dao == NULL) return FALSE; + existing_entities = entity_dao_list_all(entity_dao, error); + if (existing_entities == NULL) goto cleanup; + known_entities = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + for (guint index = 0; index < existing_entities->len; index++) + { + const EntityRecord *entity = g_ptr_array_index(existing_entities, index); + const char *entity_type = entity_record_get_type_identifier(entity); + char *normalized_value = osint_dns_integration_normalize_existing_value( + entity_type, entity_record_get_value(entity) + ); + g_hash_table_add( + known_entities, + osint_dns_integration_build_key( + entity_type, + normalized_value + ) + ); + g_free(normalized_value); + } + + now = g_date_time_new_now_utc(); + timestamp = now != NULL + ? g_date_time_format(now, "%Y-%m-%dT%H:%M:%SZ") : NULL; + if (timestamp == NULL) + { + osint_dns_integration_set_error( + error, OSINT_DNS_INTEGRATION_ERROR_MODEL, + "Impossible de dater les entités DNS." + ); + goto cleanup; + } + if (!database_transaction_begin(database)) + { + osint_dns_integration_set_error( + error, OSINT_DNS_INTEGRATION_ERROR_DATABASE, + database_error_get_message(database) != NULL + ? database_error_get_message(database) + : "Impossible de démarrer la transaction DNS." + ); + goto cleanup; + } + + for (guint index = 0; index < selected_proposals->len; index++) + { + const OsintDnsProposal *proposal = g_ptr_array_index( + selected_proposals, index + ); + const char *entity_type = osint_dns_proposal_get_entity_type(proposal); + char *normalized_value = osint_dns_proposal_dup_normalized_value(proposal); + char *entity_key = NULL; + char *identifier = NULL; + char *description = NULL; + EntityRecord *entity = NULL; + GError *model_error = NULL; + + if (entity_type == NULL || normalized_value == NULL) + { + skipped_count++; + g_free(normalized_value); + continue; + } + entity_key = osint_dns_integration_build_key(entity_type, normalized_value); + if (g_hash_table_contains(known_entities, entity_key)) + { + skipped_count++; + g_free(entity_key); + g_free(normalized_value); + continue; + } + + identifier = g_uuid_string_random(); + description = g_strdup_printf( + "Proposition DNS %s obtenue avec dns.dig depuis %s. " + "Résultat OSINT à vérifier.", + osint_dns_proposal_get_record_type(proposal), + osint_dns_proposal_get_target(proposal) + ); + entity = entity_record_new( + identifier, entity_type, normalized_value, normalized_value, + description, 50, timestamp, timestamp, ENTITY_STATUS_ACTIVE, + &model_error + ); + if (entity == NULL || !entity_dao_insert(entity_dao, entity, error)) + { + if (error != NULL && *error == NULL && model_error != NULL) + g_propagate_error(error, g_steal_pointer(&model_error)); + entity_record_free(entity); + g_clear_error(&model_error); + g_free(description); + g_free(identifier); + g_free(entity_key); + g_free(normalized_value); + database_transaction_rollback(database); + goto cleanup; + } + + g_hash_table_add(known_entities, entity_key); + inserted_count++; + entity_record_free(entity); + g_clear_error(&model_error); + g_free(description); + g_free(identifier); + g_free(normalized_value); + } + + if (!database_transaction_commit(database)) + { + osint_dns_integration_set_error( + error, OSINT_DNS_INTEGRATION_ERROR_DATABASE, + database_error_get_message(database) != NULL + ? database_error_get_message(database) + : "Impossible de valider la transaction DNS." + ); + database_transaction_rollback(database); + goto cleanup; + } + success = TRUE; + if (out_inserted_count != NULL) *out_inserted_count = inserted_count; + if (out_skipped_count != NULL) *out_skipped_count = skipped_count; + +cleanup: + g_free(timestamp); + g_clear_pointer(&now, g_date_time_unref); + g_clear_pointer(&known_entities, g_hash_table_unref); + g_clear_pointer(&existing_entities, g_ptr_array_unref); + entity_dao_free(entity_dao); + return success; +} diff --git a/src/models/osint_dns_proposal.c b/src/models/osint_dns_proposal.c index 7c441ed..63bac6f 100644 --- a/src/models/osint_dns_proposal.c +++ b/src/models/osint_dns_proposal.c @@ -4,7 +4,9 @@ ******************************************************************************/ #include "models/osint_dns_proposal.h" +#include "models/osint_dns_query.h" +#include #include struct OsintDnsProposal @@ -155,3 +157,56 @@ const char *osint_dns_proposal_get_value(const OsintDnsProposal *proposal) { return proposal != NULL ? proposal->value : NULL; } const char *osint_dns_proposal_get_target(const OsintDnsProposal *proposal) { return proposal != NULL ? proposal->target : NULL; } + +const char *osint_dns_proposal_get_entity_type( + const OsintDnsProposal *proposal +) +{ + const char *record_type = osint_dns_proposal_get_record_type(proposal); + if (g_strcmp0(record_type, "A") == 0 || + g_strcmp0(record_type, "AAAA") == 0) + { + return "ip_address"; + } + if (g_strcmp0(record_type, "CNAME") == 0 || + g_strcmp0(record_type, "NS") == 0 || + g_strcmp0(record_type, "PTR") == 0) + { + return "domain_name"; + } + return NULL; +} + +char *osint_dns_proposal_dup_normalized_value( + const OsintDnsProposal *proposal +) +{ + const char *entity_type = osint_dns_proposal_get_entity_type(proposal); + const char *value = osint_dns_proposal_get_value(proposal); + + if (entity_type == NULL || value == NULL) return NULL; + if (g_strcmp0(entity_type, "ip_address") == 0) + { + GInetAddress *address = g_inet_address_new_from_string(value); + char *normalized_value = NULL; + if (address == NULL) return NULL; + normalized_value = g_inet_address_to_string(address); + g_object_unref(address); + return normalized_value; + } + if (g_strcmp0(entity_type, "domain_name") == 0) + { + char *normalized_value = g_ascii_strdown(value, -1); + gsize value_length = normalized_value != NULL + ? strlen(normalized_value) : 0U; + if (value_length > 0U && normalized_value[value_length - 1U] == '.') + normalized_value[value_length - 1U] = '\0'; + if (!osint_dns_query_is_valid_target(normalized_value)) + { + g_free(normalized_value); + return NULL; + } + return normalized_value; + } + return NULL; +} diff --git a/src/views/osint_dns_review_dialog.c b/src/views/osint_dns_review_dialog.c new file mode 100644 index 0000000..d1aec0f --- /dev/null +++ b/src/views/osint_dns_review_dialog.c @@ -0,0 +1,276 @@ +/****************************************************************************** + * @file osint_dns_review_dialog.c + * @brief Dialogue de sélection des propositions issues d'une réponse DNS. + ******************************************************************************/ + +#include "views/osint_dns_review_dialog.h" + +#include "models/osint_dns_proposal.h" + +typedef struct +{ + GtkWindow *window; + GtkWidget *select_all_button; + GtkWidget *confirm_button; + GPtrArray *proposals; + GPtrArray *selection_buttons; + OsintDnsReviewDialogConfirmCallback callback; + gpointer user_data; +} OsintDnsReviewDialogContext; + +/** @brief Libère le contexte possédé par la fenêtre. */ +static void osint_dns_review_dialog_context_free(gpointer data) +{ + OsintDnsReviewDialogContext *context = data; + if (context == NULL) return; + g_clear_pointer(&context->selection_buttons, g_ptr_array_unref); + g_clear_pointer(&context->proposals, g_ptr_array_unref); + g_free(context); +} + +/** @brief Ferme le dialogue sans intégrer de proposition. */ +static void osint_dns_review_dialog_on_cancel_clicked( + GtkButton *button, + gpointer user_data +) +{ + OsintDnsReviewDialogContext *context = user_data; + (void) button; + if (context != NULL) gtk_window_destroy(context->window); +} + +/** @brief Actualise l'état du bouton de confirmation. */ +static void osint_dns_review_dialog_update_confirmation( + OsintDnsReviewDialogContext *context +) +{ + gboolean has_selection = FALSE; + for (guint index = 0; + context != NULL && index < context->selection_buttons->len; + index++) + { + GtkCheckButton *button = g_ptr_array_index( + context->selection_buttons, index + ); + if (gtk_widget_get_sensitive(GTK_WIDGET(button)) && + gtk_check_button_get_active(button)) + { + has_selection = TRUE; + break; + } + } + if (context != NULL) + gtk_widget_set_sensitive(context->confirm_button, has_selection); +} + +/** @brief Réagit à la sélection individuelle d'une proposition. */ +static void osint_dns_review_dialog_on_proposal_toggled( + GtkCheckButton *button, + gpointer user_data +) +{ + (void) button; + osint_dns_review_dialog_update_confirmation(user_data); +} + +/** @brief Sélectionne ou désélectionne toutes les propositions compatibles. */ +static void osint_dns_review_dialog_on_select_all_toggled( + GtkCheckButton *button, + gpointer user_data +) +{ + OsintDnsReviewDialogContext *context = user_data; + const gboolean selected = gtk_check_button_get_active(button); + for (guint index = 0; + context != NULL && index < context->selection_buttons->len; + index++) + { + GtkWidget *selection_button = g_ptr_array_index( + context->selection_buttons, index + ); + if (gtk_widget_get_sensitive(selection_button)) + gtk_check_button_set_active( + GTK_CHECK_BUTTON(selection_button), selected + ); + } + osint_dns_review_dialog_update_confirmation(context); +} + +/** @brief Transmet les propositions cochées puis ferme le dialogue. */ +static void osint_dns_review_dialog_on_confirm_clicked( + GtkButton *button, + gpointer user_data +) +{ + OsintDnsReviewDialogContext *context = user_data; + GPtrArray *selected_proposals = g_ptr_array_new(); + (void) button; + + for (guint index = 0; + context != NULL && index < context->selection_buttons->len; + index++) + { + GtkCheckButton *selection_button = g_ptr_array_index( + context->selection_buttons, index + ); + if (gtk_widget_get_sensitive(GTK_WIDGET(selection_button)) && + gtk_check_button_get_active(selection_button)) + { + g_ptr_array_add( + selected_proposals, + g_object_get_data( + G_OBJECT(selection_button), "osint-dns-proposal" + ) + ); + } + } + + if (context != NULL && context->callback != NULL && + selected_proposals->len > 0U) + { + context->callback(selected_proposals, context->user_data); + } + g_ptr_array_unref(selected_proposals); + if (context != NULL) gtk_window_destroy(context->window); +} + +/** @brief Crée une ligne décrivant une proposition DNS. */ +static GtkWidget *osint_dns_review_dialog_create_row( + OsintDnsReviewDialogContext *context, + const OsintDnsProposal *proposal +) +{ + GtkWidget *row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + const char *entity_type = osint_dns_proposal_get_entity_type(proposal); + char *normalized_value = osint_dns_proposal_dup_normalized_value(proposal); + const gboolean compatible = entity_type != NULL && normalized_value != NULL; + char *title = g_strdup_printf( + "%s → %s", + osint_dns_proposal_get_record_type(proposal), + osint_dns_proposal_get_value(proposal) + ); + char *details = g_strdup_printf( + "Cible : %s\nPropriétaire : %s\nType Labfy : %s", + osint_dns_proposal_get_target(proposal), + osint_dns_proposal_get_owner(proposal), + compatible ? entity_type : "non pris en charge" + ); + GtkWidget *selection_button = gtk_check_button_new_with_label(title); + GtkWidget *details_label = gtk_label_new(details); + + gtk_widget_set_sensitive(selection_button, compatible); + gtk_widget_set_margin_start(details_label, 28); + gtk_widget_set_halign(details_label, GTK_ALIGN_START); + gtk_label_set_xalign(GTK_LABEL(details_label), 0.0F); + gtk_label_set_selectable(GTK_LABEL(details_label), TRUE); + g_object_set_data( + G_OBJECT(selection_button), "osint-dns-proposal", (gpointer) proposal + ); + g_signal_connect( + selection_button, "toggled", + G_CALLBACK(osint_dns_review_dialog_on_proposal_toggled), context + ); + g_ptr_array_add(context->selection_buttons, selection_button); + gtk_box_append(GTK_BOX(row), selection_button); + gtk_box_append(GTK_BOX(row), details_label); + g_free(normalized_value); + g_free(title); + g_free(details); + return row; +} + +void osint_dns_review_dialog_present( + GtkWindow *parent_window, + GPtrArray *proposals, + OsintDnsReviewDialogConfirmCallback callback, + gpointer user_data +) +{ + OsintDnsReviewDialogContext *context = NULL; + GtkWidget *main_box = NULL; + GtkWidget *title_label = NULL; + GtkWidget *notice_label = NULL; + GtkWidget *scrolled_window = NULL; + GtkWidget *proposal_box = NULL; + GtkWidget *button_box = NULL; + GtkWidget *cancel_button = NULL; + + if (proposals == NULL || proposals->len == 0U) return; + context = g_new0(OsintDnsReviewDialogContext, 1); + context->window = GTK_WINDOW(gtk_window_new()); + context->proposals = g_ptr_array_ref(proposals); + context->selection_buttons = g_ptr_array_new(); + context->callback = callback; + context->user_data = user_data; + + gtk_window_set_title(context->window, "Intégrer les propositions DNS"); + gtk_window_set_default_size(context->window, 720, 560); + gtk_window_set_modal(context->window, TRUE); + gtk_window_set_destroy_with_parent(context->window, TRUE); + if (parent_window != NULL) + gtk_window_set_transient_for(context->window, parent_window); + g_object_set_data_full( + G_OBJECT(context->window), "osint-dns-review-context", context, + osint_dns_review_dialog_context_free + ); + + main_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_widget_set_margin_start(main_box, 20); + gtk_widget_set_margin_end(main_box, 20); + gtk_widget_set_margin_top(main_box, 20); + gtk_widget_set_margin_bottom(main_box, 20); + title_label = gtk_label_new("Sélectionner les propositions à intégrer"); + gtk_widget_set_halign(title_label, GTK_ALIGN_START); + gtk_widget_add_css_class(title_label, "title-2"); + notice_label = gtk_label_new( + "Aucune écriture ne sera effectuée avant confirmation. " + "Les types non pris en charge restent visibles mais désactivés." + ); + gtk_label_set_wrap(GTK_LABEL(notice_label), TRUE); + gtk_label_set_xalign(GTK_LABEL(notice_label), 0.0F); + + context->select_all_button = gtk_check_button_new_with_label( + "Sélectionner toutes les propositions compatibles" + ); + g_signal_connect( + context->select_all_button, "toggled", + G_CALLBACK(osint_dns_review_dialog_on_select_all_toggled), context + ); + proposal_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 14); + for (guint index = 0; index < proposals->len; index++) + gtk_box_append( + GTK_BOX(proposal_box), + osint_dns_review_dialog_create_row( + context, g_ptr_array_index(proposals, index) + ) + ); + scrolled_window = gtk_scrolled_window_new(); + gtk_widget_set_vexpand(scrolled_window, TRUE); + gtk_scrolled_window_set_child( + GTK_SCROLLED_WINDOW(scrolled_window), proposal_box + ); + + button_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_halign(button_box, GTK_ALIGN_END); + cancel_button = gtk_button_new_with_label("Annuler"); + context->confirm_button = gtk_button_new_with_label("Intégrer la sélection"); + gtk_widget_add_css_class(context->confirm_button, "suggested-action"); + gtk_widget_set_sensitive(context->confirm_button, FALSE); + g_signal_connect( + cancel_button, "clicked", + G_CALLBACK(osint_dns_review_dialog_on_cancel_clicked), context + ); + g_signal_connect( + context->confirm_button, "clicked", + G_CALLBACK(osint_dns_review_dialog_on_confirm_clicked), context + ); + gtk_box_append(GTK_BOX(button_box), cancel_button); + gtk_box_append(GTK_BOX(button_box), context->confirm_button); + gtk_box_append(GTK_BOX(main_box), title_label); + gtk_box_append(GTK_BOX(main_box), notice_label); + gtk_box_append(GTK_BOX(main_box), context->select_all_button); + gtk_box_append(GTK_BOX(main_box), scrolled_window); + gtk_box_append(GTK_BOX(main_box), button_box); + gtk_window_set_child(context->window, main_box); + gtk_window_present(context->window); +} diff --git a/tests/test_osint_dns_integration.c b/tests/test_osint_dns_integration.c new file mode 100644 index 0000000..b7e6d7c --- /dev/null +++ b/tests/test_osint_dns_integration.c @@ -0,0 +1,125 @@ +/****************************************************************************** + * @file test_osint_dns_integration.c + * @brief Tests de l'intégration transactionnelle des propositions DNS. + ******************************************************************************/ + +#include "core/osint_dns_integration.h" + +#include "dao/entity_dao.h" +#include "database/database.h" +#include "models/osint_dns_proposal.h" + +#include +#include + +static void test_integration_and_duplicates(void) +{ + GError *error = NULL; + char *directory = g_dir_make_tmp("labfy-dns-integration-XXXXXX", &error); + char *database_path = g_build_filename(directory, "Enquete.sqlite", NULL); + Database *database = NULL; + EntityDao *entity_dao = NULL; + GPtrArray *proposals = NULL; + GPtrArray *entities = NULL; + guint inserted = 0U; + guint skipped = 0U; + + g_assert_no_error(error); + g_assert_true(database_initialize(database_path, "DNS", directory)); + database = database_open(database_path); + proposals = osint_dns_proposal_parse( + "example.org", + "example.org. 60 IN A 192.0.2.10\n" + "example.org. 60 IN CNAME WWW.Example.ORG.\n" + "example.org. 60 IN MX 10 mail.example.org.\n" + ); + g_assert_true(osint_dns_integration_apply( + database, proposals, &inserted, &skipped, &error + )); + g_assert_no_error(error); + g_assert_cmpuint(inserted, ==, 2); + g_assert_cmpuint(skipped, ==, 1); + + g_assert_true(osint_dns_integration_apply( + database, proposals, &inserted, &skipped, &error + )); + g_assert_cmpuint(inserted, ==, 0); + g_assert_cmpuint(skipped, ==, 3); + entity_dao = entity_dao_new(database, &error); + entities = entity_dao_list_all(entity_dao, &error); + g_assert_cmpuint(entities->len, ==, 2); + + g_ptr_array_unref(entities); + entity_dao_free(entity_dao); + g_ptr_array_unref(proposals); + database_close(database); + g_assert_cmpint(g_remove(database_path), ==, 0); + g_assert_cmpint(g_rmdir(directory), ==, 0); + g_free(database_path); + g_free(directory); +} + +static void test_invalid_arguments(void) +{ + GError *error = NULL; + g_assert_false(osint_dns_integration_apply( + NULL, NULL, NULL, NULL, &error + )); + g_assert_error( + error, OSINT_DNS_INTEGRATION_ERROR, + OSINT_DNS_INTEGRATION_ERROR_INVALID_ARGUMENT + ); + g_clear_error(&error); +} + +static void test_normalized_existing_domain_is_skipped(void) +{ + GError *error = NULL; + char *directory = g_dir_make_tmp("labfy-dns-existing-XXXXXX", &error); + char *database_path = g_build_filename(directory, "Enquete.sqlite", NULL); + Database *database = NULL; + EntityDao *entity_dao = NULL; + EntityRecord *existing = NULL; + GPtrArray *proposals = NULL; + guint inserted = 0U; + guint skipped = 0U; + + g_assert_true(database_initialize(database_path, "DNS", directory)); + database = database_open(database_path); + entity_dao = entity_dao_new(database, &error); + existing = entity_record_new( + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "domain_name", + "WWW.Example.ORG.", NULL, NULL, 50, "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", ENTITY_STATUS_ACTIVE, &error + ); + g_assert_true(entity_dao_insert(entity_dao, existing, &error)); + proposals = osint_dns_proposal_parse( + "example.org", "example.org. 60 IN CNAME www.example.org.\n" + ); + g_assert_true(osint_dns_integration_apply( + database, proposals, &inserted, &skipped, &error + )); + g_assert_cmpuint(inserted, ==, 0); + g_assert_cmpuint(skipped, ==, 1); + + g_ptr_array_unref(proposals); + entity_record_free(existing); + entity_dao_free(entity_dao); + database_close(database); + g_assert_cmpint(g_remove(database_path), ==, 0); + g_assert_cmpint(g_rmdir(directory), ==, 0); + g_free(database_path); + g_free(directory); +} + +int main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/osint-dns-integration/apply", test_integration_and_duplicates); + g_test_add_func("/osint-dns-integration/invalid", test_invalid_arguments); + g_test_add_func( + "/osint-dns-integration/normalized-duplicate", + test_normalized_existing_domain_is_skipped + ); + return g_test_run(); +} diff --git a/tests/test_osint_dns_proposal.c b/tests/test_osint_dns_proposal.c index 054644f..d86a75e 100644 --- a/tests/test_osint_dns_proposal.c +++ b/tests/test_osint_dns_proposal.c @@ -49,11 +49,44 @@ static void test_invalid_arguments(void) g_assert_null(osint_dns_proposal_parse("example.org", NULL)); } +static void test_entity_mapping_and_normalization(void) +{ + const char *output = + "example.org. 60 IN A 192.0.2.10\n" + "example.org. 60 IN AAAA 2001:0db8:0:0:0:0:0:1\n" + "example.org. 60 IN CNAME WWW.Example.ORG.\n" + "example.org. 60 IN MX 10 mail.example.org.\n"; + GPtrArray *proposals = osint_dns_proposal_parse("example.org", output); + const OsintDnsProposal *ipv4 = g_ptr_array_index(proposals, 0); + const OsintDnsProposal *ipv6 = g_ptr_array_index(proposals, 1); + const OsintDnsProposal *domain = g_ptr_array_index(proposals, 2); + const OsintDnsProposal *unsupported = g_ptr_array_index(proposals, 3); + char *ipv4_value = osint_dns_proposal_dup_normalized_value(ipv4); + char *ipv6_value = osint_dns_proposal_dup_normalized_value(ipv6); + char *domain_value = osint_dns_proposal_dup_normalized_value(domain); + + g_assert_cmpstr(osint_dns_proposal_get_entity_type(ipv4), ==, "ip_address"); + g_assert_cmpstr(ipv4_value, ==, "192.0.2.10"); + g_assert_cmpstr(ipv6_value, ==, "2001:db8::1"); + g_assert_cmpstr(osint_dns_proposal_get_entity_type(domain), ==, "domain_name"); + g_assert_cmpstr(domain_value, ==, "www.example.org"); + g_assert_null(osint_dns_proposal_get_entity_type(unsupported)); + g_assert_null(osint_dns_proposal_dup_normalized_value(unsupported)); + + g_free(ipv4_value); + g_free(ipv6_value); + g_free(domain_value); + g_ptr_array_unref(proposals); +} + int main(int argc, char **argv) { g_test_init(&argc, &argv, NULL); g_test_add_func("/osint-dns-proposal/recognized", test_parse_recognized_records); g_test_add_func("/osint-dns-proposal/ignored", test_ignore_unrecognized_lines); g_test_add_func("/osint-dns-proposal/invalid", test_invalid_arguments); + g_test_add_func( + "/osint-dns-proposal/mapping", test_entity_mapping_and_normalization + ); return g_test_run(); }