Introduction

Salut ! Un petit article après un bon moment d’absence pour vous présenter un outil bien pratique.

Dans le cadre de mon boulot, je suis amené à écrire beaucoup de rapports et certaines parties sont extrêmement répétitives (analyse des ports ouverts, testssl, headers HTTP, etc). C’est pourquoi j’ai commencé à me renseigner sur les différentes possibilités qui s’offraient à moi pour automatiser cette partie “récupération d’informations” qui n’est franchement pas très intéressante.

La seule contrainte que j’avais était l’utilisation de documents Word étant donné que nos templates d’audits sont au format docm.

J’étais initialement parti pour utiliser le module Python-Docx, cependant après avoir effectué quelques tests, celui-ci s’est avéré ne pas être adapté du tout à la modification de documents existants. Enfin… C’est possible à faire, mais ça nécessitait beaucoup de code supplémentaire et de modifications de la structure XML du document (car oui, si vous ne le saviez pas, un fichier Word n’est rien d’autre qu’une archive zip contenant des fichiers XML).

J’ai donc continué mes recherches et je suis tombé un peu par hasard sur Python-Docx-Template (aka docxtpl), petit projet d’une centaine de commits basé sur le module mentionné juste avant. Cette librairie est absolument parfaite, elle a été spécialement conçue pour la modification de documents existants, notamment grâce à l’intégration de Jinja2 (gardez ce lien de coté). Ce dernier est un langage de templating extrêmement puissant, avec une syntaxe vraiment simple à appréhender.

La documentation de la librairie docxtpl donne un bon aperçu de ce que permet la librairie, mais elle manque cruellement d’exemples à mon goût. D’où cet article qui me servira de mémo à titre personnel, et qui donnera peut-être des idées à certains.

Afin de montrer un exemple concret de ce qu’il est possible de faire, nous allons insérer le résultat d’un nmap dans le document, ainsi que les headers HTTP.

Préparation

Dans un premier temps, il vous faut bien entendu installer la librairie docxtpl avec pip (ou pip3 pour Python3) :

sudo pip install docxtpl

(La librairie s’installera de manière globale en utilisant sudo, il est préférable de configurer un virtualenv afin de ne pas polluer tout votre système avec des modules.)

Et voici le code élémentaire qui vous permettra de modifier un document :

#!/usr/bin/python3
# coding: utf-8

from docxtpl import DocxTemplate

if __name__ == "__main__":
    template_values = {}
    document = DocxTemplate("template.docx")

    # Logique ici

    document.render(template_values)
    document.save("generated.docx")

 

Le code est plutôt simple à comprendre, rien d’extraordinaire. L’idée étant de “remplir” la variable template_values pour que document.render() se charge de modifier le docx en conséquence.

Si vous tentez d’insérer des données un peu inhabituelles, vous risquez de tomber sur une erreur de parsing XML :

lxml.etree.XMLSyntaxError: Failed to parse QName 'https:', line 1, column 18320

C’est tout simplement dû au fait que les données que vous avez tenté d’insérer dans le document contiennent du XML et que ca ne plait pas au parser. Pour palier à ce problème, il suffit d’échapper les caractères problématiques :

import re

def clean(str_input):
    return escape_xml(remove_ansi_codes(str_input))

def escape_xml(str_input):
    return str_input.replace("<", "&lt;").replace(">", "&gt;").replace("&", "&amp;")

def remove_ansi_codes(str_input):
    return re.sub(r'\x1B\[[0-?]*[ -/]*[@-~]', '', str_input).replace("\x1B", "")  # Supprime le reste des \x1b

Certains programmes (comme par exemple testssl) affichent des informations colorées dans le terminal, cela est possible grâce au code ANSI. Malheureusement ces caractères spéciaux ne sont pas du tout appréciés par le parser XML, c’est pourquoi il faut les enlever.

Il ne faut pas systématiquement faire appel à ces fonctions. Les objets dits RichText n’ont pas besoin d’échappement XML, uniquement d’un échappement ANSI. Nous reviendrons sur les objets RichText après. Mais en gros, à chaque fois que vous insérez quelque chose de nouveau dans le document, vous devez vous demander s’il est nécessaire de l’échapper.

Création du template

Simple texte

On rentre maintenant dans le vif du sujet.

Voici la manière d’insérer un texte quelconque dans le document (saufs  retours à la ligne, tabulations, espaces multiples, on verra après pourquoi) :

Cette variable cible devra être placée dans le dictionnaire template_values :

target = "sysgenre.it"
template_values["cible"] = target

Cet exemple est là pour illustrer le fait que vous pouvez appliquer librement des styles à votre tag dans le document et ils seront toujours appliqués une fois la variable remplacée.

Juste au dessus j’ai dit “saufs retours à la ligne, tabulations, espaces multiples”, en fait le problème vient de la manière dont Word traite ces valeurs, si vous souhaitez créer des variables plus riches en contenu avec des retours à la ligne par exemple, vous devez utiliser des objets RichText. Dans notre code nous allons utiliser l’alias R, qui construit un objet RichText à partir d’une chaîne de caractères :

