diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b7f006b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: Bug report +about: Report a reproducible problem without sharing sensitive target data +title: "[bug] " +labels: bug +--- + +## Summary + + + +## Reproduction + +- Version or commit: +- Profile: +- Operating system: +- Python version: +- External tools and versions: + +## Expected behaviour + +## Actual behaviour + +## Evidence + +Use a sanitised fixture or minimal reproduction. Remove tokens, cookies, hostnames, IPs and personal data. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d3f0ba1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature request +about: Suggest a focused improvement +title: "[feature] " +labels: enhancement +--- + +## Problem + +What authorised testing or review workflow is currently difficult? + +## Proposed change + +Describe the smallest useful change. + +## Trade-offs + +Explain expected effects on noise, coverage, runtime, output compatibility and safety. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..24dda2b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## Summary + + + +## Scope + +- [ ] Collector or external-tool integration +- [ ] Validator or detection logic +- [ ] Correlation, scoring or false-positive handling +- [ ] Reporting or output contract +- [ ] Documentation or tooling + +## Verification + +- [ ] `python -m compileall -q main.py src` +- [ ] `python -m pytest -q` +- [ ] Relevant controlled lab validation completed +- [ ] No credentials, private keys, raw engagement reports or personal data added + +## Operational impact + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb4fec8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install project and test dependencies + run: python -m pip install -e ".[dev]" + - name: Compile source + run: python -m compileall -q main.py src + - name: Run tests + run: python -m pytest -q + + - name: Build package metadata + run: python -m pip wheel --no-deps --wheel-dir dist . diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..c855510 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,31 @@ +name: Dependency security + +on: + push: + pull_request: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + dependency-audit: + name: Audit Python dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install project dependencies + run: python -m pip install -e . + + - name: Run pip-audit + uses: pypa/gh-action-pip-audit@v1.1.0 + with: + inputs: . diff --git a/.gitignore b/.gitignore index e3ffdb5..c64f7f7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ env/ # Local execution outputs scans/ +reviews/ *.log # Tooling and local state diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c20b0d..fa658ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,76 @@ -# attack_surface_mapper_project_v10.11 +# Attack Surface Mapper + +## v10.22.10 public-release hardening + +- Reworks the README around the actual pipeline, profile trade-offs, output contract and responsible-use boundary. +- Adds architecture documentation, contribution guidance, code of conduct and GitHub issue/PR templates. +- Adds MIT licensing and package metadata for a more reproducible public release. +- Adds a scheduled dependency-audit workflow and a package build gate to CI. +- Runs the dependency audit on pushes as well as pull requests, schedules and manual dispatches. +- Documents the local-first system boundary and the checks required before publishing scan tooling. + +## v10.22.9 quality hardening + +- Repairs corrupted UTF-8 text in API/auth validation evidence so generated Spanish reports remain readable and professional. +- Adds a minimal GitHub Actions quality gate for source compilation and the full pytest suite. +- Adds a regression test that rejects mojibake markers in Python source files. +- Aligns the package name, version and description with the public project identity. +- Repairs legacy changelog encoding so the project history is readable. + +## v10.22.8 polish + +- Añade un exportador independiente de Elasticsearch (`scripts/export_elasticsearch_bundle.py`) que empaqueta findings, summaries y run manifest desde un run ya generado sin tocar el pipeline principal. +- Genera mappings estables, NDJSON `_bulk` y helpers de ingesta para las tres vías pedidas por el tutor: manual/Kibana Dev Tools, `curl` y Python. +- Reutiliza el contrato actual del hallazgo (`finding_id`, `correlation_id`, `priority_score`, `finding_role`, `validated`, `validation_basis`, etc.) y evita exportar `raw` completo para mantener los índices más estables. + +- Introduce un scoring estructurado (`scoring_version`, `priority_score`) basado en severidad, confianza, rol del hallazgo y base de validación, manteniendo la salida `low/medium/high/critical` pero haciéndola más estable y auditable. +- Expone el score numérico y su razón en reportes, CSV, agregados y matriz de revisión para facilitar comparativas futuras e ingest estructurado. +- Ajusta `comparison.json` para detectar cambios en `priority_score` aunque la etiqueta de prioridad no cambie, mejorando el seguimiento fino entre runs. + +- Introduce una capa de validación explícita en el modelo de hallazgo con `finding_role`, `validated` y `validation_basis`, separando mejor descubrimiento, candidatos y evidencia validada. +- Propaga esa semántica a `report.summary.json`, `aggregate_summary.json`, `comparison.json` y la matriz de revisión para dejar el output estructurado más estable y más honesto. +- Mantiene compatibilidad con hallazgos previos o JSON legacy: reporting, agregado y comparación infieren el rol de validación cuando el campo nuevo todavía no existe. +- Amplía el golden set y la exportación de revisión con `finding_role`, `validated` y `validation_basis` para afinar falsos positivos con una semántica más clara. + +- Conserva el `debug_http_trace` de browser discovery y validación pasiva en una misma ejecución para facilitar análisis de ruido y troubleshooting. +- Ajusta el resumen ejecutivo para que la nota sobre hallazgos altos/críticos confirmados dependa de los datos reales del run. +- Amplía el `summary-json` con campos ya existentes del modelo (`kind`, `confidence`, `asset_host`, `asset_port`, `source_count`, `evidence_summary`) para mantener el output más preparado para consumo estructurado futuro. +- Alinea la documentación con el comportamiento real de targets CLI + YAML: se combinan y deduplican, no se sobrescriben. +- Separa `asset_host` estable de `asset_host_resolved` para que los outputs humanos y estructurados no cambien de hostname a IP según el punto del pipeline. +- Añade identificadores deterministas por hallazgo y correlación para facilitar ingest futura en sistemas de indexación. +- Reutiliza una única baseline probe compartida entre validadores HTTP durante la validación pasiva, reduciendo requests redundantes. +- Documenta una validación repetible con Juice Shop para contrastar perfiles pasivos. +- Añade `scripts/validate_labs.ps1` para repetir validaciones de laboratorio con Juice Shop y DVWA desde Windows/PowerShell. +- Estabiliza `report.summary.json`, `aggregate_summary.json` y `run_manifest.json` con secciones y claves más predecibles para futuro consumo estructurado. +- Endurece `scripts/validate_labs.ps1` para validar artefactos generados, IDs estables y un mínimo configurable de hallazgos. +- Reduce falsos positivos de `APIValidator` descartando pantallas de login servidas desde rutas como `/swagger` o `/graphql`. +- Enriquece `comparison.json` y la sección de comparativa en reportes con promociones, regresiones y cambios de confianza/verificación. +- Alinea mejor `verification_status`, `needs_manual_validation` y prioridad para que un hallazgo `confirmed` no siga marcado como revisión manual por inercia de categoría. +- Añade una matriz de revisión exportable (`reviews/lab_findings_review.csv`) para etiquetar hallazgos de labs como `verdadero`, `falso` o `dudoso` durante el afinado de falsos positivos. +- Ajusta la priorización de correlación para dar más peso a evidencia `confirmed` y evita que la mera multiplicidad de fuentes infle hallazgos todavía `likely`. + +- Permite validar `passive-recon-enum` dentro del flujo repetible de labs con un override local (`config/examples/lab-passive-recon-enum.yml`) que desactiva Nuclei y mantiene el perfil comparable en Docker. +- Evita que headers confirmados de severidad media, como CSP ausente, escalen a prioridad `high` solo por estar confirmados; la prioridad se reserva mejor para evidencia aplicativa o impacto más claro. +- Endurece `APIValidator`, `PanelsValidator` y `SensitiveFilesValidator` contra superficies de login servidas desde rutas de docs, respuestas HTML que simulan ficheros y respuestas GraphQL demasiado débiles o indistinguibles del fallback. +- Reordena `top_findings` y el agregado para que hallazgos confirmados de aplicación queden por delante de inventario, fingerprints y headers higiénicos cuando comparten prioridad similar. + +- Acota mejor la prioridad de documentación y superficies API: `Swagger UI Exposed` deja de escalar a `critical`, `GraphQL Endpoint Accessible Without Authentication` se mantiene en `medium` cuando sigue en `likely`, y el inventario `Multiple API Endpoints Exposed` no compite como si fuese una confirmación de impacto. +- Ajusta la matriz de revisión para que headers de higiene y superficies de inventario API queden en `revisar` o `descubrimiento`, evitando priorizar por defecto hallazgos que todavía son de contexto o endurecimiento. +- Separa mejor el reporting entre riesgo de aplicación, higiene/endurecimiento y descubrimiento: `report.summary.json` expone listas dedicadas (`top_risk_findings`, `top_hygiene_findings`, `top_discovery_findings`) y el markdown mueve headers/TLS a una sección propia para que no compitan visualmente con acceso indebido real. +- Ajusta `AuthValidator` para que rutas de superficie API como `/graphql`, `/swagger` o `/api-docs` no se expresen por defecto como fallo de autorización: ahora se reportan como `api` (`GraphQL Surface Exposed`, `Swagger UI Exposed`, etc.) y se acotan a `likely/medium` salvo evidencia más fuerte. +- Filtra `top_risk_findings` para dejar fuera señales de baja prioridad como CORS amplio `likely` cuando ya existen hallazgos medios/altos más accionables, manteniendo el summary centrado en lo que primero merece revisión. + +## v10.22.6 false-positive tuning + +- Alinea `--profile passive-stealth`, `--profile passive-recon-safe` y `--profile active-aggressive` con la semántica de los YAML para evitar que la CLI ejecute Nuclei, Nmap o probes hardcoded cuando no toca. +- El manifest de ejecución ya registra el perfil usado por CLI, no solo el definido en YAML. +- Evita correlacionar hallazgos heterogéneos solo porque comparten rutas como `/login`: headers, fingerprints y formularios ya no se fusionan en un falso hallazgo de mayor prioridad. +- Reduce ruido en headers: checks de CSP/X-Frame/Referrer se aplican solo a documentos de navegador, y cabeceras de bajo impacto quedan con prioridad baja. +- Ajusta CORS: `Access-Control-Allow-Origin: *` sin credenciales se clasifica como señal low/likely, no como hallazgo confirmado de mayor impacto. +- Trata `/login` como superficie de descubrimiento, no como panel expuesto. +- Endurece detección de ficheros sensibles: `.zip` y `.DS_Store` requieren firma real, no solo `Content-Type`. +- Reduce ruido de cookies: se ignoran cookies no relacionadas con sesión/auth y no se exige `HttpOnly` en cookies CSRF/XSRF. +- Validado contra Docker local con OWASP Juice Shop y DVWA en perfiles `passive-stealth` y `passive-recon-safe`. Versión 10.10 del proyecto: mantiene el pipeline de **Nuclei + validaciones propias + correlación + reporting**, conserva **descubrimiento opcional con Nmap** y mejora el crawling con **Scrapling + fallback a requests**, además de promover formularios y pistas de endpoints descubiertos a nuevas validaciones. @@ -123,7 +195,7 @@ python main.py --config config/examples/config.example.yml ### Generales -- `--profile {quick,normal,deep,passive,active}` +- `--profile {quick,normal,deep,passive,active,active-aggressive,passive-stealth,passive-recon-safe,passive-recon-enum}` - `--workers 4` - `--output-root scans` - `--run-name nombre_scan` diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..78a0d4e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Code of Conduct + +Participants are expected to be respectful, constructive and professional. Security discussions must remain focused on authorised testing, reproducible evidence and responsible disclosure. + +Harassment, personal attacks, discrimination, doxxing, publication of private data and requests to attack systems without permission are not acceptable. + +Project maintainers may remove content or restrict participation when these expectations are not met. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7f8d674 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing + +Thanks for helping improve Attack Surface Mapper. + +## Before opening a change + +- Work only with targets and datasets you are authorised to use. +- Keep changes focused and explain the operational or reporting impact. +- Add regression coverage for parser, validator, correlation or output changes. +- Do not commit credentials, private keys, raw reports from real engagements or personal data. + +## Local checks + +```bash +python -m compileall -q main.py src +python -m pytest -q +python -m pip wheel --no-deps --wheel-dir dist . +``` + +For changes to profiles or lab behaviour, also run the relevant controlled lab validation and update the documentation and changelog. + +## Pull requests + +Describe: + +- the problem and intended behaviour; +- the affected pipeline stages or output fields; +- tests and lab checks executed; +- any compatibility or noise/coverage trade-off. + +Avoid including live target names, sensitive evidence or operational credentials in commits and pull requests. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a10599 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gabriel Vacaro Goytia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/QUICK_DEMO.md b/QUICK_DEMO.md index 26b0a11..5e035fb 100644 --- a/QUICK_DEMO.md +++ b/QUICK_DEMO.md @@ -15,6 +15,37 @@ Optional (only for active dynamic profile): python -m playwright install ``` +## 1.5 Install Nuclei (required) + +This tool uses Nuclei (https://github.com/projectdiscovery/nuclei) for fast vulnerability detection. + +### Install via Go + +```bash +sudo apt install golang-go -y +go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest +``` + +### Add to PATH: + +```bash +export PATH=$PATH:$(go env GOPATH)/bin +``` + +#### (Optional) make it permanent: + +```bash +echo 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.bashrc +source ~/.bashrc +``` + +### Download Nuclei templates + +```bash +nuclei -update-templates +``` + + --- ## 2. Basic usage @@ -29,6 +60,8 @@ python main.py https://example.com/ ## 3. Recommended profiles (try these) +By default, passive profiles are recommended unless running controlled or authorized tests. + ### Low noise (stealth) ```bash @@ -65,7 +98,7 @@ python main.py --targets-file targets.txt --profile passive-recon-safe ## 5. Important notes -- Passing a target via CLI overrides YAML profiles +- Passing a target via CLI adds it to YAML/profile targets and deduplicates the final list - Profiles are located in: `config/profiles/` - Passive modes do NOT require Playwright - Active mode may require Playwright @@ -108,7 +141,33 @@ scans//targets//findings/vulnerabilities.json scans//run_manifest.json ``` ---- +### Elasticsearch export bundle + +You can generate an Elasticsearch-ready bundle from any completed run: + +```bash +python scripts/export_elasticsearch_bundle.py --run-dir scans/ +``` + +This creates: + +- `scans//elasticsearch/findings_mapping.json` +- `scans//elasticsearch/summaries_mapping.json` +- `scans//elasticsearch/runs_mapping.json` +- `scans//elasticsearch/findings_bulk.ndjson` +- `scans//elasticsearch/summaries_bulk.ndjson` +- `scans//elasticsearch/runs_bulk.ndjson` +- `scans//elasticsearch/manual_kibana_devtools.md` +- `scans//elasticsearch/ingest_with_curl.sh` +- `scans//elasticsearch/ingest_with_python.py` + +If you need a different prefix for the indices: + +```bash +python scripts/export_elasticsearch_bundle.py --run-dir scans/ --index-prefix asm-demo +``` + +--- ## 7. What to look at @@ -122,3 +181,84 @@ When testing the tool, compare: Key idea: **stealth vs coverage vs signal quality** + +--- + +## 8. Repeatable local lab + +Juice Shop is a good low-friction target for checking that passive profiles stay aligned with their intended noise level: + +```bash +docker run -d --rm --name asm-juice -p 3000:3000 bkimminich/juice-shop +python main.py --profile passive-stealth http://localhost:3000 +python main.py --profile passive-recon-safe http://localhost:3000 +docker stop asm-juice +``` + +Useful files to compare between both runs: + +- `run_manifest.json` +- `reports/aggregate_summary.json` +- `targets//reports/report.summary.json` + +On Windows/PowerShell, the same flow can be repeated with: + +```powershell +.\scripts\validate_labs.ps1 -Labs juice-shop +.\scripts\validate_labs.ps1 -Labs juice-shop -IncludeEnum +``` + +The script also validates the generated manifest and summary artifacts. Use +`-MinFindings 2` or similar if you want the smoke test to enforce a stricter +minimum number of correlated findings. +After each run it exports `reviews/lab_findings_review.csv`, ready to annotate +with `verdadero`, `falso` or `dudoso` during false-positive review. +With `-IncludeEnum`, the script applies `config/examples/lab-passive-recon-enum.yml` +so `passive-recon-enum` remains repeatable in Docker without a local Nuclei binary. + +Once the run is generated, you can also export the lab results to Elasticsearch: + +```powershell +python .\scripts\export_elasticsearch_bundle.py --run-dir scans\lab_juice_shop_passive_recon_enum +``` + +The generated bundle includes the three ingestion paths requested by the tutor: +- manual / Kibana Dev Tools +- `curl` +- Python + +--- + +## Passive vs Active usage (important) + +This tool supports both passive and active analysis modes. + +### Passive mode (recommended) + +- Low noise +- Non-intrusive +- Suitable for external targets + +Example: +```bash +python main.py https://example.com --profile passive-recon-safe +``` +--- + +### Active mode (use with authorization only) + +- More aggressive checks +- May generate detectable traffic +- Requires tools like Nmap and Nuclei + +Example: + +```bash +python main.py http://localhost:3000 --profile active +``` +--- + +### Notes + +- Do NOT use active profiles without permission +- For external targets → use passive mode diff --git a/README.md b/README.md index dfda3f2..50b8958 100644 --- a/README.md +++ b/README.md @@ -1,566 +1,212 @@ # Attack Surface Mapper -## Overview - -Attack Surface Mapper is a **pipeline-based security analysis tool** focused on discovering, validating and reporting the attack surface of web applications and exposed services. - -It is designed to go beyond a simple scanner. The project combines: - -- **HTTP discovery and crawling** -- **passive and active validation** -- **optional network reconnaissance** -- **correlation and deduplication of findings** -- **human-readable and machine-readable reporting** - -The result is a tool that can operate with different noise levels, from very stealthy observation to broader enumeration and more aggressive validation. - ---- - -## Main Technologies Used - -### Python -The project is implemented in **Python 3.11+** and organised in a modular way so each responsibility is separated: -- orchestration -- pipeline stages -- collectors -- validators -- parsers -- reporting -- batch aggregation - -### Nuclei -**Nuclei** is used for template-based security checks. -It provides fast extensible detection and its findings are parsed and converted into the common internal vulnerability model. - -### Nmap -**Nmap** is optional and used to enrich the analysis with **open ports and service discovery**. -This allows the tool to correlate web findings with network exposure when needed. - -### requests / Scrapling -The HTTP layer supports different backends: -- `requests` -- `scrapling` -- `auto` - -This makes it possible to run: -- quieter passive navigation -- standard crawling -- more dynamic discovery depending on the selected profile - -### Playwright -**Playwright** is only required for the **dynamic active profile**. -Passive profiles do **not** require browser installation. - -### Custom validation and correlation -The core value of the project is not only running third-party tools, but also adding: -- custom validators -- attack-surface discovery logic -- endpoint classification -- finding enrichment -- correlation -- prioritisation -- false-positive reduction - ---- - -## High-Level Architecture - -The project is built as a **multi-stage pipeline**. - -### 1. Discovery -The tool first collects observable surface from the target: -- root response -- linked resources -- forms -- JavaScript hints -- candidate endpoints -- observed navigation paths - -### 2. Validation -It then validates the discovered surface using: -- Nuclei -- security header checks -- TLS checks -- authentication surface analysis -- admin/panel checks -- API exposure checks -- sensitive file checks -- secret discovery - -### 3. Optional network reconnaissance -When enabled, Nmap adds: -- open ports -- service banners -- network exposure categories - -### 4. Correlation -The raw information is then processed to: -- deduplicate findings -- correlate related evidence -- improve prioritisation -- separate true issues from discovered/protected surface - -### 5. Reporting -Finally, the tool generates: -- per-target reports -- aggregate run reports -- JSON summaries -- Markdown / CSV / HTML outputs - ---- - -## Project Structure +[![CI](https://github.com/Gabrielvcg/attack-surface-mapper/actions/workflows/ci.yml/badge.svg)](https://github.com/Gabrielvcg/attack-surface-mapper/actions/workflows/ci.yml) +[![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![Version](https://img.shields.io/badge/version-10.22.10-0F766E)](CHANGELOG.md) +[![License](https://img.shields.io/badge/license-MIT-111827)](LICENSE) -```text -attack_surface_mapper/ -├── main.py -├── pyproject.toml -├── requirements.txt -├── README.md -├── CHANGELOG.md -├── docs/ -│ ├── OUTPUTS.md -│ ├── PIPELINE.md -│ ├── PROFILES.md -│ └── STRUCTURE.md -├── config/ -│ ├── examples/ -│ └── profiles/ -│ ├── active-aggressive.yml -│ ├── passive-recon-enum.yml -│ ├── passive-recon-safe.yml -│ ├── passive-recon.yml -│ └── passive-stealth.yml -├── scripts/ -│ └── clean_scans.sh -├── src/ -│ └── attack_surface_mapper/ -│ ├── analysis/ -│ ├── batch/ -│ ├── collectors/ -│ │ ├── crawling/ -│ │ ├── nmap/ -│ │ ├── nuclei/ -│ │ └── web/ -│ ├── core/ -│ ├── models/ -│ ├── parsers/ -│ ├── pipeline/ -│ ├── reporting/ -│ ├── runners/ -│ ├── utils/ -│ ├── validators/ -│ ├── http_client.py -│ └── orchestrator.py -├── tests/ -└── scans/ -``` - -### Important directories - -#### `config/profiles/` -Contains ready-to-use operational profiles: -- `passive-stealth` -- `passive-recon-safe` -- `passive-recon-enum` -- `active-aggressive` - -#### `src/attack_surface_mapper/pipeline/` -Defines the execution stages. - -Current stage order: -1. `NucleiStage` -2. `NmapStage` -3. `BrowserDiscoveryStage` -4. `PassiveValidationStage` -5. `CorrelationStage` -6. `ReportingStage` - -#### `src/attack_surface_mapper/collectors/` -Responsible for gathering information from: -- HTTP crawling -- browser discovery -- Nuclei -- Nmap - -#### `src/attack_surface_mapper/validators/` -Contains the project-specific validation logic: -- headers -- TLS -- authentication -- admin panels -- APIs -- sensitive files -- discovery -- secrets -- fingerprinting - -#### `src/attack_surface_mapper/reporting/` -Builds final reports in different formats. - -#### `scans/` -Stores execution results. - ---- - -## Installation - -### 1. Create virtual environment - -```bash -python3 -m venv .venv -source .venv/bin/activate -python -m pip install --upgrade pip -python -m pip install -r requirements.txt -``` +Pipeline-based attack-surface discovery, validation, correlation and reporting for authorised security testing and controlled labs. -### 2. Optional: install Playwright browsers +Attack Surface Mapper combines HTTP discovery, custom AppSec validators, optional Nuclei and Nmap integrations, finding correlation, prioritisation and multi-format reporting. The project is designed around an explicit trade-off between noise, coverage and signal quality instead of treating every discovered endpoint as a vulnerability. -Only needed for the **active dynamic profile**: +> **Responsible use:** run this tool only against systems you own or are explicitly authorised to test. Start with a passive profile and increase coverage deliberately. See [SECURITY.md](SECURITY.md) before scanning. -```bash -python -m playwright install -``` +## Why this project is interesting -### 3. External tools required +The engineering focus is the pipeline around the scanners: -#### Required -- **Python 3.11+** -- **Nuclei** available in `PATH` -- updated Nuclei templates +- one shared `Vulnerability` contract across HTTP, browser, Nuclei and Nmap evidence; +- staged execution with reusable observed URLs, actions and API references; +- semantic correlation and deduplication to reduce duplicate and low-signal findings; +- explicit `confidence`, `finding_role`, `verification_status` and numeric priority scoring; +- passive, safe-recon, enumeration and active profiles with different operational risk; +- deterministic JSON, Markdown, HTML and CSV outputs for human review and automation; +- aggregate reporting across targets, comparison between runs and optional Elasticsearch bundles; +- tests for parsers, false-positive regressions, reporting contracts and lab behaviour. -#### Optional -- **Nmap** available in `PATH` if you want network reconnaissance -- **Playwright browsers** for dynamic crawling in the active profile +## Architecture ---- - -## Configuration Model - -The tool can be configured in two ways: - -### CLI configuration -Useful for direct execution and quick testing. - -Examples: -- target passed directly as positional argument -- profile selection with `--profile` -- report format selection -- enabling/disabling modules -- Nmap tuning - -### YAML configuration -Profiles and example configurations are stored under: - -```text -config/profiles/ -config/examples/ +```mermaid +flowchart LR + T[Authorised targets] --> C[Collectors] + C --> H[HTTP and browser discovery] + C --> N[Nuclei optional] + C --> M[Nmap optional] + H --> V[Custom validators] + N --> P[Common finding model] + M --> P + V --> P + P --> X[Correlation and enrichment] + X --> R[Reports and run manifest] + R --> O[Markdown / HTML / CSV / JSON] + R --> E[Optional Elasticsearch bundle] ``` -Important note: - -> If a target is passed via CLI, it **overrides** the target defined inside the YAML profile. - -This is useful because the tutor can execute the same profile against another target without modifying the YAML. - ---- +Execution is orchestrated as: -## Main Profiles +1. Nuclei collection when enabled. +2. Nmap collection when enabled. +3. Browser discovery and crawling. +4. Passive HTTP validation. +5. Correlation and enrichment. +6. Per-target and aggregate reporting. -The project includes several operational profiles. +See [the pipeline guide](docs/PIPELINE.md) and [the architecture note](docs/ARCHITECTURE.md) for the detailed contracts. -### 1. `passive-stealth` -Designed for **very low-noise observation**. +## Quick start -Characteristics: -- no Nuclei -- no Nmap -- no invented baseline probes -- no probing of common hardcoded paths -- only observed navigation and linked resources +### Requirements -Recommended when the goal is to minimise visible activity in logs. +- Python 3.11 or newer. +- Nuclei in `PATH` for profiles that enable Nuclei. +- Nmap in `PATH` only when network reconnaissance is enabled. +- Playwright browsers only for dynamic Scrapling execution. -Example: +### Install ```bash -python main.py --profile passive-stealth https://example.com/ -``` - ---- - -### 2. `passive-recon-safe` -Designed for **safe passive reconnaissance**. - -Characteristics: -- GET-only -- realistic browsing -- JavaScript hints extraction -- observed surface reuse -- avoids aggressive hardcoded enumeration - -Recommended when the goal is to balance: -- coverage -- realism -- moderate noise - -Example: - -```bash -python main.py --profile passive-recon-safe https://example.com/ -``` - ---- - -### 3. `passive-recon-enum` -Designed for **visible but controlled enumeration**. - -Characteristics: -- GET-only -- expands the observed surface with common endpoint probing -- broader coverage than `passive-recon-safe` -- still below active-aggressive in noise - -Recommended when the goal is to enlarge the discovered surface without jumping directly to active mode. - -Example: - -```bash -python main.py --profile passive-recon-enum https://example.com/ +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" ``` ---- - -### 4. `active-aggressive` -Designed for **maximum coverage**. - -Characteristics: -- adds Nuclei -- can add Nmap -- deeper validation -- dynamic crawling when available -- highest expected noise +On Windows PowerShell: -Recommended only when broader visibility matters more than stealth. - -Profile file: -```text -config/profiles/active-aggressive.yml -``` - -CLI equivalent commonly used in this project: -```bash -python main.py --profile active https://example.com/ +```powershell +py -3.11 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" ``` ---- - -## Example Usage - -### Simple scan against a single target +Install external tools only when needed. For dynamic crawling: ```bash -python main.py https://example.com/ +python -m playwright install ``` -### Run a safe passive reconnaissance profile - -```bash -python main.py --profile passive-recon-safe https://example.com/ -``` +### First scan -### Run controlled enumeration +Use a target you own or a local lab target. The safe profile performs GET-oriented, observed-surface discovery without Nuclei, Nmap or invented endpoint probing: ```bash -python main.py --profile passive-recon-enum https://example.com/ +python main.py --profile passive-recon-safe https://TARGET-YOU-AUTHORISE/ ``` -### Run low-noise profile +For a minimal local smoke run: ```bash python main.py --profile passive-stealth https://example.com/ ``` -### Run active profile +The CLI target is combined with targets from YAML and deduplicated. It does not replace YAML targets; this keeps multi-target configuration explicit. -```bash -python main.py --profile active https://example.com/ -``` +## Operational profiles -### Use a targets file +| Profile | Noise | Coverage | External tools | Intended use | +| --- | --- | --- | --- | --- | +| `passive-stealth` | Lowest | Observed surface only | None | Low-noise authorised observation | +| `passive-recon-safe` | Low | Safe navigation and JS hints | None | Recommended starting point | +| `passive-recon-enum` | Medium | Controlled endpoint enumeration | Nuclei | Authorised recon with broader coverage | +| `active-aggressive` | Highest | HTTP, browser, Nuclei and Nmap | Nuclei, Nmap, Playwright | Approved audits and lab environments | -```bash -python main.py --targets-file targets.txt --profile passive-recon-safe -``` +Profile files live in [`config/profiles`](config/profiles). The older `passive-recon`, `passive`, `active` and `deep` names remain available for compatibility; use the explicit profiles above for new runs. -### Use YAML configuration +Examples: ```bash +python main.py --profile passive-stealth https://TARGET-YOU-AUTHORISE/ +python main.py --profile passive-recon-safe https://TARGET-YOU-AUTHORISE/ +python main.py --profile passive-recon-enum https://TARGET-YOU-AUTHORISE/ +python main.py --profile active-aggressive --use-nmap https://TARGET-YOU-AUTHORISE/ +python main.py --targets-file targets.txt --profile passive-recon-safe python main.py --config config/examples/config.example.yml ``` -### Override YAML target from CLI +## Output contract -```bash -python main.py --profile passive-recon-safe https://proba-despregamento.onrender.com/ -``` +Each run is written below `scans//`: -### Run with Nmap enabled - -```bash -python main.py https://example.com/ --profile deep --use-nmap --nmap-top-ports 200 --debug +```text +run_manifest.json +reports/ + aggregate_summary.json + aggregate_report.md + aggregate_findings.csv +targets// + findings/vulnerabilities.json + reports/report.md + reports/report.html + reports/report.csv + reports/report.summary.json + artifacts/nuclei_raw.jsonl + artifacts/nmap_raw.xml + debug/ ``` ---- +Start with `reports/aggregate_report.md` for a human overview, then use `run_manifest.json` and the structured summaries to understand what actually ran. Raw artefacts are retained per target and should be treated as potentially sensitive scan data. + +The common finding contract includes stable IDs, source, severity, category, confidence, validation status, evidence summaries, asset identity, correlation metadata and scoring rationale. See [OUTPUTS.md](docs/OUTPUTS.md). -## Simple Demo Flow +## Local lab validation -A good way to evaluate the project is to execute these three profiles against the same target: +The repository includes regression tests and a repeatable lab helper for controlled Juice Shop and DVWA environments. Do not point the lab scripts at an unowned target. ```bash -python main.py --profile passive-stealth https://TARGET/ -python main.py --profile passive-recon-safe https://TARGET/ -python main.py --profile passive-recon-enum https://TARGET/ +python -m pytest -q +python -m compileall -q main.py src ``` -Then compare: - -- number of visible requests in logs -- whether hardcoded probing appears -- amount of discovered endpoints -- quality and relevance of findings -- difference between safer recon and broader enumeration +On Windows, the PowerShell validation helper is: -This is one of the most important values of the project: -**the ability to control the trade-off between stealth, coverage and signal quality**. - ---- +```powershell +.\scripts\validate_labs.ps1 +``` -## Output Structure +The lab flow exercises passive profiles, manifest validation, stable IDs and a minimum findings threshold. It is intentionally separate from the default unit-test suite. -Each execution creates a run directory similar to: +## Project map ```text -scans/ - 2026-03-28_142330/ - run_manifest.json - reports/ - aggregate_summary.json - aggregate_report.md - aggregate_findings.csv - targets/ - https_example.com_/ - findings/ - vulnerabilities.json - reports/ - report.md - report.html - report.csv - report.summary.json - artifacts/ - nuclei_raw.jsonl - nmap_raw.xml - debug/ - debug_http_trace.json - debug_probe.json - debug_counts.json +main.py CLI and run orchestration +src/attack_surface_mapper/ Pipeline implementation +config/profiles/ Operational profile presets +config/examples/ Reproducible YAML examples +tests/ Unit and regression coverage +docs/PIPELINE.md Stage and data-flow details +docs/PROFILES.md Profile semantics and trade-offs +docs/OUTPUTS.md Output contract and artefacts +docs/ARCHITECTURE.md Design boundaries and decisions +scripts/ Lab validation and export helpers +SECURITY.md Authorised-use and vulnerability policy ``` -### Run-level outputs -Available at the root of each run: -- `run_manifest.json` -- `reports/aggregate_summary.json` -- `reports/aggregate_report.md` -- `reports/aggregate_findings.csv` - -### Target-level outputs -Available per target: -- `findings/vulnerabilities.json` -- `reports/report.*` -- `debug/debug_http_trace.json` -- `debug/debug_probe.json` -- `debug/debug_counts.json` - -### Discovery metadata -`run_manifest.json` also captures useful execution metadata such as: -- executed stages -- collectors used -- observed URLs -- observed actions count -- observed API calls - ---- - -## Which Reports to Review - -### 1. `reports/aggregate_report.md` -Best file for a quick human-readable overview of the whole run. - -### 2. `reports/aggregate_summary.json` -Useful for structured review and automation. +The fastest review path is: -### 3. `targets/.../findings/vulnerabilities.json` -Contains the final structured findings per target. +1. [Architecture](docs/ARCHITECTURE.md) +2. [Profiles](docs/PROFILES.md) +3. [Pipeline](docs/PIPELINE.md) +4. [Output contract](docs/OUTPUTS.md) +5. [Tests](tests) -### 4. `run_manifest.json` -Useful to understand: -- which stages ran -- which collectors were used -- what was actually observed +## Quality and security posture -### 5. console logs -Very useful to compare how noisy each profile is. +Every push and pull request runs source compilation and the full pytest suite through GitHub Actions. Dependency auditing is defined in the security workflow. Generated scan outputs, local credentials, virtual environments, editor metadata and build artefacts are excluded by `.gitignore`. ---- +Before a public release, review the complete reachable Git history for credentials and inspect generated examples for real hosts, personal data and operational identifiers. Never commit `.env` files, private keys, tokens, VPS credentials or raw reports from real engagements. -## What Makes the Project Valuable +## Roadmap -This project is valuable because it is not limited to raw scanner execution. +- Improve reproducible lab fixtures and representative report samples. +- Continue reducing false positives without hiding ambiguous evidence. +- Keep output schemas stable while extending integrations. +- Add focused performance benchmarks for large target sets. -It adds: -- an explicit pipeline -- operational profiles -- reusable discovery surface -- custom validation modules -- output normalisation -- finding correlation -- human-oriented reporting -- machine-readable artefacts -- lower-noise recon modes -- progressive escalation from stealth to active analysis +## License -In practice, this makes it useful both as: -- a technical project with real engineering depth -- an operational prototype for attack-surface analysis +Released under the [MIT License](LICENSE). Use of the tool remains subject to the authorised-testing requirements in [SECURITY.md](SECURITY.md). ---- +## Contact and contribution -## Recommended Files for the Reviewer - -If someone wants to understand the project quickly, the best order is: - -1. `README.md` -2. `docs/PROFILES.md` -3. `docs/PIPELINE.md` -4. `docs/OUTPUTS.md` -5. `CHANGELOG.md` - ---- - -## Notes - -- Passive profiles do **not** require Playwright browsers. -- Active dynamic mode may require `python -m playwright install`. -- Nmap is optional. -- CLI target input overrides YAML target configuration. -- The active YAML profile file is called `active-aggressive.yml`, while the CLI profile accepted by `main.py` is commonly used as `active`. - ---- - -## Changelog - -For the full technical evolution of the project, see: - -```text -CHANGELOG.md -``` +Bug reports and improvements are welcome through the repository issue tracker. For security-sensitive reports, follow [SECURITY.md](SECURITY.md) and do not publish credentials or live-target details in a public issue. diff --git a/SECURITY.md b/SECURITY.md index 30a7ac3..b70a70a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,9 @@ # Security Policy +## Scope and supported versions + +Attack Surface Mapper is a security testing tool, not a hosted service. The latest commit on the default branch is the supported development line. Older commits may contain known limitations and should not be used as a security baseline without review. + Attack Surface Mapper is intended for authorised security testing, internal audits and controlled lab environments. Do not run active modules, Nuclei templates, Nmap discovery or endpoint enumeration against third-party systems without explicit permission. For broad external reconnaissance, use only the passive profiles agreed with the target owner and keep logs, scope and configuration available for review. @@ -10,9 +14,13 @@ Recommended defaults: - Use `passive-recon-enum`, Nuclei and Nmap only when enumeration is authorised. - Use `active-aggressive` only for approved audits or lab targets. -If you find a security issue in this project, document: +If you find a security issue in this project, do not include credentials, live target details or private scan output in a public issue. Use GitHub's private vulnerability reporting when it is available, or contact the maintainer through the GitHub profile. + +Document: - affected version or commit, - reproduction steps, - impact, - suggested fix or mitigation. + +Reports that affect the scanner's own code, report redaction, dependency safety or accidental credential exposure are especially valuable. Please allow time for triage before public disclosure. diff --git a/agents.md b/agents.md index 8173e64..a50e975 100644 --- a/agents.md +++ b/agents.md @@ -127,9 +127,9 @@ python -m py_compile main.py $(find src -name '*.py') Estado verificado el 2026-04-07: - `python -m py_compile main.py $(find src -name '*.py')` pasa sin errores. -- `python -m pytest` da `59 passed`. +- `python -m pytest` da `69 passed` tras los ajustes de reduccion de falsos positivos en headers, CORS, paneles, cookies, ficheros sensibles, correlacion por rutas y semantica CLI de perfiles. -El proyecto no es repo Git en este workspace. +El proyecto es un repo Git local con remoto `origin` en `git@github.com:Gabrielvcg/attack-surface-mapper.git`. ## Precauciones Conocidas diff --git a/config/examples/lab-passive-recon-enum.yml b/config/examples/lab-passive-recon-enum.yml new file mode 100644 index 0000000..f234b15 --- /dev/null +++ b/config/examples/lab-passive-recon-enum.yml @@ -0,0 +1,18 @@ +profile: passive-recon-enum + +nuclei: + enabled: false + +validators: + baseline_probe: true + observed_only: false + panels: true + auth: true + api: true + sensitive_files: true + +modules: + headers: true + tls: true + crawl: true + secrets: true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..da94004 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,45 @@ +# Architecture + +## System boundary + +Attack Surface Mapper is a local, operator-controlled CLI. It does not provide a hosted control plane, accept unauthenticated scan jobs or store results in a remote service by default. The operator supplies authorised targets, selects a profile and owns the generated artefacts. + +```mermaid +flowchart TB + CLI[CLI and YAML configuration] --> SETTINGS[ScanSettings] + SETTINGS --> PIPELINE[ScanPipeline] + PIPELINE --> COLLECT[Collectors] + COLLECT --> HTTP[HTTP / browser discovery] + COLLECT --> NUCLEI[Nuclei runner] + COLLECT --> NMAP[Nmap collector] + HTTP --> VALIDATE[Custom validators] + NUCLEI --> MODEL[Common Vulnerability model] + NMAP --> MODEL + VALIDATE --> MODEL + MODEL --> CORRELATE[Correlation and enrichment] + CORRELATE --> TARGET[Target reports] + CORRELATE --> AGGREGATE[Aggregate reports] + TARGET --> ARTEFACTS[JSON / Markdown / HTML / CSV] + AGGREGATE --> ARTEFACTS +``` + +## Pipeline responsibilities + +- **Collectors** gather observable evidence and invoke optional external tools. +- **Validators** turn responses, navigation and tool output into candidate findings. +- **The common model** preserves source, evidence, target identity, severity and verification metadata. +- **Correlation** deduplicates findings and groups related signals without treating discovery as confirmed impact. +- **Enrichment** calculates confidence, priority, recommendation and stable identifiers. +- **Reporting** emits human-readable and machine-readable contracts without requiring Elasticsearch. + +`ScanContext` is the shared execution state. `ScanOrchestrator` remains a compatibility facade and delegates execution to the pipeline. This keeps CLI compatibility while allowing stages to evolve independently. + +## Design decisions + +1. **Observed surface before invented probes:** safe profiles reuse links, forms, scripts and API references observed during navigation. +2. **Evidence before severity:** a discovered or likely signal is not presented as a confirmed vulnerability without the corresponding validation basis. +3. **One finding contract:** HTTP, browser, Nuclei and Nmap findings share the same serialisation and reporting path. +4. **Progressive escalation:** operators select the smallest profile that answers the authorised question. +5. **Local-first artefacts:** reports stay on the operator's filesystem unless the operator explicitly exports a generated bundle. + +For profile semantics, see [PROFILES.md](PROFILES.md). For output compatibility, see [OUTPUTS.md](OUTPUTS.md). diff --git a/main.py b/main.py index caf3ff5..fe97980 100644 --- a/main.py +++ b/main.py @@ -25,11 +25,11 @@ 'deep': {'severity': 'low,medium,high,critical', 'rate_limit': 100, 'timeout': 15, 'retries': 2, 'validator_timeout': 8, 'crawl_max_pages': 40, 'crawl_max_depth': 2, 'http_mode': 'passive', 'crawl_include_js': True}, 'passive': {'severity': 'medium,high,critical', 'rate_limit': 150, 'timeout': 10, 'retries': 1, 'validator_timeout': 6, 'crawl_max_pages': 25, 'crawl_max_depth': 2, 'http_mode': 'passive', 'crawl_include_js': False}, 'active': {'severity': 'low,medium,high,critical', 'rate_limit': 100, 'timeout': 15, 'retries': 2, 'validator_timeout': 10, 'crawl_max_pages': 50, 'crawl_max_depth': 3, 'http_mode': 'active', 'crawl_include_js': True}, - 'active-aggressive': {'severity': 'low,medium,high,critical', 'rate_limit': 100, 'timeout': 15, 'retries': 2, 'validator_timeout': 10, 'crawl_max_pages': 50, 'crawl_max_depth': 3, 'http_mode': 'active', 'crawl_include_js': True}, - 'passive-stealth': {'severity': 'low,medium,high,critical', 'rate_limit': 30, 'timeout': 8, 'retries': 0, 'validator_timeout': 5, 'crawl_max_pages': 12, 'crawl_max_depth': 1, 'http_mode': 'passive', 'crawl_include_js': False}, - 'passive-recon': {'severity': 'medium,high,critical', 'rate_limit': 90, 'timeout': 10, 'retries': 1, 'validator_timeout': 6, 'crawl_max_pages': 25, 'crawl_max_depth': 2, 'http_mode': 'passive', 'crawl_include_js': True}, - 'passive-recon-safe': {'severity': 'medium,high,critical', 'rate_limit': 45, 'timeout': 8, 'retries': 0, 'validator_timeout': 5, 'crawl_max_pages': 20, 'crawl_max_depth': 2, 'http_mode': 'passive', 'crawl_include_js': True}, - 'passive-recon-enum': {'severity': 'medium,high,critical', 'rate_limit': 90, 'timeout': 10, 'retries': 1, 'validator_timeout': 6, 'crawl_max_pages': 25, 'crawl_max_depth': 2, 'http_mode': 'passive', 'crawl_include_js': True}, + 'active-aggressive': {'severity': 'low,medium,high,critical', 'rate_limit': 100, 'timeout': 15, 'retries': 2, 'validator_timeout': 10, 'crawl_max_pages': 50, 'crawl_max_depth': 4, 'http_backend': 'requests', 'crawler_backend': 'scrapling', 'crawler_scrapling_mode': 'dynamic', 'http_mode': 'active', 'crawl_include_js': True, 'run_nuclei': True, 'run_nmap': True, 'baseline_probe': True, 'observed_only': False, 'run_panels': True, 'run_auth': True, 'run_api': True, 'run_sensitive_files': True, 'run_secrets': True}, + 'passive-stealth': {'severity': 'low,medium,high,critical', 'rate_limit': 30, 'timeout': 8, 'retries': 0, 'validator_timeout': 5, 'crawl_max_pages': 12, 'crawl_max_depth': 1, 'http_backend': 'requests', 'crawler_backend': 'requests', 'http_mode': 'passive', 'crawl_include_js': False, 'run_nuclei': False, 'baseline_probe': False, 'observed_only': True, 'run_panels': False, 'run_auth': False, 'run_api': False, 'run_sensitive_files': False, 'run_secrets': False}, + 'passive-recon': {'severity': 'medium,high,critical', 'rate_limit': 90, 'timeout': 10, 'retries': 1, 'validator_timeout': 6, 'crawl_max_pages': 25, 'crawl_max_depth': 2, 'http_backend': 'requests', 'crawler_backend': 'requests', 'http_mode': 'passive', 'crawl_include_js': True, 'run_nuclei': True, 'baseline_probe': True, 'observed_only': False, 'run_panels': True, 'run_auth': True, 'run_api': True, 'run_sensitive_files': True, 'run_secrets': True}, + 'passive-recon-safe': {'severity': 'medium,high,critical', 'rate_limit': 45, 'timeout': 8, 'retries': 0, 'validator_timeout': 5, 'crawl_max_pages': 20, 'crawl_max_depth': 2, 'http_backend': 'requests', 'crawler_backend': 'requests', 'http_mode': 'passive', 'crawl_include_js': True, 'run_nuclei': False, 'baseline_probe': False, 'observed_only': True, 'run_panels': False, 'run_auth': False, 'run_api': False, 'run_sensitive_files': False, 'run_secrets': False}, + 'passive-recon-enum': {'severity': 'medium,high,critical', 'rate_limit': 90, 'timeout': 10, 'retries': 1, 'validator_timeout': 6, 'crawl_max_pages': 25, 'crawl_max_depth': 2, 'http_backend': 'requests', 'crawler_backend': 'requests', 'http_mode': 'passive', 'crawl_include_js': True, 'run_nuclei': True, 'baseline_probe': True, 'observed_only': False, 'run_panels': True, 'run_auth': True, 'run_api': True, 'run_sensitive_files': True, 'run_secrets': True}, } @@ -41,7 +41,7 @@ def split_csv(value: str | None) -> tuple[str, ...] | None: def build_arg_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description='v9: Nuclei + validaciones propias + correlación + reporting + descubrimiento opcional con Nmap.') + parser = argparse.ArgumentParser(description='Attack Surface Mapper: pipeline de Nuclei, Nmap opcional, validaciones propias, correlación y reporting.') parser.add_argument('target', nargs='?', default=None, help='Target único a escanear, por ejemplo https://example.com') parser.add_argument('--targets-file', default=None, help='Fichero con un target por línea') parser.add_argument('--config', default=None, help='Fichero YAML con configuración del escaneo') @@ -220,6 +220,110 @@ def build_report_paths(base_dir: Path, formats: tuple[str, ...] | None, skip_rep } +def build_effective_config(args: argparse.Namespace, config: dict[str, Any], workers: int) -> dict[str, Any]: + profile_name = str(get_setting(args, config, 'profile', 'normal') or 'normal').lower() + profile = PROFILE_DEFAULTS.get(profile_name, PROFILE_DEFAULTS['normal']) + report_formats = split_csv(get_setting(args, config, 'report_formats')) + http_mode = str(get_setting(args, config, 'http_mode', profile.get('http_mode', 'passive')) or profile.get('http_mode', 'passive')).lower() + crawl_include_js_default = bool(profile.get('crawl_include_js', False)) or http_mode == 'active' + return { + 'profile': profile_name, + 'workers': workers, + 'report_formats': list(report_formats or ('md', 'html', 'csv', 'summary-json', 'comparison-json')), + 'skip_reports': bool(get_setting(args, config, 'skip_reports', False) or args.skip_reports), + 'compare_with_json': args.compare_with_json, + 'debug': bool(args.debug), + 'http_backend': str(get_setting(args, config, 'http_backend', profile.get('http_backend', 'auto')) or profile.get('http_backend', 'auto')).lower(), + 'crawler_backend': str(get_setting(args, config, 'crawler_backend', profile.get('crawler_backend', profile.get('http_backend', 'auto'))) or profile.get('crawler_backend', profile.get('http_backend', 'auto'))).lower(), + 'http_mode': http_mode, + 'baseline_probe': bool(get_setting(args, config, 'baseline_probe', profile.get('baseline_probe', True))), + 'observed_only': bool(get_setting(args, config, 'observed_only', profile.get('observed_only', False))), + 'browser_discovery_enabled': bool(get_setting(args, config, 'browser_discovery_enabled', profile.get('browser_discovery_enabled', True))), + 'run_nuclei': bool(get_setting(args, config, 'run_nuclei', profile.get('run_nuclei', True))), + 'run_nmap': bool(get_setting(args, config, 'use_nmap', profile.get('run_nmap', False)) or args.use_nmap), + 'validator_timeout': safe_int(get_setting(args, config, 'validator_timeout', profile['validator_timeout']), int(profile['validator_timeout'])), + 'crawl_max_pages': safe_int(get_setting(args, config, 'crawl_max_pages', profile['crawl_max_pages']), int(profile['crawl_max_pages'])), + 'crawl_max_depth': safe_int(get_setting(args, config, 'crawl_max_depth', profile['crawl_max_depth']), int(profile['crawl_max_depth'])), + 'crawl_include_js': bool(get_setting(args, config, 'crawl_include_js', crawl_include_js_default) or args.crawl_include_js or http_mode == 'active'), + } + + +def build_run_manifest( + *, + base_dir: Path, + requested_targets: list[str], + aggregate_paths: dict[str, str], + results: list[ScanResult], + errors: list[str], + effective_config: dict[str, Any], +) -> dict[str, Any]: + ordered_results = sorted(results, key=lambda item: item.target) + successful_targets = len(ordered_results) + total_findings = sum(len(result.vulnerabilities) for result in ordered_results) + raw_findings = sum(result.raw_findings_count for result in ordered_results) + observed_urls_total = sum(len(result.observed_urls or []) for result in ordered_results) + observed_api_calls_total = sum(len(result.observed_api_calls or []) for result in ordered_results) + observed_actions_total = sum(len(result.observed_actions or []) for result in ordered_results) + + return { + 'schema_version': '1.0', + 'run_dir': str(base_dir), + 'requested_targets': requested_targets, + 'targets': [result.target for result in ordered_results], + 'aggregate_reports': aggregate_paths, + 'errors': errors, + 'profile': str(effective_config.get('profile', '') or ''), + 'effective_config': effective_config, + 'pipeline': { + 'stages': sorted({stage for result in ordered_results for stage in getattr(result, 'stages_executed', [])}), + 'collectors': sorted({collector for result in ordered_results for collector in getattr(result, 'collectors_used', [])}), + }, + 'results_summary': { + 'requested_targets': len(requested_targets), + 'successful_targets': successful_targets, + 'error_count': len(errors), + 'raw_findings_count': raw_findings, + 'correlated_findings_count': total_findings, + 'observed_urls_count': observed_urls_total, + 'observed_actions_count': observed_actions_total, + 'observed_api_calls_count': observed_api_calls_total, + }, + 'per_target': [ + { + 'target': result.target, + 'status': 'ok', + 'stages': getattr(result, 'stages_executed', []), + 'collectors': getattr(result, 'collectors_used', []), + 'summary': { + 'raw_findings_count': result.raw_findings_count, + 'correlated_findings_count': len(result.vulnerabilities), + 'priorities': result.summary, + }, + 'observed': { + 'urls': getattr(result, 'observed_urls', []), + 'urls_count': len(getattr(result, 'observed_urls', []) or []), + 'actions_count': len(getattr(result, 'observed_actions', []) or []), + 'api_calls': getattr(result, 'observed_api_calls', []), + 'api_calls_count': len(getattr(result, 'observed_api_calls', []) or []), + }, + 'reports': { + 'markdown': result.report_paths.markdown, + 'html': result.report_paths.html, + 'csv': result.report_paths.csv, + 'summary_json': result.report_paths.summary_json, + 'comparison_json': result.report_paths.comparison_json, + }, + 'observed_urls': getattr(result, 'observed_urls', []), + 'observed_actions_count': len(getattr(result, 'observed_actions', []) or []), + 'observed_api_calls': getattr(result, 'observed_api_calls', []), + 'raw_findings_count': result.raw_findings_count, + 'findings': len(result.vulnerabilities), + } + for result in ordered_results + ], + } + + def build_target_paths(targets_dir: Path, target: str) -> dict[str, Path]: @@ -248,7 +352,7 @@ def scan_one(target: str, args: argparse.Namespace, config: dict[str, Any], targ panel_paths = split_csv(get_setting(args, config, 'panel_paths')) report_formats = split_csv(get_setting(args, config, 'report_formats')) skip_reports = bool(get_setting(args, config, 'skip_reports', False) or args.skip_reports) - use_nmap = bool(get_setting(args, config, 'use_nmap', False) or args.use_nmap) + use_nmap = bool(get_setting(args, config, 'use_nmap', profile.get('run_nmap', False)) or args.use_nmap) nmap_top_ports = safe_int(get_setting(args, config, 'nmap_top_ports', 100), 100) nmap_args = split_csv(get_setting(args, config, 'nmap_args')) nmap_timing = get_setting(args, config, 'nmap_timing') @@ -260,17 +364,17 @@ def scan_one(target: str, args: argparse.Namespace, config: dict[str, Any], targ crawl_max_pages = safe_int(get_setting(args, config, 'crawl_max_pages', profile['crawl_max_pages']), int(profile['crawl_max_pages'])) crawl_max_depth = safe_int(get_setting(args, config, 'crawl_max_depth', profile['crawl_max_depth']), int(profile['crawl_max_depth'])) http_mode = str(get_setting(args, config, 'http_mode', profile.get('http_mode', 'passive')) or profile.get('http_mode', 'passive')).lower() - http_backend = str(get_setting(args, config, 'http_backend', 'auto') or 'auto').lower() - crawler_backend = str(get_setting(args, config, 'crawler_backend', http_backend) or http_backend).lower() - crawler_scrapling_mode = str(get_setting(args, config, 'crawler_scrapling_mode', 'auto') or 'auto').lower() + http_backend = str(get_setting(args, config, 'http_backend', profile.get('http_backend', 'auto')) or profile.get('http_backend', 'auto')).lower() + crawler_backend = str(get_setting(args, config, 'crawler_backend', profile.get('crawler_backend', http_backend)) or profile.get('crawler_backend', http_backend)).lower() + crawler_scrapling_mode = str(get_setting(args, config, 'crawler_scrapling_mode', profile.get('crawler_scrapling_mode', 'auto')) or profile.get('crawler_scrapling_mode', 'auto')).lower() crawl_include_js_default = bool(profile.get('crawl_include_js', False)) or http_mode == 'active' crawl_include_js = bool(get_setting(args, config, 'crawl_include_js', crawl_include_js_default) or args.crawl_include_js or http_mode == 'active') user_agent = str(get_setting(args, config, 'user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36') or 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36') - run_nuclei = bool(get_setting(args, config, 'run_nuclei', True)) - baseline_probe = bool(get_setting(args, config, 'baseline_probe', True)) - observed_only = bool(get_setting(args, config, 'observed_only', False)) + run_nuclei = bool(get_setting(args, config, 'run_nuclei', profile.get('run_nuclei', True))) + baseline_probe = bool(get_setting(args, config, 'baseline_probe', profile.get('baseline_probe', True))) + observed_only = bool(get_setting(args, config, 'observed_only', profile.get('observed_only', False))) browser_click_budget = safe_int(get_setting(args, config, 'browser_click_budget', 12), 12) - browser_discovery_enabled = bool(get_setting(args, config, 'browser_discovery_enabled', True)) + browser_discovery_enabled = bool(get_setting(args, config, 'browser_discovery_enabled', profile.get('browser_discovery_enabled', True))) target_paths = build_target_paths(targets_dir, target) report_paths = build_report_paths(target_paths['reports_dir'], report_formats, skip_reports) @@ -290,13 +394,13 @@ def scan_one(target: str, args: argparse.Namespace, config: dict[str, Any], targ include_raw=args.include_raw, compare_with_json=args.compare_with_json, run_headers=not bool(get_setting(args, config, 'skip_headers', False) or args.skip_headers), - run_panels=not bool(get_setting(args, config, 'skip_panels', False) or args.skip_panels), + run_panels=not bool(get_setting(args, config, 'skip_panels', not bool(profile.get('run_panels', True))) or args.skip_panels), run_tls=not bool(get_setting(args, config, 'skip_tls', False) or args.skip_tls), run_crawl=not bool(get_setting(args, config, 'skip_crawl', False) or args.skip_crawl), - run_secrets=not bool(get_setting(args, config, 'skip_secrets', False) or args.skip_secrets), - run_auth=not bool(get_setting(args, config, 'skip_auth', False) or args.skip_auth), - run_api=not bool(get_setting(args, config, 'skip_api', False) or args.skip_api), - run_sensitive_files=not bool(get_setting(args, config, 'skip_sensitive_files', False) or args.skip_sensitive_files), + run_secrets=not bool(get_setting(args, config, 'skip_secrets', not bool(profile.get('run_secrets', True))) or args.skip_secrets), + run_auth=not bool(get_setting(args, config, 'skip_auth', not bool(profile.get('run_auth', True))) or args.skip_auth), + run_api=not bool(get_setting(args, config, 'skip_api', not bool(profile.get('run_api', True))) or args.skip_api), + run_sensitive_files=not bool(get_setting(args, config, 'skip_sensitive_files', not bool(profile.get('run_sensitive_files', True))) or args.skip_sensitive_files), validator_timeout=validator_timeout, crawl_max_pages=crawl_max_pages, crawl_max_depth=crawl_max_depth, @@ -339,6 +443,7 @@ def main() -> int: workers = safe_int(get_setting(args, config, 'workers', 1), 1) run_name = args.run_name or config.get('run_name') base_dir, targets_dir, reports_dir = build_run_paths(args.output_root, run_name) + effective_config = build_effective_config(args, config, workers) results: list[ScanResult] = [] errors: list[str] = [] @@ -375,30 +480,14 @@ def _job(target: str) -> ScanResult: errors.append(f'{target}: {exc}') aggregate_paths = write_aggregate_reports(results, str(reports_dir)) if results else {} - manifest = { - 'run_dir': str(base_dir), - 'targets': [r.target for r in results], - 'aggregate_reports': aggregate_paths, - 'errors': errors, - 'profile': str(config.get('profile', '') or ''), - 'pipeline': { - 'stages': sorted({stage for r in results for stage in getattr(r, 'stages_executed', [])}), - 'collectors': sorted({collector for r in results for collector in getattr(r, 'collectors_used', [])}), - }, - 'per_target': [ - { - 'target': r.target, - 'stages': getattr(r, 'stages_executed', []), - 'collectors': getattr(r, 'collectors_used', []), - 'observed_urls': getattr(r, 'observed_urls', []), - 'observed_actions_count': len(getattr(r, 'observed_actions', []) or []), - 'observed_api_calls': getattr(r, 'observed_api_calls', []), - 'raw_findings_count': r.raw_findings_count, - 'findings': len(r.vulnerabilities), - } - for r in results - ], - } + manifest = build_run_manifest( + base_dir=base_dir, + requested_targets=targets, + aggregate_paths=aggregate_paths, + results=results, + errors=errors, + effective_config=effective_config, + ) (base_dir / 'run_manifest.json').write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding='utf-8') print(f'Run dir: {base_dir}') diff --git a/pyproject.toml b/pyproject.toml index 8faad3d..8fc8c19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,21 @@ requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "nuclei-mvp-project-v10" -version = "0.10.3" -description = "Nuclei + validaciones propias + correlación + reporting + Nmap + Scrapling por modos (fetcher/dynamic)" +name = "attack-surface-mapper" +version = "10.22.10" +description = "Pipeline-based attack-surface discovery, validation, correlation, and reporting." +readme = "README.md" +license = {file = "LICENSE"} +authors = [{name = "Gabriel Vacaro Goytia"}] +keywords = ["appsec", "attack-surface", "security-automation", "nuclei", "nmap", "reconnaissance"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Topic :: Security", +] requires-python = ">=3.11" dependencies = [ "requests>=2.31.0", @@ -14,6 +26,16 @@ dependencies = [ "playwright>=1.52.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0,<9", +] + +[project.urls] +Documentation = "https://github.com/Gabrielvcg/attack-surface-mapper/tree/main/docs" +Issues = "https://github.com/Gabrielvcg/attack-surface-mapper/issues" +Repository = "https://github.com/Gabrielvcg/attack-surface-mapper" + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] diff --git a/scripts/check_review_golden_set.py b/scripts/check_review_golden_set.py new file mode 100644 index 0000000..473eb26 --- /dev/null +++ b/scripts/check_review_golden_set.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / 'src' +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from attack_surface_mapper.reporting import evaluate_review_rows_against_golden_set, load_review_golden_set, load_review_matrix + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description='Comprueba una matriz de revisión de labs contra un golden set de expectativas.') + parser.add_argument('matrix_csv', help='Ruta al CSV exportado por export_review_matrix.py') + parser.add_argument('--golden-set', default='tests/data/lab_review_golden_set.json', help='Ruta al JSON con expectativas mínimas') + return parser + + +def main() -> int: + args = build_parser().parse_args() + rows = load_review_matrix(args.matrix_csv) + golden = load_review_golden_set(args.golden_set) + evaluation = evaluate_review_rows_against_golden_set(rows, golden) + print(f"Golden set comprobado: {evaluation['checked']} expectativas") + if evaluation['missing']: + print('Filas ausentes:') + for item in evaluation['missing']: + print(f" - {item['run_name']} :: {item['title']}") + if evaluation['mismatches']: + print('Desajustes:') + for item in evaluation['mismatches']: + print( + f" - {item['run_name']} :: {item['title']} :: {item['field']} " + f"(esperado={item['expected']} actual={item['actual']})" + ) + return 0 if evaluation['ok'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/export_elasticsearch_bundle.py b/scripts/export_elasticsearch_bundle.py new file mode 100644 index 0000000..17e87d3 --- /dev/null +++ b/scripts/export_elasticsearch_bundle.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / 'src' +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from attack_surface_mapper.reporting import export_elasticsearch_bundle + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description='Genera un bundle de salida para Elasticsearch a partir de un run existente.' + ) + parser.add_argument( + '--run-dir', + required=True, + help='Directorio del run ya generado, por ejemplo scans/2026-04-14_120000 o scans/lab_juice_shop_passive_recon_enum', + ) + parser.add_argument( + '--index-prefix', + default='attack-surface-mapper', + help='Prefijo de índices para findings, summaries y runs.', + ) + return parser + + +def main() -> int: + args = build_parser().parse_args() + manifest = export_elasticsearch_bundle(args.run_dir, index_prefix=args.index_prefix) + print(f"Bundle Elasticsearch exportado en: {manifest['output_dir']}") + print(f"Indices: {manifest['indices']}") + print(f"Documentos: {manifest['documents']}") + if manifest.get('warnings'): + print('Advertencias:') + for warning in manifest['warnings']: + print(f'- {warning}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/export_review_matrix.py b/scripts/export_review_matrix.py new file mode 100644 index 0000000..e21e956 --- /dev/null +++ b/scripts/export_review_matrix.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / 'src' +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from attack_surface_mapper.reporting.review_matrix import build_review_rows, write_review_matrix + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description='Exporta una matriz CSV para revisar hallazgos de labs y etiquetarlos como verdadero/falso/dudoso.') + parser.add_argument('run_dirs', nargs='*', help='Directorios de run dentro de scans/. Si se omite, usa scans/lab_*') + parser.add_argument('--output', default='reviews/lab_findings_review.csv', help='Ruta CSV de salida') + return parser + + +def main() -> int: + args = build_parser().parse_args() + if args.run_dirs: + run_dirs = [Path(item) for item in args.run_dirs] + else: + run_dirs = sorted(Path('scans').glob('lab_*')) + rows = build_review_rows(run_dirs) + if not rows: + print('No se encontraron hallazgos para exportar.') + return 1 + output = write_review_matrix(rows, args.output) + print(f'Matriz de revisión exportada: {output}') + print(f'Filas: {len(rows)}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/validate_labs.ps1 b/scripts/validate_labs.ps1 new file mode 100644 index 0000000..a55f818 --- /dev/null +++ b/scripts/validate_labs.ps1 @@ -0,0 +1,287 @@ +[CmdletBinding()] +param( + [string[]]$Labs = @('juice-shop', 'dvwa'), + [string[]]$Profiles = @('passive-stealth', 'passive-recon-safe'), + [switch]$IncludeEnum, + [int]$MinFindings = 1, + [string]$ReviewMatrixOutput = 'reviews/lab_findings_review.csv', + [string]$ReviewGoldenSet = 'tests/data/lab_review_golden_set.json', + [switch]$KeepLabsRunning +) + +$ErrorActionPreference = 'Stop' + +$workspace = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$scannerImage = 'python:3.11-slim' + +if ($IncludeEnum -and ($Profiles -notcontains 'passive-recon-enum')) { + $Profiles += 'passive-recon-enum' +} + +$labDefinitions = @{ + 'juice-shop' = @{ + Container = 'asm-lab-juice' + Image = 'bkimminich/juice-shop' + HostPort = 3000 + ContainerPort = 3000 + Target = 'http://host.docker.internal:3000' + HealthUrl = 'http://localhost:3000' + } + 'dvwa' = @{ + Container = 'asm-lab-dvwa' + Image = 'vulnerables/web-dvwa' + HostPort = 8080 + ContainerPort = 80 + Target = 'http://host.docker.internal:8080' + HealthUrl = 'http://localhost:8080' + } +} + +$profileConfigOverrides = @{ + 'passive-recon-enum' = 'config/examples/lab-passive-recon-enum.yml' +} + +function Invoke-Docker { + param([string[]]$Arguments) + Write-Host "docker $($Arguments -join ' ')" -ForegroundColor DarkGray + & docker @Arguments +} + +function Stop-LabContainer { + param([string]$Name) + try { + $existing = & docker ps -aq --filter "name=^${Name}$" + if ($existing) { + & docker rm -f $Name | Out-Null + } + } catch { + Write-Warning "No se pudo limpiar el contenedor ${Name}: $($_.Exception.Message)" + } +} + +function Wait-HttpReady { + param( + [string]$Url, + [string]$Name, + [int]$TimeoutSeconds = 180 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + try { + $response = Invoke-WebRequest -Uri $Url -TimeoutSec 5 -UseBasicParsing + if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) { + Write-Host "$Name listo en $Url (status $($response.StatusCode))" -ForegroundColor Green + return + } + } catch { + Start-Sleep -Seconds 2 + continue + } + Start-Sleep -Seconds 2 + } + + throw "Timeout esperando a que $Name estuviera listo en $Url" +} + +function Start-Lab { + param([string]$LabName) + + if (-not $labDefinitions.ContainsKey($LabName)) { + throw "Lab no soportado: $LabName" + } + + $lab = $labDefinitions[$LabName] + Stop-LabContainer -Name $lab.Container + Invoke-Docker -Arguments @( + 'run', '-d', '--rm', + '--name', $lab.Container, + '-p', "$($lab.HostPort):$($lab.ContainerPort)", + $lab.Image + ) | Out-Null + Wait-HttpReady -Url $lab.HealthUrl -Name $LabName + return $lab +} + +function Invoke-Scan { + param( + [string]$Target, + [string]$Profile, + [string]$RunName + ) + + $configArg = '' + if ($profileConfigOverrides.ContainsKey($Profile)) { + $configArg = "--config $($profileConfigOverrides[$Profile])" + } + $innerCommand = "pip install -q -r requirements.txt && python main.py $configArg --profile $Profile --run-name $RunName $Target" + Invoke-Docker -Arguments @( + 'run', '--rm', + '-v', "${workspace}:/workspace", + '-w', '/workspace', + $scannerImage, + 'sh', '-lc', $innerCommand + ) +} + +function Export-ReviewMatrix { + param( + [System.Collections.Generic.List[string]]$Runs, + [string]$OutputPath + ) + + if (-not $Runs -or $Runs.Count -eq 0) { + return + } + + $runArgs = ($Runs | ForEach-Object { $_ -replace '\\', '/' }) -join ' ' + $innerCommand = "pip install -q -r requirements.txt && python scripts/export_review_matrix.py $runArgs --output $OutputPath" + Invoke-Docker -Arguments @( + 'run', '--rm', + '-v', "${workspace}:/workspace", + '-w', '/workspace', + $scannerImage, + 'sh', '-lc', $innerCommand + ) + + $matrixPath = Join-Path $workspace ($OutputPath -replace '/', '\') + if (-not (Test-Path $matrixPath)) { + throw "No se generó la matriz de revisión esperada: $matrixPath" + } + Write-Host "Matriz de revisión exportada: $matrixPath" -ForegroundColor Green +} + +function Assert-ReviewGoldenSet { + param( + [string]$MatrixPath, + [string]$GoldenSetPath + ) + + $resolvedGoldenSet = Join-Path $workspace ($GoldenSetPath -replace '/', '\') + if (-not (Test-Path $resolvedGoldenSet)) { + Write-Warning "No se encontró el golden set de revisión: $resolvedGoldenSet" + return + } + + $innerCommand = "pip install -q -r requirements.txt && python scripts/check_review_golden_set.py $MatrixPath --golden-set $GoldenSetPath" + Invoke-Docker -Arguments @( + 'run', '--rm', + '-v', "${workspace}:/workspace", + '-w', '/workspace', + $scannerImage, + 'sh', '-lc', $innerCommand + ) + Write-Host "Golden set de revisión OK: $resolvedGoldenSet" -ForegroundColor Green +} + +function Assert-RequiredJsonProperty { + param( + $Object, + [string]$PropertyName, + [string]$Context + ) + + if (-not ($Object.PSObject.Properties.Name -contains $PropertyName)) { + throw "Falta la propiedad '$PropertyName' en $Context" + } +} + +function Assert-RunOutputs { + param( + [string]$RunName, + [string]$Target, + [int]$MinimumFindings + ) + + $slug = ($Target -replace '[^a-zA-Z0-9._-]+', '_') + if ($slug.Length -gt 80) { + $slug = $slug.Substring(0, 80) + } + if (-not $slug) { + $slug = 'target' + } + + $runDir = Join-Path $workspace "scans\$RunName" + $manifestPath = Join-Path $runDir 'run_manifest.json' + $aggregatePath = Join-Path $runDir 'reports\aggregate_summary.json' + $summaryPath = Join-Path $runDir "targets\$slug\reports\report.summary.json" + + foreach ($path in @($manifestPath, $aggregatePath, $summaryPath)) { + if (-not (Test-Path $path)) { + throw "No se generó el artefacto esperado: $path" + } + } + + $manifest = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json + $aggregate = Get-Content -Path $aggregatePath -Raw | ConvertFrom-Json + $summary = Get-Content -Path $summaryPath -Raw | ConvertFrom-Json + + Assert-RequiredJsonProperty -Object $summary -PropertyName 'comparison' -Context $summaryPath + Assert-RequiredJsonProperty -Object $summary -PropertyName 'comparison_summary' -Context $summaryPath + Assert-RequiredJsonProperty -Object $summary -PropertyName 'top_findings' -Context $summaryPath + Assert-RequiredJsonProperty -Object $summary -PropertyName 'stats' -Context $summaryPath + Assert-RequiredJsonProperty -Object $aggregate -PropertyName 'summary' -Context $aggregatePath + Assert-RequiredJsonProperty -Object $manifest -PropertyName 'effective_config' -Context $manifestPath + Assert-RequiredJsonProperty -Object $manifest -PropertyName 'results_summary' -Context $manifestPath + + if ([int]$summary.stats.total_findings -lt $MinimumFindings) { + throw "Se esperaban al menos $MinimumFindings hallazgos en $summaryPath y solo hubo $($summary.stats.total_findings)" + } + if ([int]$manifest.results_summary.successful_targets -lt 1) { + throw "El manifest no refleja targets exitosos en $manifestPath" + } + if ([int]$aggregate.summary.total_findings -lt $MinimumFindings) { + throw "El agregado no refleja el mínimo esperado de hallazgos en $aggregatePath" + } + if (@($summary.top_findings).Count -lt 1) { + throw "No hay top_findings en $summaryPath" + } + + $topFinding = @($summary.top_findings)[0] + foreach ($property in @('finding_id', 'correlation_id', 'asset_host', 'asset_host_resolved')) { + Assert-RequiredJsonProperty -Object $topFinding -PropertyName $property -Context "$summaryPath top_findings[0]" + } + if (-not $topFinding.finding_id) { + throw "top_findings[0].finding_id está vacío en $summaryPath" + } + if (-not $topFinding.correlation_id) { + throw "top_findings[0].correlation_id está vacío en $summaryPath" + } + + Write-Host "Validación estructural OK para $RunName" -ForegroundColor Green +} + +$executedRuns = New-Object System.Collections.Generic.List[string] +$startedLabs = New-Object System.Collections.Generic.List[string] + +try { + foreach ($labName in $Labs) { + $lab = Start-Lab -LabName $labName + $startedLabs.Add($lab.Container) | Out-Null + + foreach ($profile in $Profiles) { + $safeLab = $labName.Replace('-', '_') + $safeProfile = $profile.Replace('-', '_') + $runName = "lab_${safeLab}_${safeProfile}" + Write-Host "Ejecutando $profile contra $labName -> $runName" -ForegroundColor Cyan + Invoke-Scan -Target $lab.Target -Profile $profile -RunName $runName + Assert-RunOutputs -RunName $runName -Target $lab.Target -MinimumFindings $MinFindings + $executedRuns.Add((Join-Path 'scans' $runName)) | Out-Null + } + } +} finally { + if (-not $KeepLabsRunning) { + foreach ($containerName in $startedLabs) { + Stop-LabContainer -Name $containerName + } + } +} + +Export-ReviewMatrix -Runs $executedRuns -OutputPath $ReviewMatrixOutput +Assert-ReviewGoldenSet -MatrixPath $ReviewMatrixOutput -GoldenSetPath $ReviewGoldenSet + +Write-Host '' +Write-Host 'Validacion completada. Runs generados:' -ForegroundColor Green +foreach ($run in $executedRuns) { + Write-Host " - $run" +} diff --git a/src/attack_surface_mapper/analysis/comparison.py b/src/attack_surface_mapper/analysis/comparison.py index 53b26cb..0b53b58 100644 --- a/src/attack_surface_mapper/analysis/comparison.py +++ b/src/attack_surface_mapper/analysis/comparison.py @@ -5,6 +5,12 @@ from attack_surface_mapper.models.vulnerability import Vulnerability +PRIORITY_SCORE = {'low': 1, 'medium': 2, 'high': 3, 'critical': 4} +SEVERITY_SCORE = {'info': 1, 'low': 2, 'medium': 3, 'high': 4, 'critical': 5, 'unknown': 0} +CONFIDENCE_SCORE = {'low': 1, 'medium': 2, 'high': 3} +VERIFICATION_SCORE = {'discarded': 0, 'heuristic': 1, 'needs_manual_validation': 2, 'likely': 3, 'confirmed': 4} +FINDING_ROLE_SCORE = {'discovery': 0, 'candidate': 1, 'validated': 2} + def load_previous_scan(path: str | None) -> list[Vulnerability]: if not path: @@ -26,37 +32,138 @@ def _index(vulnerabilities: list[Vulnerability]) -> dict[tuple[str, str, str], V return {v.correlation_key(): v for v in vulnerabilities} -def compare_scans(current: list[Vulnerability], previous: list[Vulnerability]) -> dict[str, list[dict[str, str]]]: +def _serialise_finding(vulnerability: Vulnerability) -> dict[str, str]: + return { + 'title': vulnerability.title, + 'target': vulnerability.target, + 'priority': vulnerability.priority or '', + 'priority_score': vulnerability.priority_score if vulnerability.priority_score is not None else '', + 'severity': vulnerability.severity or '', + 'confidence': vulnerability.confidence or '', + 'verification_status': vulnerability.verification_status or '', + 'kind': vulnerability.kind or '', + 'finding_role': vulnerability.finding_role or '', + 'validated': str(bool(vulnerability.validated)).lower(), + 'validation_basis': vulnerability.validation_basis or '', + 'category': vulnerability.category or '', + 'finding_id': vulnerability.finding_id or '', + 'correlation_id': vulnerability.correlation_id or '', + } + + +def _change_direction(previous: Vulnerability, current: Vulnerability) -> str: + priority_delta = PRIORITY_SCORE.get((current.priority or '').lower(), 0) - PRIORITY_SCORE.get((previous.priority or '').lower(), 0) + numeric_priority_delta = int(current.priority_score or 0) - int(previous.priority_score or 0) + severity_delta = SEVERITY_SCORE.get((current.severity or '').lower(), 0) - SEVERITY_SCORE.get((previous.severity or '').lower(), 0) + confidence_delta = CONFIDENCE_SCORE.get((current.confidence or '').lower(), 0) - CONFIDENCE_SCORE.get((previous.confidence or '').lower(), 0) + verification_delta = VERIFICATION_SCORE.get((current.verification_status or '').lower(), 0) - VERIFICATION_SCORE.get((previous.verification_status or '').lower(), 0) + role_delta = FINDING_ROLE_SCORE.get((current.finding_role or '').lower(), 0) - FINDING_ROLE_SCORE.get((previous.finding_role or '').lower(), 0) + combined_delta = priority_delta + severity_delta + confidence_delta + verification_delta + role_delta + if combined_delta == 0: + combined_delta = numeric_priority_delta + if combined_delta > 0: + return 'promoted' + if combined_delta < 0: + return 'regressed' + return 'updated' + + +def compare_scans(current: list[Vulnerability], previous: list[Vulnerability]) -> dict: current_index = _index(current) previous_index = _index(previous) new_findings: list[dict[str, str]] = [] resolved_findings: list[dict[str, str]] = [] - changed_findings: list[dict[str, str]] = [] + changed_findings: list[dict[str, str | list[str]]] = [] + promoted_findings: list[dict[str, str | list[str]]] = [] + regressed_findings: list[dict[str, str | list[str]]] = [] + updated_findings: list[dict[str, str | list[str]]] = [] + unchanged_count = 0 + change_type_counts = { + 'priority': 0, + 'priority_score': 0, + 'severity': 0, + 'confidence': 0, + 'verification_status': 0, + 'finding_role': 0, + } for key, vulnerability in current_index.items(): if key not in previous_index: - new_findings.append({'title': vulnerability.title, 'target': vulnerability.target, 'priority': vulnerability.priority or ''}) + new_findings.append(_serialise_finding(vulnerability)) continue old = previous_index[key] - if (old.priority or '') != (vulnerability.priority or '') or (old.severity or '') != (vulnerability.severity or ''): - changed_findings.append( - { - 'title': vulnerability.title, - 'target': vulnerability.target, - 'previous_priority': old.priority or '', - 'current_priority': vulnerability.priority or '', - 'previous_severity': old.severity or '', - 'current_severity': vulnerability.severity or '', - } - ) + change_types: list[str] = [] + if (old.priority or '') != (vulnerability.priority or ''): + change_types.append('priority') + change_type_counts['priority'] += 1 + if int(old.priority_score or 0) != int(vulnerability.priority_score or 0): + change_types.append('priority_score') + change_type_counts['priority_score'] += 1 + if (old.severity or '') != (vulnerability.severity or ''): + change_types.append('severity') + change_type_counts['severity'] += 1 + if (old.confidence or '') != (vulnerability.confidence or ''): + change_types.append('confidence') + change_type_counts['confidence'] += 1 + if (old.verification_status or '') != (vulnerability.verification_status or ''): + change_types.append('verification_status') + change_type_counts['verification_status'] += 1 + if (old.finding_role or '') != (vulnerability.finding_role or ''): + change_types.append('finding_role') + change_type_counts['finding_role'] += 1 + if not change_types: + unchanged_count += 1 + continue + direction = _change_direction(old, vulnerability) + change_record = { + 'title': vulnerability.title, + 'target': vulnerability.target, + 'previous_priority': old.priority or '', + 'current_priority': vulnerability.priority or '', + 'previous_priority_score': int(old.priority_score or 0), + 'current_priority_score': int(vulnerability.priority_score or 0), + 'previous_severity': old.severity or '', + 'current_severity': vulnerability.severity or '', + 'previous_confidence': old.confidence or '', + 'current_confidence': vulnerability.confidence or '', + 'previous_verification_status': old.verification_status or '', + 'current_verification_status': vulnerability.verification_status or '', + 'previous_finding_role': old.finding_role or '', + 'current_finding_role': vulnerability.finding_role or '', + 'kind': vulnerability.kind or old.kind or '', + 'category': vulnerability.category or old.category or '', + 'change_types': change_types, + 'change_direction': direction, + } + changed_findings.append(change_record) + if direction == 'promoted': + promoted_findings.append(change_record) + elif direction == 'regressed': + regressed_findings.append(change_record) + else: + updated_findings.append(change_record) for key, vulnerability in previous_index.items(): if key not in current_index: - resolved_findings.append({'title': vulnerability.title, 'target': vulnerability.target, 'priority': vulnerability.priority or ''}) + resolved_findings.append(_serialise_finding(vulnerability)) return { + 'schema_version': '1.0', + 'summary': { + 'new_findings': len(new_findings), + 'resolved_findings': len(resolved_findings), + 'changed_findings': len(changed_findings), + 'promoted_findings': len(promoted_findings), + 'regressed_findings': len(regressed_findings), + 'updated_findings': len(updated_findings), + 'unchanged_findings': unchanged_count, + 'change_type_counts': change_type_counts, + }, 'new_findings': sorted(new_findings, key=lambda item: (item['title'], item['target'])), 'resolved_findings': sorted(resolved_findings, key=lambda item: (item['title'], item['target'])), 'changed_findings': sorted(changed_findings, key=lambda item: (item['title'], item['target'])), + 'promoted_findings': sorted(promoted_findings, key=lambda item: (item['title'], item['target'])), + 'regressed_findings': sorted(regressed_findings, key=lambda item: (item['title'], item['target'])), + 'updated_findings': sorted(updated_findings, key=lambda item: (item['title'], item['target'])), } diff --git a/src/attack_surface_mapper/analysis/correlation.py b/src/attack_surface_mapper/analysis/correlation.py index eef275c..4a45821 100644 --- a/src/attack_surface_mapper/analysis/correlation.py +++ b/src/attack_surface_mapper/analysis/correlation.py @@ -13,12 +13,26 @@ 'critical': 5, 'unknown': 0, } +CONFIDENCE_SCORE = { + 'low': 1, + 'medium': 2, + 'high': 3, +} +VERIFICATION_SCORE = { + 'discarded': 0, + 'heuristic': 1, + 'needs_manual_validation': 2, + 'likely': 3, + 'confirmed': 4, +} def _pick_primary(vulnerabilities: list[Vulnerability]) -> Vulnerability: return max( vulnerabilities, key=lambda v: ( + VERIFICATION_SCORE.get((v.verification_status or '').lower(), 0), + CONFIDENCE_SCORE.get((v.confidence or '').lower(), 0), SEVERITY_SCORE.get((v.severity or 'unknown').lower(), 0), 1 if v.source == 'nuclei' else 0, len(v.evidence or ''), diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 65b503a..b418a61 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib + from attack_surface_mapper.models.vulnerability import Vulnerability from attack_surface_mapper.utils.asset_normalizer import normalize_asset @@ -22,6 +24,35 @@ 7: 'critical', } +SCORING_VERSION = '1.0' +SEVERITY_BASE_SCORE = { + 'info': 8, + 'low': 20, + 'medium': 45, + 'high': 68, + 'critical': 85, + 'unknown': 0, +} +CONFIDENCE_ADJUSTMENTS = { + 'low': -15, + 'medium': 0, + 'high': 10, +} +ROLE_ADJUSTMENTS = { + 'discovery': -30, + 'candidate': -5, + 'validated': 15, +} +VALIDATION_BASIS_ADJUSTMENTS = { + 'context-only': -10, + 'discarded-signal': -25, + 'heuristic-evidence': -5, + 'template-evidence': 6, + 'configuration-evidence': 8, + 'response-evidence': 14, + 'correlated-evidence': 18, +} + NETWORK_DISCOVERY_CATEGORIES = {'network-service', 'database', 'remote-access', 'message-broker', 'admin-surface', 'web-service', 'file-transfer', 'search-service'} CATEGORY_RECOMMENDATIONS: dict[str, str] = { @@ -59,13 +90,26 @@ def infer_kind(vulnerability: Vulnerability) -> str: category = (vulnerability.category or '').lower() - if category in {'discovery'} or vulnerability.title.startswith('Technology Fingerprint Detected'): + if _is_inventory_or_context_finding(vulnerability): return 'discovery' if category in {'headers', 'tls', 'authentication', 'api', 'panel-exposure', 'web-service', 'network-service', 'secret', 'sensitive-file'}: return 'validation' return 'other' +def _is_inventory_or_context_finding(vulnerability: Vulnerability) -> bool: + category = (vulnerability.category or '').lower() + title = (vulnerability.title or '').lower() + return ( + category == 'discovery' + or title.startswith('technology fingerprint detected') + or title.startswith('multiple api endpoints exposed') + or title.startswith('protected api surface discovered') + or title.startswith('multiple client-side api references observed') + or title == 'client-side api reference observed' + ) + + def clean_evidence(text: str | None, max_len: int = 280) -> str | None: if not text: return text @@ -76,13 +120,68 @@ def _base_priority(vulnerability: Vulnerability) -> int: return SEVERITY_SCORE.get((vulnerability.severity or 'unknown').lower(), 0) -def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: +def _priority_label_from_score(score: int) -> str: + if score >= 85: + return 'critical' + if score >= 60: + return 'high' + if score >= 30: + return 'medium' + return 'low' + + +def _derive_validation_basis(vulnerability: Vulnerability) -> str: + verification = (vulnerability.verification_status or '').lower() + category = (vulnerability.category or '').lower() + title = (vulnerability.title or '').lower() + + if _is_inventory_or_context_finding(vulnerability): + return 'context-only' + if verification == 'discarded': + return 'discarded-signal' + if verification == 'confirmed' and vulnerability.source_count > 1: + return 'correlated-evidence' + if vulnerability.source == 'nuclei': + return 'template-evidence' + if category in {'headers', 'tls'} or title.startswith('cookie without '): + return 'configuration-evidence' + if verification == 'confirmed': + return 'response-evidence' + return 'heuristic-evidence' + + +def _apply_validation_model(vulnerability: Vulnerability) -> None: + kind = vulnerability.kind or infer_kind(vulnerability) + verification = (vulnerability.verification_status or '').lower() + + vulnerability.kind = kind + vulnerability.validation_basis = _derive_validation_basis(vulnerability) + + if kind == 'discovery' or _is_inventory_or_context_finding(vulnerability): + vulnerability.finding_role = 'discovery' + vulnerability.validated = False + vulnerability.needs_manual_validation = False + return + + if verification == 'confirmed': + vulnerability.finding_role = 'validated' + vulnerability.validated = True + vulnerability.needs_manual_validation = False + return + + vulnerability.finding_role = 'candidate' + vulnerability.validated = False + + +def compute_priority_v1(vulnerability: Vulnerability) -> tuple[str, str]: score = _base_priority(vulnerability) reasons: list[str] = [f'severidad base={vulnerability.severity.lower()}'] category = (vulnerability.category or '').lower() target = (vulnerability.target or '').lower() + title = (vulnerability.title or '').lower() confidence = (vulnerability.confidence or '').lower() verification = (vulnerability.verification_status or '').lower() + finding_role = (vulnerability.finding_role or '').lower() if category in {'secret', 'authentication', 'database', 'message-broker'}: score += 1 @@ -96,27 +195,60 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: if vulnerability.cvss_score is not None and vulnerability.cvss_score >= 7: score += 1 reasons.append(f'cvss elevado={vulnerability.cvss_score}') - if vulnerability.source_count > 1: + if finding_role == 'validated' and category not in {'headers', 'discovery'} and (vulnerability.severity or '').lower() not in {'low', 'info'}: + score += 1 + reasons.append('hallazgo validado') + if vulnerability.source_count > 1 and verification == 'confirmed': score += 1 - reasons.append('múltiples fuentes correlacionadas') + reasons.append('múltiples fuentes correlacionadas con confirmación') + elif vulnerability.source_count > 1 and confidence == 'high': + score += 1 + reasons.append('múltiples fuentes correlacionadas de alta confianza') + elif vulnerability.source_count > 1: + reasons.append('múltiples fuentes correlacionadas sin confirmación') if vulnerability.title.startswith('Multiple API Endpoints Exposed'): score += 1 reasons.append('múltiples endpoints de API expuestos') if any(token in target for token in ('/metrics', '/actuator', '/swagger', '/openapi', '/graphql')) and 'localhost' not in target: score += 1 reasons.append('exposición remota fuera de localhost') - if verification in {'heuristic', 'needs_manual_validation'}: + if verification in {'likely', 'heuristic', 'needs_manual_validation'}: score -= 1 reasons.append(f'validación={verification}') + if finding_role == 'candidate': + score = min(score, 4) + reasons.append('hallazgo candidato pendiente de validación') if confidence == 'low': score -= 2 reasons.append('confianza baja') elif confidence == 'medium' and vulnerability.needs_manual_validation: score -= 1 reasons.append('requiere validación manual') - if category == 'discovery': - score = 1 - reasons.append('hallazgo de descubrimiento, impacto limitado') + if category == 'headers' and (vulnerability.severity or '').lower() in {'low', 'info'}: + score = min(score, 1) + reasons.append('cabecera de bajo impacto') + if title in {'swagger ui exposed', 'openapi specification exposed', 'api surface exposed'}: + score = min(score, 3) + reasons.append('documentación o superficie api: prioridad acotada') + if title == 'graphql surface exposed': + max_score = 4 if verification == 'confirmed' and confidence == 'high' else 3 + score = min(score, max_score) + reasons.append('superficie graphql pública: prioridad acotada') + if title == 'graphql endpoint accessible without authentication' and verification != 'confirmed': + score = min(score, 3) + reasons.append('graphql sin prueba suficiente de acceso indebido') + if title.startswith('multiple api endpoints exposed'): + max_score = 4 if verification == 'confirmed' and confidence == 'high' else 3 + score = min(score, max_score) + reasons.append('inventario de múltiples endpoints api') + if finding_role == 'discovery' or category == 'discovery': + if title.startswith('multiple api endpoints exposed'): + score = min(score, 3) + score = max(score, 2) + reasons.append('inventario api amplio, prioridad de descubrimiento acotada') + else: + score = 1 + reasons.append('hallazgo de descubrimiento, impacto limitado') if vulnerability.source == 'nuclei' and ('epmd' in target or 'rabbit' in (vulnerability.evidence_summary or '').lower() or 'erlang port mapper' in (vulnerability.title or '').lower()): score = max(score, 2) score = min(score, 3) @@ -133,6 +265,98 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: return PRIORITY_LABELS[score], '; '.join(reasons) +def compute_priority(vulnerability: Vulnerability) -> tuple[str, int, str]: + category = (vulnerability.category or '').lower() + target = (vulnerability.target or '').lower() + title = (vulnerability.title or '').lower() + confidence = (vulnerability.confidence or '').lower() + verification = (vulnerability.verification_status or '').lower() + finding_role = (vulnerability.finding_role or '').lower() + validation_basis = (vulnerability.validation_basis or '').lower() + severity = (vulnerability.severity or 'unknown').lower() + + score = SEVERITY_BASE_SCORE.get(severity, 0) + reasons: list[str] = [f'severidad base={severity}:{score}'] + + def bump(delta: int, reason: str) -> None: + nonlocal score + score += delta + reasons.append(f'{reason} ({delta:+d})') + + bump(CONFIDENCE_ADJUSTMENTS.get(confidence, 0), f'confianza={confidence or "unknown"}') + bump(ROLE_ADJUSTMENTS.get(finding_role, 0), f'rol={finding_role or "unset"}') + bump(VALIDATION_BASIS_ADJUSTMENTS.get(validation_basis, 0), f'base={validation_basis or "unset"}') + + if category in {'secret', 'authentication', 'database', 'message-broker'}: + bump(8, f'categoría sensible={category}') + elif category in {'panel-exposure', 'api', 'sensitive-file', 'network-service', 'message-broker', 'admin-surface', 'remote-access'}: + bump(5, f'categoría expuesta={category}') + + if '/admin' in target or '/metrics' in target or '/swagger' in target or '/graphql' in target: + bump(8, 'endpoint de alto interés') + if any(token in target for token in ('/metrics', '/actuator', '/swagger', '/openapi', '/graphql')) and 'localhost' not in target: + bump(6, 'exposición remota fuera de localhost') + if vulnerability.source == 'nuclei' and any(token in target for token in ('/metrics', 'prometheus')): + bump(10, 'template nuclei sobre endpoint de métricas') + if vulnerability.cvss_score is not None and vulnerability.cvss_score >= 7: + bump(8, f'cvss elevado={vulnerability.cvss_score}') + + if vulnerability.source_count > 1 and verification == 'confirmed': + bump(10, 'múltiples fuentes correlacionadas con confirmación') + elif vulnerability.source_count > 1 and confidence == 'high': + bump(6, 'múltiples fuentes correlacionadas de alta confianza') + elif vulnerability.source_count > 1: + reasons.append('múltiples fuentes correlacionadas sin confirmación (+0)') + + if verification in {'likely', 'heuristic', 'needs_manual_validation'}: + bump(-8, f'validación={verification}') + if vulnerability.needs_manual_validation: + bump(-5, 'requiere validación manual') + + if category == 'headers' and severity in {'low', 'info'}: + score = min(score, 25) + reasons.append('cabecera de bajo impacto (tope 25)') + elif category == 'headers' and severity == 'medium': + score = min(score, 59) + reasons.append('cabecera media de endurecimiento: tope medium') + + if title in {'swagger ui exposed', 'openapi specification exposed', 'api surface exposed'}: + score = min(score, 49) + reasons.append('documentación o superficie api: tope medium') + if title == 'graphql surface exposed': + max_score = 59 if verification == 'confirmed' and confidence == 'high' else 49 + score = min(score, max_score) + reasons.append(f'superficie graphql pública: tope {max_score}') + if title == 'graphql endpoint accessible without authentication' and verification != 'confirmed': + score = min(score, 59) + reasons.append('graphql sin prueba suficiente de acceso indebido (tope high bajo)') + if title.startswith('multiple api endpoints exposed'): + if finding_role == 'discovery': + score = min(score, 49) + score = max(score, 30) + reasons.append('inventario api amplio de descubrimiento: banda medium') + else: + max_score = 59 if verification == 'confirmed' and confidence == 'high' else 49 + score = min(score, max_score) + reasons.append(f'inventario de múltiples endpoints api: tope {max_score}') + if finding_role == 'discovery' or category == 'discovery': + score = min(score, 34) + reasons.append('hallazgo de descubrimiento/contexto: tope medium bajo') + if vulnerability.source == 'nuclei' and ('epmd' in target or 'rabbit' in (vulnerability.evidence_summary or '').lower() or 'erlang port mapper' in title): + score = min(max(score, 30), 49) + reasons.append('middleware Erlang/RabbitMQ potencialmente expuesto: banda medium') + + if vulnerability.source == 'nmap' and category in NETWORK_DISCOVERY_CATEGORIES and category not in {'database', 'admin-surface'}: + score = min(score, 49) + reasons.append('descubrimiento de red: tope medium') + if vulnerability.source == 'nmap' and category == 'web-service' and (vulnerability.port or '') in {'80', '443'}: + score = min(score, 20) + reasons.append('servicio web estándar esperado: tope low') + + score = max(0, min(score, 100)) + return _priority_label_from_score(score), score, '; '.join(reasons) + + def compute_confidence(vulnerability: Vulnerability) -> str: if vulnerability.confidence: return vulnerability.confidence @@ -173,35 +397,82 @@ def build_evidence_summary(vulnerability: Vulnerability) -> str | None: return compact +def _stable_identifier(namespace: str, *parts: object) -> str: + raw = '|'.join('' if part is None else str(part).strip().lower() for part in (namespace, *parts)) + return hashlib.sha1(raw.encode('utf-8')).hexdigest() + + +def _normalise_validation_state(vulnerability: Vulnerability) -> None: + verification = (vulnerability.verification_status or '').lower() + confidence = (vulnerability.confidence or '').lower() + category = (vulnerability.category or '').lower() + inventory_like = _is_inventory_or_context_finding(vulnerability) + + if verification == 'confirmed': + vulnerability.needs_manual_validation = False + if confidence == 'low': + vulnerability.confidence = 'medium' + confidence = 'medium' + elif verification in {'likely', 'needs_manual_validation', 'heuristic'}: + vulnerability.needs_manual_validation = True + + if inventory_like: + vulnerability.needs_manual_validation = False + elif category in {'authentication', 'api', 'secret'} and verification != 'confirmed': + vulnerability.needs_manual_validation = True + + if ( + not inventory_like + and vulnerability.source != 'nuclei' + and confidence in {'low', 'medium'} + and category in {'authentication', 'api', 'panel-exposure', 'sensitive-file'} + and verification != 'confirmed' + ): + vulnerability.needs_manual_validation = True + + if not vulnerability.verification_status: + if vulnerability.confidence == 'high' and not vulnerability.needs_manual_validation: + vulnerability.verification_status = 'confirmed' + elif vulnerability.needs_manual_validation: + vulnerability.verification_status = 'needs_manual_validation' + else: + vulnerability.verification_status = 'likely' + + def enrich_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnerability]: for vulnerability in vulnerabilities: vulnerability.confidence = compute_confidence(vulnerability) if vulnerability.category == 'discovery' and (vulnerability.verification_status or '').lower() == 'confirmed': vulnerability.confidence = 'high' - if vulnerability.category in {'authentication', 'api', 'secret'}: - vulnerability.needs_manual_validation = True - if (vulnerability.verification_status or '').lower() in {'likely', 'needs_manual_validation'}: - vulnerability.needs_manual_validation = True - vulnerability.priority, vulnerability.priority_reason = compute_priority(vulnerability) + _normalise_validation_state(vulnerability) + vulnerability.kind = infer_kind(vulnerability) + _apply_validation_model(vulnerability) + vulnerability.priority, vulnerability.priority_score, vulnerability.priority_reason = compute_priority(vulnerability) + vulnerability.scoring_version = SCORING_VERSION vulnerability.recommendation = vulnerability.recommendation or compute_recommendation(vulnerability) vulnerability.evidence_summary = clean_evidence(vulnerability.evidence_summary or build_evidence_summary(vulnerability)) vulnerability.evidence = clean_evidence(vulnerability.evidence, max_len=1200) - vulnerability.kind = infer_kind(vulnerability) asset = normalize_asset(vulnerability.target or vulnerability.matched_at or '') vulnerability.target_host_original = asset.get('target_host_original') vulnerability.asset_host = asset.get('asset_host') + vulnerability.asset_host_resolved = asset.get('asset_host_resolved') vulnerability.asset_port = asset.get('asset_port') if vulnerability.host and not vulnerability.asset_host: - vulnerability.asset_host = vulnerability.host + vulnerability.asset_host = str(vulnerability.host).lower() + if vulnerability.host and not vulnerability.asset_host_resolved and str(vulnerability.host).lower() != str(vulnerability.asset_host or '').lower(): + vulnerability.asset_host_resolved = str(vulnerability.host).lower() if vulnerability.port and not vulnerability.asset_port: vulnerability.asset_port = vulnerability.port - if vulnerability.confidence in {'low', 'medium'} and vulnerability.category in {'authentication', 'api', 'panel-exposure', 'sensitive-file'}: - vulnerability.needs_manual_validation = True - if not vulnerability.verification_status: - if vulnerability.confidence == 'high' and not vulnerability.needs_manual_validation: - vulnerability.verification_status = 'confirmed' - elif vulnerability.needs_manual_validation: - vulnerability.verification_status = 'needs_manual_validation' - else: - vulnerability.verification_status = 'likely' + vulnerability.finding_id = _stable_identifier( + 'finding', + *(vulnerability.dedup_key()), + vulnerability.asset_host, + vulnerability.asset_port, + ) + vulnerability.correlation_id = _stable_identifier( + 'correlation', + *(vulnerability.correlation_key()), + vulnerability.asset_host, + vulnerability.asset_port, + ) return vulnerabilities diff --git a/src/attack_surface_mapper/batch/aggregate.py b/src/attack_surface_mapper/batch/aggregate.py index 5817859..5ef6e70 100644 --- a/src/attack_surface_mapper/batch/aggregate.py +++ b/src/attack_surface_mapper/batch/aggregate.py @@ -8,10 +8,28 @@ from typing import Iterable from attack_surface_mapper.orchestrator import ScanResult +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.reporting.review_matrix import review_bucket_for_finding NETWORK_DISCOVERY_CATEGORIES = {'network-service', 'database', 'remote-access', 'message-broker', 'admin-surface', 'web-service', 'file-transfer', 'search-service'} +PRIORITY_LABELS = ('critical', 'high', 'medium', 'low') +SEVERITY_LABELS = ('critical', 'high', 'medium', 'low', 'info', 'unknown') _PRIORITY_ORDER = {'critical': 4, 'high': 3, 'medium': 2, 'low': 1} +_PRIORITY_SORT = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} +_SEVERITY_SORT = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3, 'info': 4, 'unknown': 5} +_VERIFICATION_SORT = {'confirmed': 0, 'likely': 1, 'needs_manual_validation': 2, 'heuristic': 3, 'discarded': 4} +_CONFIDENCE_SORT = {'high': 0, 'medium': 1, 'low': 2} +_CATEGORY_SORT = { + 'authentication': 0, + 'api': 1, + 'panel-exposure': 2, + 'sensitive-file': 3, + 'secret': 4, + 'tls': 5, + 'headers': 6, + 'discovery': 7, +} _IPV4_RE = re.compile(r'^(?:\d{1,3}\.){3}\d{1,3}$') @@ -25,21 +43,78 @@ def _best_severity(current: str | None, candidate: str | None) -> str | None: return _best_priority(current, candidate) +def _stable_counts(counter: Counter, labels: tuple[str, ...]) -> dict[str, int]: + return {label: int(counter.get(label, 0)) for label in labels} + + def _is_ipv4(value: str | None) -> bool: return bool(value and _IPV4_RE.match(value)) +def _finding_role_record(item: dict) -> str: + explicit_role = str(item.get('finding_role') or '').lower() + if explicit_role: + return explicit_role + title = str(item.get('title') or '').lower() + category = str(item.get('category') or '').lower() + kind = str(item.get('kind') or '').lower() + verification = str(item.get('verification_status') or '').lower() + if ( + kind == 'discovery' + or category == 'discovery' + or title.startswith('multiple api endpoints exposed') + or title.startswith('protected api surface discovered') + or title.startswith('multiple client-side api references observed') + or title == 'client-side api reference observed' + or title.startswith('technology fingerprint detected') + ): + return 'discovery' + if bool(item.get('validated')) or verification == 'confirmed': + return 'validated' + if kind == 'validation' or verification in {'likely', 'needs_manual_validation', 'heuristic'}: + return 'candidate' + return '' + + def _derive_target_asset_hosts(results: Iterable[ScanResult]) -> dict[str, str | None]: mapping: dict[str, str | None] = {} for result in results: - ips = sorted({v.host for v in result.vulnerabilities if _is_ipv4(v.host)}) + ips = sorted({ + v.asset_host_resolved + for v in result.vulnerabilities + if _is_ipv4(v.asset_host_resolved) + } | { + v.host + for v in result.vulnerabilities + if _is_ipv4(v.host) + }) mapping[result.target] = ips[0] if len(ips) == 1 else None return mapping +def _display_asset_host(result_target: str, vuln, target_asset_hosts: dict[str, str | None]) -> str: + if vuln.asset_host: + return str(vuln.asset_host).lower() + if vuln.target_host_original: + return str(vuln.target_host_original).lower() + if vuln.host: + return str(vuln.host).lower() + location = str(vuln.matched_at or vuln.target or '') + if '://' in location: + from urllib.parse import urlparse + parsed = urlparse(location) + if parsed.hostname: + return str(parsed.hostname).lower() + return target_asset_hosts.get(result_target) or str(location).lower() + + def _canonical_asset_host(result_target: str, vuln, target_asset_hosts: dict[str, str | None]) -> str: + if _is_ipv4(vuln.asset_host_resolved): + return str(vuln.asset_host_resolved) if _is_ipv4(vuln.host): return str(vuln.host) + if vuln.asset_host: + return str(vuln.asset_host).lower() resolved = target_asset_hosts.get(result_target) if resolved: return resolved @@ -60,6 +135,72 @@ def _network_title_key(vuln) -> str: return title +def _is_inventory_like_record(item: dict) -> bool: + title = str(item.get('title') or '').lower() + category = str(item.get('category') or '').lower() + finding_role = _finding_role_record(item) + return ( + finding_role == 'discovery' + or category == 'discovery' + or title.startswith('multiple api endpoints exposed') + or title.startswith('protected api surface discovered') + or title.startswith('multiple client-side api references observed') + or title == 'client-side api reference observed' + or title.startswith('technology fingerprint detected') + ) + + +def _top_finding_bucket(item: dict) -> int: + if _is_inventory_like_record(item): + return 3 + if str(item.get('category') or '').lower() == 'headers': + return 2 + if bool(item.get('validated')) or _finding_role_record(item) == 'validated': + return 0 + return 1 + + +def _split_aggregate_findings(items: list[dict]) -> tuple[list[dict], list[dict], list[dict], list[dict], list[dict]]: + actionable_risk: list[dict] = [] + review_surface: list[dict] = [] + hygiene: list[dict] = [] + network: list[dict] = [] + discovery: list[dict] = [] + for item in items: + category = str(item.get('category') or '').lower() + if category in NETWORK_DISCOVERY_CATEGORIES: + network.append(item) + continue + if category == 'discovery': + discovery.append(item) + continue + if category in {'headers', 'tls'}: + hygiene.append(item) + continue + bucket = review_bucket_for_finding(item) + if bucket == 'priorizar': + actionable_risk.append(item) + elif bucket == 'descubrimiento': + discovery.append(item) + else: + review_surface.append(item) + return actionable_risk, review_surface, hygiene, network, discovery + + +def _top_finding_sort_key(item: dict) -> tuple: + return ( + _PRIORITY_SORT.get(str(item.get('priority') or '').lower(), 4), + -int(item.get('priority_score') or 0), + _top_finding_bucket(item), + _VERIFICATION_SORT.get(str(item.get('verification_status') or '').lower(), 5), + _CATEGORY_SORT.get(str(item.get('category') or '').lower(), 98), + _SEVERITY_SORT.get(str(item.get('severity') or '').lower(), 6), + _CONFIDENCE_SORT.get(str(item.get('confidence') or '').lower(), 3), + -int(item.get('target_count') or 1), + str(item.get('title') or ''), + ) + + def _aggregate_key(result_target: str, vuln, target_asset_hosts: dict[str, str | None]) -> tuple[str, str, str, str]: category = (vuln.category or 'uncategorised').lower() title = vuln.title or 'Unknown finding' @@ -71,6 +212,7 @@ def _aggregate_key(result_target: str, vuln, target_asset_hosts: dict[str, str | def _build_finding_record(result_target: str, vuln, target_asset_hosts: dict[str, str | None]) -> dict: canonical_host = _canonical_asset_host(result_target, vuln, target_asset_hosts) + display_host = _display_asset_host(result_target, vuln, target_asset_hosts) location = vuln.matched_at or vuln.target if (vuln.category or '').lower() in NETWORK_DISCOVERY_CATEGORIES and canonical_host and vuln.port: location = f'{canonical_host}:{vuln.port}' @@ -78,15 +220,29 @@ def _build_finding_record(result_target: str, vuln, target_asset_hosts: dict[str 'target': result_target, 'location': location, 'title': vuln.title, + 'finding_id': vuln.finding_id, + 'correlation_id': vuln.correlation_id, + 'scoring_version': vuln.scoring_version, 'priority': vuln.priority, + 'priority_score': vuln.priority_score, + 'priority_reason': vuln.priority_reason, 'severity': vuln.severity, 'category': vuln.category, + 'kind': vuln.kind, + 'finding_role': vuln.finding_role, + 'validated': vuln.validated, + 'validation_basis': vuln.validation_basis, + 'confidence': vuln.confidence, 'verification_status': vuln.verification_status, + 'source_count': vuln.source_count, + 'evidence_summary': vuln.evidence_summary, 'recommendation': vuln.recommendation, 'targets': [result_target], - 'asset_host': canonical_host, - 'asset_port': vuln.port, - 'asset_hosts': [canonical_host] if canonical_host else [], + 'target_host_original': vuln.target_host_original, + 'asset_host': display_host, + 'asset_host_resolved': canonical_host if _is_ipv4(canonical_host) else vuln.asset_host_resolved, + 'asset_port': vuln.asset_port or vuln.port, + 'asset_hosts': [display_host] if display_host else [], 'scope': 'target-specific', } @@ -97,6 +253,7 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: total_targets = len(result_list) raw_total_findings = sum(len(r.vulnerabilities) for r in result_list) priority_counter = Counter() + severity_counter = Counter() category_counter = Counter() per_target: list[dict] = [] aggregated_findings: dict[tuple[str, str, str, str], dict] = {} @@ -104,6 +261,7 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: for result in result_list: for vuln in result.vulnerabilities: priority_counter[(vuln.priority or vuln.severity).lower()] += 1 + severity_counter[(vuln.severity or 'unknown').lower()] += 1 category_counter[(vuln.category or 'uncategorised').lower()] += 1 key = _aggregate_key(result.target, vuln, target_asset_hosts) if key not in aggregated_findings: @@ -112,7 +270,7 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: record = aggregated_findings[key] if result.target not in record['targets']: record['targets'].append(result.target) - host = _canonical_asset_host(result.target, vuln, target_asset_hosts) + host = _display_asset_host(result.target, vuln, target_asset_hosts) if host and host not in record['asset_hosts']: record['asset_hosts'].append(host) prior_priority = record.get('priority') @@ -120,12 +278,28 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: record['severity'] = _best_severity(record.get('severity'), vuln.severity) candidate_score = _PRIORITY_ORDER.get((vuln.priority or '').lower(), 0) record_score = _PRIORITY_ORDER.get((prior_priority or '').lower(), 0) - if candidate_score >= record_score: + if candidate_score > record_score or not prior_priority: record['verification_status'] = vuln.verification_status record['recommendation'] = vuln.recommendation - # Prefer more descriptive titles when merging the same asset view. - if len(vuln.title or '') > len(record.get('title') or ''): - record['title'] = vuln.title + record['finding_id'] = vuln.finding_id + record['correlation_id'] = vuln.correlation_id + record['scoring_version'] = vuln.scoring_version + record['kind'] = vuln.kind + record['finding_role'] = vuln.finding_role + record['validated'] = vuln.validated + record['validation_basis'] = vuln.validation_basis + record['priority_score'] = vuln.priority_score + record['priority_reason'] = vuln.priority_reason + record['confidence'] = vuln.confidence + record['source_count'] = vuln.source_count + record['evidence_summary'] = vuln.evidence_summary + record['target_host_original'] = vuln.target_host_original + record['asset_host'] = _display_asset_host(result.target, vuln, target_asset_hosts) + record['asset_host_resolved'] = _canonical_asset_host(result.target, vuln, target_asset_hosts) + record['asset_port'] = vuln.asset_port or vuln.port + # Prefer more descriptive titles even when the priority is tied. + if len(vuln.title or '') > len(record.get('title') or ''): + record['title'] = vuln.title per_target.append({ 'target': result.target, @@ -142,6 +316,7 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: top_findings = list(aggregated_findings.values()) for finding in top_findings: finding['targets'].sort() + finding['asset_hosts'].sort() finding['target_count'] = len(finding['targets']) finding['target_labels'] = ', '.join(finding['targets']) finding['asset_host_count'] = len([host for host in finding.get('asset_hosts', []) if host]) @@ -150,26 +325,47 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: else: finding['scope'] = 'target-specific' - top_findings.sort( - key=lambda item: ( - _PRIORITY_ORDER.get((item.get('priority') or '').lower(), 0), - item.get('target_count', 1), - item['title'], - ), - reverse=True, - ) + top_findings.sort(key=_top_finding_sort_key) + per_target.sort(key=lambda item: item['target']) shared_asset_findings = [f for f in top_findings if f.get('scope') == 'shared-host'] target_specific_findings = [f for f in top_findings if f.get('scope') != 'shared-host'] + actionable_risk_findings, review_surface_findings, hygiene_findings, network_findings, discovery_findings = _split_aggregate_findings(target_specific_findings) return { + 'schema_version': '1.0', + 'scoring_version': max({v.scoring_version for result in result_list for v in result.vulnerabilities if v.scoring_version} or {'1.0'}), + 'finding_contract': Vulnerability.contract_metadata(), + 'summary': { + 'total_targets': total_targets, + 'raw_total_findings': raw_total_findings, + 'total_findings': len(top_findings), + 'average_priority_score': round(sum(int(item.get('priority_score') or 0) for item in top_findings) / len(top_findings), 1) if top_findings else None, + 'validated_findings': sum(1 for item in top_findings if bool(item.get('validated')) or _finding_role_record(item) == 'validated'), + 'candidate_findings': sum(1 for item in top_findings if _finding_role_record(item) == 'candidate'), + 'actionable_risk_findings': len(actionable_risk_findings), + 'review_surface_findings': len(review_surface_findings), + 'hygiene_findings': len(hygiene_findings), + 'discovery_findings': len(discovery_findings), + 'priority_counts': _stable_counts(priority_counter, PRIORITY_LABELS), + 'severity_counts': _stable_counts(severity_counter, SEVERITY_LABELS), + 'category_counts': dict(sorted(category_counter.items(), key=lambda item: (-item[1], item[0]))), + }, 'total_targets': total_targets, 'raw_total_findings': raw_total_findings, 'total_findings': len(top_findings), - 'priority_counts': dict(priority_counter), - 'category_counts': dict(category_counter), + 'average_priority_score': round(sum(int(item.get('priority_score') or 0) for item in top_findings) / len(top_findings), 1) if top_findings else None, + 'validated_findings': sum(1 for item in top_findings if bool(item.get('validated')) or _finding_role_record(item) == 'validated'), + 'candidate_findings': sum(1 for item in top_findings if _finding_role_record(item) == 'candidate'), + 'priority_counts': _stable_counts(priority_counter, PRIORITY_LABELS), + 'severity_counts': _stable_counts(severity_counter, SEVERITY_LABELS), + 'category_counts': dict(sorted(category_counter.items(), key=lambda item: (-item[1], item[0]))), 'per_target': per_target, 'shared_asset_findings': shared_asset_findings[:50], 'target_specific_findings': target_specific_findings[:50], + 'top_risk_findings': actionable_risk_findings[:25], + 'top_review_findings': review_surface_findings[:25], + 'top_hygiene_findings': hygiene_findings[:25], + 'top_discovery_findings': discovery_findings[:25], 'top_findings': top_findings[:50], 'target_asset_hosts': target_asset_hosts, } @@ -185,6 +381,11 @@ def write_aggregate_reports(results: Iterable[ScanResult], output_dir: str) -> d summary_json.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8') md = output / 'aggregate_report.md' + vuln_findings = payload['top_risk_findings'] + review_surface_findings = payload['top_review_findings'] + hygiene_findings = payload['top_hygiene_findings'] + network_findings = [f for f in payload['target_specific_findings'] if f.get('category') in NETWORK_DISCOVERY_CATEGORIES] + discovery_findings = payload['top_discovery_findings'] lines = [ '# Informe agregado de batch scan', '', @@ -200,9 +401,6 @@ def write_aggregate_reports(results: Iterable[ScanResult], output_dir: str) -> d for item in payload['per_target']: lines.append(f"- **{item['target']}**: {item['findings']} hallazgos, prioridades {item['priorities']}") - vuln_findings = [f for f in payload['target_specific_findings'] if f.get('category') not in NETWORK_DISCOVERY_CATEGORIES and f.get('category') != 'discovery'] - network_findings = [f for f in payload['target_specific_findings'] if f.get('category') in NETWORK_DISCOVERY_CATEGORIES] - discovery_findings = [f for f in payload['target_specific_findings'] if f.get('category') == 'discovery'] shared_assets = [f for f in payload['shared_asset_findings'] if f.get('category') in NETWORK_DISCOVERY_CATEGORIES] lines += ['', '## Vulnerabilidades y misconfiguraciones más relevantes', ''] @@ -212,6 +410,20 @@ def write_aggregate_reports(results: Iterable[ScanResult], output_dir: str) -> d suffix = f" | targets: {finding['target_labels']}" if finding.get('target_labels') else '' lines.append(f"- **{finding['priority'] or finding['severity']}** | `{finding['location']}` | {finding['title']} ({finding['category']}){suffix}") + lines += ['', '## Superficie pública a revisar', ''] + if not review_surface_findings: + lines.append('- No se detectó superficie pública relevante pendiente de revisión manual en el agregado.') + for finding in review_surface_findings: + suffix = f" | targets: {finding['target_labels']}" if finding.get('target_labels') else '' + lines.append(f"- **{finding['priority'] or finding['severity']}** | `{finding['location']}` | {finding['title']} ({finding['category']}){suffix}") + + lines += ['', '## Higiene y endurecimiento', ''] + if not hygiene_findings: + lines.append('- No se detectaron hallazgos adicionales de higiene o endurecimiento en el agregado.') + for finding in hygiene_findings: + suffix = f" | targets: {finding['target_labels']}" if finding.get('target_labels') else '' + lines.append(f"- **{finding['priority'] or finding['severity']}** | `{finding['location']}` | {finding['title']} ({finding['category']}){suffix}") + lines += ['', '## Activos compartidos entre targets', ''] if not shared_assets: lines.append('- No se detectaron activos compartidos relevantes en el agregado.') @@ -239,19 +451,30 @@ def write_aggregate_reports(results: Iterable[ScanResult], output_dir: str) -> d csv_path = output / 'aggregate_findings.csv' with csv_path.open('w', encoding='utf-8', newline='') as handle: writer = csv.writer(handle) - writer.writerow(['scope', 'targets', 'target_count', 'location', 'asset_host', 'asset_port', 'title', 'priority', 'severity', 'category', 'verification_status', 'recommendation']) + writer.writerow(['scope', 'targets', 'target_count', 'location', 'target_host_original', 'asset_host', 'asset_host_resolved', 'asset_port', 'finding_id', 'correlation_id', 'title', 'priority', 'priority_score', 'scoring_version', 'priority_reason', 'severity', 'category', 'kind', 'finding_role', 'validated', 'validation_basis', 'verification_status', 'recommendation']) for finding in payload['top_findings']: writer.writerow([ finding.get('scope', ''), finding.get('target_labels', ''), finding.get('target_count', 1), finding.get('location', ''), + finding.get('target_host_original', ''), finding.get('asset_host', ''), + finding.get('asset_host_resolved', ''), finding.get('asset_port', ''), + finding.get('finding_id', ''), + finding.get('correlation_id', ''), finding['title'], finding['priority'], + finding.get('priority_score', ''), + finding.get('scoring_version', ''), + finding.get('priority_reason', ''), finding['severity'], finding['category'], + finding.get('kind', ''), + finding.get('finding_role', ''), + str(bool(finding.get('validated'))).lower(), + finding.get('validation_basis', ''), finding.get('verification_status', ''), finding['recommendation'], ]) diff --git a/src/attack_surface_mapper/collectors/web/__init__.py b/src/attack_surface_mapper/collectors/web/__init__.py index 11bc29a..26102c5 100644 --- a/src/attack_surface_mapper/collectors/web/__init__.py +++ b/src/attack_surface_mapper/collectors/web/__init__.py @@ -1,6 +1,7 @@ from attack_surface_mapper.collectors.web.http import ( HttpResponse, RequestError, + add_debug_trace, build_http_session, get_debug_trace, http_get, @@ -11,6 +12,7 @@ __all__ = [ 'HttpResponse', 'RequestError', + 'add_debug_trace', 'build_http_session', 'get_debug_trace', 'http_get', diff --git a/src/attack_surface_mapper/core/scan_context.py b/src/attack_surface_mapper/core/scan_context.py index 58c94da..881675f 100644 --- a/src/attack_surface_mapper/core/scan_context.py +++ b/src/attack_surface_mapper/core/scan_context.py @@ -26,6 +26,7 @@ class ScanArtifacts: observed_actions: list[dict[str, Any]] = field(default_factory=list) observed_api_calls: list[str] = field(default_factory=list) entry_response: Any | None = None + shared_baseline: Any | None = None @dataclass(slots=True) diff --git a/src/attack_surface_mapper/models/vulnerability.py b/src/attack_surface_mapper/models/vulnerability.py index 1e4e31e..27733d6 100644 --- a/src/attack_surface_mapper/models/vulnerability.py +++ b/src/attack_surface_mapper/models/vulnerability.py @@ -3,6 +3,43 @@ from dataclasses import asdict, dataclass, field from typing import Any +FINDING_SCHEMA_VERSION = '1.0' +FINDING_REQUIRED_FIELDS = ( + 'finding_id', + 'correlation_id', + 'title', + 'target', + 'finding_role', + 'validated', + 'priority', + 'severity', + 'category', + 'kind', + 'confidence', + 'verification_status', + 'source_count', + 'asset_host', + 'asset_host_resolved', + 'asset_port', +) +FINDING_IDENTITY_FIELDS = ('finding_id', 'correlation_id', 'title', 'target', 'target_host_original') +FINDING_CLASSIFICATION_FIELDS = ( + 'finding_role', + 'validated', + 'validation_basis', + 'scoring_version', + 'priority_score', + 'priority', + 'severity', + 'category', + 'kind', + 'confidence', + 'verification_status', +) +FINDING_ASSET_FIELDS = ('asset_host', 'asset_host_resolved', 'asset_port') +FINDING_ASSESSMENT_FIELDS = ('finding_role', 'validated', 'validation_basis', 'kind', 'confidence', 'verification_status') +FINDING_SCORING_FIELDS = ('scoring_version', 'priority_score', 'priority_reason') + @dataclass(slots=True) class Vulnerability: @@ -39,9 +76,17 @@ class Vulnerability: related_evidence: list[str] = field(default_factory=list) raw: dict[str, Any] = field(default_factory=dict) kind: str | None = None + finding_role: str | None = None + validated: bool = False + validation_basis: str | None = None + scoring_version: str | None = None + priority_score: int | None = None target_host_original: str | None = None asset_host: str | None = None + asset_host_resolved: str | None = None asset_port: str | None = None + finding_id: str | None = None + correlation_id: str | None = None def dedup_key(self) -> tuple[str | None, str | None, str | None, str | None]: return ( @@ -59,19 +104,12 @@ def correlation_key(self) -> tuple[str, str, str]: title = (self.template_id or self.title or '').strip().lower() parsed = urlparse(location if '://' in location else f'http://placeholder{location}') host_key = parsed.netloc or parsed.path.split('/')[0] or location + route_groupable = ( + category in {'api', 'authentication', 'panel-exposure', 'exposure', 'misconfiguration'} + or self.source == 'nuclei' + or self.title.startswith('Protected ') + ) - if any(token in location for token in ('/metrics', 'prometheus')): - return 'exposure', 'metrics-exposure', '/metrics' - if any(token in location for token in ('/swagger', '/swagger-ui', '/openapi', '/api-docs')): - return 'exposure', 'api-docs-exposure', '/api-docs' - if '/graphql' in location: - return 'exposure', 'graphql-exposure', '/graphql' - if '/actuator' in location: - return 'exposure', 'actuator-exposure', '/actuator' - if any(token in location for token in ('/admin', '/dashboard', '/management')): - return 'exposure', 'admin-surface-exposure', '/admin-surface' - if '/login' in location: - return 'exposure', 'login-surface', '/login' if self.title == 'API Surface Exposed': return 'api', 'api-surface-exposed', host_key if self.title.startswith('Protected API Surface Discovered'): @@ -82,11 +120,26 @@ def correlation_key(self) -> tuple[str, str, str]: return 'discovery', title, host_key if category == 'headers': title = f'header-{self.title.strip().lower()}' - elif category == 'tls': - title = 'tls-configuration' - elif category == 'secret': - title = 'secret-exposure' - elif category == 'authentication': + return category, title, location + if category == 'tls': + return category, 'tls-configuration', location + if category == 'secret': + return category, 'secret-exposure', location + + if route_groupable and any(token in location for token in ('/metrics', 'prometheus')): + return 'exposure', 'metrics-exposure', '/metrics' + if route_groupable and any(token in location for token in ('/swagger', '/swagger-ui', '/openapi', '/api-docs')): + return 'exposure', 'api-docs-exposure', '/api-docs' + if route_groupable and '/graphql' in location: + return 'exposure', 'graphql-exposure', '/graphql' + if route_groupable and any(token in location for token in ('/actuator', '/management')): + return 'exposure', 'operations-exposure', '/operations' + if route_groupable and any(token in location for token in ('/admin', '/dashboard')): + return 'exposure', 'admin-surface-exposure', '/admin-surface' + if route_groupable and '/login' in location: + return 'exposure', 'login-surface', '/login' + + if category == 'authentication': title = 'authentication-issue' elif category == 'discovery': title = f'discovery-{self.title.strip().lower()}' @@ -95,3 +148,45 @@ def correlation_key(self) -> tuple[str, str, str]: def to_dict(self) -> dict[str, Any]: return asdict(self) + + @staticmethod + def contract_metadata() -> dict[str, Any]: + return { + 'schema_version': FINDING_SCHEMA_VERSION, + 'required_fields': list(FINDING_REQUIRED_FIELDS), + 'identity_fields': list(FINDING_IDENTITY_FIELDS), + 'classification_fields': list(FINDING_CLASSIFICATION_FIELDS), + 'assessment_fields': list(FINDING_ASSESSMENT_FIELDS), + 'scoring_fields': list(FINDING_SCORING_FIELDS), + 'asset_fields': list(FINDING_ASSET_FIELDS), + } + + def to_summary_record(self) -> dict[str, Any]: + record = { + 'finding_id': self.finding_id, + 'correlation_id': self.correlation_id, + 'title': self.title, + 'target': self.target, + 'finding_role': self.finding_role, + 'validated': self.validated, + 'validation_basis': self.validation_basis, + 'scoring_version': self.scoring_version, + 'priority_score': self.priority_score, + 'priority_reason': self.priority_reason, + 'priority': self.priority, + 'severity': self.severity, + 'category': self.category, + 'kind': self.kind, + 'confidence': self.confidence, + 'verification_status': self.verification_status, + 'source_count': self.source_count, + 'target_host_original': self.target_host_original, + 'asset_host': self.asset_host, + 'asset_host_resolved': self.asset_host_resolved, + 'asset_port': self.asset_port, + 'evidence_summary': self.evidence_summary, + 'recommendation': self.recommendation, + } + for field_name in FINDING_REQUIRED_FIELDS: + record.setdefault(field_name, None) + return record diff --git a/src/attack_surface_mapper/pipeline/stages.py b/src/attack_surface_mapper/pipeline/stages.py index 922fdeb..ebd09c8 100644 --- a/src/attack_surface_mapper/pipeline/stages.py +++ b/src/attack_surface_mapper/pipeline/stages.py @@ -7,7 +7,7 @@ from attack_surface_mapper.collectors.crawling import BrowserDiscoveryCollector, CrawlerCollector from attack_surface_mapper.collectors.nmap import NmapCollector from attack_surface_mapper.collectors.nuclei import NucleiCollector -from attack_surface_mapper.collectors.web import RequestError, get_debug_trace, http_get, reset_debug_trace, set_debug_trace_enabled +from attack_surface_mapper.collectors.web import RequestError, add_debug_trace, build_http_session, get_debug_trace, http_get, reset_debug_trace, set_debug_trace_enabled from attack_surface_mapper.core import ScanContext from attack_surface_mapper.reporting import ReportGenerator from attack_surface_mapper.utils.io import save_vulnerabilities_json @@ -17,6 +17,7 @@ from attack_surface_mapper.validators.discovery import analyse_documents, findings_from_analysis from attack_surface_mapper.validators.fingerprint_validator import FingerprintValidator from attack_surface_mapper.validators.headers_validator import HeadersValidator +from attack_surface_mapper.validators.http_fingerprint import baseline_fingerprint from attack_surface_mapper.validators.panels_validator import PanelsValidator from attack_surface_mapper.validators.secrets_validator import SecretsValidator from attack_surface_mapper.validators.sensitive_files_validator import SensitiveFilesValidator @@ -30,6 +31,25 @@ def run(self, context: ScanContext) -> ScanContext: # pragma: no cover - interf return context +def _ensure_http_trace(context: ScanContext) -> None: + if not context.settings.debug: + return + if not context.debug.enabled: + context.debug.enabled = True + set_debug_trace_enabled(True) + reset_debug_trace() + + +def _merge_http_trace(context: ScanContext) -> None: + if not context.settings.debug: + return + merged = list(context.debug.http_trace) + for event in get_debug_trace(): + if event not in merged: + merged.append(event) + context.debug.http_trace = merged + + class NucleiStage(PipelineStage): name = 'nuclei' @@ -102,6 +122,7 @@ def run(self, context: ScanContext) -> ScanContext: if not s.run_crawl or not s.browser_discovery_enabled: context.debug.counts[self.name] = 0 return context + _ensure_http_trace(context) context.mark_stage(self.name) collector_backend = (s.crawler_backend or s.http_backend) context.mark_collector(f"browser:{collector_backend}") @@ -119,7 +140,8 @@ def run(self, context: ScanContext) -> ScanContext: ) result = collector.collect(context.target) except Exception as exc: - context.debug.http_trace.append({'component': 'browser_discovery', 'event': 'collector_error', 'error': str(exc)}) + add_debug_trace({'component': 'browser_discovery', 'event': 'collector_error', 'error': str(exc)}) + _merge_http_trace(context) result = None if result is None: context.debug.counts[self.name] = 0 @@ -137,6 +159,7 @@ def run(self, context: ScanContext) -> ScanContext: for api_url in result.observed_api_calls: context.add_api_call(api_url) context.debug.counts[self.name] = len(result.observed_urls) + len(result.observed_api_calls) + _merge_http_trace(context) return context class PassiveValidationStage(PipelineStage): @@ -149,21 +172,32 @@ def run(self, context: ScanContext) -> ScanContext: return context context.mark_stage(self.name) context.mark_collector(context.settings.http_backend or 'requests') - context.debug.enabled = bool(s.debug) - set_debug_trace_enabled(bool(s.debug)) - reset_debug_trace() + _ensure_http_trace(context) - preloaded_response = None - if s.observed_only: - preloaded_response = context.artifacts.entry_response - if preloaded_response is not None: + preloaded_response = context.artifacts.entry_response + if preloaded_response is not None: + context.add_observed(preloaded_response.url) + elif s.observed_only: + try: + preloaded_response = http_get(context.target, timeout=s.validator_timeout, allow_redirects=True, user_agent=s.user_agent, backend=s.http_backend, mode=s.http_mode) context.add_observed(preloaded_response.url) - else: - try: - preloaded_response = http_get(context.target, timeout=s.validator_timeout, allow_redirects=True, user_agent=s.user_agent, backend=s.http_backend, mode=s.http_mode) - context.add_observed(preloaded_response.url) - except RequestError: - preloaded_response = None + except RequestError: + preloaded_response = None + + shared_baseline = context.artifacts.shared_baseline + baseline_candidates_enabled = any((s.run_sensitive_files, s.run_panels, s.run_auth, s.run_api)) + if shared_baseline is None and s.baseline_probe and baseline_candidates_enabled: + try: + with build_http_session(backend=s.http_backend, mode=s.http_mode, timeout=s.validator_timeout, user_agent=s.user_agent) as baseline_session: + shared_baseline = baseline_fingerprint(baseline_session, context.target, s.validator_timeout) + except RequestError: + shared_baseline = None + context.artifacts.shared_baseline = shared_baseline + add_debug_trace({ + 'component': 'passive_validation', + 'event': 'shared_baseline_computed', + 'available': bool(shared_baseline), + }) def _safe_collect(name: str, fn) -> None: try: @@ -184,7 +218,7 @@ def _safe_collect(name: str, fn) -> None: context.add_findings(items) _safe_collect('fingerprint', lambda: FingerprintValidator(timeout=s.validator_timeout, backend=s.http_backend, mode=s.http_mode, user_agent=s.user_agent).run(context.target, response=preloaded_response)) if s.run_sensitive_files: - _safe_collect('sensitive_files', lambda: SensitiveFilesValidator(timeout=max(3, min(s.validator_timeout, 6)), backend=s.http_backend, mode=s.http_mode, user_agent=s.user_agent, use_baseline_probe=s.baseline_probe).run(context.target)) + _safe_collect('sensitive_files', lambda: SensitiveFilesValidator(timeout=max(3, min(s.validator_timeout, 6)), backend=s.http_backend, mode=s.http_mode, user_agent=s.user_agent, use_baseline_probe=s.baseline_probe).run(context.target, baseline=shared_baseline)) crawled_documents: dict[str, str] = context.artifacts.crawled_documents or {} if s.run_crawl and not crawled_documents and not s.observed_only: @@ -206,15 +240,15 @@ def _safe_collect(name: str, fn) -> None: if s.run_panels: seed_panel_paths = tuple(s.panel_paths) if s.panel_paths else () effective_panel_paths = tuple(dict.fromkeys([*seed_panel_paths, *discovery_analysis.panel_paths, *(observed_auth_paths if s.observed_only else ())])) or None - _safe_collect('panels', lambda: PanelsValidator(timeout=max(3, min(s.validator_timeout, 6)), paths=effective_panel_paths, backend=s.http_backend, mode=s.http_mode, user_agent=s.user_agent, use_baseline_probe=s.baseline_probe).run(context.target)) + _safe_collect('panels', lambda: PanelsValidator(timeout=max(3, min(s.validator_timeout, 6)), paths=effective_panel_paths, backend=s.http_backend, mode=s.http_mode, user_agent=s.user_agent, use_baseline_probe=s.baseline_probe).run(context.target, baseline=shared_baseline)) if s.run_auth: auth_seed = AuthValidator.DEFAULT_PROTECTED_PATHS if enum_mode else () effective_auth_paths = tuple(dict.fromkeys([*auth_seed, *discovery_analysis.auth_paths, *observed_auth_paths])) - _safe_collect('auth', lambda: AuthValidator(timeout=max(3, min(s.validator_timeout, 6)), paths=effective_auth_paths, backend=s.http_backend, mode=s.http_mode, user_agent=s.user_agent, use_baseline_probe=s.baseline_probe, observed_only=s.observed_only).run(context.target)) + _safe_collect('auth', lambda: AuthValidator(timeout=max(3, min(s.validator_timeout, 6)), paths=effective_auth_paths, backend=s.http_backend, mode=s.http_mode, user_agent=s.user_agent, use_baseline_probe=s.baseline_probe, observed_only=s.observed_only).run(context.target, baseline=shared_baseline)) if s.run_api: api_seed = APIValidator.DEFAULT_PATHS if enum_mode else () effective_api_paths = tuple(dict.fromkeys([*api_seed, *discovery_analysis.api_paths, *observed_api_paths])) - _safe_collect('api', lambda: APIValidator(timeout=max(3, min(s.validator_timeout, 6)), paths=effective_api_paths, backend=s.http_backend, mode=s.http_mode, user_agent=s.user_agent, use_baseline_probe=s.baseline_probe, observed_only=s.observed_only).run(context.target)) + _safe_collect('api', lambda: APIValidator(timeout=max(3, min(s.validator_timeout, 6)), paths=effective_api_paths, backend=s.http_backend, mode=s.http_mode, user_agent=s.user_agent, use_baseline_probe=s.baseline_probe, observed_only=s.observed_only).run(context.target, baseline=shared_baseline)) discovery_findings = findings_from_analysis(context.target, discovery_analysis) context.debug.counts['discovery'] = len(discovery_findings) @@ -240,7 +274,7 @@ def _safe_collect(name: str, fn) -> None: context.debug.counts['crawl_candidate_paths'] = len(discovery_analysis.candidate_paths) context.debug.counts['crawl_forms'] = len(discovery_analysis.forms) context.debug.counts['crawl_js_hints'] = len(discovery_analysis.js_hints) - context.debug.http_trace = get_debug_trace() + _merge_http_trace(context) return context diff --git a/src/attack_surface_mapper/reporting/__init__.py b/src/attack_surface_mapper/reporting/__init__.py index db25f48..646bee3 100644 --- a/src/attack_surface_mapper/reporting/__init__.py +++ b/src/attack_surface_mapper/reporting/__init__.py @@ -1,3 +1,23 @@ from .generator import ReportGenerator, ReportPaths, ReportStats +from .elasticsearch_export import export_elasticsearch_bundle +from .review_matrix import ( + build_review_rows, + evaluate_review_rows_against_golden_set, + load_review_golden_set, + load_review_matrix, + review_bucket_for_finding, + write_review_matrix, +) -__all__ = ['ReportGenerator', 'ReportPaths', 'ReportStats'] +__all__ = [ + 'ReportGenerator', + 'ReportPaths', + 'ReportStats', + 'export_elasticsearch_bundle', + 'build_review_rows', + 'evaluate_review_rows_against_golden_set', + 'load_review_golden_set', + 'load_review_matrix', + 'review_bucket_for_finding', + 'write_review_matrix', +] diff --git a/src/attack_surface_mapper/reporting/elasticsearch_export.py b/src/attack_surface_mapper/reporting/elasticsearch_export.py new file mode 100644 index 0000000..be82693 --- /dev/null +++ b/src/attack_surface_mapper/reporting/elasticsearch_export.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def _utc_timestamp() -> str: + return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') + + +def _load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding='utf-8')) + + +def _write_json(path: Path, payload: Any) -> None: + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') + + +def _write_text(path: Path, content: str) -> None: + path.write_text(content.rstrip() + '\n', encoding='utf-8') + + +def _keyword_text_field() -> dict[str, Any]: + return { + 'type': 'text', + 'fields': { + 'keyword': { + 'type': 'keyword', + 'ignore_above': 1024, + } + }, + } + + +def findings_mapping() -> dict[str, Any]: + return { + 'mappings': { + 'dynamic': False, + 'properties': { + 'document_type': {'type': 'keyword'}, + 'run_name': {'type': 'keyword'}, + 'exported_at': {'type': 'date'}, + 'finding_id': {'type': 'keyword'}, + 'correlation_id': {'type': 'keyword'}, + 'source': {'type': 'keyword'}, + 'title': _keyword_text_field(), + 'target': {'type': 'keyword'}, + 'matched_at': {'type': 'keyword'}, + 'target_host_original': {'type': 'keyword'}, + 'asset_host': {'type': 'keyword'}, + 'asset_host_resolved': {'type': 'keyword'}, + 'asset_port': {'type': 'integer'}, + 'scheme': {'type': 'keyword'}, + 'category': {'type': 'keyword'}, + 'severity': {'type': 'keyword'}, + 'priority': {'type': 'keyword'}, + 'priority_score': {'type': 'integer'}, + 'scoring_version': {'type': 'keyword'}, + 'confidence': {'type': 'keyword'}, + 'verification_status': {'type': 'keyword'}, + 'finding_role': {'type': 'keyword'}, + 'validated': {'type': 'boolean'}, + 'validation_basis': {'type': 'keyword'}, + 'kind': {'type': 'keyword'}, + 'needs_manual_validation': {'type': 'boolean'}, + 'source_count': {'type': 'integer'}, + 'title_family': {'type': 'keyword'}, + 'recommendation': _keyword_text_field(), + 'evidence_summary': _keyword_text_field(), + 'priority_reason': _keyword_text_field(), + 'description': _keyword_text_field(), + 'cve': {'type': 'keyword'}, + 'cwe': {'type': 'keyword'}, + 'tags': {'type': 'keyword'}, + 'references': {'type': 'keyword'}, + 'related_sources': {'type': 'keyword'}, + 'related_titles': {'type': 'keyword'}, + 'related_targets': {'type': 'keyword'}, + 'related_evidence': {'type': 'keyword'}, + }, + } + } + + +def summaries_mapping() -> dict[str, Any]: + counts_object = { + 'type': 'object', + 'dynamic': True, + } + return { + 'mappings': { + 'dynamic': False, + 'properties': { + 'document_type': {'type': 'keyword'}, + 'run_name': {'type': 'keyword'}, + 'exported_at': {'type': 'date'}, + 'schema_version': {'type': 'keyword'}, + 'scoring_version': {'type': 'keyword'}, + 'target': {'type': 'keyword'}, + 'target_slug': {'type': 'keyword'}, + 'source_report': {'type': 'keyword'}, + 'title': _keyword_text_field(), + 'executive_summary': _keyword_text_field(), + 'total_findings': {'type': 'integer'}, + 'average_priority_score': {'type': 'float'}, + 'validated_findings': {'type': 'integer'}, + 'candidate_findings': {'type': 'integer'}, + 'application_findings': {'type': 'integer'}, + 'review_surface_findings': {'type': 'integer'}, + 'hygiene_findings': {'type': 'integer'}, + 'discovery_findings': {'type': 'integer'}, + 'needs_manual_validation': {'type': 'integer'}, + 'confirmed_high_or_critical': {'type': 'integer'}, + 'priority_counts': counts_object, + 'severity_counts': counts_object, + 'category_counts': counts_object, + 'source_counts': counts_object, + 'comparison_summary': {'type': 'flattened'}, + 'finding_contract': {'type': 'flattened'}, + }, + } + } + + +def runs_mapping() -> dict[str, Any]: + return { + 'mappings': { + 'dynamic': False, + 'properties': { + 'document_type': {'type': 'keyword'}, + 'run_name': {'type': 'keyword'}, + 'exported_at': {'type': 'date'}, + 'schema_version': {'type': 'keyword'}, + 'profile': {'type': 'keyword'}, + 'run_dir': {'type': 'keyword'}, + 'requested_targets': {'type': 'keyword'}, + 'targets': {'type': 'keyword'}, + 'error_count': {'type': 'integer'}, + 'errors': _keyword_text_field(), + 'pipeline': {'type': 'flattened'}, + 'effective_config': {'type': 'flattened'}, + 'results_summary': {'type': 'flattened'}, + 'aggregate_reports': {'type': 'flattened'}, + }, + } + } + + +def _to_int(value: Any) -> int | None: + if value in (None, ''): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _title_family(title: str | None) -> str: + if not title: + return 'unknown' + lowered = title.lower() + if lowered.startswith('multiple api endpoints exposed'): + return 'multiple-api-endpoints-exposed' + if lowered.startswith('protected api surface discovered'): + return 'protected-api-surface-discovered' + if lowered.startswith('multiple client-side api references observed'): + return 'multiple-client-side-api-references-observed' + if lowered.startswith('technology fingerprint detected'): + return 'technology-fingerprint-detected' + return lowered + + +def _build_finding_document(item: dict[str, Any], *, run_name: str, exported_at: str) -> dict[str, Any]: + return { + 'document_type': 'finding', + 'run_name': run_name, + 'exported_at': exported_at, + 'finding_id': item.get('finding_id'), + 'correlation_id': item.get('correlation_id'), + 'source': item.get('source'), + 'title': item.get('title'), + 'title_family': _title_family(item.get('title')), + 'target': item.get('target'), + 'matched_at': item.get('matched_at'), + 'target_host_original': item.get('target_host_original'), + 'asset_host': item.get('asset_host'), + 'asset_host_resolved': item.get('asset_host_resolved'), + 'asset_port': _to_int(item.get('asset_port')), + 'scheme': item.get('scheme'), + 'category': item.get('category'), + 'severity': item.get('severity'), + 'priority': item.get('priority'), + 'priority_score': _to_int(item.get('priority_score')), + 'scoring_version': item.get('scoring_version'), + 'confidence': item.get('confidence'), + 'verification_status': item.get('verification_status'), + 'finding_role': item.get('finding_role'), + 'validated': bool(item.get('validated')), + 'validation_basis': item.get('validation_basis'), + 'kind': item.get('kind'), + 'needs_manual_validation': bool(item.get('needs_manual_validation')), + 'source_count': _to_int(item.get('source_count')) or 0, + 'recommendation': item.get('recommendation'), + 'evidence_summary': item.get('evidence_summary'), + 'priority_reason': item.get('priority_reason'), + 'description': item.get('description'), + 'cve': item.get('cve') or [], + 'cwe': item.get('cwe') or [], + 'tags': item.get('tags') or [], + 'references': item.get('references') or [], + 'related_sources': item.get('related_sources') or [], + 'related_titles': item.get('related_titles') or [], + 'related_targets': item.get('related_targets') or [], + 'related_evidence': item.get('related_evidence') or [], + } + + +def _build_target_summary_document( + payload: dict[str, Any], + *, + run_name: str, + target_slug: str, + source_report: str, + exported_at: str, +) -> dict[str, Any]: + stats = payload.get('stats') or {} + return { + 'document_type': 'target_summary', + 'run_name': run_name, + 'exported_at': exported_at, + 'schema_version': payload.get('schema_version'), + 'scoring_version': payload.get('scoring_version'), + 'target': payload.get('target'), + 'target_slug': target_slug, + 'source_report': source_report, + 'title': payload.get('title'), + 'executive_summary': payload.get('executive_summary'), + 'total_findings': _to_int(stats.get('total_findings')) or 0, + 'average_priority_score': stats.get('average_priority_score'), + 'validated_findings': _to_int(stats.get('validated_findings')) or 0, + 'candidate_findings': _to_int(stats.get('candidate_findings')) or 0, + 'application_findings': _to_int(stats.get('application_findings')) or 0, + 'review_surface_findings': _to_int(stats.get('review_surface_findings')) or 0, + 'hygiene_findings': _to_int(stats.get('hygiene_findings')) or 0, + 'discovery_findings': _to_int(stats.get('discovery_findings')) or 0, + 'needs_manual_validation': _to_int(stats.get('needs_manual_validation')) or 0, + 'confirmed_high_or_critical': _to_int(stats.get('confirmed_high_or_critical')) or 0, + 'priority_counts': stats.get('priority_counts') or {}, + 'severity_counts': stats.get('severity_counts') or {}, + 'category_counts': stats.get('category_counts') or {}, + 'source_counts': stats.get('source_counts') or {}, + 'comparison_summary': payload.get('comparison_summary') or {}, + 'finding_contract': payload.get('finding_contract') or {}, + } + + +def _build_aggregate_summary_document( + payload: dict[str, Any], + *, + run_name: str, + source_report: str, + exported_at: str, +) -> dict[str, Any]: + summary = payload.get('summary') or {} + return { + 'document_type': 'aggregate_summary', + 'run_name': run_name, + 'exported_at': exported_at, + 'schema_version': payload.get('schema_version'), + 'scoring_version': payload.get('scoring_version'), + 'target': None, + 'target_slug': None, + 'source_report': source_report, + 'title': 'Resumen agregado del run', + 'executive_summary': None, + 'total_findings': _to_int(summary.get('total_findings')) or 0, + 'average_priority_score': summary.get('average_priority_score'), + 'validated_findings': _to_int(summary.get('validated_findings')) or 0, + 'candidate_findings': _to_int(summary.get('candidate_findings')) or 0, + 'application_findings': _to_int(summary.get('actionable_risk_findings')) or 0, + 'review_surface_findings': _to_int(summary.get('review_surface_findings')) or 0, + 'hygiene_findings': _to_int(summary.get('hygiene_findings')) or 0, + 'discovery_findings': _to_int(summary.get('discovery_findings')) or 0, + 'needs_manual_validation': 0, + 'confirmed_high_or_critical': 0, + 'priority_counts': summary.get('priority_counts') or {}, + 'severity_counts': summary.get('severity_counts') or {}, + 'category_counts': summary.get('category_counts') or {}, + 'source_counts': {}, + 'comparison_summary': {}, + 'finding_contract': payload.get('finding_contract') or {}, + } + + +def _build_run_document(payload: dict[str, Any], *, run_name: str, exported_at: str) -> dict[str, Any]: + errors = payload.get('errors') or [] + return { + 'document_type': 'run_manifest', + 'run_name': run_name, + 'exported_at': exported_at, + 'schema_version': payload.get('schema_version'), + 'profile': payload.get('profile'), + 'run_dir': payload.get('run_dir'), + 'requested_targets': payload.get('requested_targets') or [], + 'targets': payload.get('targets') or [], + 'error_count': len(errors), + 'errors': [str(item) for item in errors], + 'pipeline': payload.get('pipeline') or {}, + 'effective_config': payload.get('effective_config') or {}, + 'results_summary': payload.get('results_summary') or {}, + 'aggregate_reports': payload.get('aggregate_reports') or {}, + } + + +def _bundle_manifest( + *, + run_name: str, + run_dir: Path, + output_dir: Path, + index_prefix: str, + exported_at: str, + findings_count: int, + summaries_count: int, + runs_count: int, + warnings: list[str], +) -> dict[str, Any]: + return { + 'run_name': run_name, + 'run_dir': str(run_dir), + 'output_dir': str(output_dir), + 'index_prefix': index_prefix, + 'exported_at': exported_at, + 'documents': { + 'findings': findings_count, + 'summaries': summaries_count, + 'runs': runs_count, + }, + 'indices': { + 'findings': f'{index_prefix}-findings', + 'summaries': f'{index_prefix}-summaries', + 'runs': f'{index_prefix}-runs', + }, + 'artifacts': { + 'findings_mapping': 'findings_mapping.json', + 'summaries_mapping': 'summaries_mapping.json', + 'runs_mapping': 'runs_mapping.json', + 'findings_bulk': 'findings_bulk.ndjson', + 'summaries_bulk': 'summaries_bulk.ndjson', + 'runs_bulk': 'runs_bulk.ndjson', + 'manual_helper': 'manual_kibana_devtools.md', + 'curl_helper': 'ingest_with_curl.sh', + 'python_helper': 'ingest_with_python.py', + }, + 'warnings': warnings, + } + + +def _write_bulk( + path: Path, + *, + index_name: str, + documents: list[dict[str, Any]], + id_getter, +) -> None: + lines: list[str] = [] + for document in documents: + doc_id = str(id_getter(document) or '') + lines.append(json.dumps({'index': {'_index': index_name, '_id': doc_id}}, ensure_ascii=False)) + lines.append(json.dumps(document, ensure_ascii=False)) + path.write_text('\n'.join(lines).rstrip() + '\n', encoding='utf-8') + + +def _write_manual_kibana( + output_path: Path, + *, + output_dir: Path, + run_name: str, + index_prefix: str, +) -> None: + lines = [ + '# Ingesta manual en Kibana Dev Tools', + '', + f'Run exportado: `{run_name}`', + f'Bundle local: `{output_dir}`', + '', + '## 1. Crear índices con el mapping exportado', + '', + 'Abre los archivos de mapping del bundle y pega su contenido en Dev Tools:', + '', + f'- `PUT /{index_prefix}-findings` con el contenido de `findings_mapping.json`', + f'- `PUT /{index_prefix}-summaries` con el contenido de `summaries_mapping.json`', + f'- `PUT /{index_prefix}-runs` con el contenido de `runs_mapping.json`', + '', + '## 2. Cargar los NDJSON', + '', + 'En Dev Tools no se puede referenciar directamente un archivo local, así que abre cada `*_bulk.ndjson`', + 'y pega su contenido completo en una petición `_bulk` separada:', + '', + f'- `POST /{index_prefix}-findings/_bulk` con `findings_bulk.ndjson`', + f'- `POST /{index_prefix}-summaries/_bulk` con `summaries_bulk.ndjson`', + f'- `POST /{index_prefix}-runs/_bulk` con `runs_bulk.ndjson`', + '', + '## 3. Verificación rápida', + '', + '```http', + f'GET /{index_prefix}-findings/_count', + '', + f'GET /{index_prefix}-summaries/_search?q=document_type:aggregate_summary', + '', + f'GET /{index_prefix}-findings/_search', + '{', + ' "size": 5,', + ' "query": {', + ' "term": {', + ' "finding_role": "validated"', + ' }', + ' },', + ' "sort": [', + ' { "priority_score": "desc" }', + ' ]', + '}', + '```', + ] + _write_text(output_path, '\n'.join(lines)) + + +def _write_curl_helper( + output_path: Path, + *, + index_prefix: str, +) -> None: + content = f"""#!/usr/bin/env sh +set -eu + +ES_URL="${{ES_URL:-http://localhost:9200}}" + +curl -sS -X PUT "$ES_URL/{index_prefix}-findings" -H 'Content-Type: application/json' --data-binary @findings_mapping.json +curl -sS -X PUT "$ES_URL/{index_prefix}-summaries" -H 'Content-Type: application/json' --data-binary @summaries_mapping.json +curl -sS -X PUT "$ES_URL/{index_prefix}-runs" -H 'Content-Type: application/json' --data-binary @runs_mapping.json + +curl -sS -X POST "$ES_URL/{index_prefix}-findings/_bulk" -H 'Content-Type: application/x-ndjson' --data-binary @findings_bulk.ndjson +curl -sS -X POST "$ES_URL/{index_prefix}-summaries/_bulk" -H 'Content-Type: application/x-ndjson' --data-binary @summaries_bulk.ndjson +curl -sS -X POST "$ES_URL/{index_prefix}-runs/_bulk" -H 'Content-Type: application/x-ndjson' --data-binary @runs_bulk.ndjson + +curl -sS "$ES_URL/{index_prefix}-findings/_count" +curl -sS "$ES_URL/{index_prefix}-findings/_search?q=finding_role:validated&sort=priority_score:desc&size=5" +""" + _write_text(output_path, content) + + +def _write_python_helper( + output_path: Path, + *, + index_prefix: str, +) -> None: + content = f"""from __future__ import annotations + +import json +from pathlib import Path + +import requests + + +ES_URL = 'http://localhost:9200' +BASE_DIR = Path(__file__).resolve().parent + + +def put_json(index_name: str, filename: str) -> None: + payload = json.loads((BASE_DIR / filename).read_text(encoding='utf-8')) + response = requests.put( + f'{{ES_URL}}/{{index_name}}', + headers={{'Content-Type': 'application/json'}}, + json=payload, + timeout=30, + ) + response.raise_for_status() + + +def post_bulk(index_name: str, filename: str) -> None: + payload = (BASE_DIR / filename).read_text(encoding='utf-8') + response = requests.post( + f'{{ES_URL}}/{{index_name}}/_bulk', + headers={{'Content-Type': 'application/x-ndjson'}}, + data=payload.encode('utf-8'), + timeout=60, + ) + response.raise_for_status() + + +put_json('{index_prefix}-findings', 'findings_mapping.json') +put_json('{index_prefix}-summaries', 'summaries_mapping.json') +put_json('{index_prefix}-runs', 'runs_mapping.json') + +post_bulk('{index_prefix}-findings', 'findings_bulk.ndjson') +post_bulk('{index_prefix}-summaries', 'summaries_bulk.ndjson') +post_bulk('{index_prefix}-runs', 'runs_bulk.ndjson') + +response = requests.get( + f'{{ES_URL}}/{index_prefix}-findings/_search', + params={{'q': 'finding_role:validated', 'sort': 'priority_score:desc', 'size': 5}}, + timeout=30, +) +response.raise_for_status() +print(response.text) +""" + _write_text(output_path, content) + + +def export_elasticsearch_bundle(run_dir: str | Path, *, index_prefix: str = 'attack-surface-mapper') -> dict[str, Any]: + run_path = Path(run_dir) + if not run_path.exists() or not run_path.is_dir(): + raise FileNotFoundError(f'No existe el directorio de run: {run_path}') + output_dir = run_path / 'elasticsearch' + output_dir.mkdir(parents=True, exist_ok=True) + + run_name = run_path.name + exported_at = _utc_timestamp() + warnings: list[str] = [] + + findings_docs: list[dict[str, Any]] = [] + for findings_path in sorted(run_path.glob('targets/*/findings/vulnerabilities.json')): + for item in _load_json(findings_path): + findings_docs.append(_build_finding_document(item, run_name=run_name, exported_at=exported_at)) + if not findings_docs: + warnings.append('No se encontraron findings en targets/*/findings/vulnerabilities.json') + + summaries_docs: list[dict[str, Any]] = [] + for summary_path in sorted(run_path.glob('targets/*/reports/report.summary.json')): + payload = _load_json(summary_path) + target_slug = summary_path.parents[1].name + summaries_docs.append(_build_target_summary_document( + payload, + run_name=run_name, + target_slug=target_slug, + source_report=str(summary_path), + exported_at=exported_at, + )) + aggregate_summary_path = run_path / 'reports' / 'aggregate_summary.json' + if aggregate_summary_path.exists(): + summaries_docs.append(_build_aggregate_summary_document( + _load_json(aggregate_summary_path), + run_name=run_name, + source_report=str(aggregate_summary_path), + exported_at=exported_at, + )) + else: + warnings.append('No se encontró reports/aggregate_summary.json') + if not summaries_docs: + warnings.append('No se encontraron summaries exportables') + + run_docs: list[dict[str, Any]] = [] + run_manifest_path = run_path / 'run_manifest.json' + if run_manifest_path.exists(): + run_docs.append(_build_run_document(_load_json(run_manifest_path), run_name=run_name, exported_at=exported_at)) + else: + warnings.append('No se encontró run_manifest.json') + + _write_json(output_dir / 'findings_mapping.json', findings_mapping()) + _write_json(output_dir / 'summaries_mapping.json', summaries_mapping()) + _write_json(output_dir / 'runs_mapping.json', runs_mapping()) + + _write_bulk( + output_dir / 'findings_bulk.ndjson', + index_name=f'{index_prefix}-findings', + documents=findings_docs, + id_getter=lambda document: document.get('finding_id'), + ) + _write_bulk( + output_dir / 'summaries_bulk.ndjson', + index_name=f'{index_prefix}-summaries', + documents=summaries_docs, + id_getter=lambda document: ( + f'aggregate::{run_name}' + if document.get('document_type') == 'aggregate_summary' + else f"summary::{run_name}::{document.get('target_slug')}" + ), + ) + _write_bulk( + output_dir / 'runs_bulk.ndjson', + index_name=f'{index_prefix}-runs', + documents=run_docs, + id_getter=lambda _document: f'run::{run_name}', + ) + + _write_manual_kibana(output_dir / 'manual_kibana_devtools.md', output_dir=output_dir, run_name=run_name, index_prefix=index_prefix) + _write_curl_helper(output_dir / 'ingest_with_curl.sh', index_prefix=index_prefix) + _write_python_helper(output_dir / 'ingest_with_python.py', index_prefix=index_prefix) + + bundle_manifest = _bundle_manifest( + run_name=run_name, + run_dir=run_path, + output_dir=output_dir, + index_prefix=index_prefix, + exported_at=exported_at, + findings_count=len(findings_docs), + summaries_count=len(summaries_docs), + runs_count=len(run_docs), + warnings=warnings, + ) + _write_json(output_dir / 'export_manifest.json', bundle_manifest) + return bundle_manifest diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index 97597ed..0d7c667 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -9,15 +9,184 @@ from statistics import mean from typing import Iterable -from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.models.vulnerability import FINDING_SCHEMA_VERSION, Vulnerability +from attack_surface_mapper.reporting.review_matrix import review_bucket_for_finding SEVERITY_LABELS = ['critical', 'high', 'medium', 'low', 'info', 'unknown'] PRIORITY_LABELS = ['critical', 'high', 'medium', 'low'] +COMPARISON_KEYS = ('new_findings', 'resolved_findings', 'changed_findings') +OPTIONAL_COMPARISON_KEYS = ('promoted_findings', 'regressed_findings', 'updated_findings') +VERIFICATION_ORDER = {'confirmed': 0, 'likely': 1, 'needs_manual_validation': 2, 'heuristic': 3, 'discarded': 4} +CONFIDENCE_ORDER = {'high': 0, 'medium': 1, 'low': 2} +CATEGORY_ORDER = { + 'authentication': 0, + 'api': 1, + 'panel-exposure': 2, + 'sensitive-file': 3, + 'secret': 4, + 'tls': 5, + 'headers': 6, + 'discovery': 7, +} NETWORK_DISCOVERY_CATEGORIES = {'network-service', 'database', 'remote-access', 'message-broker', 'admin-surface', 'web-service', 'file-transfer', 'search-service'} + +def _stable_counts(counter: Counter, labels: list[str]) -> dict[str, int]: + return {label: int(counter.get(label, 0)) for label in labels} + + +def _nonzero_count_items(counts: dict[str, int]) -> list[tuple[str, int]]: + return [(name, count) for name, count in counts.items() if count > 0] + + +def _normalise_comparison(comparison: dict | None) -> dict[str, list[dict]]: + payload = comparison or {} + normalised = dict(payload) + for key in COMPARISON_KEYS + OPTIONAL_COMPARISON_KEYS: + normalised[key] = list(payload.get(key, []) or []) + summary = dict(payload.get('summary') or {}) + summary.setdefault('new_findings', len(normalised['new_findings'])) + summary.setdefault('resolved_findings', len(normalised['resolved_findings'])) + summary.setdefault('changed_findings', len(normalised['changed_findings'])) + summary.setdefault('promoted_findings', len(normalised['promoted_findings'])) + summary.setdefault('regressed_findings', len(normalised['regressed_findings'])) + summary.setdefault('updated_findings', len(normalised['updated_findings'])) + summary.setdefault('unchanged_findings', 0) + summary.setdefault('change_type_counts', {}) + normalised['summary'] = summary + normalised.setdefault('schema_version', '1.0') + return normalised + + +def _finding_role(vulnerability: Vulnerability) -> str: + explicit_role = (vulnerability.finding_role or '').lower() + if explicit_role: + return explicit_role + title = (vulnerability.title or '').lower() + category = (vulnerability.category or '').lower() + kind = (vulnerability.kind or '').lower() + verification = (vulnerability.verification_status or '').lower() + if ( + kind == 'discovery' + or category == 'discovery' + or title.startswith('multiple api endpoints exposed') + or title.startswith('protected api surface discovered') + or title.startswith('multiple client-side api references observed') + or title == 'client-side api reference observed' + or title.startswith('technology fingerprint detected') + ): + return 'discovery' + if bool(vulnerability.validated) or verification == 'confirmed': + return 'validated' + if kind == 'validation' or verification in {'likely', 'needs_manual_validation', 'heuristic'}: + return 'candidate' + return '' + + +def _is_inventory_like(vulnerability: Vulnerability) -> bool: + title = (vulnerability.title or '').lower() + category = (vulnerability.category or '').lower() + finding_role = _finding_role(vulnerability) + return ( + finding_role == 'discovery' + or category == 'discovery' + or title.startswith('multiple api endpoints exposed') + or title.startswith('protected api surface discovered') + or title.startswith('multiple client-side api references observed') + or title == 'client-side api reference observed' + or title.startswith('technology fingerprint detected') + ) + + +def _sort_bucket(vulnerability: Vulnerability) -> int: + if _is_inventory_like(vulnerability): + return 3 + if (vulnerability.category or '').lower() == 'headers': + return 2 + if _finding_role(vulnerability) == 'validated' or bool(vulnerability.validated): + return 0 + return 1 + + +def _report_sort_key(vulnerability: Vulnerability) -> tuple: + priority_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3, None: 4} + severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3, 'info': 4, 'unknown': 5, None: 6} + return ( + priority_order.get(vulnerability.priority, 4), + -(vulnerability.priority_score or 0), + _sort_bucket(vulnerability), + VERIFICATION_ORDER.get((vulnerability.verification_status or '').lower(), 5), + CATEGORY_ORDER.get((vulnerability.category or '').lower(), 98), + severity_order.get(vulnerability.severity, 6), + CONFIDENCE_ORDER.get((vulnerability.confidence or '').lower(), 3), + -(vulnerability.source_count or 1), + (vulnerability.category or ''), + vulnerability.title, + vulnerability.target, + ) + + +def _is_hygiene_finding(vulnerability: Vulnerability) -> bool: + category = (vulnerability.category or '').lower() + return category in {'headers', 'tls'} + + +def _is_application_finding(vulnerability: Vulnerability) -> bool: + category = (vulnerability.category or '').lower() + return category not in NETWORK_DISCOVERY_CATEGORIES | {'discovery'} and not _is_hygiene_finding(vulnerability) + + +def _is_review_surface(vulnerability: Vulnerability) -> bool: + if not _is_application_finding(vulnerability): + return False + return review_bucket_for_finding(vulnerability.to_summary_record()) == 'revisar' + + +def _is_discovery_like_surface(vulnerability: Vulnerability) -> bool: + if not _is_application_finding(vulnerability): + return False + return review_bucket_for_finding(vulnerability.to_summary_record()) == 'descubrimiento' + + +def _split_report_groups(vulnerabilities: Iterable[Vulnerability]) -> tuple[list[Vulnerability], list[Vulnerability], list[Vulnerability], list[Vulnerability], list[Vulnerability]]: + sorted_vulns = list(vulnerabilities) + application = [v for v in sorted_vulns if _is_application_finding(v) and not _is_review_surface(v) and not _is_discovery_like_surface(v)] + review_surface = [v for v in sorted_vulns if _is_review_surface(v)] + hygiene = [v for v in sorted_vulns if _is_hygiene_finding(v)] + network = [v for v in sorted_vulns if (v.category or '').lower() in NETWORK_DISCOVERY_CATEGORIES] + discovery = [v for v in sorted_vulns if (v.category or '').lower() == 'discovery' or _is_discovery_like_surface(v)] + return application, review_surface, hygiene, network, discovery + + +def _headline_risk_findings(vulnerabilities: Iterable[Vulnerability]) -> list[Vulnerability]: + items = list(vulnerabilities) + prioritised = [ + vuln + for vuln in items + if review_bucket_for_finding({ + 'title': vuln.title, + 'kind': vuln.kind, + 'finding_role': vuln.finding_role, + 'validated': vuln.validated, + 'category': vuln.category, + 'verification_status': vuln.verification_status, + 'confidence': vuln.confidence, + 'priority': vuln.priority, + }) == 'priorizar' + ] + if prioritised: + return prioritised + preferred = [ + vuln + for vuln in items + if (vuln.priority or '').lower() in {'critical', 'high', 'medium'} + or _finding_role(vuln) == 'validated' + ] + return preferred or items + @dataclass(slots=True) class ReportPaths: markdown: str | None = None @@ -37,7 +206,15 @@ class ReportStats: average_cvss: float | None unique_cves: list[str] unique_cwes: list[str] + average_priority_score: float | None needs_manual_validation: int + confirmed_high_or_critical: int + validated_findings: int + candidate_findings: int + application_findings: int + review_surface_findings: int + hygiene_findings: int + discovery_findings: int class ReportGenerator: @@ -46,22 +223,12 @@ def __init__(self, title: str = 'Informe de vulnerabilidades y misconfiguracione @staticmethod def sort_vulnerabilities(vulnerabilities: Iterable[Vulnerability]) -> list[Vulnerability]: - priority_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3, None: 4} - severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3, 'info': 4, 'unknown': 5, None: 6} - return sorted( - vulnerabilities, - key=lambda v: ( - priority_order.get(v.priority, 4), - severity_order.get(v.severity, 6), - (v.category or ''), - v.title, - v.target, - ), - ) + return sorted(vulnerabilities, key=_report_sort_key) @staticmethod def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: vulns = list(vulnerabilities) + application_findings, review_surface_findings, hygiene_findings, _, discovery_findings = _split_report_groups(vulns) severity_counter = Counter((v.severity or 'unknown').lower() for v in vulns) priority_counter = Counter((v.priority or 'low').lower() for v in vulns) category_counter = Counter(v.category or 'uncategorised' for v in vulns) @@ -71,31 +238,53 @@ def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: unique_cwes = sorted({item for v in vulns for item in v.cwe if item}) return ReportStats( total_findings=len(vulns), - severity_counts={label: severity_counter[label] for label in SEVERITY_LABELS if severity_counter.get(label, 0) > 0}, - priority_counts={label: priority_counter[label] for label in PRIORITY_LABELS if priority_counter.get(label, 0) > 0}, + severity_counts=_stable_counts(severity_counter, SEVERITY_LABELS), + priority_counts=_stable_counts(priority_counter, PRIORITY_LABELS), category_counts=dict(sorted(category_counter.items(), key=lambda item: (-item[1], item[0]))), source_counts=dict(sorted(source_counter.items(), key=lambda item: (-item[1], item[0]))), average_cvss=round(mean(cvss_values), 2) if cvss_values else None, + average_priority_score=round(mean([v.priority_score for v in vulns if v.priority_score is not None]), 1) if any(v.priority_score is not None for v in vulns) else None, unique_cves=unique_cves, unique_cwes=unique_cwes, needs_manual_validation=sum( 1 for v in vulns - if v.needs_manual_validation or (v.verification_status or '').lower() in {'likely', 'needs_manual_validation'} + if v.needs_manual_validation or (v.verification_status or '').lower() == 'needs_manual_validation' + ), + confirmed_high_or_critical=sum( + 1 + for v in vulns + if _finding_role(v) == 'validated' + and (v.priority or v.severity or '').lower() in {'critical', 'high'} ), + validated_findings=sum(1 for v in vulns if _finding_role(v) == 'validated' or bool(v.validated)), + candidate_findings=sum(1 for v in vulns if _finding_role(v) == 'candidate'), + application_findings=len(application_findings), + review_surface_findings=len(review_surface_findings), + hygiene_findings=len(hygiene_findings), + discovery_findings=len(discovery_findings), ) @staticmethod def executive_summary(stats: ReportStats) -> str: if stats.total_findings == 0: return 'No se detectaron hallazgos durante la ejecución actual.' - priorities = ', '.join(f'{count} {name}' for name, count in stats.priority_counts.items()) - severities = ', '.join(f'{count} {name}' for name, count in stats.severity_counts.items()) + priorities = ', '.join(f'{count} {name}' for name, count in _nonzero_count_items(stats.priority_counts)) or 'sin prioridades clasificadas' + severities = ', '.join(f'{count} {name}' for name, count in _nonzero_count_items(stats.severity_counts)) or 'sin severidades clasificadas' + high_or_critical = stats.priority_counts.get('critical', 0) + stats.priority_counts.get('high', 0) + if stats.confirmed_high_or_critical: + impact_note = f'Se observaron {stats.confirmed_high_or_critical} hallazgos confirmados de prioridad alta o crítica.' + elif high_or_critical: + impact_note = 'No se observaron hallazgos confirmados de prioridad alta o crítica; los hallazgos de mayor prioridad requieren revisión manual.' + else: + impact_note = 'No se observaron hallazgos de prioridad alta o crítica en esta ejecución.' return ( f'Se identificaron {stats.total_findings} hallazgos correlacionados. ' + f'Estado de validación: {stats.validated_findings} validados, {stats.candidate_findings} candidatos, {stats.discovery_findings} de descubrimiento/contexto. ' + f'Riesgo accionable: {stats.application_findings}; superficie a revisar: {stats.review_surface_findings}; higiene/endurecimiento: {stats.hygiene_findings}; descubrimiento: {stats.discovery_findings}. ' f'Prioridades: {priorities}. Severidades: {severities}. ' f'Hallazgos que requieren validación manual: {stats.needs_manual_validation}. ' - 'No se observaron vulnerabilidades críticas confirmadas en esta ejecución, pero la superficie expuesta descubierta requiere revisión.' + f'{impact_note}' ) @staticmethod @@ -105,27 +294,38 @@ def _write(path: str | Path, content: str) -> str: file_path.write_text(content, encoding='utf-8') return str(file_path) + @staticmethod + def _serialize_top_finding(vulnerability: Vulnerability) -> dict: + return vulnerability.to_summary_record() + def build_summary_payload(self, vulnerabilities: Iterable[Vulnerability], target: str, comparison: dict | None = None) -> dict: sorted_vulns = self.sort_vulnerabilities(vulnerabilities) stats = self.compute_stats(sorted_vulns) + application_findings, review_surface_findings, hygiene_findings, _, discovery_findings = _split_report_groups(sorted_vulns) + headline_risk_findings = _headline_risk_findings(application_findings) + if not headline_risk_findings: + headline_risk_findings = _headline_risk_findings(review_surface_findings) + comparison_payload = _normalise_comparison(comparison) return { + 'schema_version': FINDING_SCHEMA_VERSION, + 'scoring_version': max({v.scoring_version for v in sorted_vulns if v.scoring_version} or {FINDING_SCHEMA_VERSION}), + 'finding_contract': Vulnerability.contract_metadata(), 'title': self.title, 'target': target, 'executive_summary': self.executive_summary(stats), 'stats': asdict(stats), - 'comparison': comparison or {}, - 'top_findings': [ - { - 'title': v.title, - 'target': v.target, - 'priority': v.priority, - 'severity': v.severity, - 'category': v.category, - 'verification_status': v.verification_status, - 'recommendation': v.recommendation, - } - for v in sorted_vulns[:10] - ], + 'comparison': comparison_payload, + 'comparison_summary': dict(comparison_payload.get('summary') or {}), + 'top_finding_count': min(len(sorted_vulns), 10), + 'top_findings': [self._serialize_top_finding(v) for v in sorted_vulns[:10]], + 'top_risk_finding_count': min(len(headline_risk_findings), 5), + 'top_risk_findings': [self._serialize_top_finding(v) for v in headline_risk_findings[:5]], + 'top_review_finding_count': min(len(review_surface_findings), 5), + 'top_review_findings': [self._serialize_top_finding(v) for v in review_surface_findings[:5]], + 'top_hygiene_finding_count': min(len(hygiene_findings), 5), + 'top_hygiene_findings': [self._serialize_top_finding(v) for v in hygiene_findings[:5]], + 'top_discovery_finding_count': min(len(discovery_findings), 5), + 'top_discovery_findings': [self._serialize_top_finding(v) for v in discovery_findings[:5]], } def generate_summary_json(self, vulnerabilities: Iterable[Vulnerability], target: str, output_path: str, comparison: dict | None = None) -> str: @@ -133,7 +333,7 @@ def generate_summary_json(self, vulnerabilities: Iterable[Vulnerability], target return self._write(output_path, json.dumps(payload, indent=2, ensure_ascii=False)) def generate_comparison_json(self, comparison: dict, output_path: str) -> str: - return self._write(output_path, json.dumps(comparison, indent=2, ensure_ascii=False)) + return self._write(output_path, json.dumps(_normalise_comparison(comparison), indent=2, ensure_ascii=False)) def generate_csv(self, vulnerabilities: Iterable[Vulnerability], output_path: str) -> str: file_path = Path(output_path) @@ -142,13 +342,13 @@ def generate_csv(self, vulnerabilities: Iterable[Vulnerability], output_path: st with file_path.open('w', encoding='utf-8', newline='') as handle: writer = csv.writer(handle) writer.writerow([ - 'source', 'severity', 'priority', 'priority_reason', 'category', 'confidence', 'verification_status', 'title', 'target', 'description', + 'finding_id', 'correlation_id', 'source', 'severity', 'priority', 'priority_score', 'scoring_version', 'priority_reason', 'category', 'kind', 'finding_role', 'validated', 'validation_basis', 'confidence', 'verification_status', 'title', 'target', 'target_host_original', 'asset_host', 'asset_host_resolved', 'asset_port', 'description', 'evidence_summary', 'cve', 'cwe', 'cvss_score', 'source_count', 'related_sources', 'recommendation' ]) for vuln in sorted_vulns: writer.writerow([ - vuln.source, vuln.severity, vuln.priority or '', vuln.priority_reason or '', vuln.category or '', vuln.confidence or '', vuln.verification_status or '', - vuln.title, vuln.target, vuln.description, vuln.evidence_summary or '', + vuln.finding_id or '', vuln.correlation_id or '', vuln.source, vuln.severity, vuln.priority or '', vuln.priority_score if vuln.priority_score is not None else '', vuln.scoring_version or '', vuln.priority_reason or '', vuln.category or '', vuln.kind or '', vuln.finding_role or '', str(bool(vuln.validated)).lower(), vuln.validation_basis or '', vuln.confidence or '', vuln.verification_status or '', + vuln.title, vuln.target, vuln.target_host_original or '', vuln.asset_host or '', vuln.asset_host_resolved or '', vuln.asset_port or '', vuln.description, vuln.evidence_summary or '', ', '.join(vuln.cve), ', '.join(vuln.cwe), vuln.cvss_score if vuln.cvss_score is not None else '', vuln.source_count, ', '.join(vuln.related_sources), vuln.recommendation or '' ]) @@ -159,7 +359,11 @@ def _append_markdown_finding(self, lines: list[str], vuln: Vulnerability, label: f'### [{label}] {vuln.title}', '', f'- **Prioridad:** {vuln.priority or "N/A"}', + f'- **Score de prioridad:** {vuln.priority_score if vuln.priority_score is not None else "N/A"}', f'- **Motivo de prioridad:** {vuln.priority_reason or "N/A"}', + f'- **Rol del hallazgo:** {vuln.finding_role or "N/A"}', + f'- **Validado:** {"sí" if vuln.validated else "no"}', + f'- **Base de validación:** {vuln.validation_basis or "N/A"}', f'- **Severidad:** {vuln.severity}', f'- **Categoría:** {vuln.category or "N/A"}', f'- **Confianza:** {vuln.confidence or "N/A"}', @@ -184,11 +388,14 @@ def _append_markdown_finding(self, lines: list[str], vuln: Vulnerability, label: def generate_markdown(self, vulnerabilities: Iterable[Vulnerability], target: str, output_path: str, comparison: dict | None = None) -> str: sorted_vulns = self.sort_vulnerabilities(vulnerabilities) stats = self.compute_stats(sorted_vulns) - network_services = [v for v in sorted_vulns if (v.category or '').lower() in NETWORK_DISCOVERY_CATEGORIES] - discovery_like = [v for v in sorted_vulns if (v.category or '').lower() == 'discovery'] - vulnerability_like = [v for v in sorted_vulns if (v.category or '').lower() not in NETWORK_DISCOVERY_CATEGORIES | {'discovery'}] - confirmed = [v for v in vulnerability_like if (v.verification_status or '').lower() == 'confirmed'] - plausible = [v for v in vulnerability_like if (v.verification_status or '').lower() != 'confirmed'] + comparison_payload = _normalise_comparison(comparison) if comparison else {} + application_findings, review_surface_findings, hygiene_findings, network_services, discovery_like = _split_report_groups(sorted_vulns) + confirmed = [v for v in application_findings if _finding_role(v) == 'validated'] + plausible = [v for v in application_findings if _finding_role(v) != 'validated'] + review_confirmed = [v for v in review_surface_findings if _finding_role(v) == 'validated'] + review_plausible = [v for v in review_surface_findings if _finding_role(v) != 'validated'] + hygiene_confirmed = [v for v in hygiene_findings if _finding_role(v) == 'validated'] + hygiene_plausible = [v for v in hygiene_findings if _finding_role(v) != 'validated'] lines = [ f'# {self.title}', @@ -200,19 +407,22 @@ def generate_markdown(self, vulnerabilities: Iterable[Vulnerability], target: st '## Prioridades', '', ] - for name, count in stats.priority_counts.items(): + for name, count in _nonzero_count_items(stats.priority_counts): lines.append(f'- **{name}**: {count}') lines += ['', '## Severidades', ''] - for name, count in stats.severity_counts.items(): + for name, count in _nonzero_count_items(stats.severity_counts): lines.append(f'- **{name}**: {count}') lines += ['', '## Categorías', ''] for name, count in stats.category_counts.items(): lines.append(f'- **{name}**: {count}') - if comparison: + if comparison_payload: lines += ['', '## Comparación con baseline', ''] - lines.append(f"- **Nuevos hallazgos:** {len(comparison.get('new_findings', []))}") - lines.append(f"- **Hallazgos resueltos:** {len(comparison.get('resolved_findings', []))}") - lines.append(f"- **Hallazgos modificados:** {len(comparison.get('changed_findings', []))}") + summary = comparison_payload.get('summary', {}) + lines.append(f"- **Nuevos hallazgos:** {summary.get('new_findings', len(comparison_payload.get('new_findings', [])))}") + lines.append(f"- **Hallazgos resueltos:** {summary.get('resolved_findings', len(comparison_payload.get('resolved_findings', [])))}") + lines.append(f"- **Hallazgos modificados:** {summary.get('changed_findings', len(comparison_payload.get('changed_findings', [])))}") + lines.append(f"- **Promovidos en riesgo/confianza:** {summary.get('promoted_findings', len(comparison_payload.get('promoted_findings', [])))}") + lines.append(f"- **Regresados o debilitados:** {summary.get('regressed_findings', len(comparison_payload.get('regressed_findings', [])))}") lines += ['', '## Hallazgos confirmados de aplicación', ''] if not confirmed: @@ -226,6 +436,22 @@ def generate_markdown(self, vulnerabilities: Iterable[Vulnerability], target: st for idx, vuln in enumerate(plausible, 1): self._append_markdown_finding(lines, vuln, f'P{idx}') + lines += ['', '## Superficie pública a revisar', ''] + if not review_surface_findings: + lines.append('No se detectó superficie pública relevante pendiente de revisión manual.') + for idx, vuln in enumerate(review_confirmed, 1): + self._append_markdown_finding(lines, vuln, f'R{idx}') + for idx, vuln in enumerate(review_plausible, 1): + self._append_markdown_finding(lines, vuln, f'RP{idx}') + + lines += ['', '## Hallazgos de higiene y endurecimiento', ''] + if not hygiene_findings: + lines.append('No se detectaron hallazgos adicionales de higiene o endurecimiento.') + for idx, vuln in enumerate(hygiene_confirmed, 1): + self._append_markdown_finding(lines, vuln, f'H{idx}') + for idx, vuln in enumerate(hygiene_plausible, 1): + self._append_markdown_finding(lines, vuln, f'HP{idx}') + lines += ['', '## Servicios y puertos descubiertos', ''] if not network_services: lines.append('No se detectaron servicios o puertos relevantes mediante descubrimiento de red.') @@ -243,11 +469,13 @@ def generate_markdown(self, vulnerabilities: Iterable[Vulnerability], target: st def generate_html(self, vulnerabilities: Iterable[Vulnerability], target: str, output_path: str, comparison: dict | None = None) -> str: sorted_vulns = self.sort_vulnerabilities(vulnerabilities) stats = self.compute_stats(sorted_vulns) + comparison_payload = _normalise_comparison(comparison) if comparison else {} findings = [] for vuln in sorted_vulns: findings.append( f"

