Escáner de Vulnerabilidades
Los escáneres de seguridad parecen magia hasta que ves las partes: una lista de versiones conocidas como malas (esa es la base de datos CVE), una comprobación “¿está mi versión en un rango malo?” (esa es la matemática de versiones) y un pase por patrones que no deberían estar en el código enviado (eso es SAST). Este proyecto construye las tres en Python puro, parsear requirements.txt en dependencias estructuradas, compararlas contra una pequeña base de datos CVE con severidad, grepear archivos de código por anti-patrones peligrosos y emitir un único reporte clasificado por severidad con sugerencias de actualización. No cubrirá toda tu cadena de suministro; desmitificará exactamente cómo piensa un escáner así.
Esto asume Python 101 y un poco de regex, no se requiere nada de Análisis de Datos. Es opcional y no se califica; consulta Proyectos del Mundo Real para ver la lista completa y en crecimiento.
🎯 Lo que harás
- Parsear un
requirements.txtfijado en dependencias estructuradas. - Modelar una pequeña base de datos CVE con rangos de versiones afectados y severidades.
- Comparar versiones instaladas contra los rangos y recolectar hallazgos.
- Ejecutar SAST basado en regex sobre archivos de código por patrones peligrosos.
- Combinar ambos en un reporte clasificado por severidad con sugerencias de corrección.
Dónde ejecutar esto
Localmente con uv es la ruta principal. El escáner es solo biblioteca estándar, pero su valor real está en apuntarlo a los requirements.txt y src/ de tu proyecto, archivos que viven en un disco que posees.
Google Colab, Kaggle Notebooks y Binder ejecutan cada celda de forma idéntica (solo stdlib), y el notebook de ejemplo incluye el requirements.txt de muestra y fragile.py justo adentro, así que ves el escaneo completo contra un proyecto de muestra fijo. La salvedad honesta: un notebook que escanee las propias dependencias del repo del curso te mostraría el mismo motor contra lo real, pero su sistema de archivos efímero hace de “escanea mi proyecto” una jugada solo local. Usa las insignias para ver el motor; ejecuta uv para la auditoría real.
Configuración
Crea el proyecto. El escáner usa solo la biblioteca estándar, json, re, pathlib y un helper de división de versiones que escribirás porque “cuál versión es más nueva” es un algoritmo real.
uv init vulnerability-scanner
cd vulnerability-scanner
uv run python -c "import json, re; from pathlib import Path; print('ok')"
json almacena la base de datos CVE como datos, re alimenta los patrones SAST y pathlib recorre tu árbol src/. Escribirás la lógica de comparación de versiones tú mismo en el Paso 3 en lugar de importar una biblioteca de versiones, porque esa comparación es una de las dos ideas que enseña este proyecto.
✅ Lista de verificación
- ✅
uv init vulnerability-scannercreó una carpeta con unpyproject.toml. - ✅ La comprobación de import imprime
ok, cero paquetes añadidos.
Paso 1: Parsear dependencias en especificaciones estructuradas
Cada escaneo empieza con “¿qué tenemos realmente instalado?” Un requirements.txt es la verdad fijada, pero solo si conviertes cada línea en una comparación, no en una cadena.
1.1 Escribe parse_version y parse_requirements
👟 Pista inicial : Divide una cadena de versión como 2.28.1 en una tupla numérica, Python compara tuplas en el orden correcto gratis, luego aplica regex a cada línea de requirements en name, operator y version.
# scanner.py
import json
import re
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Dependency:
name: str
operator: str # "==" | ">=" | ">" | "<" | "<=" | "any"
version: tuple[int, ...]
def parse_version(v: str) -> tuple[int, ...]:
return tuple(int(part) for part in v.split("."))
def parse_requirements(path: str = "requirements.txt") -> list[Dependency]:
deps = []
pattern = re.compile(r"([\w\-\.]+)\s*(==|>=|<=|>|<)\s*([0-9\.]+)")
for line in Path(path).read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
match = pattern.match(line)
if not match:
continue
deps.append(Dependency(name=match.group(1),
operator=match.group(2),
version=parse_version(match.group(3))))
return deps
Path("requirements.txt").write_text(
"requests==2.28.1\nflask==2.2.5\nurllib3==1.26.0\nmakedata==0.9.0\n"
)
for dep in parse_requirements():
print(dep)
parse_version es el héroe silencioso: al dividir 2.28.1 en (2, 28, 1), la comparación nativa de tuplas de Python hace el trabajo duro, (2, 28, 1) < (2, 31, 0) es verdadero, y eso es precisamente cómo se responde “¿es esta versión vulnerable?” en el Paso 3. El @dataclass le da a cada dependencia un nombre y un contrato de comparación en lugar de una cadena que analizar manualmente más tarde. El regex evita que el espacio en blanco y los comentarios se conviertan en dependencias falsas: las líneas # pinned se omiten, y solo las líneas con un nombre, un operador y una versión punteada válidos se vuelven objetos Dependency.
🎯 Resultado esperado : Cuatro líneas Dependency(name=..., operator='==', version=(2, 28, 1)), una por requirement real, con comentarios y vacíos ignorados.
🩹 Si sale mal : Si la versión de una dependencia vuelve como una tupla vacía, parse_version nunca corrió (un grupo None del regex llegó al dataclass). Si líneas como flask --hash=... se bloquean, el match del regex falla y continue lo filtra, pero un match() estricto en ([\w\-\.]+) también descarta paquetes legítimos con _ en el nombre; amplía la clase a [\w\.\-]. Si los requirements se parsean desde la carpeta equivocada, Path("requirements.txt") es relativo al directorio de trabajo.
1.2 Verifica el parseo
✅ Lista de verificación
- ✅ Cuatro dependencias se parsean del archivo de muestra; los comentarios se ignoran.
- ✅ Una línea
flask>=3.0.0produceoperator='>='yversion=(3, 0, 0). - ✅ Las tuplas de versión comparan correctamente:
(2, 28, 1) < (2, 31, 0)esTrue.
🤔 Pregunta(s) socrática(s)
- Una versión como
1.10.0compararía más baja que1.9.0si la almacenaras como un número (int("1.10.0"), literalmente imposible). ¿Qué parte de este diseño hace que1.10.0 > 1.9.0salga bien, y dónde rompería una versión de paquete como"2026.9.2rc1"? - El regex ignora en silencio las líneas que no puede hacer coincidir. ¿Cuándo omitir en silencio un requirement malformado es peor que dar error, y qué registrarías para hacer visible la omisión?
Paso 2: Modela una base de datos CVE
Un escáner es tan inteligente como su base de datos. Este paso codifica algunos CVEs como datos, cada uno con un rango afectado y una severidad, y los carga desde JSON para que el escáner los “conozca” de la misma manera que una herramienta real conoce su feed.
2.1 Carga la base de datos CVE
👟 Pista inicial : Mantén los CVEs como un pequeño archivo JSON y cárgalo una vez con json.load, los pares operator/version reutilizan exactamente el contrato de comparación que construyó el Paso 1.
# scanner.py (continuación)
CVE_DB_PATH = Path("cve_db.json")
CVE_DB_PATH.write_text(json.dumps([
{"id": "CVE-2026-0001", "package": "requests", "operator": "<",
"version": "2.31.0", "severity": "high",
"summary": "SSL verification bypass on redirect"},
{"id": "CVE-2025-1234", "package": "flask", "operator": "<=",
"version": "2.2.5", "severity": "critical",
"summary": "RCE reachable in debug mode"},
{"id": "CVE-2026-1000", "package": "makedata", "operator": "<",
"version": "1.0.0", "severity": "medium",
"summary": "Slowloris-style memory leak"},
], indent=2))
def load_cves(path: str | None = None) -> list[dict]:
with open(path or CVE_DB_PATH) as f:
return json.load(f)
print([c["id"] for c in load_cves()])
La decisión crítica de modelado es que un CVE lleva un operador más una versión, ("<", "2.31.0") significa “cualquier versión por debajo de 2.31.0 está afectada”, así que compararlo con una dependencia en el Paso 3 es solo aplicar la misma comparación de tuplas que ya escribiste para parse_version. Mantener severity como datos (no código) significa que clasificar por ella más tarde (Paso 5) es un orden, no un bosque de if-else. Como la base de datos vive en JSON en lugar de un archivo Python, actualizarla es una edición de datos, no una edición de código.
🎯 Resultado esperado : ['CVE-2026-0001', 'CVE-2025-1234', 'CVE-2026-1000'].
🩹 Si sale mal : Si la lista está vacía, load_cves abrió un archivo vacío o json.load tragó un desajuste de ruta. Si un rango muestra enteros (1, 2), el archivo almacenó numéricos pero el esquema espera severidad como una cadena exacta como "high". Si un nuevo CVE “no aplica sin importar la versión”, sus campos operator/version están mal escritos.
2.2 Verifica la base de datos
✅ Lista de verificación
- ✅
load_cves()devuelve los tres CVEs conid,package,operator,version,severity,summary. - ✅ Los valores de
severityson exactamentecritical/high/medium(el orden del Paso 5 depende de ello). - ✅ Editar
cve_db.jsoncambia el conocimiento del escáner sin tocar el código.
🤔 Pregunta(s) socrática(s)
- Cada CVE aquí apunta a un paquete. Los CVEs reales usan rangos de versión (
>=1.0, <1.5). ¿Qué pasa con tu modelo de operador único cuando una corrección lanza un 1.5.0 que reintroduce el error, y qué cambio de esquema expresaría dos operadores? - El puntaje CVSS que decide la
severityen la vida real se calcula a partir del vector de ataque y la explotabilidad. Si lo almacenaras como un número en lugar decritical/high/medium, ¿qué podría hacer tu reporte que una severidad de cadena no puede?
Paso 3: Compara dependencias contra la base de datos
Con las dependencias parseadas y los CVEs cargados, el escaneo en sí es una función de comparación: “¿está esta versión instalada en el rango afectado de este CVE?” Este paso la aplica a cada par dependencia/CVE.
3.1 Escribe la lógica de comparación
👟 Pista inicial : Escribe un helper in_range(dep_version, cve) que use la cadena del operador como un dict de lambdas de comparación, luego haz un bucle de cada dep × cada CVE.
# scanner.py (continuación)
COMPARE = {
"<": lambda a, b: a < b,
"<=": lambda a, b: a <= b,
">": lambda a, b: a > b,
">=": lambda a, b: a >= b,
"==": lambda a, b: a == b,
}
def in_range(dep: Dependency, cve: dict) -> bool:
if dep.name != cve["package"]:
return False
cve_version = parse_version(cve["version"])
return COMPARE[cve["operator"]](dep.version, cve_version)
def scan_dependencies(deps: list[Dependency], cves: list[dict]) -> list[dict]:
findings = []
for dep in deps:
for cve in cves:
if in_range(dep, cve):
findings.append({
"type": "dependency",
"package": dep.name,
"installed": ".".join(str(p) for p in dep.version),
"cve": cve["id"],
"severity": cve["severity"],
"summary": cve["summary"],
})
return findings
for f in scan_dependencies(parse_requirements(), load_cves()):
print(f["severity"], f["package"], f["installed"], f["cve"])
COMPARE como un dict de lambdas es la sentencia switch que Python no tiene: la cadena del operador es la rama de código, así que un CVE que llegue con "<=" funciona sin editar el comparador. El guard dep.name != cve["package"] cortocircuita los desajustes de paquete antes de cualquier matemática de versiones, que es lo que mantiene barato el doble bucle (deps × CVEs) a escala real. El dict de hallazgo es el contrato que consume cada etapa posterior, lleva la severidad para el orden del Paso 5 y el resumen para la legibilidad humana.
🎯 Resultado esperado : Tres hallazgos ordenados por los datos, no por suerte: requests 2.28.1 CVE-2026-0001, flask 2.2.5 CVE-2025-1234, makedata 0.9.0 CVE-2026-1000, con el de flask reportando la severidad critical.
🩹 Si sale mal : Si requests nunca coincide a pesar de ser < 2.31.0, in_range compara dep.version contra una cadena que no fue pasada por parse_version. Si cada dependencia coincide con cada CVE, falta el guard de paquete. Si un KeyError ocurre en COMPARE[...], un CVE tiene un operador que el mapa de cinco no cubre, añádelo a COMPARE o valida la base de datos al cargar.
3.2 Verifica el escaneo de dependencias
✅ Lista de verificación
- ✅ Tres hallazgos coinciden exactamente con los fijados vulnerables de la muestra;
urllib3no produce ninguno. - ✅
CVE-2026-0001(< 2.31.0) no se dispara para un hipotéticorequests==2.31.0. - ✅ El dato de clasificación de severidad (critical/high/medium) está presente en cada hallazgo.
🤔 Pregunta(s) socrática(s)
- Las dependencias fijadas con
>=en lugar de==declaran un mínimo, no una instalación exacta. ¿Qué puede afirmar un escáner realmente sobre una líneaflask>=2.0.0versus unaflask==2.2.5, y cuál es el sujeto honesto de una comprobación de versión? - El comparador asume que tienes la versión instalada exacta. ¿Dónde encajan los lockfiles (
uv.lock,package-lock.json), qué te compra escanear un lockfile que escanearrequirements.txtno puede?
Paso 4: Escanea el código fuente por anti-patrones con SAST
Las versiones de dependencias son un modo de fallo; el código es el otro. Las pruebas de seguridad de aplicaciones estáticas (SAST) escanean el código fuente por patrones que no deberían enviarse, eval, cadenas de shell, secretos codificados, sin ejecutar el programa. Este paso ejecuta un pequeño pase SAST sobre cada archivo .py de una carpeta.
4.1 Escribe la lista de patrones y el escáner
👟 Pista inicial : Mantén los patrones como tuplas (regex, label, severity), recorre archivos .py con Path.rglob y busca en cada línea, marcando números de línea para que el reporte sea accionable.
# scanner.py (continuación)
ANTI_PATTERNS = [
(re.compile(r"\beval\s*\("), "eval() on untrusted data", "high"),
(re.compile(r"\bshell\s*=\s*True"), "subprocess with shell=True", "high"),
(re.compile(r"password\s*=\s*['\"][^'\"]+['\"]"), "Hardcoded password", "critical"),
(re.compile(r"\bassert\s+"), "assert used for runtime checks", "low"),
(re.compile(r"\bTODO\b|\bFIXME\b"), "Unresolved marker", "low"),
]
def scan_source(path: str = "src") -> list[dict]:
findings = []
for file in Path(path).rglob("*.py"):
for lineno, line in enumerate(Path(file).read_text().splitlines(), 1):
for pattern, label, severity in ANTI_PATTERNS:
if pattern.search(line):
findings.append({
"type": "sast",
"file": str(file),
"line": lineno,
"severity": severity,
"summary": label,
})
return findings
src = Path("src")
src.mkdir(exist_ok=True)
(src / "fragile.py").write_text(
"import subprocess\n"
"data = eval(input('code: '))\n"
"password = 'hunter2'\n"
"def run(cmd):\n"
" return subprocess.run(cmd, shell=True)\n"
"assert password != ''\n"
"# TODO: remove before ship\n"
)
for f in scan_source():
print(f["line"], f["severity"], f["summary"])
El diseño de patrón-como-datos (regex, label, severity) significa que añadir una comprobación es añadir una tupla, no reescribir el escáner, exactamente cómo las herramientas reales te dejan añadir reglas personalizadas. rglob("*.py") encuentra archivos en carpetas anidadas, e iterar splitlines() con enumerate(..., 1) da números de línea humanos. El hallazgo lleva el archivo y la línea, que es lo que convierte una lista de problemas en una revisión diff-able. assert y TODO están deliberadamente en severidad baja: son sobre todo higiene, incluidos para que veas que la severidad tiene rango.
🎯 Resultado esperado : Cinco hallazgos con números de línea 2–7, el eval (high) en la línea 2, una contraseña codificada (critical) en la línea 3, shell=True (high) en la línea 5, y el assert y TODO (low) en sus líneas.
🩹 Si sale mal : Si no se imprime nada, rglob("*.py") no encontró archivos, comprueba la ruta de la carpeta src. Si la regla de contraseña codificada se dispara en una variable llamada password = getenv(...), el regex ['\"][^'\"]+ también está coincidiendo una llamada de función, exige un literal entre comillas. Si los hallazgos cuentan dos veces una línea, varios patrones coincidieron la misma línea (legítimo) pero quieres un hallazgo representativo por línea, deduplica por (file, line).
4.2 Verifica SAST
✅ Lista de verificación
- ✅
scan_source("src")devuelve cinco hallazgos con archivo, línea, severidad, resumen. - ✅ La regla de contraseña codificada reporta
critical. - ✅ Añadir una nueva tupla
(regex, label, severity)aANTI_PATTERNSproduce inmediatamente hallazgos en las líneas coincidentes.
🤔 Pregunta(s) socrática(s)
- El SAST de regex ve
shell=Truetambién en un comentario y un docstring, porque nunca ejecuta el código. ¿Qué clase de falso positivo produce eso, y qué tendría que hacer una herramienta real basada en parser (un AST) en su lugar para distinguir comentario de código? evalestá marcadohigh, nuncacritical, pero unevalen entrada de atacante es fácilmente critical. ¿Qué información carece un escaneo de línea que te permitiría elevar esa severidad de forma responsable?
Paso 5: Construye el reporte clasificado por severidad con correcciones
El último paso hace útil al escáner: fusiona los hallazgos de dependencias y SAST, los clasifica por severidad, adjunta una sugerencia de actualización donde existe una, imprime un resumen humano y escribe todo en report.json.
5.1 Escribe build_report y el punto de entrada principal
👟 Pista inicial : Ordena por un mapa de rango de severidad (critical ←1 → low), adjunta recommendation desde un mapa de versiones fijas, imprime conteos + hallazgos principales y vuelca la lista fusionada a JSON.
# scanner.py (continuación)
import sys
SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
FIXED_VERSIONS = {"requests": "2.32.0", "flask": "3.0.0", "makedata": "1.0.1"}
FIX_HINT = "upgrade to >= {}"
def build_report(deps: list[Dependency], cves: list[dict], source_dir: str = "src") -> list[dict]:
findings = scan_dependencies(deps, cves) + scan_source(source_dir)
for f in findings:
if f["type"] == "dependency" and f["package"] in FIXED_VERSIONS:
f["recommendation"] = FIX_HINT.format(FIXED_VERSIONS[f["package"]])
findings.sort(key=lambda f: SEVERITY_RANK.get(f["severity"], 9))
return findings
def main() -> None:
report = build_report(parse_requirements(), load_cves())
Path("report.json").write_text(json.dumps(report, indent=2))
print(f"report.json: {len(report)} findings")
for f in report:
rec = f.get("recommendation", "")
print(f" [{f['severity']:>8}] {f['summary']:<40} {f['package'] if f['type']=='dependency' else f['file']} {rec}")
if __name__ == "__main__":
main()
build_report fusiona ambos escáneres y entrega la lista a un único orden de severidad, SEVERITY_RANK.get(severity, 9) cae en un número grande para que una severidad inesperada se ordene al final en lugar de bloquearse. La recommendation se adjunta como datos solo donde existe una versión fija conocida, manteniendo honesta la columna “¿cómo arreglo esto?” en lugar de adivinar. sys aparece solo para gatear main detrás de __name__, para que import scanner en un test nunca ejecute el escaneo. El JSON al final es el contrato legible por máquina que leería un pipeline de CI (el consumidor natural de un escáner).
🎯 Resultado esperado : report.json contains 8 findings; la lista impresa empieza con los dos elementos critical (el RCE de flask y la contraseña codificada), luego los high, luego los low, con recomendaciones de actualización en los tres hallazgos de dependencias.
🩹 Si sale mal : Si el reporte empieza con lows, las búsquedas SEVERITY_RANK están fallando y cada severidad se ordenó al bucket 9. Si recommendation nunca aparece, FIXED_VERSIONS tiene una clave de paquete que no está en los hallazgos (diferencias de mayúsculas, normalizar nombres al parsear lo arregla). Si report.json se escribe pero un parser de CI se atraganta con él, a un hallazgo le falta uno de los campos que espera el parser, mantén el contrato del dict idéntico entre ambos tipos de escáner.
5.2 Verifica el escáner terminado
✅ Lista de verificación
- ✅
uv run python scanner.pyescribereport.jsoncon 8 hallazgos, criticals primero. - ✅ Los tres hallazgos de dependencias llevan cada uno una recomendación de actualización concreta.
- ✅ Los hallazgos SAST llevan
file/line; los de dependencias llevanpackage/installed. - ✅ Ejecutar el escáner sobre su propio
srcañade exactamente los hallazgos que esperas, un escáner que se marca a sí mismo está funcionando.
🤔 Pregunta(s) socrática(s)
- El reporte ordena por severidad pero deja la alcanzabilidad de una vulnerabilidad fuera de su clasificación. ¿Qué importa más al clasificar, la severidad sola, o severidad × “¿está esto siquiera en la ruta caliente?” ¿Qué columna añadirías para codificar eso?
- Un escáner que reporta todo entrena a los equipos a ignorarlo todo. ¿Qué (en los datos de este reporte) presentarías de forma diferente para un equipo que recibe 200 hallazgos al mes versus uno que recibe 2, y por qué la UI de clasificación decide si un escáner vive o muere?
⚠️ Errores comunes
- Tuplas de versión que no se parsean. Una versión de cadena como
"1.10.0rc1"mataint(part)y todo el escaneo se bloquea. Solución: elimina sufijos (split("-")[0], descarta unrcNfinal) antes de convertir, y deja que los fijados malformados registren en lugar de abortar. - Deriva de mayúsculas en nombres de paquete.
Requestsvsrequestsvsrequests[socks]fallan todos el guard de coincidencia exacta y omiten en silencio CVEs. Solución: normaliza nombres (y descarta extras como[socks]) una vez al parsear. - Confiando en
requirements.txtpara la verdad instalada. Los fijados expresan intención, no necesariamente la versión en ejecución. Solución: si el entorno tiene un lockfile o la salida depip freeze, escanea eso en su lugar, es el inventario real. - SAST de regex que grita lobo en comentarios.
shell=Trueen un comentario es un archivo de política, no un agujero. Solución: repórtalo pero deja que un humano lo pondere, y prefiere comprobaciones estilo AST (árboles de nombres, literales de cadena) para cualquier cosa sobre la que actuarías automáticamente. - Severidades no clasificadas que se ordenan al final por accidente. Un CVE con un
"critial"mal escrito se hunde al fondo en lugar de al tope. Solución:SEVERITY_RANK.get(severity, 9)es el fallback seguro, más una advertencia cuando se ve una severidad desconocida.
Lo que acabas de construir
Un escáner de vulnerabilidades funcional y de biblioteca estándar: parseo estructurado de dependencias, una base de datos CVE JSON con comparación de rangos, SAST basado en regex sobre el código fuente y un único reporte clasificado por severidad con recomendaciones de actualización escrito a JSON. La habilidad transferible es convertir “seguridad” en operaciones de datos: comparación de rangos de versión, coincidencia de patrones y ordenamiento por severidad son exactamente los mismos movimientos detrás de los bots de dependencias, las reglas de lint y toda herramienta de “revisa mi proyecto”, ahora has construido tres de ellas desde cero.
Ejecuta una versión más completa sin configuración local
examples/vulnerability-scanner/ en el repo del curso es una versión más completa del código anterior, con soporte de lockfile y un proyecto de muestra más rico para escanear. Clónalo, o abre todo el repo en un GitHub Codespace, y ejecútalo desde ahí.
Hacia dónde ir desde aquí
- Amplía
parse_requirementspara leer tablas[project]depyproject.toml, para que el escáner cubra proyectos uv/pip, no solo losrequirements.txtheredados. - Añade un modo
--astque recorra el AST en lugar de líneas y marqueeval/execsolo cuando quizá alcancen entrada no confiable, menos falsos positivos, misma cobertura. - Conecta un puntaje de severidad CVE numérico (vía la idea CVSS numérica de la pregunta del Paso 2) e imprime un total de riesgo a nivel de proyecto como titular del resumen.
- Haz que
maindevuelva un código de salida distinto de cero cuando exista cualquier hallazgocritical/high, para que un trabajo de CI que ejecuta el escáner realmente falle una compilación.
Comparte tu proyecto con la clase
¿Construiste algo de lo que te sientas orgulloso? examples/student-projects/ es una galería de proyectos que otros estudiantes han enviado, y su README tiene un recorrido completo, apto para principiantes, para añadir el tuyo mediante un pull request, incluso si nunca has usado git antes: hacer fork del repo, crear una rama, hacer commit de tus archivos y abrir el PR, un paso a la vez. No se asume experiencia previa con git.
Bienvenido a escribir Python fuera del navegador. 🎓
Termina todos los pasos y luego marca este proyecto como completo para ganar su XP.