Organización y estructura

En este bloque se tratan los casos de estudio relacionados con la organización y estructura de proyectos.


ST-001 - Estructura de proyecto

Problema

Muchas herramientas no contemplan la opción de ser usadas como librería (con un “import”) además de por linea de comandos, o la interfaz que tengan definida.

Su uso puede ser complicado y, muchas veces, tan solo pueden ser usadas por linea de comandos, lo que implica llamar a un proceso externo del sistema y parsear su salida.

Solución

Una estructura correcta en la organización de archivos, pensada para ser re-utilizable.

Cómo

En la imagen se puede ver la estructura propuesta:

../_images/st-001.01.png

Donde:

  • LICENCE: Fichero de licencia. Contiene el texto legal con el que queremos redistribuir nuestro código.
  • README.rst: Fichero índice de documentación del proyecto. Será el primero en mostrarse y leerse por defecto.
  • requirements.txt: Contiene las dependencias de librerías externas de nuestro proyecto.
  • setup.py: Contiene información para la instalación, configuración y redistribución de nuestro software.
  • TODO.rst: Ideas futuras a implementar en nuestro proyecto. Es buena idea tenerlo por si alguien quiere colaborar con nuestro proyecto, ya que podrá encontrar tareas e ideas por hacer.
  • app_name: Carpeta, que actúa como paquete Python, con el nombre de nuestra aplicación.
  • app_name/bin: Ejecutables disponibles en nuestra aplicación.
  • app_name/doc: Archivos de documentación, usualmente escrita con Shpinx. Esta guía es un ejemplo de su uso.
  • app_name/lib: Librerías propias que genere nuestra aplicación.
  • app_name/test: Contiene los diferentes test de la aplicación: Unitarios, de integración, rendimiento...

ST-002 - Entrada de parámetros globales

Problema

Una vez preparada la estructura para nuestra aplicación, ahora queremos “llamar” o ejecutar el programa en cuestión. Problemas:

  • Añadir nuevos parámetros de entrada al programa, requiere muchos cambios.
  • El código se hace cada vez más enrevesado e inmanejable.
  • Cambios de versiones de un mismos programas son incompatibles.

Ejemplo ST-002.P01

Este ejemplo de programa que divide dos números. Acepta 2 parámetros por linea de comandos:

  • Nominado
  • Denominador

Si ocurre una división por 0 devuelve -1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import argparse


# ----------------------------------------------------------------------
def divide(param1, param2):
    try:
        return param1 / param2
    except ZeroDivisionError:
        return -1

# ----------------------------------------------------------------------
if __name__ == '__main__':

    parser = argparse.ArgumentParser(description='OMSTD Example')
    parser.add_argument("-p1", dest="param1", help="parameter1")
    parser.add_argument("-p2", dest="param1", help="parameter2")

    params = parser.parse_args()

    d = divide(params.param1, params.param2)

    print(d)

Ejemplo ST-002.P02

Ahora queremos añadir otro parámetro, “verbosity”, como opción a nuestro programa.

Vemos que hay que modificar el código en 2 sitios diferentes. En todos aquellos donde se tenga que pasar información de las opciones de ejecución:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# ----------------------------------------------------------------------
def divide(param1, param2, verbosity):
    try:
        return param1 / param2
    except ZeroDivisionError as e:
        if verbosity > 0:
            print(e)
        return -1

# ----------------------------------------------------------------------
if __name__ == '__main__':

    parser = argparse.ArgumentParser(description='OMSTD Example')
    parser.add_argument("-p1", dest="param1", help="parameter1")
    parser.add_argument("-p2", dest="param1", help="parameter2")
    parser.add_argument("-v", dest="verbosity", type="int", help="verbosity")

    params = parser.parse_args()

    d = divide(params.param1, params.param2, params.verbosity)

    print(d)

Solución

Hacer un objeto global que sea el que contenga los parámetros globales de ejecución (proyectos grandes, como nmap, proyecto lo hacen de esta forma):

Cómo