{html.escape(vuln.title)}

" f"

Prioridad: {html.escape(vuln.priority or 'N/A')} | Severidad: {html.escape(vuln.severity)}

" + f"

Score de prioridad: {html.escape(str(vuln.priority_score) if vuln.priority_score is not None else 'N/A')} | Versión de scoring: {html.escape(vuln.scoring_version or 'N/A')}

" f"

Motivo de prioridad: {html.escape(vuln.priority_reason or 'N/A')}

" f"

Categoría: {html.escape(vuln.category or 'N/A')} | Confianza: {html.escape(vuln.confidence or 'N/A')} | Verificación: {html.escape(vuln.verification_status or 'N/A')}

" f"

Target: {html.escape(vuln.target)}

" @@ -256,12 +484,14 @@ def generate_html(self, vulnerabilities: Iterable[Vulnerability], target: str, o f"

Recomendación: {html.escape(vuln.recommendation or '')}

" ) comparison_html = '' - if comparison: + if comparison_payload: comparison_html = ( f"

Comparación con baseline

    " - f"
  • Nuevos hallazgos: {len(comparison.get('new_findings', []))}
  • " - f"
  • Hallazgos resueltos: {len(comparison.get('resolved_findings', []))}
  • " - f"
  • Hallazgos modificados: {len(comparison.get('changed_findings', []))}
  • " + f"
  • Nuevos hallazgos: {comparison_payload.get('summary', {}).get('new_findings', len(comparison_payload.get('new_findings', [])))}
  • " + f"
  • Hallazgos resueltos: {comparison_payload.get('summary', {}).get('resolved_findings', len(comparison_payload.get('resolved_findings', [])))}
  • " + f"
  • Hallazgos modificados: {comparison_payload.get('summary', {}).get('changed_findings', len(comparison_payload.get('changed_findings', [])))}
  • " + f"
  • Promovidos en riesgo/confianza: {comparison_payload.get('summary', {}).get('promoted_findings', len(comparison_payload.get('promoted_findings', [])))}
  • " + f"
  • Regresados o debilitados: {comparison_payload.get('summary', {}).get('regressed_findings', len(comparison_payload.get('regressed_findings', [])))}
  • " f"
