feat(osint): integrate selected DNS proposals

This commit is contained in:
grayTerminal-sh 2026-07-22 12:18:18 +02:00
parent 5a196f6ac6
commit f6f3e71002
12 changed files with 975 additions and 51 deletions

View file

@ -117,6 +117,7 @@ TEST_OSINT_SELECTION_CONTEXT := tests/test_osint_selection_context
TEST_OSINT_ACTION_CATALOG := tests/test_osint_action_catalog TEST_OSINT_ACTION_CATALOG := tests/test_osint_action_catalog
TEST_OSINT_DNS_QUERY := tests/test_osint_dns_query TEST_OSINT_DNS_QUERY := tests/test_osint_dns_query
TEST_OSINT_DNS_PROPOSAL := tests/test_osint_dns_proposal TEST_OSINT_DNS_PROPOSAL := tests/test_osint_dns_proposal
TEST_OSINT_DNS_INTEGRATION := tests/test_osint_dns_integration
all: $(TARGET) all: $(TARGET)
@ -554,9 +555,24 @@ $(TEST_OSINT_DNS_QUERY): \
$(TEST_OSINT_DNS_PROPOSAL): \ $(TEST_OSINT_DNS_PROPOSAL): \
tests/test_osint_dns_proposal.c \ 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) $(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): \ $(TEST_INVESTIGATION_GRAPH_LOAD_TASK): \
tests/test_investigation_graph_load_task.c \ tests/test_investigation_graph_load_task.c \
src/core/investigation_graph_load_task.c \ src/core/investigation_graph_load_task.c \
@ -628,7 +644,8 @@ test: \
$(TEST_OSINT_SELECTION_CONTEXT) \ $(TEST_OSINT_SELECTION_CONTEXT) \
$(TEST_OSINT_ACTION_CATALOG) \ $(TEST_OSINT_ACTION_CATALOG) \
$(TEST_OSINT_DNS_QUERY) \ $(TEST_OSINT_DNS_QUERY) \
$(TEST_OSINT_DNS_PROPOSAL) $(TEST_OSINT_DNS_PROPOSAL) \
$(TEST_OSINT_DNS_INTEGRATION)
@echo "Exécution des tests..." @echo "Exécution des tests..."
@./$(TEST_NODE) @./$(TEST_NODE)
@./$(TEST_TREE_MODEL) @./$(TEST_TREE_MODEL)
@ -680,6 +697,7 @@ test: \
@$(TEST_OSINT_ACTION_CATALOG) @$(TEST_OSINT_ACTION_CATALOG)
@$(TEST_OSINT_DNS_QUERY) @$(TEST_OSINT_DNS_QUERY)
@$(TEST_OSINT_DNS_PROPOSAL) @$(TEST_OSINT_DNS_PROPOSAL)
@$(TEST_OSINT_DNS_INTEGRATION)
@echo "Tous les tests sont valides." @echo "Tous les tests sont valides."
%.o: %.c %.o: %.c
@ -737,7 +755,8 @@ clean:
$(TEST_OSINT_SELECTION_CONTEXT) \ $(TEST_OSINT_SELECTION_CONTEXT) \
$(TEST_OSINT_ACTION_CATALOG) \ $(TEST_OSINT_ACTION_CATALOG) \
$(TEST_OSINT_DNS_QUERY) \ $(TEST_OSINT_DNS_QUERY) \
$(TEST_OSINT_DNS_PROPOSAL) $(TEST_OSINT_DNS_PROPOSAL) \
$(TEST_OSINT_DNS_INTEGRATION)
-include $(DEP) -include $(DEP)

View file

@ -147,7 +147,9 @@ Le socle actuel comprend notamment :
- disponibilité des actions OSINT synchronisée avec le registre doutils ; - disponibilité des actions OSINT synchronisée avec le registre doutils ;
- résolution DNS asynchrone avec `dig` depuis une entité domaine ; - résolution DNS asynchrone avec `dig` depuis une entité domaine ;
- affichage sélectionnable des sorties standard et derreur, sans persistance ; - affichage sélectionnable des sorties standard et derreur, 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 : Les outils actuellement présents dans le catalogue initial sont :

View file

@ -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à réalisée avec précaution afin de préserver la cohérence des données déjà
enregistrées. 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 # 6. Tables de liaison

View file

@ -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 <glib.h>
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

View file