Ejemplo ST-002.S01

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# --------------------------------------------------------------------------
class Parameters:
    """Program parameters"""

    # ----------------------------------------------------------------------
    def __init__(self, **kwargs):
        self.param1 = kwargs.get("param1")
        self.param2 = kwargs.get("param2")
        self.verbosity = kwargs.get("verbosity")


# ----------------------------------------------------------------------
def divide(input_params):
    try:
        return input_params.param1 / input_params.param2
    except ZeroDivisionError as e:
        if input_params.verbosity > 0:
            print(e)
        return -1

# ----------------------------------------------------------------------
if __name__ == '__main__':

    parser = argparse.ArgumentParser(description='OMSTD Example')
    parser.add_argument("-p1", dest="param1", help="parameter1")
    parser.add_argument("-p2", dest="param1", help="parameter2")
    parser.add_argument("-v", dest="verbosity", type="int", help="verbosity")

    params = parser.parse_args()

    input_parameters = Parameters(param1=params.param1,
                                  param2=params.param2,
                                  verbosity=params.verbosity)

    d = divide(input_parameters)

    print(d)

ST-003 - Gestión de resultados

Problema

Recuperar la información de salida de una aplicación es muy complicado:

  • No está estandarizada
  • Hay que parsear muchos XML/JSON.
  • La herramienta solo reporta resultados por consola.

Usando el mismo ejemplo que en el anterior caso:

Ejemplo ST-003.P01

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import argparse


# ----------------------------------------------------------------------
def divide(param1, param2):
    try:
        return param1 / param2
    except ZeroDivisionError:
        return -1

# ----------------------------------------------------------------------
if __name__ == '__main__':

    parser = argparse.ArgumentParser(description='OMSTD Example')
    parser.add_argument("-p1", dest="param1", help="parameter1")
    parser.add_argument("-p2", dest="param1", help="parameter2")

    params = parser.parse_args()

    d = divide(params.param1, params.param2)

    print(d)

Cuando queremos añadir un parámetro nuevo en la devolución, vemos que hay que modificar varias lineas de código:

Ejemplo ST-003.P02

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# ----------------------------------------------------------------------
def divide(param1, param2):
    try:
        results = param1 / param2
        is_2_divisor = results % 2

        return results, is_2_divisor
    except ZeroDivisionError as e:
        return -1, False

# ----------------------------------------------------------------------
if __name__ == '__main__':

    parser = argparse.ArgumentParser(description='OMSTD Example')
    parser.add_argument("-p1", dest="param1", help="parameter1")
    parser.add_argument("-p2", dest="param1", help="parameter2")

    params = parser.parse_args()

    result, is_divisor = divide(params.param1, params.param2)

    print("Results: %s - %s" % (result, is_divisor))

Solución

Usar objetos genéricos que contengan la información resultante de la ejecución del a herramienta.

Además, estos objetos, nos permiten abstraer el almacenamiento de información, de cómo ésta es exportada o transformada: XML, JSON, HTML, PDF ...

Cómo

Ejemplo ST-003.S01

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# --------------------------------------------------------------------------
class Results:
    """Program parameters"""

    # ----------------------------------------------------------------------
    def __init__(self, **kwargs):
        self.result = kwargs.get("result", 0)
        self.is_2_divisor = kwargs.get("is_2_multiple", False)


# ----------------------------------------------------------------------
def divide(param1, param2):
    try:
        results = param1 / param2
        is_2_divisor = results % 2

        return Results(result=results,
                       is_2_divisor=is_2_divisor)
    except ZeroDivisionError:
        return Results()

# ----------------------------------------------------------------------
if __name__ == '__main__':

    parser = argparse.ArgumentParser(description='ktcal2 - SSH brute forcer')
    parser.add_argument("-p1", dest="param1", help="parameter1")
    parser.add_argument("-p2", dest="param1", help="parameter2")
    parser.add_argument("-v", dest="verbosity", type="int", help="verbosity")

    params = parser.parse_args()

    result = divide(params.param1, params.param2)

    print("Results: %s - %s" % (result.result, result.is_2_divisor))