" ) content = f""" @@ -280,7 +510,7 @@ def generate_html(self, vulnerabilities: Iterable[Vulnerability], target: str, o

Target: {html.escape(target)}

{html.escape(self.executive_summary(stats))}

Resumen

-
    {''.join(f'
  • {html.escape(k)}: {v}
  • ' for k, v in stats.priority_counts.items())}
+
    {''.join(f'
  • {html.escape(k)}: {v}
  • ' for k, v in _nonzero_count_items(stats.priority_counts))}
{comparison_html}

Hallazgos

{''.join(findings) if findings else '

Sin hallazgos.

'}
diff --git a/src/attack_surface_mapper/reporting/review_matrix.py b/src/attack_surface_mapper/reporting/review_matrix.py new file mode 100644 index 0000000..b213851 --- /dev/null +++ b/src/attack_surface_mapper/reporting/review_matrix.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import csv +import json +from pathlib import Path +from typing import Iterable + + +def review_bucket_for_finding(finding: dict) -> str: + title = str(finding.get('title') or '').lower() + kind = str(finding.get('kind') or '').lower() + finding_role = str(finding.get('finding_role') or '').lower() + category = str(finding.get('category') or '').lower() + verification = str(finding.get('verification_status') or '').lower() + confidence = str(finding.get('confidence') or '').lower() + priority = str(finding.get('priority') or '').lower() + validated = str(finding.get('validated') or '').lower() in {'true', '1', 'yes', 'si', 'sí'} + + documentation_like = title in { + 'swagger ui exposed', + 'openapi specification exposed', + 'api surface exposed', + } + surface_like = title in { + 'graphql surface exposed', + 'broad cors policy observed', + } + inventory_like = ( + finding_role == 'discovery' + or kind == 'discovery' + or category == 'discovery' + or title.startswith('multiple api endpoints exposed') + or title.startswith('protected api surface discovered') + or title.startswith('multiple client-side api references observed') + or title == 'client-side api reference observed' + or title.startswith('technology fingerprint detected') + ) + if inventory_like: + return 'descubrimiento' + if documentation_like: + return 'revisar' + if surface_like: + return 'revisar' + if category == 'headers': + return 'revisar' + if finding_role == 'candidate': + return 'revisar' + if (validated or verification == 'confirmed') and confidence == 'high' and priority in {'medium', 'high', 'critical'}: + return 'priorizar' + if verification in {'confirmed', 'likely', 'needs_manual_validation'}: + return 'revisar' + return 'triage' + + +def _load_json(path: Path) -> object: + return json.loads(path.read_text(encoding='utf-8')) + + +def load_review_matrix(path: str | Path) -> list[dict[str, str]]: + with Path(path).open(encoding='utf-8', newline='') as handle: + return list(csv.DictReader(handle)) + + +def load_review_golden_set(path: str | Path) -> list[dict[str, str]]: + payload = _load_json(Path(path)) + return [dict(item) for item in payload or []] + + +def evaluate_review_rows_against_golden_set(rows: Iterable[dict[str, str]], golden_set: Iterable[dict[str, str]]) -> dict[str, object]: + indexed = { + ( + str(row.get('run_name') or ''), + str(row.get('title') or ''), + ): row + for row in rows + } + checked = 0 + mismatches: list[dict[str, str]] = [] + missing: list[dict[str, str]] = [] + for expected in golden_set: + checked += 1 + key = (str(expected.get('run_name') or ''), str(expected.get('title') or '')) + row = indexed.get(key) + if row is None: + missing.append({'run_name': key[0], 'title': key[1]}) + continue + for field_name, expected_value in expected.items(): + if field_name in {'run_name', 'title'}: + continue + actual_value = str(row.get(field_name) or '') + if actual_value != str(expected_value): + mismatches.append({ + 'run_name': key[0], + 'title': key[1], + 'field': field_name, + 'expected': str(expected_value), + 'actual': actual_value, + }) + return { + 'checked': checked, + 'missing': missing, + 'mismatches': mismatches, + 'ok': not missing and not mismatches, + } + + +def _iter_vulnerability_files(run_dir: Path) -> Iterable[tuple[str, str, Path]]: + manifest_path = run_dir / 'run_manifest.json' + if not manifest_path.exists(): + return [] + manifest = _load_json(manifest_path) + profile = str(((manifest or {}).get('effective_config') or {}).get('profile') or (manifest or {}).get('profile') or '') + rows = [] + for target_info in (manifest.get('per_target') or []): + target = str(target_info.get('target') or '') + report_summary = (((target_info.get('reports') or {}).get('summary_json')) or '') + if report_summary: + target_dir = Path(report_summary).parents[1] + vulnerabilities_path = target_dir / 'findings' / 'vulnerabilities.json' + else: + target_dir = None + vulnerabilities_path = None + if vulnerabilities_path and vulnerabilities_path.exists(): + rows.append((profile, target, vulnerabilities_path)) + return rows + + +def build_review_rows(run_dirs: Iterable[str | Path]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for run_dir in (Path(item) for item in run_dirs): + for profile, target, vulnerabilities_path in _iter_vulnerability_files(run_dir): + findings = _load_json(vulnerabilities_path) + for finding in findings: + row = { + 'run_name': run_dir.name, + 'profile': profile, + 'target': target, + 'finding_id': str(finding.get('finding_id') or ''), + 'correlation_id': str(finding.get('correlation_id') or ''), + 'title': str(finding.get('title') or ''), + 'category': str(finding.get('category') or ''), + 'kind': str(finding.get('kind') or ''), + 'finding_role': str(finding.get('finding_role') or ''), + 'validated': 'true' if bool(finding.get('validated')) else 'false', + 'validation_basis': str(finding.get('validation_basis') or ''), + 'priority': str(finding.get('priority') or ''), + 'priority_score': str(finding.get('priority_score') or ''), + 'scoring_version': str(finding.get('scoring_version') or ''), + 'severity': str(finding.get('severity') or ''), + 'confidence': str(finding.get('confidence') or ''), + 'verification_status': str(finding.get('verification_status') or ''), + 'needs_manual_validation': 'true' if bool(finding.get('needs_manual_validation')) else 'false', + 'source_count': str(finding.get('source_count') or ''), + 'asset_host': str(finding.get('asset_host') or ''), + 'asset_port': str(finding.get('asset_port') or ''), + 'evidence_summary': str(finding.get('evidence_summary') or ''), + 'bucket_revision': review_bucket_for_finding(finding), + 'etiqueta_analista': '', + 'notas': '', + } + rows.append(row) + rows.sort(key=lambda item: (item['run_name'], item['target'], item['priority'], item['title'])) + return rows + + +def write_review_matrix(rows: Iterable[dict[str, str]], output_path: str | Path) -> str: + fieldnames = [ + 'run_name', + 'profile', + 'target', + 'finding_id', + 'correlation_id', + 'title', + 'category', + 'kind', + 'finding_role', + 'validated', + 'validation_basis', + 'priority', + 'priority_score', + 'scoring_version', + 'severity', + 'confidence', + 'verification_status', + 'needs_manual_validation', + 'source_count', + 'asset_host', + 'asset_port', + 'evidence_summary', + 'bucket_revision', + 'etiqueta_analista', + 'notas', + ] + destination = Path(output_path) + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open('w', encoding='utf-8', newline='') as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({key: row.get(key, '') for key in fieldnames}) + return str(destination) diff --git a/src/attack_surface_mapper/utils/asset_normalizer.py b/src/attack_surface_mapper/utils/asset_normalizer.py index 0f57932..92d791d 100644 --- a/src/attack_surface_mapper/utils/asset_normalizer.py +++ b/src/attack_surface_mapper/utils/asset_normalizer.py @@ -1,22 +1,37 @@ from __future__ import annotations +import ipaddress import socket from urllib.parse import urlparse +def _is_ip_address(value: str | None) -> bool: + if not value: + return False + try: + ipaddress.ip_address(value) + except ValueError: + return False + return True + + def normalize_asset(target: str) -> dict[str, str | None]: parsed = urlparse(target if '://' in target else f'http://{target}') - host_original = parsed.hostname + host_original = (parsed.hostname or '').strip().lower() or None port = str(parsed.port or (443 if parsed.scheme == 'https' else 80)) resolved = None if host_original: - try: - resolved = socket.gethostbyname(host_original) - except Exception: + if _is_ip_address(host_original): resolved = host_original + else: + try: + resolved = socket.gethostbyname(host_original) + except Exception: + resolved = None return { 'target_host_original': host_original, - 'asset_host': resolved or host_original, + 'asset_host': host_original, + 'asset_host_resolved': resolved, 'asset_port': port, } diff --git a/src/attack_surface_mapper/validators/api_validator.py b/src/attack_surface_mapper/validators/api_validator.py index 165df67..a548bf2 100644 --- a/src/attack_surface_mapper/validators/api_validator.py +++ b/src/attack_surface_mapper/validators/api_validator.py @@ -5,7 +5,36 @@ from attack_surface_mapper.http_client import RequestError, build_http_session from attack_surface_mapper.models.vulnerability import Vulnerability from attack_surface_mapper.validators.base import BaseValidator -from attack_surface_mapper.validators.http_fingerprint import baseline_fingerprint, looks_like_baseline, normalise_text +from attack_surface_mapper.validators.http_fingerprint import baseline_fingerprint, looks_like_baseline, looks_like_login_surface, normalise_text + + +def _header_value(headers: dict[str, str], name: str) -> str: + lower_name = name.lower() + for key, value in (headers or {}).items(): + if str(key).lower() == lower_name: + return str(value) + return '' + + +def _graphql_signature_strength(preview: str) -> str: + strong_tokens = ( + 'graphql', + '__schema', + 'graphiql', + 'graphql-playground', + 'apollo sandbox', + 'must provide query string', + ) + if any(token in preview for token in strong_tokens): + return 'strong' + weak_token_groups = ( + ('errors', 'message'), + ('query', 'mutation'), + ('operationname', 'variables'), + ) + if any(all(token in preview for token in group) for group in weak_token_groups): + return 'weak' + return '' class APIValidator(BaseValidator): @@ -18,7 +47,7 @@ class APIValidator(BaseValidator): '/graphql/playground', ) - def __init__(self, timeout: int = 6, paths: tuple[str, ...] | None = None, *, backend: str = 'auto', mode: str = 'passive', user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', use_baseline_probe: bool = True, observed_only: bool = False) -> None: + def __init__(self, timeout: int = 6, paths: tuple[str, ...] | None = None, *, backend: str = 'requests', mode: str = 'passive', user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', use_baseline_probe: bool = True, observed_only: bool = False) -> None: self.timeout = timeout self.paths = paths or self.DEFAULT_PATHS self.backend = backend @@ -27,14 +56,14 @@ def __init__(self, timeout: int = 6, paths: tuple[str, ...] | None = None, *, ba self.use_baseline_probe = use_baseline_probe self.observed_only = observed_only - def run(self, target: str) -> list[Vulnerability]: + def run(self, target: str, baseline=None) -> list[Vulnerability]: findings: list[Vulnerability] = [] parsed_target = urlparse(target) host = parsed_target.hostname port = str(parsed_target.port) if parsed_target.port else None scheme = parsed_target.scheme with build_http_session(backend=self.backend, mode=self.mode, timeout=self.timeout, user_agent=self.user_agent) as session: - baseline = baseline_fingerprint(session, target, self.timeout) if self.use_baseline_probe else None + baseline = baseline if baseline is not None else (baseline_fingerprint(session, target, self.timeout) if self.use_baseline_probe else None) if not self.observed_only: findings.extend(self._check_cors(session, target, host, port, scheme)) findings.extend(self._check_api_docs(session, target, host, port, scheme, baseline)) @@ -48,14 +77,15 @@ def _check_cors(self, session, target: str, host: str | None, port: str | None, except RequestError: return findings - allow_origin = response.headers.get('Access-Control-Allow-Origin', '') - allow_credentials = response.headers.get('Access-Control-Allow-Credentials', '') + allow_origin = _header_value(response.headers, 'Access-Control-Allow-Origin').strip() + allow_credentials = _header_value(response.headers, 'Access-Control-Allow-Credentials').strip() + credentials_enabled = allow_credentials.lower() == 'true' if allow_origin == '*': findings.append(Vulnerability( source='custom-api-check', - title='Permissive CORS Policy', + title='Permissive CORS Policy' if credentials_enabled else 'Broad CORS Policy Observed', description='El servidor permite cualquier origen mediante Access-Control-Allow-Origin: *.', - severity='medium', + severity='medium' if credentials_enabled else 'low', target=response.url, evidence=f'Access-Control-Allow-Origin: {allow_origin}; Access-Control-Allow-Credentials: {allow_credentials or ""}', cwe=['CWE-942'], @@ -67,8 +97,9 @@ def _check_cors(self, session, target: str, host: str | None, port: str | None, scheme=scheme, type='http', category='api', - confidence='high', - verification_status='confirmed', + confidence='high' if credentials_enabled else 'medium', + needs_manual_validation=not credentials_enabled, + verification_status='confirmed' if credentials_enabled else 'likely', )) return findings @@ -135,6 +166,20 @@ def _check_api_docs(self, session, target: str, host: str | None, port: str | No def _classify_path(self, path: str, response, preview: str, content_type: str, baseline): baseline_like = looks_like_baseline(response, baseline) + graphql_signature = '' + if looks_like_login_surface(response, preview): + title = 'API Surface Exposed' + description = 'Se ha detectado una superficie de API accesible públicamente.' + if path in {'/swagger', '/swagger-ui', '/api-docs'}: + title = 'Swagger UI Exposed' + description = 'Se ha detectado una interfaz de documentación Swagger accesible sin restricciones claras.' + elif path == '/openapi.json': + title = 'OpenAPI Specification Exposed' + description = 'Se ha detectado un documento OpenAPI/Swagger accesible públicamente.' + elif path.startswith('/graphql'): + title = 'GraphQL Surface Exposed' + description = 'Se ha detectado un endpoint o interfaz GraphQL accesible.' + return False, 'low', 'respuesta parece una superficie de login pública', 'discarded', title, description, 'medium' score = 0 reasons: list[str] = [] if response.status_code in (200, 201, 202, 204): @@ -172,15 +217,33 @@ def _classify_path(self, path: str, response, preview: str, content_type: str, b title = 'GraphQL Surface Exposed' description = 'Se ha detectado un endpoint o interfaz GraphQL accesible.' severity = 'high' - if any(token in preview for token in ('graphql', '__schema', 'query', 'mutation', 'errors', 'data')): + graphql_signature = _graphql_signature_strength(preview) + if graphql_signature == 'strong': score += 3 - reasons.append('marcadores graphql encontrados') + reasons.append('marcadores graphql fuertes encontrados') + elif graphql_signature == 'weak': + score += 2 + reasons.append('marcadores graphql plausibles encontrados') if 'json' in content_type or 'html' in content_type: score += 1 reasons.append('content-type compatible') + if path.startswith('/graphql') and not graphql_signature: + reasons.append('sin firma graphql suficiente') + return False, 'low', '; '.join(reasons), 'discarded', title, description, severity + if path.startswith('/graphql') and baseline_like and graphql_signature != 'strong': + reasons.append('respuesta similar al fallback sin firma graphql fuerte') + return False, 'low', '; '.join(reasons), 'discarded', title, description, severity if baseline_like and score < 4: return False, 'low', '; '.join(reasons), 'discarded', title, description, severity + if path in {'/swagger', '/swagger-ui', '/api-docs', '/openapi.json'} and score >= 4: + confidence = 'high' if score >= 6 else 'medium' + reasons.append('documentación api pública: revisar antes de tratarla como riesgo principal') + return True, confidence, '; '.join(reasons), 'likely', title, description, severity + if path.startswith('/graphql') and score >= 4: + confidence = 'high' if graphql_signature == 'strong' else 'medium' + reasons.append('superficie graphql pública: requiere evidencia adicional para acceso indebido') + return True, confidence, '; '.join(reasons), 'likely', title, description, severity if score >= 6: return True, 'high', '; '.join(reasons), 'confirmed', title, description, severity if score >= 4: diff --git a/src/attack_surface_mapper/validators/auth_validator.py b/src/attack_surface_mapper/validators/auth_validator.py index f818827..fae6bd8 100644 --- a/src/attack_surface_mapper/validators/auth_validator.py +++ b/src/attack_surface_mapper/validators/auth_validator.py @@ -9,6 +9,14 @@ class AuthValidator(BaseValidator): + API_SURFACE_PATHS: tuple[str, ...] = ( + '/swagger', + '/swagger-ui', + '/openapi.json', + '/graphql', + '/api-docs', + ) + DEFAULT_PROTECTED_PATHS: tuple[str, ...] = ( '/admin', '/dashboard', @@ -33,7 +41,7 @@ class AuthValidator(BaseValidator): '/admin/login', ) - def __init__(self, timeout: int = 6, paths: tuple[str, ...] | None = None, *, backend: str = 'auto', mode: str = 'passive', user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', use_baseline_probe: bool = True, observed_only: bool = False) -> None: + def __init__(self, timeout: int = 6, paths: tuple[str, ...] | None = None, *, backend: str = 'requests', mode: str = 'passive', user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', use_baseline_probe: bool = True, observed_only: bool = False) -> None: self.timeout = timeout self.paths = paths or self.DEFAULT_PROTECTED_PATHS self.backend = backend @@ -42,7 +50,7 @@ def __init__(self, timeout: int = 6, paths: tuple[str, ...] | None = None, *, ba self.use_baseline_probe = use_baseline_probe self.observed_only = observed_only - def run(self, target: str) -> list[Vulnerability]: + def run(self, target: str, baseline=None) -> list[Vulnerability]: findings: list[Vulnerability] = [] parsed_target = urlparse(target) with build_http_session(backend=self.backend, mode=self.mode, timeout=self.timeout, user_agent=self.user_agent) as session: @@ -52,7 +60,7 @@ def run(self, target: str) -> list[Vulnerability]: host = final_parsed.hostname or parsed_target.hostname port = str(final_parsed.port) if final_parsed.port else (str(parsed_target.port) if parsed_target.port else None) scheme = final_parsed.scheme or parsed_target.scheme - baseline = baseline_fingerprint(session, target, self.timeout) if self.use_baseline_probe else None + baseline = baseline if baseline is not None else (baseline_fingerprint(session, target, self.timeout) if self.use_baseline_probe else None) findings.extend(self._check_cookie_flags(response, final_url, host, port, scheme)) if not self.observed_only: @@ -65,7 +73,11 @@ def _check_cookie_flags(self, response, url: str, host: str | None, port: str | for cookie_header in raw_cookie_headers: lower_header = cookie_header.lower() name = cookie_header.split('=', 1)[0].strip() or 'cookie' - if 'httponly' not in lower_header: + is_session_cookie = self._is_likely_session_cookie(name) + is_csrf_cookie = self._is_csrf_cookie(name) + if not is_session_cookie and not is_csrf_cookie: + continue + if is_session_cookie and 'httponly' not in lower_header: findings.append(Vulnerability(source='custom-auth-check', title='Cookie Without HttpOnly Flag', description='La aplicación establece una cookie sin el flag HttpOnly.', severity='medium', target=url, evidence=f'Set-Cookie: {self._truncate(cookie_header)}', cwe=['CWE-1004'], tags=['auth', 'cookie', 'session'], template_id=f'custom-auth-cookie-httponly-{name.lower()}', matched_at=url, host=host, port=port, scheme=scheme, type='http', category='authentication', confidence='high', verification_status='confirmed')) if (scheme == 'https') and ('secure' not in lower_header): findings.append(Vulnerability(source='custom-auth-check', title='Cookie Without Secure Flag', description='La aplicación HTTPS establece una cookie sin el flag Secure.', severity='medium', target=url, evidence=f'Set-Cookie: {self._truncate(cookie_header)}', cwe=['CWE-614'], tags=['auth', 'cookie', 'session'], template_id=f'custom-auth-cookie-secure-{name.lower()}', matched_at=url, host=host, port=port, scheme=scheme, type='http', category='authentication', confidence='high', verification_status='confirmed')) @@ -148,6 +160,34 @@ def _check_protected_paths(self, session, target: str, host: str | None, port: s include, confidence, reason, verification = self._classify_open_access(path, response, body_preview, baseline) if not include: continue + if path in self.API_SURFACE_PATHS: + if verification == 'confirmed': + verification = 'likely' + reason = f'{reason}; superficie api pública no demuestra acceso privilegiado por sí sola' + if confidence == 'high': + confidence = 'medium' + title, description, severity = self._api_surface_metadata(path) + findings.append(Vulnerability( + source='custom-auth-check', + title=title, + description=description, + severity=severity, + target=response.url, + evidence=f'GET {response.url} devolvió {response.status_code}; validación={reason}', + cwe=['CWE-200'], + tags=['api', 'exposure'], + template_id=f"custom-auth-open-{path.strip('/') or 'root'}", + matched_at=response.url, + host=host, + port=port, + scheme=scheme, + type='http', + category='api', + confidence=confidence, + needs_manual_validation=verification != 'confirmed', + verification_status=verification, + )) + continue endpoint_name = self._endpoint_name(path) findings.append(Vulnerability( source='custom-auth-check', @@ -249,6 +289,32 @@ def _endpoint_name(path: str) -> str: } return mapping.get(path, f'Endpoint {path}') + @staticmethod + def _api_surface_metadata(path: str) -> tuple[str, str, str]: + if path in {'/swagger', '/swagger-ui', '/api-docs'}: + return ( + 'Swagger UI Exposed', + 'Se ha detectado una interfaz de documentación o superficie API accesible sin restricciones claras.', + 'medium', + ) + if path == '/openapi.json': + return ( + 'OpenAPI Specification Exposed', + 'Se ha detectado un documento OpenAPI/Swagger accesible públicamente.', + 'medium', + ) + if path == '/graphql': + return ( + 'GraphQL Surface Exposed', + 'Se ha detectado una superficie GraphQL accesible sin evidencia suficiente de control de acceso fuerte.', + 'medium', + ) + return ( + 'API Surface Exposed', + f'Se ha detectado una superficie de API accesible públicamente en {path}.', + 'medium', + ) + @staticmethod def _extract_set_cookie_headers(response) -> list[str]: raw = getattr(response, 'raw', None) @@ -264,6 +330,18 @@ def _extract_set_cookie_headers(response) -> list[str]: def _truncate(value: str, max_length: int = 160) -> str: return value if len(value) <= max_length else value[:max_length] + '...[truncated]' + @staticmethod + def _is_csrf_cookie(name: str) -> bool: + lowered = (name or '').strip().lower() + return any(token in lowered for token in ('csrf', 'xsrf')) + + @classmethod + def _is_likely_session_cookie(cls, name: str) -> bool: + lowered = (name or '').strip().lower() + if cls._is_csrf_cookie(lowered): + return False + return any(token in lowered for token in ('session', 'sess', 'sid', 'jwt', 'auth', 'access', 'refresh', 'remember', 'login')) + @classmethod def _is_public_auth_entry(cls, path: str, response_url: str, body_preview: str) -> bool: response_path = urlparse(response_url or '').path diff --git a/src/attack_surface_mapper/validators/headers_validator.py b/src/attack_surface_mapper/validators/headers_validator.py index c773182..c79ec4c 100644 --- a/src/attack_surface_mapper/validators/headers_validator.py +++ b/src/attack_surface_mapper/validators/headers_validator.py @@ -7,6 +7,24 @@ from attack_surface_mapper.validators.base import BaseValidator +def _header_value(headers: dict[str, str], name: str) -> str: + lower_name = name.lower() + for key, value in headers.items(): + if str(key).lower() == lower_name: + return str(value) + return '' + + +def _is_browser_document(response: HttpResponse) -> bool: + content_type = _header_value(response.headers, 'Content-Type').lower() + body_preview = (response.text or '')[:800].lstrip().lower() + if any(token in content_type for token in ('text/html', 'application/xhtml+xml')): + return True + if not content_type and any(token in body_preview for token in (' list[Vulnera headers = {key.lower(): value for key, value in response.headers.items()} parsed = urlparse(response.url) base_target = response.url + browser_document = _is_browser_document(response) for header_name, metadata in self.REQUIRED_HEADERS.items(): + if not browser_document: + continue if header_name in headers: continue diff --git a/src/attack_surface_mapper/validators/panels_validator.py b/src/attack_surface_mapper/validators/panels_validator.py index 78ebea7..2407957 100644 --- a/src/attack_surface_mapper/validators/panels_validator.py +++ b/src/attack_surface_mapper/validators/panels_validator.py @@ -13,13 +13,14 @@ class PanelsValidator(BaseValidator): '/admin', '/login', '/dashboard', + '/management', '/swagger', '/swagger-ui', '/actuator', '/metrics', ) - def __init__(self, timeout: int = 5, paths: tuple[str, ...] | None = None, *, backend: str = 'auto', mode: str = 'passive', user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', use_baseline_probe: bool = True) -> None: + def __init__(self, timeout: int = 5, paths: tuple[str, ...] | None = None, *, backend: str = 'requests', mode: str = 'passive', user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', use_baseline_probe: bool = True) -> None: self.timeout = timeout self.paths = paths or self.DEFAULT_PATHS self.backend = backend @@ -27,11 +28,11 @@ def __init__(self, timeout: int = 5, paths: tuple[str, ...] | None = None, *, ba self.user_agent = user_agent self.use_baseline_probe = use_baseline_probe - def run(self, target: str) -> list[Vulnerability]: + def run(self, target: str, baseline=None) -> list[Vulnerability]: vulnerabilities: list[Vulnerability] = [] parsed = urlparse(target) with build_http_session(backend=self.backend, mode=self.mode, timeout=self.timeout, user_agent=self.user_agent) as session: - baseline = baseline_fingerprint(session, target, self.timeout) if self.use_baseline_probe else None + baseline = baseline if baseline is not None else (baseline_fingerprint(session, target, self.timeout) if self.use_baseline_probe else None) for path in self.paths: url = urljoin(target.rstrip('/') + '/', path.lstrip('/')) try: @@ -43,7 +44,7 @@ def run(self, target: str) -> list[Vulnerability]: continue preview = normalise_text(response.text) - if path in {'/admin', '/dashboard'} and looks_like_login_surface(response, preview): + if path != '/login' and looks_like_login_surface(response, preview): continue include, confidence, reason, verification = self._classify(path, response, preview, baseline) if not include: @@ -54,19 +55,19 @@ def run(self, target: str) -> list[Vulnerability]: Vulnerability( source='custom-panel-check', title=self._build_title(path, response), - description=f'Se ha detectado un panel o endpoint potencialmente sensible accesible en {final_url}.', + description=self._description_for(path, final_url), severity=self._severity_for(path, confidence), target=final_url, evidence=f'Status {response.status_code} en {final_url}; validación={reason}', cwe=['CWE-200'], - tags=['panel', 'exposure'], - template_id=f'custom-panel-{path.strip('/') or "root"}', + tags=self._tags_for(path), + template_id=f"custom-panel-{path.strip('/') or 'root'}", matched_at=final_url, host=parsed.hostname, port=str(parsed.port) if parsed.port else None, scheme=parsed.scheme, type='http', - category='panel-exposure', + category=self._category_for(path), confidence=confidence, needs_manual_validation=verification != 'confirmed', verification_status=verification, @@ -96,10 +97,10 @@ def _classify(self, path: str, response, body_preview: str, baseline) -> tuple[b if 'text/plain' in content_type: score += 1 reasons.append('content-type text/plain') - elif path == '/actuator': + elif path in {'/actuator', '/management'}: if any(token in body_preview for token in ('"_links"', 'actuator', '"status"', 'health')): score += 3 - reasons.append('marcadores actuator encontrados') + reasons.append('marcadores operativos encontrados') if 'json' in content_type: score += 1 reasons.append('content-type json') @@ -135,9 +136,9 @@ def _classify(self, path: str, response, body_preview: str, baseline) -> tuple[b @staticmethod def _severity_for(path: str, confidence: str) -> str: - if confidence == 'high' and path in {'/metrics', '/actuator'}: + if confidence == 'high' and path in {'/metrics', '/actuator', '/management'}: return 'medium' - if path in {'/metrics', '/actuator', '/admin', '/dashboard', '/swagger', '/swagger-ui'}: + if path in {'/metrics', '/actuator', '/management', '/admin', '/dashboard', '/swagger', '/swagger-ui'}: return 'medium' return 'low' @@ -148,6 +149,8 @@ def _build_title(path: str, response) -> str: return 'Exposed Metrics Endpoint' if path == '/actuator': return 'Exposed Actuator Endpoint' + if path == '/management': + return 'Exposed Management Endpoint' if path in {'/swagger', '/swagger-ui'}: return 'Exposed API Documentation Panel' if path == '/admin': @@ -155,5 +158,23 @@ def _build_title(path: str, response) -> str: if path == '/dashboard': return f'Accessible Dashboard ({status})' if path == '/login': - return f'Public Login Panel ({status})' + return f'Login Surface Discovered ({status})' return f'Accessible Sensitive Endpoint ({status})' + + @staticmethod + def _category_for(path: str) -> str: + if path == '/login': + return 'discovery' + return 'panel-exposure' + + @staticmethod + def _tags_for(path: str) -> list[str]: + if path == '/login': + return ['panel', 'auth', 'discovery'] + return ['panel', 'exposure'] + + @staticmethod + def _description_for(path: str, final_url: str) -> str: + if path == '/login': + return f'Se ha descubierto una superficie de autenticación pública en {final_url}; se registra como inventario de superficie, no como acceso indebido.' + return f'Se ha detectado un panel o endpoint potencialmente sensible accesible en {final_url}.' diff --git a/src/attack_surface_mapper/validators/sensitive_files_validator.py b/src/attack_surface_mapper/validators/sensitive_files_validator.py index 25a9f2e..3cb2bbb 100644 --- a/src/attack_surface_mapper/validators/sensitive_files_validator.py +++ b/src/attack_surface_mapper/validators/sensitive_files_validator.py @@ -9,6 +9,10 @@ from attack_surface_mapper.validators.http_fingerprint import baseline_fingerprint, looks_like_baseline, normalise_text +def _looks_like_html_document(preview: str, content_type: str) -> bool: + return ' None: + def __init__(self, timeout: int = 6, paths: tuple[str, ...] | None = None, *, backend: str = 'requests', mode: str = 'passive', user_agent: str = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', use_baseline_probe: bool = True) -> None: self.timeout = timeout self.paths = paths or self.DEFAULT_PATHS self.backend = backend @@ -31,14 +35,14 @@ def __init__(self, timeout: int = 6, paths: tuple[str, ...] | None = None, *, ba self.user_agent = user_agent self.use_baseline_probe = use_baseline_probe - def run(self, target: str) -> list[Vulnerability]: + def run(self, target: str, baseline=None) -> list[Vulnerability]: findings: list[Vulnerability] = [] parsed_target = urlparse(target) host = parsed_target.hostname port = str(parsed_target.port) if parsed_target.port else None scheme = parsed_target.scheme with build_http_session(backend=self.backend, mode=self.mode, timeout=self.timeout, user_agent=self.user_agent) as session: - baseline = baseline_fingerprint(session, target, self.timeout) if self.use_baseline_probe else None + baseline = baseline if baseline is not None else (baseline_fingerprint(session, target, self.timeout) if self.use_baseline_probe else None) for path in self.paths: url = urljoin(target.rstrip('/') + '/', path.lstrip('/')) try: @@ -62,7 +66,7 @@ def run(self, target: str) -> list[Vulnerability]: evidence=f'GET {response.url} devolvió {response.status_code}; validación={reason}; vista previa={self._preview(preview)}', cwe=['CWE-200'], tags=['file', 'exposure'], - template_id=f'custom-sensitive-file-{path.strip('/') or "root"}', + template_id=f"custom-sensitive-file-{path.strip('/') or 'root'}", matched_at=response.url, host=host, port=port, @@ -77,6 +81,8 @@ def run(self, target: str) -> list[Vulnerability]: def _classify(self, path: str, response, preview: str) -> tuple[bool, str, str]: content_type = (response.headers.get('Content-Type') or '').lower() + if path in {'/.git/HEAD', '/.env', '/application.properties', '/application.yml', '/docker-compose.yml', '/db.sql', '/robots.txt', '/sitemap.xml'} and _looks_like_html_document(preview, content_type): + return False, 'low', 'respuesta html, no parece un fichero real' if path == '/.git/HEAD': ok = preview.startswith('ref: refs/') return ok, 'high' if ok else 'low', 'git HEAD marker' @@ -85,9 +91,15 @@ def _classify(self, path: str, response, preview: str) -> tuple[bool, str, str]: strong = any(token in preview for token in ('app_key=', 'secret_key=', 'database_url=', 'api_key=', 'token=')) ok = key_matches >= 2 and strong return ok, 'high' if ok else 'low', 'env-like key=value patterns' - if path in {'/application.properties', '/application.yml', '/docker-compose.yml'}: - ok = any(token in preview for token in ('spring.', 'server:', 'datasource', 'management.', 'services:', 'version:')) - return ok, 'medium' if ok else 'low', 'configuration markers' + if path == '/application.properties': + ok = any(token in preview for token in ('spring.', 'server.port', 'datasource.', 'management.', 'security.', 'password=')) + return ok, 'medium' if ok else 'low', 'application.properties markers' + if path == '/application.yml': + ok = any(token in preview for token in ('spring:', 'datasource:', 'management:', 'security:', 'database:', 'password:')) + return ok, 'medium' if ok else 'low', 'application.yml markers' + if path == '/docker-compose.yml': + ok = 'services:' in preview and any(token in preview for token in ('image:', 'build:', 'ports:', 'environment:')) + return ok, 'medium' if ok else 'low', 'docker-compose markers' if path == '/robots.txt': ok = 'disallow:' in preview or 'user-agent:' in preview return ok, 'medium' if ok else 'low', 'robots syntax' @@ -96,15 +108,17 @@ def _classify(self, path: str, response, preview: str) -> tuple[bool, str, str]: return ok, 'medium' if ok else 'low', 'xml sitemap markers' if path == '/backup.zip': raw = response.content[:4] - ok = raw.startswith(b'PK\x03\x04') or 'application/zip' in content_type - return ok, 'high' if ok else 'low', 'zip signature or content-type' + ok = raw.startswith(b'PK\x03\x04') + reason = 'zip signature' if ok else f'content-type only ({content_type or "absent"})' + return ok, 'high' if ok else 'low', reason if path == '/db.sql': ok = any(token in preview for token in ('create table', 'insert into', 'sql dump', '-- phpmyadmin')) return ok, 'high' if ok else 'low', 'sql dump markers' if path == '/.DS_Store': raw = response.content[:8] - ok = raw.startswith(bytes.fromhex('0000000142756431')) or 'application/octet-stream' in content_type - return ok, 'medium' if ok else 'low', 'ds_store markers' + ok = raw.startswith(bytes.fromhex('0000000142756431')) + reason = 'ds_store signature' if ok else f'content-type only ({content_type or "absent"})' + return ok, 'medium' if ok else 'low', reason return bool(preview.strip()), 'low', 'generic non-empty response' @staticmethod diff --git a/tests/data/lab_review_golden_set.json b/tests/data/lab_review_golden_set.json new file mode 100644 index 0000000..3e1b7a6 --- /dev/null +++ b/tests/data/lab_review_golden_set.json @@ -0,0 +1,102 @@ +[ + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Metrics Endpoint Accessible Without Authentication", + "bucket_revision": "priorizar", + "finding_role": "validated", + "validated": "true", + "priority": "critical", + "verification_status": "confirmed", + "needs_manual_validation": "false" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Swagger UI Exposed", + "bucket_revision": "revisar", + "finding_role": "candidate", + "validated": "false", + "priority": "medium", + "verification_status": "likely", + "needs_manual_validation": "true" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "GraphQL Surface Exposed", + "bucket_revision": "revisar", + "finding_role": "candidate", + "validated": "false", + "priority": "medium", + "verification_status": "likely", + "needs_manual_validation": "true" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Multiple API Endpoints Exposed (10)", + "bucket_revision": "descubrimiento", + "finding_role": "discovery", + "validated": "false", + "priority": "medium", + "verification_status": "likely", + "needs_manual_validation": "false" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Broad CORS Policy Observed", + "bucket_revision": "revisar", + "finding_role": "candidate", + "validated": "false", + "priority": "low", + "verification_status": "likely", + "needs_manual_validation": "true" + }, + { + "run_name": "lab_dvwa_passive_recon_safe", + "title": "Missing Content-Security-Policy Header", + "bucket_revision": "revisar", + "finding_role": "validated", + "validated": "true", + "priority": "medium", + "verification_status": "confirmed", + "needs_manual_validation": "false" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Multiple Client-Side API References Observed (7)", + "bucket_revision": "descubrimiento", + "finding_role": "discovery", + "validated": "false", + "priority": "low", + "verification_status": "confirmed", + "needs_manual_validation": "false" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Protected API Surface Discovered (7 endpoints)", + "bucket_revision": "descubrimiento", + "finding_role": "discovery", + "validated": "false", + "priority": "low", + "verification_status": "confirmed", + "needs_manual_validation": "false" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Technology Fingerprint Detected (Angular)", + "bucket_revision": "descubrimiento", + "finding_role": "discovery", + "validated": "false", + "priority": "low", + "verification_status": "likely", + "needs_manual_validation": "false" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "robots.txt Exposed", + "bucket_revision": "descubrimiento", + "finding_role": "discovery", + "validated": "false", + "priority": "low", + "verification_status": "likely", + "needs_manual_validation": "false" + } +] diff --git a/tests/test_source_encoding.py b/tests/test_source_encoding.py new file mode 100644 index 0000000..0ccf817 --- /dev/null +++ b/tests/test_source_encoding.py @@ -0,0 +1,19 @@ +from pathlib import Path + + +def test_public_project_text_has_no_mojibake_markers() -> None: + root = Path(__file__).resolve().parents[1] + source_files = [ + root / "main.py", + root / "README.md", + root / "CHANGELOG.md", + root / "pyproject.toml", + *sorted((root / "src").rglob("*.py")), + ] + offenders = [ + str(path.relative_to(root)) + for path in source_files + if any(marker in path.read_text(encoding="utf-8") for marker in ("Ã", "Â", "â€", "�")) + ] + + assert offenders == [] diff --git a/tests/test_v10_22_10_contract_and_false_positive_hardening.py b/tests/test_v10_22_10_contract_and_false_positive_hardening.py new file mode 100644 index 0000000..70530b8 --- /dev/null +++ b/tests/test_v10_22_10_contract_and_false_positive_hardening.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from attack_surface_mapper.batch.aggregate import build_aggregate_payload +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.orchestrator import ScanResult +from attack_surface_mapper.reporting import ReportGenerator, ReportPaths +from attack_surface_mapper.validators.api_validator import APIValidator +from attack_surface_mapper.validators.sensitive_files_validator import SensitiveFilesValidator +from main import build_run_manifest + + +class FakeResponse: + def __init__(self, url: str, text: str, status_code: int = 200, headers: dict | None = None, content: bytes | None = None): + self.url = url + self.text = text + self.status_code = status_code + self.headers = headers or {'Content-Type': 'text/html'} + self.content = content if content is not None else text.encode() + self.raw = SimpleNamespace(headers=SimpleNamespace(getlist=lambda name: [])) + + +def test_summary_payload_exposes_stable_schema_and_comparison_counts() -> None: + vuln = Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='api', + priority='high', + confidence='high', + verification_status='confirmed', + finding_id='finding-1', + correlation_id='corr-1', + asset_host='target.example', + asset_host_resolved='203.0.113.10', + asset_port='443', + ) + + payload = ReportGenerator().build_summary_payload([vuln], 'https://target.example', comparison={'new_findings': [{'title': 'x'}]}) + + assert payload['schema_version'] == '1.0' + assert payload['comparison']['new_findings'] == [{'title': 'x'}] + assert payload['comparison']['resolved_findings'] == [] + assert payload['comparison']['changed_findings'] == [] + assert payload['comparison']['promoted_findings'] == [] + assert payload['comparison']['regressed_findings'] == [] + assert payload['comparison_summary'] == { + 'new_findings': 1, + 'resolved_findings': 0, + 'changed_findings': 0, + 'promoted_findings': 0, + 'regressed_findings': 0, + 'updated_findings': 0, + 'unchanged_findings': 0, + 'change_type_counts': {}, + } + assert payload['stats']['priority_counts'] == {'critical': 0, 'high': 1, 'medium': 0, 'low': 0} + assert payload['stats']['severity_counts']['info'] == 0 + assert payload['finding_contract']['schema_version'] == '1.0' + assert 'finding_id' in payload['finding_contract']['required_fields'] + assert payload['stats']['application_findings'] == 0 + assert payload['stats']['review_surface_findings'] == 1 + assert payload['stats']['hygiene_findings'] == 0 + assert payload['stats']['discovery_findings'] == 0 + assert payload['top_finding_count'] == 1 + assert payload['top_risk_finding_count'] == 1 + assert payload['top_review_finding_count'] == 1 + assert payload['top_hygiene_finding_count'] == 0 + assert payload['top_discovery_finding_count'] == 0 + + +def test_aggregate_payload_exposes_stable_summary_and_finding_fields() -> None: + vuln = Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + priority='high', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='api', + kind='validation', + confidence='high', + verification_status='confirmed', + source_count=2, + evidence_summary='graphql marker', + finding_id='finding-1', + correlation_id='corr-1', + asset_host='target.example', + asset_host_resolved='203.0.113.10', + asset_port='443', + ) + result = ScanResult( + target='https://target.example', + vulnerabilities=[vuln], + command=[], + return_code=0, + stdout='', + stderr='', + raw_findings_count=1, + output_json_path=None, + raw_output_path=None, + summary={'high': 1}, + report_paths=ReportPaths(), + ) + + payload = build_aggregate_payload([result]) + + assert payload['schema_version'] == '1.0' + assert payload['finding_contract']['schema_version'] == '1.0' + assert payload['summary']['priority_counts'] == {'critical': 0, 'high': 1, 'medium': 0, 'low': 0} + assert payload['summary']['severity_counts']['high'] == 1 + finding = payload['top_findings'][0] + assert finding['kind'] == 'validation' + assert finding['confidence'] == 'high' + assert finding['source_count'] == 2 + assert finding['evidence_summary'] == 'graphql marker' + assert finding['asset_hosts'] == ['target.example'] + + +def test_build_run_manifest_separates_effective_config_and_results(tmp_path) -> None: + result = ScanResult( + target='https://target.example', + vulnerabilities=[ + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + target='https://target.example/graphql', + ) + ], + command=[], + return_code=0, + stdout='', + stderr='', + raw_findings_count=3, + output_json_path=str(tmp_path / 'vulnerabilities.json'), + raw_output_path=None, + summary={'high': 1}, + report_paths=ReportPaths(summary_json=str(tmp_path / 'report.summary.json')), + stages_executed=['browser-discovery', 'passive-validation'], + collectors_used=['browser-discovery'], + observed_urls=['https://target.example/'], + observed_actions=[{'kind': 'navigate'}], + observed_api_calls=['https://target.example/graphql'], + ) + + payload = build_run_manifest( + base_dir=tmp_path, + requested_targets=['https://target.example'], + aggregate_paths={'summary_json': str(tmp_path / 'aggregate_summary.json')}, + results=[result], + errors=[], + effective_config={'profile': 'passive-recon-safe', 'debug': False}, + ) + + assert payload['schema_version'] == '1.0' + assert payload['effective_config']['profile'] == 'passive-recon-safe' + assert payload['results_summary']['successful_targets'] == 1 + assert payload['results_summary']['correlated_findings_count'] == 1 + assert payload['per_target'][0]['summary']['raw_findings_count'] == 3 + assert payload['per_target'][0]['observed']['api_calls_count'] == 1 + assert payload['per_target'][0]['reports']['summary_json'].endswith('report.summary.json') + + +def test_api_validator_discards_login_surface_served_from_swagger_path(monkeypatch) -> None: + login_html = "
" + + def fake_get(self, url, timeout=0, allow_redirects=True, headers=None): + if '__attack_surface_mapper_not_found__' in url: + return FakeResponse(url, 'not found', status_code=404) + if url.endswith('/swagger'): + return FakeResponse('https://target.example/login?next=/swagger', login_html, headers={'Content-Type': 'text/html'}) + return FakeResponse(url, 'home') + + monkeypatch.setattr('requests.sessions.Session.get', fake_get) + + findings = APIValidator(timeout=1, paths=('/swagger',)).run('https://target.example') + + assert findings == [] + + +def test_sensitive_files_env_requires_multiple_assignments_and_strong_markers(monkeypatch) -> None: + def fake_get(self, url, timeout=0, allow_redirects=True, headers=None): + if '__attack_surface_mapper_not_found__' in url: + return FakeResponse(url, 'not found', status_code=404) + if url.endswith('/.env'): + return FakeResponse(url, 'DEBUG=true\nPORT=3000', headers={'Content-Type': 'text/plain'}) + return FakeResponse(url, 'home') + + monkeypatch.setattr('requests.sessions.Session.get', fake_get) + + findings = SensitiveFilesValidator(timeout=1, paths=('/.env',)).run('https://target.example') + + assert findings == [] diff --git a/tests/test_v10_22_11_comparison_and_validation_consistency.py b/tests/test_v10_22_11_comparison_and_validation_consistency.py new file mode 100644 index 0000000..d73f7b4 --- /dev/null +++ b/tests/test_v10_22_11_comparison_and_validation_consistency.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +from attack_surface_mapper.analysis.correlation import correlate_vulnerabilities +from attack_surface_mapper.analysis.comparison import compare_scans +from attack_surface_mapper.analysis.enrichment import enrich_vulnerabilities +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.reporting.generator import ReportGenerator + + +def test_compare_scans_reports_promotions_regressions_and_change_types() -> None: + previous = [ + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='medium', + priority='medium', + confidence='medium', + verification_status='likely', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='api', + kind='validation', + ), + Vulnerability( + source='custom-api-check', + title='Swagger UI Exposed', + description='d', + severity='high', + priority='high', + confidence='high', + verification_status='confirmed', + target='https://target.example/swagger', + matched_at='https://target.example/swagger', + category='api', + kind='validation', + ), + ] + current = [ + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + priority='high', + confidence='high', + verification_status='confirmed', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='api', + kind='validation', + ), + Vulnerability( + source='custom-api-check', + title='Swagger UI Exposed', + description='d', + severity='medium', + priority='medium', + confidence='medium', + verification_status='likely', + target='https://target.example/swagger', + matched_at='https://target.example/swagger', + category='api', + kind='validation', + ), + Vulnerability( + source='custom-auth-check', + title='Admin Panel Accessible Without Authentication', + description='d', + severity='high', + priority='high', + confidence='high', + verification_status='confirmed', + target='https://target.example/admin', + matched_at='https://target.example/admin', + category='authentication', + kind='validation', + ), + ] + + comparison = compare_scans(current, previous) + + assert comparison['schema_version'] == '1.0' + assert comparison['summary']['new_findings'] == 1 + assert comparison['summary']['changed_findings'] == 2 + assert comparison['summary']['promoted_findings'] == 1 + assert comparison['summary']['regressed_findings'] == 1 + assert comparison['summary']['change_type_counts']['priority'] == 2 + assert comparison['summary']['change_type_counts']['verification_status'] == 2 + assert comparison['promoted_findings'][0]['title'] == 'GraphQL Surface Exposed' + assert comparison['regressed_findings'][0]['title'] == 'Swagger UI Exposed' + assert 'confidence' in comparison['promoted_findings'][0]['change_types'] + + +def test_report_summary_and_markdown_surface_comparison_summary(tmp_path) -> None: + vuln = Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + priority='high', + confidence='high', + verification_status='confirmed', + target='https://target.example/graphql', + category='api', + ) + comparison = { + 'schema_version': '1.0', + 'summary': { + 'new_findings': 1, + 'resolved_findings': 0, + 'changed_findings': 2, + 'promoted_findings': 1, + 'regressed_findings': 1, + 'updated_findings': 0, + 'unchanged_findings': 3, + 'change_type_counts': {'priority': 2}, + }, + 'new_findings': [{'title': 'x', 'target': 'y'}], + 'resolved_findings': [], + 'changed_findings': [{'title': 'z', 'target': 'y'}], + 'promoted_findings': [{'title': 'x', 'target': 'y'}], + 'regressed_findings': [{'title': 'z', 'target': 'y'}], + 'updated_findings': [], + } + + generator = ReportGenerator() + payload = generator.build_summary_payload([vuln], 'https://target.example', comparison=comparison) + markdown = generator.generate_markdown([vuln], 'https://target.example', str(tmp_path / 'asm_compare_report.md'), comparison=comparison) + + assert payload['comparison_summary']['promoted_findings'] == 1 + assert payload['comparison']['promoted_findings'][0]['title'] == 'x' + content = open(markdown, encoding='utf-8').read() + assert 'Promovidos en riesgo/confianza' in content + assert 'Regresados o debilitados' in content + + +def test_confirmed_application_findings_do_not_force_manual_validation() -> None: + vuln = Vulnerability( + source='custom-auth-check', + title='Cookie Without HttpOnly Flag', + description='d', + severity='medium', + target='https://target.example', + category='authentication', + confidence='high', + verification_status='confirmed', + ) + + enrich_vulnerabilities([vuln]) + + assert vuln.needs_manual_validation is False + assert vuln.verification_status == 'confirmed' + + +def test_likely_application_findings_still_require_manual_validation() -> None: + vuln = Vulnerability( + source='custom-api-check', + title='Swagger UI Exposed', + description='d', + severity='medium', + target='https://target.example/swagger', + category='api', + confidence='medium', + verification_status='likely', + ) + + enrich_vulnerabilities([vuln]) + + assert vuln.needs_manual_validation is True + assert vuln.priority in {'low', 'medium', 'high'} + + +def test_correlation_prefers_confirmed_primary_over_likely_nuclei_match() -> None: + items = [ + Vulnerability( + source='nuclei', + title='GraphQL Surface Exposed', + description='d', + severity='high', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='api', + confidence='medium', + verification_status='likely', + evidence='heuristic graphql match', + ), + Vulnerability( + source='custom-auth-check', + title='GraphQL Endpoint Accessible Without Authentication', + description='d', + severity='medium', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='authentication', + confidence='high', + verification_status='confirmed', + evidence='GET /graphql devolvió 200 con marcadores graphql', + ), + ] + + correlated = correlate_vulnerabilities(items) + + assert len(correlated) == 1 + assert correlated[0].title == 'GraphQL Endpoint Accessible Without Authentication' + assert correlated[0].verification_status == 'confirmed' + assert correlated[0].source_count == 2 + + +def test_unique_confirmed_finding_keeps_higher_priority_than_multi_source_likely() -> None: + confirmed = Vulnerability( + source='custom-auth-check', + title='GraphQL Endpoint Accessible Without Authentication', + description='d', + severity='medium', + target='http://localhost:3000/graphql', + matched_at='http://localhost:3000/graphql', + category='authentication', + confidence='high', + verification_status='confirmed', + ) + multi_source_likely = Vulnerability( + source='custom-api-check', + title='Multiple API Endpoints Exposed (3)', + description='d', + severity='medium', + target='http://localhost:3000/api', + matched_at='http://localhost:3000/api', + category='api', + confidence='high', + verification_status='likely', + source_count=3, + needs_manual_validation=True, + ) + + enrich_vulnerabilities([confirmed, multi_source_likely]) + rank = {'low': 1, 'medium': 2, 'high': 3, 'critical': 4} + + assert rank[confirmed.priority] >= rank[multi_source_likely.priority] + + +def test_confirmed_medium_header_does_not_escalate_to_high_priority_by_default() -> None: + vuln = Vulnerability( + source='custom-header-check', + title='Missing Content-Security-Policy Header', + description='d', + severity='medium', + target='https://target.example', + category='headers', + confidence='high', + verification_status='confirmed', + ) + + enrich_vulnerabilities([vuln]) + + assert vuln.priority == 'medium' + + +def test_confirmed_swagger_documentation_does_not_escalate_to_critical_priority() -> None: + vuln = Vulnerability( + source='custom-api-check', + title='Swagger UI Exposed', + description='d', + severity='medium', + target='https://target.example/swagger', + category='api', + confidence='high', + verification_status='confirmed', + ) + + enrich_vulnerabilities([vuln]) + + assert vuln.priority == 'medium' + + +def test_likely_graphql_without_auth_stays_below_high_priority() -> None: + vuln = Vulnerability( + source='custom-auth-check', + title='GraphQL Endpoint Accessible Without Authentication', + description='d', + severity='medium', + target='https://target.example/graphql', + category='authentication', + confidence='medium', + verification_status='likely', + needs_manual_validation=True, + ) + + enrich_vulnerabilities([vuln]) + + assert vuln.priority == 'medium' + + +def test_multiple_api_endpoints_inventory_priority_stays_medium() -> None: + vuln = Vulnerability( + source='custom-api-check', + title='Multiple API Endpoints Exposed (10)', + description='d', + severity='medium', + target='https://target.example/api', + category='api', + confidence='high', + verification_status='likely', + source_count=10, + needs_manual_validation=True, + ) + + enrich_vulnerabilities([vuln]) + + assert vuln.priority == 'medium' diff --git a/tests/test_v10_22_12_review_matrix.py b/tests/test_v10_22_12_review_matrix.py new file mode 100644 index 0000000..0ba3ec0 --- /dev/null +++ b/tests/test_v10_22_12_review_matrix.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import csv +import json +from pathlib import Path + +from attack_surface_mapper.reporting.review_matrix import build_review_rows, review_bucket_for_finding, write_review_matrix + + +def test_review_bucket_prefers_discovery_and_confirmed_validation() -> None: + assert review_bucket_for_finding({'kind': 'discovery', 'verification_status': 'confirmed', 'confidence': 'high'}) == 'descubrimiento' + assert review_bucket_for_finding({'kind': 'validation', 'verification_status': 'confirmed', 'confidence': 'high', 'priority': 'high'}) == 'priorizar' + assert review_bucket_for_finding({'kind': 'validation', 'verification_status': 'confirmed', 'confidence': 'high', 'priority': 'low'}) == 'revisar' + assert review_bucket_for_finding({'kind': 'validation', 'verification_status': 'likely', 'confidence': 'medium'}) == 'revisar' + assert review_bucket_for_finding({'kind': 'validation', 'category': 'headers', 'verification_status': 'confirmed', 'confidence': 'high', 'priority': 'medium'}) == 'revisar' + assert review_bucket_for_finding({'kind': 'validation', 'category': 'api', 'title': 'Swagger UI Exposed', 'verification_status': 'confirmed', 'confidence': 'high', 'priority': 'medium'}) == 'revisar' + assert review_bucket_for_finding({'kind': 'validation', 'category': 'api', 'title': 'GraphQL Surface Exposed', 'verification_status': 'likely', 'confidence': 'high', 'priority': 'medium'}) == 'revisar' + assert review_bucket_for_finding({'kind': 'validation', 'category': 'api', 'title': 'Broad CORS Policy Observed', 'verification_status': 'likely', 'confidence': 'medium', 'priority': 'low'}) == 'revisar' + assert review_bucket_for_finding({'kind': 'validation', 'category': 'api', 'title': 'Multiple API Endpoints Exposed (10)', 'verification_status': 'likely', 'confidence': 'medium', 'priority': 'medium'}) == 'descubrimiento' + + +def test_build_review_rows_reads_run_manifest_and_vulnerabilities(tmp_path: Path) -> None: + run_dir = tmp_path / 'lab_demo' + target_dir = run_dir / 'targets' / 'http_example' + findings_dir = target_dir / 'findings' + reports_dir = target_dir / 'reports' + findings_dir.mkdir(parents=True) + reports_dir.mkdir(parents=True) + + (findings_dir / 'vulnerabilities.json').write_text( + json.dumps([ + { + 'title': 'GraphQL Surface Exposed', + 'target': 'https://target.example/graphql', + 'priority': 'high', + 'severity': 'high', + 'confidence': 'high', + 'verification_status': 'confirmed', + 'category': 'api', + 'kind': 'validation', + 'finding_id': 'finding-1', + 'correlation_id': 'corr-1', + 'needs_manual_validation': False, + 'source_count': 2, + 'asset_host': 'target.example', + 'asset_port': '443', + 'evidence_summary': 'graphql marker', + } + ]), + encoding='utf-8', + ) + (run_dir / 'run_manifest.json').write_text( + json.dumps({ + 'profile': 'passive-recon-safe', + 'effective_config': {'profile': 'passive-recon-safe'}, + 'per_target': [ + { + 'target': 'https://target.example', + 'reports': { + 'summary_json': str(reports_dir / 'report.summary.json'), + }, + } + ], + }), + encoding='utf-8', + ) + + rows = build_review_rows([run_dir]) + + assert len(rows) == 1 + assert rows[0]['run_name'] == 'lab_demo' + assert rows[0]['profile'] == 'passive-recon-safe' + assert rows[0]['finding_id'] == 'finding-1' + assert rows[0]['bucket_revision'] == 'revisar' + assert rows[0]['etiqueta_analista'] == '' + + +def test_write_review_matrix_creates_csv_with_expected_columns(tmp_path: Path) -> None: + output_path = tmp_path / 'review.csv' + write_review_matrix([ + { + 'run_name': 'lab_demo', + 'profile': 'passive-recon-safe', + 'target': 'https://target.example', + 'finding_id': 'finding-1', + 'correlation_id': 'corr-1', + 'title': 'GraphQL Surface Exposed', + 'category': 'api', + 'kind': 'validation', + 'priority': 'high', + 'severity': 'high', + 'confidence': 'high', + 'verification_status': 'confirmed', + 'needs_manual_validation': 'false', + 'source_count': '2', + 'asset_host': 'target.example', + 'asset_port': '443', + 'evidence_summary': 'graphql marker', + 'bucket_revision': 'revisar', + 'etiqueta_analista': '', + 'notas': '', + } + ], output_path) + + with output_path.open(encoding='utf-8', newline='') as handle: + reader = csv.DictReader(handle) + rows = list(reader) + + assert rows[0]['run_name'] == 'lab_demo' + assert rows[0]['bucket_revision'] == 'revisar' + assert rows[0]['etiqueta_analista'] == '' diff --git a/tests/test_v10_22_13_precision_iteration.py b/tests/test_v10_22_13_precision_iteration.py new file mode 100644 index 0000000..aa5fd48 --- /dev/null +++ b/tests/test_v10_22_13_precision_iteration.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from attack_surface_mapper.batch.aggregate import build_aggregate_payload +from attack_surface_mapper.analysis.enrichment import enrich_vulnerabilities +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.orchestrator import ScanResult +from attack_surface_mapper.reporting import ReportGenerator, ReportPaths +from attack_surface_mapper.validators.api_validator import APIValidator +from attack_surface_mapper.validators.http_fingerprint import fingerprint_response, normalise_text +from attack_surface_mapper.validators.panels_validator import PanelsValidator +from attack_surface_mapper.validators.sensitive_files_validator import SensitiveFilesValidator + + +class FakeResponse: + def __init__(self, url: str, text: str, status_code: int = 200, headers: dict | None = None, content: bytes | None = None): + self.url = url + self.text = text + self.status_code = status_code + self.headers = headers or {'Content-Type': 'text/html'} + self.content = content if content is not None else text.encode() + self.raw = SimpleNamespace(headers=SimpleNamespace(getlist=lambda name: [])) + + +def test_api_validator_discards_baseline_like_graphql_without_strong_signature() -> None: + response = FakeResponse( + 'https://target.example/graphql', + '{"errors":[{"message":"try again"}],"data":[]}', + headers={'Content-Type': 'application/json'}, + ) + preview = normalise_text(response.text, 1500) + baseline = fingerprint_response(response) + + include, confidence, reason, verification, title, _, _ = APIValidator()._classify_path( + '/graphql', + response, + preview, + 'application/json', + baseline, + ) + + assert include is False + assert confidence == 'low' + assert verification == 'discarded' + assert title == 'GraphQL Surface Exposed' + assert 'sin firma graphql fuerte' in reason + + +def test_api_validator_keeps_swagger_as_likely_review_surface_even_with_strong_markers() -> None: + response = FakeResponse( + 'https://target.example/swagger', + 'swagger-ui openapi docs', + headers={'Content-Type': 'text/html'}, + ) + preview = normalise_text(response.text, 1500) + + include, confidence, reason, verification, title, _, _ = APIValidator()._classify_path( + '/swagger', + response, + preview, + 'text/html', + baseline=None, + ) + + assert include is True + assert title == 'Swagger UI Exposed' + assert confidence == 'high' + assert verification == 'likely' + assert 'documentación api pública' in reason + + +def test_auth_validator_reports_graphql_as_api_surface_not_auth_failure(monkeypatch) -> None: + def fake_get(self, url, timeout=0, allow_redirects=True, headers=None): + if url == 'http://localhost:3000': + return FakeResponse(url, 'home') + if '__attack_surface_mapper_not_found__' in url: + return FakeResponse(url, 'SPAshell') + if url.endswith('/graphql'): + return FakeResponse(url, '{"errors":[{"message":"must provide query string"}],"data":null}', headers={'Content-Type': 'application/json'}) + return FakeResponse(url, 'SPAshell') + + monkeypatch.setattr('requests.sessions.Session.get', fake_get) + + from attack_surface_mapper.validators.auth_validator import AuthValidator + + findings = AuthValidator(timeout=1, paths=('/graphql',)).run('http://localhost:3000') + + assert len(findings) == 1 + assert findings[0].title == 'GraphQL Surface Exposed' + assert findings[0].category == 'api' + assert findings[0].verification_status == 'likely' + enrich_vulnerabilities(findings) + assert findings[0].priority == 'medium' + + +def test_panels_validator_skips_swagger_login_surface(monkeypatch) -> None: + login_html = "
" + + def fake_get(self, url, timeout=0, allow_redirects=True, headers=None): + if url == 'https://target.example': + return FakeResponse(url, 'home') + if '__attack_surface_mapper_not_found__' in url: + return FakeResponse(url, 'not found', status_code=404) + if url.endswith('/swagger'): + return FakeResponse('https://target.example/login?next=/swagger', login_html, headers={'Content-Type': 'text/html'}) + return FakeResponse(url, 'not found', status_code=404) + + monkeypatch.setattr('requests.sessions.Session.get', fake_get) + + findings = PanelsValidator(timeout=1, paths=('/swagger',)).run('https://target.example') + + assert findings == [] + + +def test_sensitive_files_validator_rejects_html_shell_for_robots(monkeypatch) -> None: + def fake_get(self, url, timeout=0, allow_redirects=True, headers=None): + if '__attack_surface_mapper_not_found__' in url: + return FakeResponse(url, 'not found', status_code=404) + if url.endswith('/robots.txt'): + return FakeResponse(url, 'User-Agent: demo', headers={'Content-Type': 'text/html'}) + return FakeResponse(url, 'home') + + monkeypatch.setattr('requests.sessions.Session.get', fake_get) + + findings = SensitiveFilesValidator(timeout=1, paths=('/robots.txt',)).run('https://target.example') + + assert findings == [] + + +def test_summary_top_findings_prioritise_application_risk_over_headers_and_inventory() -> None: + findings = [ + Vulnerability( + source='custom-header-check', + title='Missing Content-Security-Policy Header', + description='d', + severity='medium', + priority='medium', + target='https://target.example/', + category='headers', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-api-check', + title='Multiple API Endpoints Exposed (10)', + description='d', + severity='medium', + priority='medium', + target='https://target.example/api', + category='api', + confidence='medium', + verification_status='likely', + ), + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + priority='critical', + target='https://target.example/graphql', + category='api', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-fingerprint-check', + title='Technology Fingerprint Detected (Angular)', + description='d', + severity='low', + priority='low', + target='https://target.example/', + category='discovery', + confidence='medium', + verification_status='likely', + ), + ] + + payload = ReportGenerator().build_summary_payload(findings, 'https://target.example') + titles = [item['title'] for item in payload['top_findings']] + + assert titles[:4] == [ + 'GraphQL Surface Exposed', + 'Missing Content-Security-Policy Header', + 'Multiple API Endpoints Exposed (10)', + 'Technology Fingerprint Detected (Angular)', + ] + assert payload['top_risk_findings'][0]['title'] == 'GraphQL Surface Exposed' + assert payload['top_review_findings'][0]['title'] == 'GraphQL Surface Exposed' + assert payload['top_hygiene_findings'][0]['title'] == 'Missing Content-Security-Policy Header' + assert payload['top_discovery_findings'][0]['title'] == 'Multiple API Endpoints Exposed (10)' + + +def test_top_risk_findings_skip_low_likely_application_noise_when_stronger_items_exist() -> None: + findings = [ + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='medium', + priority='medium', + target='https://target.example/graphql', + category='api', + confidence='medium', + verification_status='likely', + ), + Vulnerability( + source='custom-api-check', + title='Broad CORS Policy Observed', + description='d', + severity='low', + priority='low', + target='https://target.example', + category='api', + confidence='medium', + verification_status='likely', + ), + ] + + payload = ReportGenerator().build_summary_payload(findings, 'https://target.example') + + assert [item['title'] for item in payload['top_risk_findings']] == ['GraphQL Surface Exposed'] + + +def test_top_risk_findings_follow_review_matrix_and_hide_review_only_surface_when_prioritised_item_exists() -> None: + findings = [ + Vulnerability( + source='custom-auth-check', + title='Metrics Endpoint Accessible Without Authentication', + description='d', + severity='high', + priority='critical', + target='https://target.example/metrics', + category='authentication', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-api-check', + title='Swagger UI Exposed', + description='d', + severity='medium', + priority='medium', + target='https://target.example/swagger', + category='api', + confidence='high', + verification_status='likely', + ), + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='medium', + priority='medium', + target='https://target.example/graphql', + category='api', + confidence='high', + verification_status='likely', + ), + ] + + payload = ReportGenerator().build_summary_payload(findings, 'https://target.example') + + assert [item['title'] for item in payload['top_risk_findings']] == ['Metrics Endpoint Accessible Without Authentication'] + + +def test_markdown_has_dedicated_hygiene_section(tmp_path) -> None: + findings = [ + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + priority='high', + target='https://target.example/graphql', + category='api', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-header-check', + title='Missing Content-Security-Policy Header', + description='d', + severity='medium', + priority='medium', + target='https://target.example/', + category='headers', + confidence='high', + verification_status='confirmed', + ), + ] + + content_path = ReportGenerator().generate_markdown(findings, 'https://target.example', str(tmp_path / 'report.md')) + content = open(content_path, encoding='utf-8').read() + + assert '## Hallazgos confirmados de aplicación' in content + assert '## Hallazgos de higiene y endurecimiento' in content + assert 'Missing Content-Security-Policy Header' in content + + +def test_aggregate_top_findings_prioritise_application_risk_over_headers_and_inventory() -> None: + findings = [ + Vulnerability( + source='custom-header-check', + title='Missing Content-Security-Policy Header', + description='d', + severity='medium', + priority='medium', + target='https://target.example/', + category='headers', + kind='validation', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-api-check', + title='Multiple API Endpoints Exposed (10)', + description='d', + severity='medium', + priority='medium', + target='https://target.example/api', + category='api', + kind='validation', + confidence='medium', + verification_status='likely', + ), + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + priority='critical', + target='https://target.example/graphql', + category='api', + kind='validation', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-fingerprint-check', + title='Technology Fingerprint Detected (Angular)', + description='d', + severity='low', + priority='low', + target='https://target.example/', + category='discovery', + kind='discovery', + confidence='medium', + verification_status='likely', + ), + ] + result = ScanResult( + target='https://target.example', + vulnerabilities=findings, + command=[], + return_code=0, + stdout='', + stderr='', + raw_findings_count=len(findings), + output_json_path=None, + raw_output_path=None, + summary={'critical': 1, 'medium': 2, 'low': 1}, + report_paths=ReportPaths(), + ) + + payload = build_aggregate_payload([result]) + titles = [item['title'] for item in payload['top_findings']] + + assert titles[:4] == [ + 'GraphQL Surface Exposed', + 'Missing Content-Security-Policy Header', + 'Multiple API Endpoints Exposed (10)', + 'Technology Fingerprint Detected (Angular)', + ] diff --git a/tests/test_v10_22_14_golden_set_and_operational_coherence.py b/tests/test_v10_22_14_golden_set_and_operational_coherence.py new file mode 100644 index 0000000..5616974 --- /dev/null +++ b/tests/test_v10_22_14_golden_set_and_operational_coherence.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from attack_surface_mapper.batch.aggregate import build_aggregate_payload +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.orchestrator import ScanResult +from attack_surface_mapper.reporting import ( + ReportGenerator, + ReportPaths, + evaluate_review_rows_against_golden_set, + load_review_golden_set, +) + + +def test_management_and_actuator_share_operational_correlation_bucket() -> None: + actuator = Vulnerability( + source='custom-auth-check', + title='Actuator Endpoint Accessible Without Authentication', + description='d', + severity='medium', + target='https://target.example/actuator', + matched_at='https://target.example/actuator', + category='authentication', + ) + management = Vulnerability( + source='custom-panel-check', + title='Exposed Management Endpoint', + description='d', + severity='medium', + target='https://target.example/management', + matched_at='https://target.example/management', + category='panel-exposure', + ) + + assert actuator.correlation_key() == management.correlation_key() + + +def test_summary_payload_separates_review_surface_from_actionable_risk() -> None: + findings = [ + Vulnerability( + source='custom-auth-check', + title='Metrics Endpoint Accessible Without Authentication', + description='d', + severity='high', + priority='critical', + target='https://target.example/metrics', + category='authentication', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-api-check', + title='Swagger UI Exposed', + description='d', + severity='medium', + priority='medium', + target='https://target.example/swagger', + category='api', + confidence='high', + verification_status='likely', + ), + Vulnerability( + source='custom-header-check', + title='Missing Content-Security-Policy Header', + description='d', + severity='medium', + priority='medium', + target='https://target.example', + category='headers', + confidence='high', + verification_status='confirmed', + ), + ] + + payload = ReportGenerator().build_summary_payload(findings, 'https://target.example') + + assert payload['stats']['application_findings'] == 1 + assert payload['stats']['review_surface_findings'] == 1 + assert payload['stats']['hygiene_findings'] == 1 + assert [item['title'] for item in payload['top_risk_findings']] == ['Metrics Endpoint Accessible Without Authentication'] + assert [item['title'] for item in payload['top_review_findings']] == ['Swagger UI Exposed'] + + +def test_inventory_like_findings_do_not_force_manual_validation() -> None: + findings = [ + Vulnerability( + source='custom-api-check', + title='Multiple API Endpoints Exposed (10)', + description='d', + severity='medium', + target='https://target.example/api', + category='api', + confidence='medium', + verification_status='likely', + ), + Vulnerability( + source='custom-fingerprint-check', + title='Technology Fingerprint Detected (Angular)', + description='d', + severity='low', + target='https://target.example/', + category='discovery', + confidence='medium', + verification_status='likely', + ), + ] + + payload = ReportGenerator().build_summary_payload(findings, 'https://target.example') + + assert payload['stats']['needs_manual_validation'] == 0 + assert payload['top_discovery_findings'][0]['title'] == 'Multiple API Endpoints Exposed (10)' + + +def test_aggregate_payload_exposes_finding_contract_and_keeps_review_surface_in_top_findings() -> None: + findings = [ + Vulnerability( + source='custom-auth-check', + title='Metrics Endpoint Accessible Without Authentication', + description='d', + severity='high', + priority='critical', + target='https://target.example/metrics', + category='authentication', + confidence='high', + verification_status='confirmed', + kind='validation', + ), + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='medium', + priority='medium', + target='https://target.example/graphql', + category='api', + confidence='medium', + verification_status='likely', + kind='validation', + ), + ] + result = ScanResult( + target='https://target.example', + vulnerabilities=findings, + command=[], + return_code=0, + stdout='', + stderr='', + raw_findings_count=len(findings), + output_json_path=None, + raw_output_path=None, + summary={'critical': 1, 'medium': 1}, + report_paths=ReportPaths(), + ) + + payload = build_aggregate_payload([result]) + + assert payload['finding_contract']['schema_version'] == '1.0' + assert payload['summary']['actionable_risk_findings'] == 1 + assert payload['summary']['review_surface_findings'] == 1 + assert payload['top_findings'][0]['title'] == 'Metrics Endpoint Accessible Without Authentication' + assert any(item['title'] == 'GraphQL Surface Exposed' for item in payload['top_findings']) + + +def test_review_golden_set_matches_expected_rows() -> None: + rows = [ + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Metrics Endpoint Accessible Without Authentication', + 'bucket_revision': 'priorizar', + 'finding_role': 'validated', + 'validated': 'true', + 'priority': 'critical', + 'verification_status': 'confirmed', + 'needs_manual_validation': 'false', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Swagger UI Exposed', + 'bucket_revision': 'revisar', + 'finding_role': 'candidate', + 'validated': 'false', + 'priority': 'medium', + 'verification_status': 'likely', + 'needs_manual_validation': 'true', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'GraphQL Surface Exposed', + 'bucket_revision': 'revisar', + 'finding_role': 'candidate', + 'validated': 'false', + 'priority': 'medium', + 'verification_status': 'likely', + 'needs_manual_validation': 'true', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Multiple API Endpoints Exposed (10)', + 'bucket_revision': 'descubrimiento', + 'finding_role': 'discovery', + 'validated': 'false', + 'priority': 'medium', + 'verification_status': 'likely', + 'needs_manual_validation': 'false', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Broad CORS Policy Observed', + 'bucket_revision': 'revisar', + 'finding_role': 'candidate', + 'validated': 'false', + 'priority': 'low', + 'verification_status': 'likely', + 'needs_manual_validation': 'true', + }, + { + 'run_name': 'lab_dvwa_passive_recon_safe', + 'title': 'Missing Content-Security-Policy Header', + 'bucket_revision': 'revisar', + 'finding_role': 'validated', + 'validated': 'true', + 'priority': 'medium', + 'verification_status': 'confirmed', + 'needs_manual_validation': 'false', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Multiple Client-Side API References Observed (7)', + 'bucket_revision': 'descubrimiento', + 'finding_role': 'discovery', + 'validated': 'false', + 'priority': 'low', + 'verification_status': 'confirmed', + 'needs_manual_validation': 'false', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Protected API Surface Discovered (7 endpoints)', + 'bucket_revision': 'descubrimiento', + 'finding_role': 'discovery', + 'validated': 'false', + 'priority': 'low', + 'verification_status': 'confirmed', + 'needs_manual_validation': 'false', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Technology Fingerprint Detected (Angular)', + 'bucket_revision': 'descubrimiento', + 'finding_role': 'discovery', + 'validated': 'false', + 'priority': 'low', + 'verification_status': 'likely', + 'needs_manual_validation': 'false', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'robots.txt Exposed', + 'bucket_revision': 'descubrimiento', + 'finding_role': 'discovery', + 'validated': 'false', + 'priority': 'low', + 'verification_status': 'likely', + 'needs_manual_validation': 'false', + }, + ] + + golden = load_review_golden_set(Path('tests/data/lab_review_golden_set.json')) + evaluation = evaluate_review_rows_against_golden_set(rows, golden) + + assert evaluation['ok'] is True + assert evaluation['checked'] == len(golden) diff --git a/tests/test_v10_22_15_validation_layer.py b/tests/test_v10_22_15_validation_layer.py new file mode 100644 index 0000000..08c9de7 --- /dev/null +++ b/tests/test_v10_22_15_validation_layer.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from attack_surface_mapper.analysis import compare_scans, enrich_vulnerabilities +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.reporting import ReportGenerator +from attack_surface_mapper.reporting.review_matrix import build_review_rows, review_bucket_for_finding + + +def test_enrichment_assigns_explicit_validation_roles() -> None: + findings = enrich_vulnerabilities([ + Vulnerability( + source='custom-auth-check', + title='Metrics Endpoint Accessible Without Authentication', + description='d', + severity='high', + target='https://target.example/metrics', + category='authentication', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='medium', + target='https://target.example/graphql', + category='api', + confidence='medium', + verification_status='likely', + needs_manual_validation=True, + ), + Vulnerability( + source='custom-api-check', + title='Multiple API Endpoints Exposed (10)', + description='d', + severity='medium', + target='https://target.example/api', + category='api', + confidence='medium', + verification_status='likely', + ), + ]) + + validated, candidate, discovery = findings + + assert validated.finding_role == 'validated' + assert validated.validated is True + assert validated.validation_basis == 'response-evidence' + + assert candidate.finding_role == 'candidate' + assert candidate.validated is False + assert candidate.validation_basis == 'heuristic-evidence' + assert candidate.needs_manual_validation is True + + assert discovery.finding_role == 'discovery' + assert discovery.validated is False + assert discovery.validation_basis == 'context-only' + assert discovery.needs_manual_validation is False + + +def test_summary_payload_exposes_validation_layer_counts() -> None: + findings = enrich_vulnerabilities([ + Vulnerability( + source='custom-auth-check', + title='Metrics Endpoint Accessible Without Authentication', + description='d', + severity='high', + target='https://target.example/metrics', + category='authentication', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-api-check', + title='Swagger UI Exposed', + description='d', + severity='medium', + target='https://target.example/swagger', + category='api', + confidence='medium', + verification_status='likely', + needs_manual_validation=True, + ), + Vulnerability( + source='custom-api-check', + title='Multiple API Endpoints Exposed (10)', + description='d', + severity='medium', + target='https://target.example/api', + category='api', + confidence='medium', + verification_status='likely', + ), + ]) + + payload = ReportGenerator().build_summary_payload(findings, 'https://target.example') + + assert payload['stats']['validated_findings'] == 1 + assert payload['stats']['candidate_findings'] == 1 + assert payload['stats']['discovery_findings'] == 1 + assert payload['top_risk_findings'][0]['finding_role'] == 'validated' + assert payload['top_review_findings'][0]['finding_role'] == 'candidate' + assert payload['top_discovery_findings'][0]['finding_role'] == 'discovery' + + +def test_review_matrix_rows_include_validation_layer_fields(tmp_path: Path) -> None: + run_dir = tmp_path / 'lab_demo' + target_dir = run_dir / 'targets' / 'http_example' + findings_dir = target_dir / 'findings' + reports_dir = target_dir / 'reports' + findings_dir.mkdir(parents=True) + reports_dir.mkdir(parents=True) + + (findings_dir / 'vulnerabilities.json').write_text( + json.dumps([ + { + 'title': 'GraphQL Surface Exposed', + 'target': 'https://target.example/graphql', + 'priority': 'medium', + 'severity': 'medium', + 'confidence': 'medium', + 'verification_status': 'likely', + 'category': 'api', + 'kind': 'validation', + 'finding_role': 'candidate', + 'validated': False, + 'validation_basis': 'heuristic-evidence', + 'finding_id': 'finding-1', + 'correlation_id': 'corr-1', + 'needs_manual_validation': True, + 'source_count': 1, + 'asset_host': 'target.example', + 'asset_port': '443', + 'evidence_summary': 'graphql marker', + } + ]), + encoding='utf-8', + ) + (run_dir / 'run_manifest.json').write_text( + json.dumps({ + 'profile': 'passive-recon-safe', + 'effective_config': {'profile': 'passive-recon-safe'}, + 'per_target': [ + { + 'target': 'https://target.example', + 'reports': { + 'summary_json': str(reports_dir / 'report.summary.json'), + }, + } + ], + }), + encoding='utf-8', + ) + + rows = build_review_rows([run_dir]) + + assert rows[0]['finding_role'] == 'candidate' + assert rows[0]['validated'] == 'false' + assert rows[0]['validation_basis'] == 'heuristic-evidence' + assert rows[0]['bucket_revision'] == 'revisar' + + +def test_review_bucket_prefers_explicit_finding_role() -> None: + assert review_bucket_for_finding({ + 'finding_role': 'discovery', + 'verification_status': 'confirmed', + 'confidence': 'high', + }) == 'descubrimiento' + assert review_bucket_for_finding({ + 'finding_role': 'candidate', + 'verification_status': 'confirmed', + 'confidence': 'high', + 'priority': 'high', + }) == 'revisar' + assert review_bucket_for_finding({ + 'finding_role': 'validated', + 'validated': True, + 'verification_status': 'confirmed', + 'confidence': 'high', + 'priority': 'high', + }) == 'priorizar' + + +def test_compare_scans_tracks_finding_role_changes() -> None: + previous = enrich_vulnerabilities([ + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='medium', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='api', + confidence='medium', + verification_status='likely', + ) + ]) + current = enrich_vulnerabilities([ + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='medium', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='api', + confidence='high', + verification_status='confirmed', + ) + ]) + + comparison = compare_scans(current, previous) + + assert comparison['summary']['change_type_counts']['finding_role'] == 1 + assert comparison['changed_findings'][0]['previous_finding_role'] == 'candidate' + assert comparison['changed_findings'][0]['current_finding_role'] == 'validated' diff --git a/tests/test_v10_22_16_scoring_model.py b/tests/test_v10_22_16_scoring_model.py new file mode 100644 index 0000000..c42883a --- /dev/null +++ b/tests/test_v10_22_16_scoring_model.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from attack_surface_mapper.analysis import enrich_vulnerabilities +from attack_surface_mapper.batch.aggregate import build_aggregate_payload +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.orchestrator import ScanResult +from attack_surface_mapper.reporting.generator import ReportGenerator, ReportPaths + + +def test_scoring_orders_validated_candidate_and_discovery_findings() -> None: + findings = enrich_vulnerabilities([ + Vulnerability( + source='custom-auth-check', + title='Metrics Endpoint Accessible Without Authentication', + description='d', + severity='high', + target='https://target.example/metrics', + matched_at='https://target.example/metrics', + category='authentication', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-api-check', + title='Swagger UI Exposed', + description='d', + severity='medium', + target='https://target.example/swagger', + matched_at='https://target.example/swagger', + category='api', + confidence='high', + verification_status='likely', + needs_manual_validation=True, + ), + Vulnerability( + source='custom-api-check', + title='Multiple API Endpoints Exposed (10)', + description='d', + severity='medium', + target='https://target.example/api', + matched_at='https://target.example/api', + category='api', + confidence='medium', + verification_status='likely', + source_count=10, + ), + ]) + + validated, candidate, discovery = findings + + assert validated.priority_score > candidate.priority_score > discovery.priority_score + assert validated.priority == 'critical' + assert candidate.priority == 'medium' + assert discovery.priority == 'medium' + assert validated.scoring_version == '1.0' + + +def test_summary_payload_exposes_scoring_metadata() -> None: + findings = enrich_vulnerabilities([ + Vulnerability( + source='custom-header-check', + title='Missing Content-Security-Policy Header', + description='d', + severity='medium', + target='https://target.example', + category='headers', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-api-check', + title='Swagger UI Exposed', + description='d', + severity='medium', + target='https://target.example/swagger', + category='api', + confidence='medium', + verification_status='likely', + needs_manual_validation=True, + ), + ]) + + payload = ReportGenerator().build_summary_payload(findings, 'https://target.example') + + assert payload['scoring_version'] == '1.0' + assert payload['stats']['average_priority_score'] is not None + assert payload['top_findings'][0]['priority_score'] >= payload['top_findings'][1]['priority_score'] + + +def test_aggregate_payload_keeps_numeric_priority_score() -> None: + findings = enrich_vulnerabilities([ + Vulnerability( + source='custom-auth-check', + title='Metrics Endpoint Accessible Without Authentication', + description='d', + severity='high', + target='https://target.example/metrics', + matched_at='https://target.example/metrics', + category='authentication', + confidence='high', + verification_status='confirmed', + ), + Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='medium', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='api', + confidence='medium', + verification_status='likely', + needs_manual_validation=True, + ), + ]) + + result = ScanResult( + target='https://target.example', + vulnerabilities=findings, + command=[], + return_code=0, + stdout='', + stderr='', + raw_findings_count=len(findings), + output_json_path=None, + raw_output_path=None, + summary={'critical': 1, 'medium': 1}, + report_paths=ReportPaths(), + ) + + payload = build_aggregate_payload([result]) + + assert payload['scoring_version'] == '1.0' + assert payload['average_priority_score'] is not None + assert payload['top_findings'][0]['priority_score'] >= payload['top_findings'][1]['priority_score'] diff --git a/tests/test_v10_22_17_elasticsearch_export.py b/tests/test_v10_22_17_elasticsearch_export.py new file mode 100644 index 0000000..abc7963 --- /dev/null +++ b/tests/test_v10_22_17_elasticsearch_export.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from attack_surface_mapper.reporting import export_elasticsearch_bundle + + +def _write_json(path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8') + + +def _bulk_documents(path: Path) -> list[dict]: + lines = path.read_text(encoding='utf-8').splitlines() + assert len(lines) % 2 == 0 + documents: list[dict] = [] + for index in range(1, len(lines), 2): + documents.append(json.loads(lines[index])) + return documents + + +def test_export_elasticsearch_bundle_writes_expected_files(tmp_path: Path) -> None: + run_dir = tmp_path / 'lab_export_demo' + target_dir = run_dir / 'targets' / 'http_example' + + _write_json( + target_dir / 'findings' / 'vulnerabilities.json', + [ + { + 'finding_id': 'finding-1', + 'correlation_id': 'corr-1', + 'source': 'custom-auth-check', + 'title': 'Metrics Endpoint Accessible Without Authentication', + 'target': 'https://target.example/metrics', + 'matched_at': 'https://target.example/metrics', + 'target_host_original': 'target.example', + 'asset_host': 'target.example', + 'asset_host_resolved': '203.0.113.10', + 'asset_port': '443', + 'scheme': 'https', + 'category': 'authentication', + 'severity': 'high', + 'priority': 'critical', + 'priority_score': 100, + 'scoring_version': '1.0', + 'confidence': 'high', + 'verification_status': 'confirmed', + 'finding_role': 'validated', + 'validated': True, + 'validation_basis': 'response-evidence', + 'kind': 'validation', + 'needs_manual_validation': False, + 'source_count': 1, + 'recommendation': 'Proteger /metrics.', + 'evidence_summary': '200 + metrics markers', + 'priority_reason': 'scoring details', + 'description': 'Exposición de métricas', + 'tags': ['auth'], + 'cwe': ['CWE-306'], + 'related_sources': ['custom-auth-check'], + 'raw': {'ignored': True}, + } + ], + ) + _write_json( + target_dir / 'reports' / 'report.summary.json', + { + 'schema_version': '1.0', + 'scoring_version': '1.0', + 'title': 'Informe target', + 'target': 'https://target.example', + 'executive_summary': 'Resumen corto', + 'stats': { + 'total_findings': 1, + 'average_priority_score': 100.0, + 'validated_findings': 1, + 'candidate_findings': 0, + 'application_findings': 1, + 'review_surface_findings': 0, + 'hygiene_findings': 0, + 'discovery_findings': 0, + 'needs_manual_validation': 0, + 'confirmed_high_or_critical': 1, + 'priority_counts': {'critical': 1}, + 'severity_counts': {'high': 1}, + 'category_counts': {'authentication': 1}, + 'source_counts': {'custom-auth-check': 1}, + }, + 'comparison_summary': {'new_findings': 1}, + 'finding_contract': {'schema_version': '1.0'}, + }, + ) + _write_json( + run_dir / 'reports' / 'aggregate_summary.json', + { + 'schema_version': '1.0', + 'scoring_version': '1.0', + 'finding_contract': {'schema_version': '1.0'}, + 'summary': { + 'total_findings': 1, + 'average_priority_score': 100.0, + 'validated_findings': 1, + 'candidate_findings': 0, + 'actionable_risk_findings': 1, + 'review_surface_findings': 0, + 'hygiene_findings': 0, + 'discovery_findings': 0, + 'priority_counts': {'critical': 1}, + 'severity_counts': {'high': 1}, + 'category_counts': {'authentication': 1}, + }, + }, + ) + _write_json( + run_dir / 'run_manifest.json', + { + 'schema_version': '1.0', + 'profile': 'passive-recon-safe', + 'run_dir': str(run_dir), + 'requested_targets': ['https://target.example'], + 'targets': ['https://target.example'], + 'errors': [], + 'pipeline': {'stages': ['browser_discovery', 'passive_validation']}, + 'effective_config': {'profile': 'passive-recon-safe'}, + 'results_summary': {'successful_targets': 1}, + 'aggregate_reports': {'summary_json': str(run_dir / 'reports' / 'aggregate_summary.json')}, + }, + ) + + manifest = export_elasticsearch_bundle(run_dir, index_prefix='asm-test') + output_dir = run_dir / 'elasticsearch' + + assert manifest['documents'] == {'findings': 1, 'summaries': 2, 'runs': 1} + assert (output_dir / 'findings_mapping.json').exists() + assert (output_dir / 'summaries_mapping.json').exists() + assert (output_dir / 'runs_mapping.json').exists() + assert (output_dir / 'findings_bulk.ndjson').exists() + assert (output_dir / 'summaries_bulk.ndjson').exists() + assert (output_dir / 'runs_bulk.ndjson').exists() + assert (output_dir / 'manual_kibana_devtools.md').exists() + assert (output_dir / 'ingest_with_curl.sh').exists() + assert (output_dir / 'ingest_with_python.py').exists() + assert (output_dir / 'export_manifest.json').exists() + + finding_docs = _bulk_documents(output_dir / 'findings_bulk.ndjson') + assert finding_docs[0]['finding_id'] == 'finding-1' + assert finding_docs[0]['finding_role'] == 'validated' + assert finding_docs[0]['validated'] is True + assert finding_docs[0]['validation_basis'] == 'response-evidence' + assert finding_docs[0]['priority_score'] == 100 + assert finding_docs[0]['scoring_version'] == '1.0' + assert 'raw' not in finding_docs[0] + + summaries_docs = _bulk_documents(output_dir / 'summaries_bulk.ndjson') + assert {doc['document_type'] for doc in summaries_docs} == {'target_summary', 'aggregate_summary'} + assert any(doc.get('target_slug') == 'http_example' for doc in summaries_docs) + + runs_docs = _bulk_documents(output_dir / 'runs_bulk.ndjson') + assert runs_docs[0]['document_type'] == 'run_manifest' + assert runs_docs[0]['run_name'] == 'lab_export_demo' + + curl_script = (output_dir / 'ingest_with_curl.sh').read_text(encoding='utf-8') + assert 'findings_bulk.ndjson' in curl_script + assert 'asm-test-findings' in curl_script + + python_script = (output_dir / 'ingest_with_python.py').read_text(encoding='utf-8') + assert 'summaries_mapping.json' in python_script + assert 'asm-test-runs' in python_script + + manual_helper = (output_dir / 'manual_kibana_devtools.md').read_text(encoding='utf-8') + assert 'findings_mapping.json' in manual_helper + assert 'runs_bulk.ndjson' in manual_helper + + +def test_export_elasticsearch_bundle_uses_stable_bulk_ids(tmp_path: Path) -> None: + run_dir = tmp_path / 'lab_export_ids' + target_dir = run_dir / 'targets' / 'http_example' + + _write_json( + target_dir / 'findings' / 'vulnerabilities.json', + [ + { + 'finding_id': 'finding-alpha', + 'correlation_id': 'corr-alpha', + 'title': 'GraphQL Surface Exposed', + 'target': 'https://target.example/graphql', + 'asset_host': 'target.example', + 'asset_host_resolved': '203.0.113.11', + 'asset_port': '443', + 'category': 'api', + 'severity': 'medium', + 'priority': 'medium', + 'priority_score': 41, + 'scoring_version': '1.0', + 'confidence': 'medium', + 'verification_status': 'likely', + 'finding_role': 'candidate', + 'validated': False, + 'validation_basis': 'heuristic-evidence', + 'kind': 'validation', + 'source_count': 1, + } + ], + ) + _write_json( + target_dir / 'reports' / 'report.summary.json', + { + 'schema_version': '1.0', + 'scoring_version': '1.0', + 'title': 'Informe target', + 'target': 'https://target.example', + 'stats': {'total_findings': 1}, + }, + ) + _write_json(run_dir / 'reports' / 'aggregate_summary.json', {'summary': {'total_findings': 1}}) + _write_json(run_dir / 'run_manifest.json', {'targets': ['https://target.example'], 'errors': []}) + + export_elasticsearch_bundle(run_dir, index_prefix='asm-id') + output_dir = run_dir / 'elasticsearch' + + findings_lines = (output_dir / 'findings_bulk.ndjson').read_text(encoding='utf-8').splitlines() + summaries_lines = (output_dir / 'summaries_bulk.ndjson').read_text(encoding='utf-8').splitlines() + runs_lines = (output_dir / 'runs_bulk.ndjson').read_text(encoding='utf-8').splitlines() + + assert json.loads(findings_lines[0])['index']['_id'] == 'finding-alpha' + assert json.loads(summaries_lines[0])['index']['_id'] == 'summary::lab_export_ids::http_example' + assert json.loads(summaries_lines[2])['index']['_id'] == 'aggregate::lab_export_ids' + assert json.loads(runs_lines[0])['index']['_id'] == 'run::lab_export_ids' diff --git a/tests/test_v10_22_6_false_positive_tuning.py b/tests/test_v10_22_6_false_positive_tuning.py new file mode 100644 index 0000000..9059c8b --- /dev/null +++ b/tests/test_v10_22_6_false_positive_tuning.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from attack_surface_mapper.analysis.correlation import correlate_vulnerabilities +from attack_surface_mapper.analysis.enrichment import enrich_vulnerabilities +from attack_surface_mapper.http_client import HttpResponse +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.validators.api_validator import APIValidator +from attack_surface_mapper.validators.auth_validator import AuthValidator +from attack_surface_mapper.validators.headers_validator import HeadersValidator +from attack_surface_mapper.validators.panels_validator import PanelsValidator +from attack_surface_mapper.validators.sensitive_files_validator import SensitiveFilesValidator + + +class FakeResponse: + def __init__(self, url: str, text: str, status_code: int = 200, headers: dict | None = None, content: bytes | None = None): + self.url = url + self.text = text + self.status_code = status_code + self.headers = headers or {'Content-Type': 'text/html'} + self.content = content if content is not None else text.encode() + self.raw = SimpleNamespace(headers=SimpleNamespace(getlist=lambda name: [])) + + +def test_headers_validator_skips_browser_headers_for_json_response() -> None: + response = HttpResponse( + status_code=200, + url='https://api.example.test/status', + headers={'Content-Type': 'application/json'}, + text='{"ok":true}', + content=b'{"ok":true}', + ) + + findings = HeadersValidator(timeout=1).run('https://api.example.test/status', response=response) + + assert [finding.title for finding in findings] == ['Missing Strict-Transport-Security Header'] + + +def test_panels_validator_treats_public_login_as_discovery(monkeypatch) -> None: + login_html = "
" + + def fake_get(self, url, timeout=0, allow_redirects=True, headers=None): + if '__attack_surface_mapper_not_found__' in url: + return FakeResponse(url, 'not found', status_code=404) + if url.endswith('/login'): + return FakeResponse(url, login_html, headers={'Content-Type': 'text/html'}) + return FakeResponse(url, 'home') + + monkeypatch.setattr('requests.sessions.Session.get', fake_get) + findings = PanelsValidator(timeout=1, paths=('/login',)).run('https://target.example') + + assert len(findings) == 1 + assert findings[0].title == 'Login Surface Discovered (200)' + assert findings[0].category == 'discovery' + assert findings[0].severity == 'low' + + +def test_sensitive_files_rejects_content_type_only_binary_matches(monkeypatch) -> None: + def fake_get(self, url, timeout=0, allow_redirects=True, headers=None): + if '__attack_surface_mapper_not_found__' in url: + return FakeResponse(url, 'not found', status_code=404) + if url.endswith('/backup.zip'): + return FakeResponse(url, '', headers={'Content-Type': 'application/zip'}, content=b'not-a-zip') + if url.endswith('/.DS_Store'): + return FakeResponse(url, '', headers={'Content-Type': 'application/octet-stream'}, content=b'not-ds-store') + return FakeResponse(url, 'home') + + monkeypatch.setattr('requests.sessions.Session.get', fake_get) + findings = SensitiveFilesValidator(timeout=1, paths=('/backup.zip', '/.DS_Store')).run('https://target.example') + + assert findings == [] + + +def test_cors_wildcard_without_credentials_is_low_confidence_signal(monkeypatch) -> None: + def fake_get(self, url, timeout=0, allow_redirects=True, headers=None): + if headers and headers.get('Origin'): + return FakeResponse( + url, + '{"ok": true}', + headers={'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + ) + return FakeResponse(url, 'home') + + monkeypatch.setattr('requests.sessions.Session.get', fake_get) + findings = APIValidator(timeout=1, paths=()).run('https://api.example.test') + + assert len(findings) == 1 + assert findings[0].title == 'Broad CORS Policy Observed' + assert findings[0].severity == 'low' + assert findings[0].confidence == 'medium' + assert findings[0].verification_status == 'likely' + + +def test_auth_cookie_flags_ignore_non_auth_cookie_and_do_not_require_httponly_on_csrf_cookie() -> None: + validator = AuthValidator(timeout=1) + response = FakeResponse( + 'https://target.example', + 'home', + headers={'Content-Type': 'text/html'}, + ) + response.raw = SimpleNamespace(headers=SimpleNamespace(getlist=lambda name: [ + 'analytics_id=abc123; Path=/', + 'csrftoken=abc123; Path=/; Secure', + 'sessionid=abc123; Path=/; Secure', + ])) + + findings = validator._check_cookie_flags(response, 'https://target.example', 'target.example', None, 'https') + + titles = [finding.title for finding in findings] + assert 'Cookie Without HttpOnly Flag' in titles + assert titles.count('Cookie Without SameSite Attribute') == 2 + assert not any('analytics_id' in (finding.evidence or '') for finding in findings) + assert not any(finding.title == 'Cookie Without HttpOnly Flag' and 'csrftoken' in (finding.evidence or '') for finding in findings) + + +def test_correlation_does_not_merge_login_headers_fingerprint_and_form_discovery() -> None: + location = 'http://localhost:8080/login.php' + items = [ + Vulnerability( + source='custom-header-check', + title='Missing Content-Security-Policy Header', + description='d', + severity='medium', + target=location, + matched_at=location, + category='headers', + ), + Vulnerability( + source='custom-fingerprint-check', + title='Technology Fingerprint Detected (Apache httpd)', + description='d', + severity='low', + target=location, + matched_at=location, + category='discovery', + ), + Vulnerability( + source='custom-discovery-check', + title='Login Form Discovered Via Crawl', + description='d', + severity='low', + target=location, + matched_at=location, + category='discovery', + ), + ] + + correlated = correlate_vulnerabilities(items) + + assert len(correlated) == 3 + assert {finding.title for finding in correlated} == { + 'Missing Content-Security-Policy Header', + 'Technology Fingerprint Detected (Apache httpd)', + 'Login Form Discovered Via Crawl', + } + assert all(finding.source_count == 1 for finding in correlated) + + +def test_low_impact_missing_headers_keep_low_priority() -> None: + vuln = Vulnerability( + source='custom-header-check', + title='Missing Referrer-Policy Header', + description='d', + severity='low', + target='http://localhost:3000/', + category='headers', + confidence='high', + ) + + enrich_vulnerabilities([vuln]) + + assert vuln.priority == 'low' diff --git a/tests/test_v10_22_7_cli_profile_semantics.py b/tests/test_v10_22_7_cli_profile_semantics.py new file mode 100644 index 0000000..46b711f --- /dev/null +++ b/tests/test_v10_22_7_cli_profile_semantics.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import main + + +class DummyOrchestrator: + calls: list[dict] = [] + + def scan_target(self, **kwargs): + self.calls.append(kwargs) + return SimpleNamespace(target=kwargs['target']) + + +def _run_profile(monkeypatch, tmp_path, profile: str): + DummyOrchestrator.calls = [] + monkeypatch.setattr(main, 'ScanOrchestrator', lambda: DummyOrchestrator()) + args = main.build_arg_parser().parse_args(['--profile', profile, 'http://target.example']) + main.scan_one('http://target.example', args, {}, tmp_path) + assert DummyOrchestrator.calls + return DummyOrchestrator.calls[-1] + + +def test_cli_passive_stealth_matches_low_noise_profile_semantics(monkeypatch, tmp_path) -> None: + call = _run_profile(monkeypatch, tmp_path, 'passive-stealth') + + assert call['run_nuclei'] is False + assert call['run_nmap'] is False + assert call['baseline_probe'] is False + assert call['observed_only'] is True + assert call['http_backend'] == 'requests' + assert call['crawler_backend'] == 'requests' + assert call['run_panels'] is False + assert call['run_auth'] is False + assert call['run_api'] is False + assert call['run_sensitive_files'] is False + assert call['run_secrets'] is False + + +def test_cli_passive_recon_safe_keeps_observed_only_without_hardcoded_probes(monkeypatch, tmp_path) -> None: + call = _run_profile(monkeypatch, tmp_path, 'passive-recon-safe') + + assert call['run_nuclei'] is False + assert call['baseline_probe'] is False + assert call['observed_only'] is True + assert call['crawl_include_js'] is True + assert call['run_panels'] is False + assert call['run_auth'] is False + assert call['run_api'] is False + assert call['run_sensitive_files'] is False + + +def test_cli_active_aggressive_uses_dynamic_crawler_and_nmap(monkeypatch, tmp_path) -> None: + call = _run_profile(monkeypatch, tmp_path, 'active-aggressive') + + assert call['run_nuclei'] is True + assert call['run_nmap'] is True + assert call['baseline_probe'] is True + assert call['observed_only'] is False + assert call['http_mode'] == 'active' + assert call['crawler_backend'] == 'scrapling' + assert call['crawler_scrapling_mode'] == 'dynamic' + assert call['crawl_include_js'] is True diff --git a/tests/test_v10_22_8_polish.py b/tests/test_v10_22_8_polish.py new file mode 100644 index 0000000..e0f2c56 --- /dev/null +++ b/tests/test_v10_22_8_polish.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from attack_surface_mapper.http_client import HttpResponse, add_debug_trace +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.pipeline.stages import BrowserDiscoveryStage, PassiveValidationStage +from attack_surface_mapper.reporting.generator import ReportGenerator +from attack_surface_mapper.core.scan_context import ScanContext, ScanOutputs, ScanSettings +from main import build_arg_parser, resolve_targets + + +def test_debug_trace_keeps_browser_events_across_passive_validation(monkeypatch) -> None: + entry_response = HttpResponse( + status_code=200, + url='https://target.example/', + headers={'Content-Type': 'text/html'}, + text='home', + content=b'home', + ) + + class DummyCollector: + def __init__(self, *args, **kwargs): + pass + + def collect(self, target): + add_debug_trace({'component': 'browser_discovery', 'event': 'dummy_collect', 'target': target}) + class DummyResult: + documents = {'https://target.example/': 'home'} + observed_urls = ['https://target.example/'] + observed_actions = [] + observed_api_calls = [] + analysis = None + + result = DummyResult() + result.entry_response = entry_response + return result + + monkeypatch.setattr('attack_surface_mapper.pipeline.stages.BrowserDiscoveryCollector', DummyCollector) + + settings = ScanSettings( + debug=True, + run_crawl=True, + browser_discovery_enabled=True, + observed_only=True, + run_headers=True, + run_tls=False, + run_sensitive_files=False, + run_panels=False, + run_auth=False, + run_api=False, + run_secrets=False, + ) + ctx = ScanContext(target='https://target.example/', settings=settings, outputs=ScanOutputs()) + + BrowserDiscoveryStage().run(ctx) + PassiveValidationStage().run(ctx) + + assert any(event.get('event') == 'dummy_collect' for event in ctx.debug.http_trace) + + +def test_executive_summary_reflects_confirmed_high_findings() -> None: + vuln = Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + target='https://target.example/graphql', + matched_at='https://target.example/graphql', + category='api', + priority='high', + confidence='high', + verification_status='confirmed', + evidence_summary='graphql marker', + kind='validation', + asset_host='target.example', + asset_port='443', + ) + + payload = ReportGenerator().build_summary_payload([vuln], 'https://target.example/') + + assert 'Se observaron 1 hallazgos confirmados de prioridad alta o crítica.' in payload['executive_summary'] + assert payload['top_findings'][0]['kind'] == 'validation' + assert payload['top_findings'][0]['confidence'] == 'high' + assert payload['top_findings'][0]['asset_host'] == 'target.example' + assert payload['top_findings'][0]['evidence_summary'] == 'graphql marker' + + +def test_cli_targets_are_combined_with_yaml_targets_and_deduplicated() -> None: + args = build_arg_parser().parse_args(['https://cli.example']) + + targets = resolve_targets(args, {'targets': ['https://yaml.example', 'https://cli.example']}) + + assert targets == ['https://cli.example', 'https://yaml.example'] diff --git a/tests/test_v10_22_9_identity_and_baseline.py b/tests/test_v10_22_9_identity_and_baseline.py new file mode 100644 index 0000000..34b7add --- /dev/null +++ b/tests/test_v10_22_9_identity_and_baseline.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from attack_surface_mapper.analysis.enrichment import enrich_vulnerabilities +from attack_surface_mapper.batch.aggregate import build_aggregate_payload +from attack_surface_mapper.core.scan_context import ScanContext, ScanOutputs, ScanSettings +from attack_surface_mapper.models.vulnerability import Vulnerability +from attack_surface_mapper.orchestrator import ScanResult +from attack_surface_mapper.pipeline.stages import PassiveValidationStage +from attack_surface_mapper.reporting import ReportPaths +from attack_surface_mapper.utils.asset_normalizer import normalize_asset + + +def test_normalize_asset_keeps_stable_host_and_resolved_ip_separate(monkeypatch) -> None: + monkeypatch.setattr('attack_surface_mapper.utils.asset_normalizer.socket.gethostbyname', lambda host: '203.0.113.10') + + asset = normalize_asset('https://App.Example/login') + + assert asset['target_host_original'] == 'app.example' + assert asset['asset_host'] == 'app.example' + assert asset['asset_host_resolved'] == '203.0.113.10' + assert asset['asset_port'] == '443' + + +def test_enrichment_populates_stable_asset_fields_and_ids(monkeypatch) -> None: + monkeypatch.setattr('attack_surface_mapper.utils.asset_normalizer.socket.gethostbyname', lambda host: '203.0.113.10') + vuln = Vulnerability( + source='custom-api-check', + title='GraphQL Surface Exposed', + description='d', + severity='high', + target='https://App.Example/graphql', + matched_at='https://App.Example/graphql', + category='api', + ) + + enrich_vulnerabilities([vuln]) + + assert vuln.target_host_original == 'app.example' + assert vuln.asset_host == 'app.example' + assert vuln.asset_host_resolved == '203.0.113.10' + assert vuln.asset_port == '443' + assert vuln.finding_id + assert vuln.correlation_id + assert vuln.finding_id != vuln.correlation_id + + +def test_aggregate_groups_network_assets_by_resolved_ip_but_keeps_display_host() -> None: + first = Vulnerability( + source='nmap', + title='Exposed PostgreSQL Service', + description='d', + severity='medium', + priority='high', + target='db.example:5432', + matched_at='db.example:5432', + category='database', + verification_status='confirmed', + recommendation='rec', + asset_host='db.example', + asset_host_resolved='203.0.113.10', + asset_port='5432', + port='5432', + ) + second = Vulnerability( + source='nmap', + title='Exposed PostgreSQL Service', + description='d', + severity='medium', + priority='high', + target='db-alias.example:5432', + matched_at='db-alias.example:5432', + category='database', + verification_status='confirmed', + recommendation='rec', + asset_host='db-alias.example', + asset_host_resolved='203.0.113.10', + asset_port='5432', + port='5432', + ) + + payload = build_aggregate_payload([ + ScanResult(target='https://app-one.example', vulnerabilities=[first], command=[], return_code=0, stdout='', stderr='', raw_findings_count=0, output_json_path=None, raw_output_path=None, summary={'high': 1}, report_paths=ReportPaths()), + ScanResult(target='https://app-two.example', vulnerabilities=[second], command=[], return_code=0, stdout='', stderr='', raw_findings_count=0, output_json_path=None, raw_output_path=None, summary={'high': 1}, report_paths=ReportPaths()), + ]) + + assert len(payload['shared_asset_findings']) == 1 + finding = payload['shared_asset_findings'][0] + assert finding['asset_host'] == 'db.example' + assert finding['asset_host_resolved'] == '203.0.113.10' + assert finding['target_count'] == 2 + + +def test_passive_validation_reuses_shared_baseline_once(monkeypatch) -> None: + shared_baseline = object() + calls: dict[str, list[object] | int] = { + 'baseline': 0, + 'validators': [], + } + + class DummySession: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return None + + class DummyValidator: + def __init__(self, *args, **kwargs): + pass + + def run(self, target, baseline=None): + calls['validators'].append(baseline) + return [] + + class DummyAuthValidator(DummyValidator): + DEFAULT_PROTECTED_PATHS: tuple[str, ...] = () + + class DummyAPIValidator(DummyValidator): + DEFAULT_PATHS: tuple[str, ...] = () + + monkeypatch.setattr('attack_surface_mapper.pipeline.stages.build_http_session', lambda **kwargs: DummySession()) + + def fake_baseline(session, target, timeout): + calls['baseline'] += 1 + return shared_baseline + + monkeypatch.setattr('attack_surface_mapper.pipeline.stages.baseline_fingerprint', fake_baseline) + monkeypatch.setattr('attack_surface_mapper.pipeline.stages.PanelsValidator', DummyValidator) + monkeypatch.setattr('attack_surface_mapper.pipeline.stages.AuthValidator', DummyAuthValidator) + monkeypatch.setattr('attack_surface_mapper.pipeline.stages.APIValidator', DummyAPIValidator) + monkeypatch.setattr('attack_surface_mapper.pipeline.stages.SensitiveFilesValidator', DummyValidator) + + settings = ScanSettings( + baseline_probe=True, + run_headers=False, + run_fingerprint=False, + run_tls=False, + run_crawl=False, + run_secrets=False, + run_panels=True, + run_auth=True, + run_api=True, + run_sensitive_files=True, + ) + ctx = ScanContext(target='https://target.example', settings=settings, outputs=ScanOutputs()) + + PassiveValidationStage().run(ctx) + + assert calls['baseline'] == 1 + assert calls['validators'] == [shared_baseline, shared_baseline, shared_baseline, shared_baseline] + assert ctx.artifacts.shared_baseline is shared_baseline