diff --git a/Makefile b/Makefile index 630ad8f..57ba039 100644 --- a/Makefile +++ b/Makefile @@ -116,6 +116,7 @@ TEST_GRAPH_NODE_POSITION_DAO := tests/test_graph_node_position_dao 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 all: $(TARGET) @@ -551,6 +552,11 @@ $(TEST_OSINT_DNS_QUERY): \ src/models/osint_dns_query.c $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) +$(TEST_OSINT_DNS_PROPOSAL): \ + tests/test_osint_dns_proposal.c \ + src/models/osint_dns_proposal.c + $(CC) $(TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) + $(TEST_INVESTIGATION_GRAPH_LOAD_TASK): \ tests/test_investigation_graph_load_task.c \ src/core/investigation_graph_load_task.c \ @@ -621,7 +627,8 @@ test: \ $(TEST_GRAPH_NODE_POSITION_DAO) \ $(TEST_OSINT_SELECTION_CONTEXT) \ $(TEST_OSINT_ACTION_CATALOG) \ - $(TEST_OSINT_DNS_QUERY) + $(TEST_OSINT_DNS_QUERY) \ + $(TEST_OSINT_DNS_PROPOSAL) @echo "Exécution des tests..." @./$(TEST_NODE) @./$(TEST_TREE_MODEL) @@ -672,6 +679,7 @@ test: \ @$(TEST_OSINT_SELECTION_CONTEXT) @$(TEST_OSINT_ACTION_CATALOG) @$(TEST_OSINT_DNS_QUERY) + @$(TEST_OSINT_DNS_PROPOSAL) @echo "Tous les tests sont valides." %.o: %.c @@ -728,7 +736,8 @@ clean: $(TEST_GRAPH_NODE_POSITION_DAO) \ $(TEST_OSINT_SELECTION_CONTEXT) \ $(TEST_OSINT_ACTION_CATALOG) \ - $(TEST_OSINT_DNS_QUERY) + $(TEST_OSINT_DNS_QUERY) \ + $(TEST_OSINT_DNS_PROPOSAL) -include $(DEP) diff --git a/README.md b/README.md index 424d640..4c028de 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,8 @@ Le socle actuel comprend notamment : - catalogue déterministe des actions compatibles avec la sélection OSINT ; - 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. +- affichage sélectionnable des sorties standard et d’erreur, sans persistance ; +- révision des réponses DNS sous forme de propositions avant intégration. Les outils actuellement présents dans le catalogue initial sont : diff --git a/include/models/osint_dns_proposal.h b/include/models/osint_dns_proposal.h new file mode 100644 index 0000000..7d3f3cf --- /dev/null +++ b/include/models/osint_dns_proposal.h @@ -0,0 +1,80 @@ +/****************************************************************************** + * @file osint_dns_proposal.h + * @brief Propositions structurées extraites d'une sortie DNS brute. + ******************************************************************************/ + +#ifndef LABFY_INVESTIGATION_OSINT_DNS_PROPOSAL_H +#define LABFY_INVESTIGATION_OSINT_DNS_PROPOSAL_H + +#include + +G_BEGIN_DECLS + +/** @brief Proposition DNS opaque produite avant toute intégration. */ +typedef struct OsintDnsProposal OsintDnsProposal; + +/** + * @brief Extrait les enregistrements reconnus d'une sortie `dig +answer`. + * + * Le tableau retourné possède ses propositions et doit être libéré avec + * g_ptr_array_unref(). Les lignes vides, les commentaires et les lignes + * invalides sont ignorés sans modifier la sortie brute d'origine. + * + * @param target_value Cible DNS ayant produit la sortie. + * @param raw_output Sortie standard UTF-8 de dig. + * + * @return Nouveau tableau, éventuellement vide, ou NULL si les arguments + * sont invalides. + */ +GPtrArray *osint_dns_proposal_parse( + const char *target_value, + const char *raw_output +); + +/** + * @brief Retourne le propriétaire de l'enregistrement. + * + * @param proposal Proposition consultée. + * + * @return Chaîne empruntée, ou NULL. + */ +const char *osint_dns_proposal_get_owner( + const OsintDnsProposal *proposal +); + +/** + * @brief Retourne le type d'enregistrement DNS. + * + * @param proposal Proposition consultée. + * + * @return Chaîne empruntée, ou NULL. + */ +const char *osint_dns_proposal_get_record_type( + const OsintDnsProposal *proposal +); + +/** + * @brief Retourne la valeur brute de l'enregistrement. + * + * @param proposal Proposition consultée. + * + * @return Chaîne empruntée, ou NULL. + */ +const char *osint_dns_proposal_get_value( + const OsintDnsProposal *proposal +); + +/** + * @brief Retourne la cible initialement interrogée. + * + * @param proposal Proposition consultée. + * + * @return Chaîne empruntée, ou NULL. + */ +const char *osint_dns_proposal_get_target( + const OsintDnsProposal *proposal +); + +G_END_DECLS + +#endif diff --git a/include/views/application_message_dialog.h b/include/views/application_message_dialog.h index fe0d95c..7530869 100644 --- a/include/views/application_message_dialog.h +++ b/include/views/application_message_dialog.h @@ -32,6 +32,15 @@ typedef void (*ApplicationMessageDialogConfirmationCallback)( gpointer user_data ); +/** + * @brief Callback appelé par l'action facultative d'un dialogue détaillé. + * + * @param user_data Données associées à l'action. + */ +typedef void (*ApplicationMessageDialogActionCallback)( + gpointer user_data +); + /** * @brief Affiche une fenêtre modale contenant un message. * @@ -69,6 +78,34 @@ void application_message_dialog_present_details( const char *details ); +/** + * @brief Affiche un message détaillé avec une action facultative. + * + * Le dialogue devient propriétaire de action_data lorsque l'affichage est + * créé. Les données sont libérées avec action_data_destroy à sa fermeture. + * + * @param parent_window Fenêtre parente, ou NULL. + * @param message_type Type de message. + * @param title Titre de la fenêtre, ou NULL. + * @param message Message principal, ou NULL. + * @param details Contenu détaillé, ou NULL. + * @param action_label Libellé de l'action, ou NULL. + * @param action_callback Callback de l'action, ou NULL. + * @param action_data Données transmises au callback. + * @param action_data_destroy Destructeur des données, ou NULL. + */ +void application_message_dialog_present_details_action( + GtkWindow *parent_window, + ApplicationMessageDialogType message_type, + const char *title, + const char *message, + const char *details, + const char *action_label, + ApplicationMessageDialogActionCallback action_callback, + gpointer action_data, + GDestroyNotify action_data_destroy +); + /** * @brief Affiche un dialogue modal demandant une confirmation. * diff --git a/src/core/application.c b/src/core/application.c index d6b16d2..242103b 100644 --- a/src/core/application.c +++ b/src/core/application.c @@ -14,6 +14,7 @@ #include "models/investigation_graph_model.h" #include "models/investigation_record.h" #include "models/osint_dns_query.h" +#include "models/osint_dns_proposal.h" #include "core/investigation_tree_builder.h" #include "views/create_investigation_dialog.h" #include "views/folder_dialog.h" @@ -162,6 +163,76 @@ typedef struct char *target_value; } ApplicationOsintActionContext; +/** @brief Données possédées par l'étape de révision OSINT. */ +typedef struct +{ + Application *application; + char *proposal_summary; +} ApplicationOsintReviewContext; + +/** @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_free(context); +} + +/** @brief Affiche les propositions sans les intégrer à l'enquête. */ +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) + { + 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++) + { + 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) + ); + } + + g_string_append( + summary, + "Ces propositions sont uniquement préparées pour révision. " + "Elles ne sont pas enregistrées dans SQLite." + ); + return g_string_free(summary, FALSE); +} + /** * @brief Libère le contexte d'une action OSINT. */ @@ -989,6 +1060,8 @@ static void application_on_dns_lookup_completed( char *stderr_text = NULL; char *details = NULL; char *message = NULL; + GPtrArray *proposals = NULL; + ApplicationOsintReviewContext *review_context = NULL; GError *error = NULL; if (task == NULL || context == NULL || context->application == NULL) @@ -1002,6 +1075,17 @@ static void application_on_dns_lookup_completed( return; } + if (background_task_get_state(task) == BACKGROUND_TASK_STATE_CANCELLED) + { + application_message_dialog_present( + main_window_get_window(application->main_window), + APPLICATION_MESSAGE_DIALOG_INFORMATION, + "Résolution DNS annulée", + "La tâche a été annulée. Aucun résultat n'a été enregistré." + ); + return; + } + if (background_task_get_state(task) != BACKGROUND_TASK_STATE_COMPLETED) { error = background_task_dup_error(task); @@ -1055,20 +1139,45 @@ static void application_on_dns_lookup_completed( details = g_strdup("Aucun enregistrement DNS retourné."); } + proposals = osint_dns_proposal_parse(context->target_value, stdout_text); + if (tool_process_result_is_success(process_result) && + proposals != NULL && proposals->len > 0U) + { + review_context = g_try_new0(ApplicationOsintReviewContext, 1); + if (review_context != NULL) + { + review_context->application = application; + review_context->proposal_summary = + application_build_dns_proposal_summary(proposals); + } + if (review_context == NULL || review_context->proposal_summary == NULL) + { + application_osint_review_context_free(review_context); + review_context = NULL; + } + } + message = g_strdup_printf( - "Cible : %s — code de sortie : %d", + "Cible : %s\nOutil : dns.dig\nCode de sortie : %d\n" + "Résultat brut non enregistré dans l'enquête.", context->target_value, tool_process_result_get_exit_status(process_result) ); - application_message_dialog_present_details( + application_message_dialog_present_details_action( main_window_get_window(application->main_window), tool_process_result_is_success(process_result) ? APPLICATION_MESSAGE_DIALOG_INFORMATION : APPLICATION_MESSAGE_DIALOG_WARNING, "Résultat de la résolution DNS", message, - details + details, + review_context != NULL ? "Préparer l'intégration" : NULL, + review_context != NULL + ? application_on_osint_prepare_integration + : NULL, + review_context, + application_osint_review_context_free ); g_clear_pointer(&stdout_bytes, g_bytes_unref); @@ -1077,6 +1186,7 @@ static void application_on_dns_lookup_completed( g_free(stderr_text); g_free(details); g_free(message); + g_clear_pointer(&proposals, g_ptr_array_unref); } /** diff --git a/src/models/osint_dns_proposal.c b/src/models/osint_dns_proposal.c new file mode 100644 index 0000000..7c441ed --- /dev/null +++ b/src/models/osint_dns_proposal.c @@ -0,0 +1,157 @@ +/****************************************************************************** + * @file osint_dns_proposal.c + * @brief Propositions structurées extraites d'une sortie DNS brute. + ******************************************************************************/ + +#include "models/osint_dns_proposal.h" + +#include + +struct OsintDnsProposal +{ + char *owner; + char *record_type; + char *value; + char *target; +}; + +/** @brief Libère une proposition possédée par un tableau. */ +static void osint_dns_proposal_free(gpointer data) +{ + OsintDnsProposal *proposal = data; + if (proposal == NULL) return; + g_free(proposal->target); + g_free(proposal->value); + g_free(proposal->record_type); + g_free(proposal->owner); + g_free(proposal); +} + +/** @brief Indique si un type DNS produit une proposition structurée. */ +static gboolean osint_dns_proposal_type_is_recognized(const char *record_type) +{ + static const char *recognized_types[] = { + "A", "AAAA", "CAA", "CNAME", "MX", "NS", "PTR", "SOA", "SRV", + "TXT", NULL + }; + + for (gsize index = 0; recognized_types[index] != NULL; index++) + { + if (g_strcmp0(record_type, recognized_types[index]) == 0) return TRUE; + } + return FALSE; +} + +/** @brief Extrait le prochain champ séparé par des espaces ASCII. */ +static char *osint_dns_proposal_take_field(char **cursor) +{ + char *field_start = NULL; + char *field_end = NULL; + + if (cursor == NULL || *cursor == NULL) return NULL; + field_start = *cursor; + while (*field_start != '\0' && g_ascii_isspace(*field_start)) field_start++; + if (*field_start == '\0') + { + *cursor = field_start; + return NULL; + } + field_end = field_start; + while (*field_end != '\0' && !g_ascii_isspace(*field_end)) field_end++; + *cursor = field_end; + return g_strndup(field_start, (gsize) (field_end - field_start)); +} + +/** @brief Parse une ligne de réponse DNS, ou retourne NULL. */ +static OsintDnsProposal *osint_dns_proposal_parse_line( + const char *target_value, + const char *line +) +{ + char *line_copy = g_strdup(line); + char *cursor = line_copy; + char *owner = NULL; + char *time_to_live = NULL; + char *record_class = NULL; + char *record_type = NULL; + char *value = NULL; + OsintDnsProposal *proposal = NULL; + + if (line_copy == NULL) return NULL; + g_strstrip(line_copy); + if (line_copy[0] == '\0' || line_copy[0] == ';') goto cleanup; + + owner = osint_dns_proposal_take_field(&cursor); + time_to_live = osint_dns_proposal_take_field(&cursor); + record_class = osint_dns_proposal_take_field(&cursor); + record_type = osint_dns_proposal_take_field(&cursor); + while (cursor != NULL && *cursor != '\0' && g_ascii_isspace(*cursor)) cursor++; + value = cursor != NULL ? g_strdup(cursor) : NULL; + if (value != NULL) g_strstrip(value); + + if (owner == NULL || time_to_live == NULL || record_class == NULL || + record_type == NULL || value == NULL || value[0] == '\0' || + !g_ascii_string_to_unsigned(time_to_live, 10, 0, G_MAXUINT32, NULL, NULL) || + g_strcmp0(record_class, "IN") != 0 || + !osint_dns_proposal_type_is_recognized(record_type)) + { + goto cleanup; + } + + proposal = g_try_new0(OsintDnsProposal, 1); + if (proposal != NULL) + { + proposal->owner = g_strdup(owner); + proposal->record_type = g_strdup(record_type); + proposal->value = g_strdup(value); + proposal->target = g_strdup(target_value); + } + if (proposal == NULL || proposal->owner == NULL || + proposal->record_type == NULL || proposal->value == NULL || + proposal->target == NULL) + { + osint_dns_proposal_free(proposal); + proposal = NULL; + } + +cleanup: + g_free(value); + g_free(record_type); + g_free(record_class); + g_free(time_to_live); + g_free(owner); + g_free(line_copy); + return proposal; +} + +GPtrArray *osint_dns_proposal_parse( + const char *target_value, + const char *raw_output +) +{ + GPtrArray *proposals = NULL; + char **lines = NULL; + + if (target_value == NULL || target_value[0] == '\0' || raw_output == NULL) + return NULL; + proposals = g_ptr_array_new_with_free_func(osint_dns_proposal_free); + lines = g_strsplit(raw_output, "\n", -1); + for (gsize index = 0; lines != NULL && lines[index] != NULL; index++) + { + OsintDnsProposal *proposal = osint_dns_proposal_parse_line( + target_value, lines[index] + ); + if (proposal != NULL) g_ptr_array_add(proposals, proposal); + } + g_strfreev(lines); + return proposals; +} + +const char *osint_dns_proposal_get_owner(const OsintDnsProposal *proposal) +{ return proposal != NULL ? proposal->owner : NULL; } +const char *osint_dns_proposal_get_record_type(const OsintDnsProposal *proposal) +{ return proposal != NULL ? proposal->record_type : NULL; } +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; } diff --git a/src/views/application_message_dialog.c b/src/views/application_message_dialog.c index 1705eb9..d620af1 100644 --- a/src/views/application_message_dialog.c +++ b/src/views/application_message_dialog.c @@ -35,6 +35,36 @@ typedef struct gboolean completed; } ApplicationMessageDialogConfirmationContext; +/** @brief Données possédées par l'action d'un dialogue détaillé. */ +typedef struct +{ + ApplicationMessageDialogActionCallback callback; + gpointer user_data; + GDestroyNotify user_data_destroy; +} ApplicationMessageDialogActionContext; + +/** @brief Libère les données possédées par une action détaillée. */ +static void application_message_dialog_action_context_free(gpointer data) +{ + ApplicationMessageDialogActionContext *context = data; + if (context == NULL) return; + if (context->user_data_destroy != NULL) + context->user_data_destroy(context->user_data); + g_free(context); +} + +/** @brief Déclenche l'action facultative du dialogue détaillé. */ +static void application_message_dialog_on_action_clicked( + GtkButton *button, + gpointer user_data +) +{ + ApplicationMessageDialogActionContext *context = user_data; + (void) button; + if (context != NULL && context->callback != NULL) + context->callback(context->user_data); +} + /** * @brief Retourne le libellé correspondant au type de message. * @@ -355,12 +385,16 @@ void application_message_dialog_present( ); } -void application_message_dialog_present_details( +void application_message_dialog_present_details_action( GtkWindow *parent_window, ApplicationMessageDialogType message_type, const char *title, const char *message, - const char *details + const char *details, + const char *action_label, + ApplicationMessageDialogActionCallback action_callback, + gpointer action_data, + GDestroyNotify action_data_destroy ) { GtkWindow *dialog_window = GTK_WINDOW(gtk_window_new()); @@ -374,6 +408,9 @@ void application_message_dialog_present_details( GtkWidget *scrolled_window = gtk_scrolled_window_new(); GtkWidget *details_view = gtk_text_view_new(); GtkWidget *close_button = gtk_button_new_with_label("Fermer"); + GtkWidget *button_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + GtkWidget *action_button = NULL; + ApplicationMessageDialogActionContext *action_context = NULL; GtkTextBuffer *details_buffer = gtk_text_view_get_buffer( GTK_TEXT_VIEW(details_view) ); @@ -426,7 +463,7 @@ void application_message_dialog_present_details( details_view ); - gtk_widget_set_halign(close_button, GTK_ALIGN_END); + gtk_widget_set_halign(button_box, GTK_ALIGN_END); g_signal_connect( close_button, "clicked", @@ -434,14 +471,65 @@ void application_message_dialog_present_details( dialog_window ); + if (action_label != NULL && action_label[0] != '\0' && + action_callback != NULL) + { + action_context = g_new0(ApplicationMessageDialogActionContext, 1); + action_context->callback = action_callback; + action_context->user_data = action_data; + action_context->user_data_destroy = action_data_destroy; + g_object_set_data_full( + G_OBJECT(dialog_window), + "application-message-dialog-action-context", + action_context, + application_message_dialog_action_context_free + ); + action_button = gtk_button_new_with_label(action_label); + gtk_widget_add_css_class(action_button, "suggested-action"); + g_signal_connect( + action_button, + "clicked", + G_CALLBACK(application_message_dialog_on_action_clicked), + action_context + ); + gtk_box_append(GTK_BOX(button_box), action_button); + } + else if (action_data_destroy != NULL) + { + action_data_destroy(action_data); + } + + gtk_box_append(GTK_BOX(button_box), close_button); + gtk_box_append(GTK_BOX(main_box), title_label); gtk_box_append(GTK_BOX(main_box), message_label); gtk_box_append(GTK_BOX(main_box), scrolled_window); - gtk_box_append(GTK_BOX(main_box), close_button); + gtk_box_append(GTK_BOX(main_box), button_box); gtk_window_set_child(dialog_window, main_box); gtk_window_present(dialog_window); } +void application_message_dialog_present_details( + GtkWindow *parent_window, + ApplicationMessageDialogType message_type, + const char *title, + const char *message, + const char *details +) +{ + application_message_dialog_present_details_action( + parent_window, + message_type, + title, + message, + details, + NULL, + NULL, + NULL, + NULL + ); +} + /** * @brief Libère le contexte associé à une fenêtre de confirmation. */ diff --git a/tests/test_osint_dns_proposal.c b/tests/test_osint_dns_proposal.c new file mode 100644 index 0000000..054644f --- /dev/null +++ b/tests/test_osint_dns_proposal.c @@ -0,0 +1,59 @@ +/****************************************************************************** + * @file test_osint_dns_proposal.c + * @brief Tests des propositions structurées extraites de dig. + ******************************************************************************/ + +#include "models/osint_dns_proposal.h" + +#include + +static void test_parse_recognized_records(void) +{ + const char *output = + "example.org. 300 IN A 93.184.216.34\n" + "example.org. 300 IN MX 10 mail.example.org.\n" + "example.org. 300 IN TXT \"texte avec espaces\"\n"; + GPtrArray *proposals = osint_dns_proposal_parse("example.org", output); + const OsintDnsProposal *first = g_ptr_array_index(proposals, 0); + const OsintDnsProposal *third = g_ptr_array_index(proposals, 2); + + g_assert_cmpuint(proposals->len, ==, 3); + g_assert_cmpstr(osint_dns_proposal_get_owner(first), ==, "example.org."); + g_assert_cmpstr(osint_dns_proposal_get_record_type(first), ==, "A"); + g_assert_cmpstr(osint_dns_proposal_get_value(first), ==, "93.184.216.34"); + g_assert_cmpstr(osint_dns_proposal_get_target(first), ==, "example.org"); + g_assert_cmpstr( + osint_dns_proposal_get_value(third), ==, "\"texte avec espaces\"" + ); + g_ptr_array_unref(proposals); +} + +static void test_ignore_unrecognized_lines(void) +{ + const char *output = + ";; comment\n" + "invalid line\n" + "example.org. nope IN A 93.184.216.34\n" + "example.org. 60 CH A 93.184.216.34\n" + "example.org. 60 IN TYPE999 opaque\n"; + GPtrArray *proposals = osint_dns_proposal_parse("example.org", output); + g_assert_nonnull(proposals); + g_assert_cmpuint(proposals->len, ==, 0); + g_ptr_array_unref(proposals); +} + +static void test_invalid_arguments(void) +{ + g_assert_null(osint_dns_proposal_parse(NULL, "")); + g_assert_null(osint_dns_proposal_parse("", "")); + g_assert_null(osint_dns_proposal_parse("example.org", NULL)); +} + +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); + return g_test_run(); +}