ST-004 - Unificar puntos de entrada

Problema

Tengo que cambiar mucho código y rehacer gran parte de mi aplicación, cada vez que quiero hacer una nueva UI (User Interface):

  • Linea de comandos.
  • Interfaz gráfica.
  • Web.
  • Que se use como librería.
../_images/st-004.01.png

Solución

Centralizar el punto de entrada a la ejecución de tu aplicación en un único punto y que todas las UI usen el mismo.

../_images/st-004.02.png

Cómo

Incluir el concepto de api.py y enseñar como el command line y el import funcionan igual.

../_images/st-004.03.png

Ejemplo ST-004.P01

Tras incluir el fichero “api.py”, la UI de linea de comandos (st-004-s1.py) nos quedará como sigue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from .api import Parameters, run

# ----------------------------------------------------------------------
if __name__ == '__main__':

    parser = argparse.ArgumentParser(description='OMSTD Example')
    parser.add_argument("-p1", dest="param1", help="parameter 1")
    parser.add_argument("-p2", dest="param2", help="parameter 2")

    params = parser.parse_args()

    input_parameters = Parameters(param1=params.param1,
                                  param2=params.param2,
                                  verbosity=params.verbosity)

    run(params)

Si echamos un vistazo a api.py, podemos observar que este fichera centraliza las llamadas al resto de librerías:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Import data
from .lib.data import *

# Import libs
from .lib.operations import divide
from .lib.exports import display_results


# ----------------------------------------------------------------------
def run(input_parameters):

    # Run operation
    r = divide(input_parameters)

    # Display results
    display_results(r)

ST-005 - La linea de comandos

Problema

Crear una linea de comandos avanzada puede no resultar sencilla, y mucho menos intutiva.

Estos son algunos de los problemas más comunes que podemos encontrarnos:

  • Crear opciones posicionales: main.py -n -i 9 POSITIONAL_PARAM.
  • Crear una ayuda personalizada, cuando se ejecute la opción -h: main.py -h.
  • Los tipos de datos recogidos por la linea de comandos son incorrectos: main.py -t 4, por defecto 4 será tratado como un string, no como un int.
  • Ausencia de opciones por defecto.
  • Establecer ciertas opciones como obligatorios.
  • Establecer un opciones y una versión abreviada de las mismas: main.py -t 8 equivalente a main.py --threads 8.
  • Opciones sin parámetros, tratados como bool: main.py -j.
  • Opciones que puedan usarse como acumuladores: main.py -v -> main.py -vv -> main.py -vvv.

Solución

En este caso de prueba la solución no es otra que usar correctamente la libraría de Python argparse

Cómo

Para que sea más ilustrativo, vamos a partir de una linea de comandos básica y vamos ir mejorándola poco a poco.

La linea de comandos de la que partiremos la que sigue, de un posible escaneador de puertos:

Ejemplo ST-005.P01

1
2
3
4
5
6
7
8
9
if __name__ == "__main__":

    parser = argparse.ArgumentParser(description='OMSTD Example')
    parser.add_argument("-t", dest="targets", help="target")
    parser.add_argument('-v', dest='verbosity level')
    parser.add_argument("--open", dest="only_open", help="only display open ports")
    parser.add_argument("--port", dest="port")

    params = parser.parse_args()

Que mostrará el siguiente resultado por consola, cuando ejecutamos st-005-p1.py -h:

usage: st-005-p1.py [-h] [-t TARGETS] [-v VERBOSITY LEVEL] [--open ONLY_OPEN]
                [-p PORTS_RANGE]

OMSTD Example

optional arguments:
  -h, --help          show this help message and exit
  -t TARGETS          target
  -v VERBOSITY LEVEL
  --open ONLY_OPEN    only display open ports
  --port PORT

Nota

Nótese que se puede ejecutar sin parámetros y no se devolvería ningún error a pesar de que, por razones obvias, necesitamos como mínimo un parámetros: targets.

1 - Opciones obligatorios