@ -75,6 +75,35 @@ const char *osint_dns_proposal_get_target(
const OsintDnsProposal *proposal 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 G_END_DECLS
#endif #endif

View file

@ -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 <gtk/gtk.h>
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

View file

@ -24,6 +24,7 @@
#include "core/background_task.h" #include "core/background_task.h"
#include "core/tool_initializer.h" #include "core/tool_initializer.h"
#include "core/tool_task.h" #include "core/tool_task.h"
#include "core/osint_dns_integration.h"
#include "views/file_dialog.h" #include "views/file_dialog.h"
#include "core/evidence_import_task.h" #include "core/evidence_import_task.h"
#include "models/evidence_record.h" #include "models/evidence_record.h"
@ -31,6 +32,7 @@
#include "dao/graph_node_position_dao.h" #include "dao/graph_node_position_dao.h"
#include "views/evidence_import_dialog.h" #include "views/evidence_import_dialog.h"
#include "views/create_relation_dialog.h" #include "views/create_relation_dialog.h"
#include "views/osint_dns_review_dialog.h"
#include "dao/evidence_dao.h" #include "dao/evidence_dao.h"
#include "dao/entity_dao.h" #include "dao/entity_dao.h"
#include "models/evidence_type.h" #include "models/evidence_type.h"
@ -167,70 +169,99 @@ typedef struct
typedef struct typedef struct
{ {
Application *application; Application *application;
char *proposal_summary; GPtrArray *proposals;
} ApplicationOsintReviewContext; } 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. */ /** @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) static void application_osint_review_context_free(gpointer user_data)
{ {
ApplicationOsintReviewContext *context = user_data; ApplicationOsintReviewContext *context = user_data;
if (context == NULL) return; if (context == NULL) return;
g_free(context->proposal_summary); g_clear_pointer(&context->proposals, g_ptr_array_unref);
g_free(context); g_free(context);
} }
/** @brief Affiche les propositions sans les intégrer à l'enquête. */ /** @brief Intègre les propositions confirmées puis recharge le graphe. */
static void application_on_osint_prepare_integration(gpointer user_data) static void application_on_osint_integration_confirmed(
GPtrArray *selected_proposals,
gpointer user_data
)
{ {
ApplicationOsintReviewContext *context = user_data; Application *application = user_data;
if (context == NULL || context->application == NULL || const InvestigationProject *project = NULL;
context->application->main_window == 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; return;
} }
application_message_dialog_present_details( database = investigation_session_get_database(application->session);
main_window_get_window(context->application->main_window), 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
))
{
application_present_error(
application,
"Intégration DNS impossible",
error != NULL ? error->message
: "La transaction DNS a échoué."
);
g_clear_error(&error);
return;
}
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, APPLICATION_MESSAGE_DIALOG_INFORMATION,
"Propositions DNS à réviser", "Intégration DNS terminée",
"Aucune donnée n'a été enregistrée dans l'enquête.", message
context->proposal_summary
); );
g_free(message);
if (database_path != NULL && database_path[0] != '\0')
application_start_graph_loading(application, database_path);
} }
/** @brief Construit le résumé lisible des propositions DNS. */ /** @brief Affiche les propositions et demande une sélection explicite. */
static char *application_build_dns_proposal_summary(GPtrArray *proposals) static void application_on_osint_prepare_integration(gpointer user_data)
{ {
GString *summary = g_string_new(NULL); ApplicationOsintReviewContext *context = user_data;
if (context == NULL || context->application == NULL ||
if (summary == NULL) return NULL; context->application->main_window == NULL || context->proposals == NULL)
g_string_append_printf( return;
summary, osint_dns_review_dialog_present(
"Propositions détectées : %u\n\n", main_window_get_window(context->application->main_window),
proposals != NULL ? proposals->len : 0U context->proposals,
application_on_osint_integration_confirmed,
context->application
); );
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);
} }
/** /**
@ -1147,10 +1178,9 @@ static void application_on_dns_lookup_completed(
if (review_context != NULL) if (review_context != NULL)
{ {
review_context->application = application; review_context->application = application;
review_context->proposal_summary = review_context->proposals = g_ptr_array_ref(proposals);
application_build_dns_proposal_summary(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); application_osint_review_context_free(review_context);
review_context = NULL; review_context = NULL;

View file

@ -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 <gio/gio.h>
#include <string.h>
/** @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;
}

View file

@ -4,7 +4,9 @@
******************************************************************************/ ******************************************************************************/
#include "models/osint_dns_proposal.h" #include "models/osint_dns_proposal.h"
#include "models/osint_dns_query.h"
#include <gio/gio.h>
#include <string.h> #include <string.h>
struct OsintDnsProposal struct OsintDnsProposal
@ -155,3 +157,56 @@ const char *osint_dns_proposal_get_value(const OsintDnsProposal *proposal)
{ return proposal != NULL ? proposal->value : NULL; } { return proposal != NULL ? proposal->value : NULL; }
const char *osint_dns_proposal_get_target(const OsintDnsProposal *proposal) const char *osint_dns_proposal_get_target(const OsintDnsProposal *proposal)
{ return proposal != NULL ? proposal->target : NULL; } { 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;
}

View file

@ -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);
}

View file

@ -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 <glib.h>
#include <glib/gstdio.h>
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();
}

View file

@ -49,11 +49,44 @@ static void test_invalid_arguments(void)
g_assert_null(osint_dns_proposal_parse("example.org", NULL)); 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) int main(int argc, char **argv)
{ {
g_test_init(&argc, &argv, NULL); 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/recognized", test_parse_recognized_records);
g_test_add_func("/osint-dns-proposal/ignored", test_ignore_unrecognized_lines); 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/invalid", test_invalid_arguments);
g_test_add_func(
"/osint-dns-proposal/mapping", test_entity_mapping_and_normalization
);
return g_test_run(); return g_test_run();
} }