Vous remarquerez l’utilisation du r après {{, c’est pour signaler à docxtpl que nous souhaitons insérer du RichText.

On va utiliser la sortie de la commande nmap comme “données complexe” :

template_values["nmap_command"] = "nmap -T4 -p- -sV " + target
result = subprocess.run(template_values["nmap_command"].split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
template_values["nmap_output"] = R(result.stdout.decode())

Et voilà ce que ça donne dans le document :

Conditions et boucles

Grâce à Jinja, il est possible de faire des conditions au sein de notre document de la manière suivante :

Il faut voir cette commande comme un “si la variable hello existe, on l’affiche, sinon on met un Hello World par défaut”. Et il est possible de faire tout un tas de conditions bien plus complexes que ca ! Je vous laisse regarder la doc Jinja plus en détails.

Vous avez également la possibilité de faire des boucles. Les style utilisé entre les deux balises seront conservés. Attention cependant, les tags for et endfor doivent être du même style.

En python, il suffit de faire une liste et de la remplir de chaînes de caractères. Attention, ici il n’y a pas le r de RichText, la variable port doit donc être une simple chaîne de caractère (sans retour à la ligne, sans tabulation, etc) :

template_values["ports"] = []

for line in template_values["nmap_output"].split("\n"):
    if "open" in line:  # Si un port ouvert a été trouvé
        tokens = line.split()
        port = tokens[0].split("/")
        li = "{}/{} ({})".format(port[1].upper(), port[0], clean(tokens[2].upper()))
        template_values["ports"].append(li)

Notez l’utilisation du clean() lors du formatage de la chaîne de caractères. On sait pas trop ce qui pourrait se trouver dans cette chaîne… Donc dans le doute on fait un clean() pour échapper du potentiel XML.

Et voilà le résultat :

Tableaux et dictionnaires

On passe maintenant à un exemple un peu plus complexe :

Remarquez ici la condition if headers|length > 0 qui vérifie que le tableau headers n’est pas vide (afin de ne pas afficher l’entête du tableau pour rien.

Il faut également noter l’utilisation de tr après {% : cela permet d’indiquer à docxtpl que nous voulons créer des lignes dans notre tableau.

Il est également possible d’accéder à des dictionnaires python avec Jinja, c’est ce qui permet de faire les header.name et header.value.

Voici le code python permettant d’intégrer les headers HTTP de la cible dans ce tableau :

result = requests.get("https://" + target)
template_values["headers"] = [{"name":clean(header), "value":clean(result.headers[header])} for header in result.headers]

S’il y en a parmi vous qui ne sont pas familiers avec Python, la deuxième ligne permet simplement de créer un tableau de dictionnaires, si on devait illustrer la structure de données, ça donnerait quelque chose comme ça :

[{
    "name": "Header1",
    "value": "Value1"
},
{
    "name": "Header2",
    "value": "Value2"
}]

Et voici le résultat :

Nous venons de créer des lignes dans ce tableau, il est à noter qu’il est également possible de créer des colonnes dynamiquement, en replaçant tr par tc et en changeant la disposition des tags :

Ce qui donnera par la suite, le résultat suivant :

Dernière petite info concernant les tableaux, il est possible de colorer une cellule en utilisant la balise cellbg : {% cellbg '#ffffff' %}

Et il même possible avec Jinja, de faire un loop.cycle() qui va permettre d’alterner à chaque tour de boucle, la couleur choisie :

Résultat :

Bonus : support de nouveaux formats

Si vous rencontrez une erreur similaire :

ValueError: file 'template.docm' is not a Word file, content type is 'application/vnd.ms-word.document.macroEnabled.main+xml'

Alors cette partie de l’article devrait vous intéresser.

La librairie python-docx sur laquelle se base docxtpl ne supporte que certains formats de documents Word dont le docx. Il peut arriver que vous soyez amenés à travailler avec d’autres formats, comme c’était le cas pour moi : je devais travailler avec des docm, qui sont en fait des docx qui embarquent des macros. Comme python-docx ne supporte pas la gestion des macros, ils ont carrément décidé d’empêcher l’utilisation de la librairie dessus (bien que bon, ok il y a des macros en plus, mais la structure du document reste la même).

Du coup on va devoir patcher la librairie python-docx. Pour ce faire, commencez par désinstaller python-docx avec pip. Et mettez le dossier docx (présent dans le repo) au même niveau que votre script d’automatisation.

Ensuite ouvrez le fichier docx/opc/constants.py, puis dans la class CONTENT_TYPE, rajoutez le type de fichier que vous avez eu dans l’erreur :

WML_DOCUMENT_MAIN_MACRO = (
    'application/vnd.ms-word.document.macroEnabled.main+xml'
)

Puis modifiez le fichier docx/__init__.py pour rajouter un élément au “PartFactory” :

PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN_MACRO] = DocumentPart

Pensez bien entendu à modifier WML_DOCUMENT_MAIN_MACRO par ce que vous avez choisi comme nom dans constants.py.

Pour finir, ouvrez le fichier docx/api.py et remplacez cette ligne :

if document_part.content_type != CT.WML_DOCUMENT_MAIN:

Par celle-ci:

if document_part.content_type != CT.WML_DOCUMENT_MAIN and document_part.content_type != CT.WML_DOCUMENT_MAIN_MACRO:

Je ne peux pas garantir que cette solution va fonctionner pour tous les types de fichiers, mais ça devrait le faire pour ceux qui se rapproche d’un format docx.

Source : Issue Github

The End

Voilà qui conclue cet article d’automatisation de docx ! J’espère que ca donnera des idées à certains 🙂

Amusez vous bien à automatiser vos rapports, ça a un côté assez satisfaisant je trouve ! A une prochaine !