En primer lugar vamos a solventar el problema del parámetro obligatorio y vamos a obligar a especificar un target:

1
2
3
4
5
6
7
parser = argparse.ArgumentParser(description='OMSTD Example')
parser.add_argument("-t", dest="targets", help="target", required=True)
parser.add_argument('-v', dest='verbosity' help='verbosity level', type=int)
parser.add_argument("--open", dest="only_open", help="only display open ports", default=0)
parser.add_argument("--port", dest="port", type=int, help="port to scan")

params = parser.parse_args()

Vemos cómo, tras en cambio, se obliga al usuario a especificar un target:

usage: st-005-p1.py [-h] -t TARGETS [-v VERBOSITY LEVEL] [--open ONLY_OPEN]
                    [-p PORTS_RANGE]
st-005-p1.py: error: the following arguments are required: -t

2 - Tipos de datos

Vemos que las opciones verbosity (0-2) y port son tratadas como string (por defecto), pero realmente son del tipo int.

Añadimos la mejora:

1
2
3
4
5
6
7
parser = argparse.ArgumentParser(description='OMSTD Example')
parser.add_argument("-t", dest="targets", help="target", required=True)
parser.add_argument('-v', dest='verbosity' help='verbosity level', type=int)
parser.add_argument("--open", dest="only_open", help="only display open ports", default=0)
parser.add_argument("--port", dest="port", type=int, help="port to scan")

params = parser.parse_args()

3 - Valores por defecto

Sería interesante contar con valores por defecto para cada tipo de parámetro.

De esta forma evitaremos errores, por ausencia de dichos valores por parte del usuarios, e introduciremos configuración predeterminada, lo que hará el uso de la herramienta más fácil:

1
2
3
4
5
6
7
8
parser = argparse.ArgumentParser(description='OMSTD Example')
parser.add_argument("-t", dest="targets", help="target", required=True)
parser.add_argument('-v', dest='verbosity' help='verbosity level', type=int, default=0)
parser.add_argument("--open", dest="only_open", help="only display open ports", default=0)
parser.add_argument("--port", dest="port", type=int, default=80,
    help="port to scan. Default: 80.")

params = parser.parse_args()

4 - Opciones sin parámetros

Si observamos, la opción –open, es realmente un booleano. Es decir, que solo necesitamos saber si está a True o a False.

Actualizamos para que dicha opción sea tratada como un booleano.

1
2
3
4
5
6
7
8
9
parser = argparse.ArgumentParser(description='OMSTD Example')
parser.add_argument("-t", dest="targets", help="target", required=True)
parser.add_argument('-v', dest='verbosity' help='verbosity level', type=int, default=0)
parser.add_argument("--open", dest="only_open", action="store_true",
    help="only display open ports", default=False)
parser.add_argument("--port", dest="port", type=int, default=80,
    help="port to scan. Default: 80.")

params = parser.parse_args()

Nota

Los datos booleanos no necesitan indicarles el tipo, cuando se usan con la opción action="store_true|false. El tipo está implícido cuando usamos el parámetro de configuración action="store_XXXX".

5 - Opciones abreviadas

Cuando tenemos muchas opciones en la linea de comandos, es intersante poder poner la opciones en formato más abrevidado.

Para nuestro ejemplo, cuando indicamos del puerto, podríamos crear la opción abreviada -p, además de --port como sigue:

1
2
3
4
5
6
7
8
9
parser = argparse.ArgumentParser(description='OMSTD Example')
parser.add_argument("-t", dest="targets", help="target", required=True)
parser.add_argument('-v', dest='verbosity' help='verbosity level', type=int, default=0)
parser.add_argument("--open", dest="only_open", action="store_true",
    help="only display open ports", default=False)
parser.add_argument("-p", "--port", dest="port", type=int, default=80,
    help="port to scan. Default: 80.")

params = parser.parse_args()

6 - Opciones auto-incrementales

Existen ciertas opciones que tienen más sentido que su valor vaya incrementando en función de las veces que se repite. Por ejemplo:

  • -v en lugar de 0.
  • -vv en lugar de 1.
  • -vvv en lugar de 2.

En nuestro ejemplo, dicho comportamiento es perfecto para el opación verbosity:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
parser = argparse.ArgumentParser(description='OMSTD Example')
parser.add_argument("-t", dest="targets", help="target", required=True)
parser.add_argument('-v', dest='verbosity' help='verbosity level: -v, -vv, -vvv.',
    action="count", type=int, default=0)
parser.add_argument("--open", dest="only_open", action="store_true",
    help="only display open ports", default=False)
parser.add_argument("-p", "--port", dest="port", type=int, default=80,
    help="port to scan. Default: 80.")

params = parser.parse_args()

7 - Opciones posicionales

Por último, puede que nos interese tener parámetro qeu no van precedidos de ninguna opción que empiece por -. Por ejemplo:

  • main.py 127.0.0.1 en lugar de main.py -t 127.0.0.1.

Esta forma de configurar las opciones de la linea de comandos se conoce como opciones posicionales.

Entenderemos mejor esto con un ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
parser = argparse.ArgumentParser(description='OMSTD Example')
parser.add_argument("targets", metavar='TARGETS', help="targets to scan", nargs="+",
    required=True)
parser.add_argument('-v', dest='verbosity' help='verbosity level: -v, -vv, -vvv.',
    action="count", type=int, default=0)
parser.add_argument("--open", dest="only_open", action="store_true",
    help="only display open ports", default=False)
parser.add_argument("-p", "--port", dest="port", type=int, default=80,
    help="port to scan. Default: 80.")

params = parser.parse_args()

La linea de comando ahora se verá así:

usage: st-005-s1.py [-h] [-v] [--open] [-p PORT] TARGETS [TARGETS ...]

OMSTD Example

positional arguments:
  TARGETS               targets to scan

optional arguments:
  -h, --help            show this help message and exit
  -v                    verbosity level: -v, -vv, -vvv.
  --open                only display open ports
  -p PORT, --ports PORT
                        port to scan. Defaul: 80.

Una ver parseada la linea de comandos, en el objeto params, podremos obtener la lista de los parámetros posicionales leyendo el parámetro params.targets.

8 - Documentación extendida

Suele ser muy útil para nuestros programas poner pequeños ejemplos al final de la ayuda que nos proporciona la opción -h. Para esto, usamos el parámetro epilog, del módulo argparse:

st-005-s1.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    footed_description = """

    Usage examples:
        + Basic scan:
            %(name)s 127.0.0.1
        + Specifing port to test:
            %(name)s -p 443 127.0.0.1
        + Only diplay open ports
            %(name)s -p 139 127.0.0.1

    """"" % dict(name="st-005-s1.py")

    parser = argparse.ArgumentParser(description='OMSTD Example', epilog=footed_description,
                                     formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("targets", metavar='TARGETS', help="targets to scan", nargs="+")
    parser.add_argument('-v', dest='verbosity', default=0, action="count",
                        help="verbosity level: -v, -vv, -vvv.")
    parser.add_argument("--open", dest="only_open", action="store_true",
                        help="only display open ports", default=False)
    parser.add_argument("-p", "--ports", dest="port", type=int,
                        help="port to scan. Defaul: 80.", default=80)

    params = parser.parse_args()

Este es el aspecto que tendría al ejecutar st-005-s1.py -h:

usage: st-005-s1.py [-h] [-v] [--open] [-p PORT] TARGETS [TARGETS ...]

OMSTD Example

positional arguments:
  TARGETS               targets to scan

optional arguments:
  -h, --help            show this help message and exit
  -v                    verbosity level: -v, -vv, -vvv.
  --open                only display open ports
  -p PORT, --ports PORT
                        port to scan. Defaul: 80.

Usage examples:
    + Basic scan:
        st-005-s1.py 127.0.0.1
    + Specifing port to test:
        st-005-s1.py -p 443 127.0.0.1
    + Only diplay open ports
        st-005-s1.py -p 139 127.0.0.1