From b877606bb59e84976d24dea30a1632474cd61bd4 Mon Sep 17 00:00:00 2001 From: Gabrielvcg Date: Tue, 7 Apr 2026 16:21:33 -0400 Subject: [PATCH 01/24] Reduce passive false positives --- agents.md | 4 +- .../validators/api_validator.py | 22 +++- .../validators/auth_validator.py | 18 ++- .../validators/headers_validator.py | 21 ++++ .../validators/panels_validator.py | 26 +++- .../validators/sensitive_files_validator.py | 22 ++-- tests/test_v10_22_6_false_positive_tuning.py | 111 ++++++++++++++++++ 7 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 tests/test_v10_22_6_false_positive_tuning.py diff --git a/agents.md b/agents.md index 8173e64..57f91e9 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 `64 passed` tras los ajustes de reduccion de falsos positivos en headers, CORS, paneles, cookies y ficheros sensibles. -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/src/attack_surface_mapper/validators/api_validator.py b/src/attack_surface_mapper/validators/api_validator.py index 165df67..725c4b8 100644 --- a/src/attack_surface_mapper/validators/api_validator.py +++ b/src/attack_surface_mapper/validators/api_validator.py @@ -8,6 +8,14 @@ from attack_surface_mapper.validators.http_fingerprint import baseline_fingerprint, looks_like_baseline, 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 '' + + class APIValidator(BaseValidator): DEFAULT_PATHS: tuple[str, ...] = ( '/swagger', @@ -48,14 +56,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 +76,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 diff --git a/src/attack_surface_mapper/validators/auth_validator.py b/src/attack_surface_mapper/validators/auth_validator.py index f818827..d8c5fe5 100644 --- a/src/attack_surface_mapper/validators/auth_validator.py +++ b/src/attack_surface_mapper/validators/auth_validator.py @@ -65,7 +65,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')) @@ -264,6 +268,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..bbdde57 100644 --- a/src/attack_surface_mapper/validators/panels_validator.py +++ b/src/attack_surface_mapper/validators/panels_validator.py @@ -54,19 +54,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'], + 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, @@ -155,5 +155,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..9fe3b39 100644 --- a/src/attack_surface_mapper/validators/sensitive_files_validator.py +++ b/src/attack_surface_mapper/validators/sensitive_files_validator.py @@ -85,9 +85,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 +102,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/test_v10_22_6_false_positive_tuning.py b/tests/test_v10_22_6_false_positive_tuning.py new file mode 100644 index 0000000..fe8efe6 --- /dev/null +++ b/tests/test_v10_22_6_false_positive_tuning.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from attack_surface_mapper.http_client import HttpResponse +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) From 05a9cbc0bdfd841eee3b964b6edc0da1b63d0cd3 Mon Sep 17 00:00:00 2001 From: Gabrielvcg Date: Tue, 7 Apr 2026 16:50:37 -0400 Subject: [PATCH 02/24] Align passive profiles and correlation tuning --- CHANGELOG.md | 12 ++++ agents.md | 2 +- main.py | 38 +++++------ .../analysis/enrichment.py | 3 + .../models/vulnerability.py | 42 +++++++----- tests/test_v10_22_6_false_positive_tuning.py | 62 ++++++++++++++++++ tests/test_v10_22_7_cli_profile_semantics.py | 64 +++++++++++++++++++ 7 files changed, 186 insertions(+), 37 deletions(-) create mode 100644 tests/test_v10_22_7_cli_profile_semantics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c20b0d..aa99f44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # attack_surface_mapper_project_v10.11 +## 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. ## Qué aporta la v10 diff --git a/agents.md b/agents.md index 57f91e9..a50e975 100644 --- a/agents.md +++ b/agents.md @@ -127,7 +127,7 @@ 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 `64 passed` tras los ajustes de reduccion de falsos positivos en headers, CORS, paneles, cookies y ficheros sensibles. +- `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 es un repo Git local con remoto `origin` en `git@github.com:Gabrielvcg/attack-surface-mapper.git`. diff --git a/main.py b/main.py index caf3ff5..fee9045 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}, } @@ -248,7 +248,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 +260,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 +290,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, @@ -380,7 +380,7 @@ def _job(target: str) -> ScanResult: 'targets': [r.target for r in results], 'aggregate_reports': aggregate_paths, 'errors': errors, - 'profile': str(config.get('profile', '') or ''), + 'profile': str(get_setting(args, config, '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', [])}), diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 65b503a..0f35bf5 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -114,6 +114,9 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: elif confidence == 'medium' and vulnerability.needs_manual_validation: score -= 1 reasons.append('requiere validación manual') + if category == 'headers' and (vulnerability.severity or '').lower() in {'low', 'info'}: + score = min(score, 1) + reasons.append('cabecera de bajo impacto') if category == 'discovery': score = 1 reasons.append('hallazgo de descubrimiento, impacto limitado') diff --git a/src/attack_surface_mapper/models/vulnerability.py b/src/attack_surface_mapper/models/vulnerability.py index 1e4e31e..d87b927 100644 --- a/src/attack_surface_mapper/models/vulnerability.py +++ b/src/attack_surface_mapper/models/vulnerability.py @@ -59,19 +59,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 +75,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 '/actuator' in location: + return 'exposure', 'actuator-exposure', '/actuator' + if route_groupable and any(token in location for token in ('/admin', '/dashboard', '/management')): + 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()}' diff --git a/tests/test_v10_22_6_false_positive_tuning.py b/tests/test_v10_22_6_false_positive_tuning.py index fe8efe6..9059c8b 100644 --- a/tests/test_v10_22_6_false_positive_tuning.py +++ b/tests/test_v10_22_6_false_positive_tuning.py @@ -2,7 +2,10 @@ 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 @@ -109,3 +112,62 @@ def test_auth_cookie_flags_ignore_non_auth_cookie_and_do_not_require_httponly_on 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 From b124bc708ac6b3414dff61d17bba21829db92ff7 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 19:43:14 +0200 Subject: [PATCH 03/24] Polish pipeline tracing and stabilize asset identity --- CHANGELOG.md | 15 +- QUICK_DEMO.md | 21 ++- README.md | 25 ++- main.py | 2 +- .../analysis/enrichment.py | 24 ++- src/attack_surface_mapper/batch/aggregate.py | 63 ++++++-- .../collectors/web/__init__.py | 2 + .../core/scan_context.py | 1 + .../models/vulnerability.py | 3 + src/attack_surface_mapper/pipeline/stages.py | 74 ++++++--- .../reporting/generator.py | 32 +++- .../utils/asset_normalizer.py | 25 ++- .../validators/api_validator.py | 6 +- .../validators/auth_validator.py | 6 +- .../validators/panels_validator.py | 8 +- .../validators/sensitive_files_validator.py | 8 +- tests/test_v10_22_8_polish.py | 92 +++++++++++ tests/test_v10_22_9_identity_and_baseline.py | 151 ++++++++++++++++++ 18 files changed, 496 insertions(+), 62 deletions(-) create mode 100644 tests/test_v10_22_8_polish.py create mode 100644 tests/test_v10_22_9_identity_and_baseline.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aa99f44..40ee362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ -# attack_surface_mapper_project_v10.11 +# attack_surface_mapper_project_v10.22 + +## v10.22.8 polish + +- 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. ## v10.22.6 false-positive tuning @@ -135,7 +146,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/QUICK_DEMO.md b/QUICK_DEMO.md index 26b0a11..83a06bf 100644 --- a/QUICK_DEMO.md +++ b/QUICK_DEMO.md @@ -65,7 +65,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 @@ -122,3 +122,22 @@ 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` diff --git a/README.md b/README.md index dfda3f2..e1d54b2 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ config/examples/ Important note: -> If a target is passed via CLI, it **overrides** the target defined inside the YAML profile. +> If a target is passed via CLI, it is added to the targets defined inside the YAML profile and the final list is deduplicated. This is useful because the tutor can execute the same profile against another target without modifying the YAML. @@ -399,7 +399,7 @@ 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 +### Add a CLI target to YAML/profile targets ```bash python main.py --profile passive-recon-safe https://proba-despregamento.onrender.com/ @@ -434,6 +434,23 @@ Then compare: This is one of the most important values of the project: **the ability to control the trade-off between stealth, coverage and signal quality**. +### Repeatable local validation + +For quick regression checks of the passive profiles, a local Juice Shop container is a practical baseline: + +```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 +``` + +When comparing both runs, start with: + +- `run_manifest.json` +- `reports/aggregate_summary.json` +- `targets/.../reports/report.summary.json` + --- ## Output Structure @@ -552,8 +569,8 @@ If someone wants to understand the project quickly, the best order is: - 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`. +- CLI target input is combined with YAML targets and deduplicated. +- The active YAML profile file is called `active-aggressive.yml`; the CLI accepts both `active` and `active-aggressive`. --- diff --git a/main.py b/main.py index fee9045..5cff0b6 100644 --- a/main.py +++ b/main.py @@ -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') diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 0f35bf5..334163a 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 @@ -176,6 +178,11 @@ 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 enrich_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnerability]: for vulnerability in vulnerabilities: vulnerability.confidence = compute_confidence(vulnerability) @@ -193,9 +200,12 @@ def enrich_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnera 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'}: @@ -207,4 +217,16 @@ def enrich_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnera 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..e15f0a9 100644 --- a/src/attack_surface_mapper/batch/aggregate.py +++ b/src/attack_surface_mapper/batch/aggregate.py @@ -32,14 +32,42 @@ def _is_ipv4(value: str | None) -> bool: 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 @@ -71,6 +99,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 +107,19 @@ 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, 'priority': vuln.priority, 'severity': vuln.severity, 'category': vuln.category, 'verification_status': vuln.verification_status, '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', } @@ -112,7 +145,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 +153,18 @@ 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['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, @@ -239,15 +278,19 @@ 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', 'severity', 'category', '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['severity'], 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 d87b927..39b1c48 100644 --- a/src/attack_surface_mapper/models/vulnerability.py +++ b/src/attack_surface_mapper/models/vulnerability.py @@ -41,7 +41,10 @@ class Vulnerability: kind: str | 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 ( 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/generator.py b/src/attack_surface_mapper/reporting/generator.py index 97597ed..6b2b5cb 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -38,6 +38,7 @@ class ReportStats: unique_cves: list[str] unique_cwes: list[str] needs_manual_validation: int + confirmed_high_or_critical: int class ReportGenerator: @@ -83,6 +84,12 @@ def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: for v in vulns if v.needs_manual_validation or (v.verification_status or '').lower() in {'likely', 'needs_manual_validation'} ), + confirmed_high_or_critical=sum( + 1 + for v in vulns + if (v.verification_status or '').lower() == 'confirmed' + and (v.priority or v.severity or '').lower() in {'critical', 'high'} + ), ) @staticmethod @@ -91,11 +98,18 @@ def executive_summary(stats: ReportStats) -> str: 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()) + 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'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 @@ -121,7 +135,17 @@ def build_summary_payload(self, vulnerabilities: Iterable[Vulnerability], target 'priority': v.priority, 'severity': v.severity, 'category': v.category, + 'finding_id': v.finding_id, + 'correlation_id': v.correlation_id, + 'kind': v.kind, + 'confidence': v.confidence, 'verification_status': v.verification_status, + 'source_count': v.source_count, + 'target_host_original': v.target_host_original, + 'asset_host': v.asset_host, + 'asset_host_resolved': v.asset_host_resolved, + 'asset_port': v.asset_port, + 'evidence_summary': v.evidence_summary, 'recommendation': v.recommendation, } for v in sorted_vulns[:10] @@ -142,13 +166,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_reason', 'category', 'kind', '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_reason or '', vuln.category or '', vuln.kind 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 '' ]) 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 725c4b8..f8b6974 100644 --- a/src/attack_surface_mapper/validators/api_validator.py +++ b/src/attack_surface_mapper/validators/api_validator.py @@ -26,7 +26,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 @@ -35,14 +35,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)) diff --git a/src/attack_surface_mapper/validators/auth_validator.py b/src/attack_surface_mapper/validators/auth_validator.py index d8c5fe5..ad0727d 100644 --- a/src/attack_surface_mapper/validators/auth_validator.py +++ b/src/attack_surface_mapper/validators/auth_validator.py @@ -33,7 +33,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 +42,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 +52,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: diff --git a/src/attack_surface_mapper/validators/panels_validator.py b/src/attack_surface_mapper/validators/panels_validator.py index bbdde57..e57a827 100644 --- a/src/attack_surface_mapper/validators/panels_validator.py +++ b/src/attack_surface_mapper/validators/panels_validator.py @@ -19,7 +19,7 @@ class PanelsValidator(BaseValidator): '/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 +27,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: @@ -60,7 +60,7 @@ def run(self, target: str) -> list[Vulnerability]: evidence=f'Status {response.status_code} en {final_url}; validación={reason}', cwe=['CWE-200'], tags=self._tags_for(path), - template_id=f'custom-panel-{path.strip('/') or "root"}', + 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, diff --git a/src/attack_surface_mapper/validators/sensitive_files_validator.py b/src/attack_surface_mapper/validators/sensitive_files_validator.py index 9fe3b39..c64cccb 100644 --- a/src/attack_surface_mapper/validators/sensitive_files_validator.py +++ b/src/attack_surface_mapper/validators/sensitive_files_validator.py @@ -23,7 +23,7 @@ class SensitiveFilesValidator(BaseValidator): '/sitemap.xml', ) - 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) -> 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 +31,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 +62,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, 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 From 9da1be819eb4629aa98fff3c177246c3c6637992 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 19:54:13 +0200 Subject: [PATCH 04/24] Add repeatable lab validation script --- CHANGELOG.md | 1 + QUICK_DEMO.md | 6 ++ README.md | 6 ++ scripts/validate_labs.ps1 | 145 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 scripts/validate_labs.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ee362..162de1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 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. ## v10.22.6 false-positive tuning diff --git a/QUICK_DEMO.md b/QUICK_DEMO.md index 83a06bf..e6cbde6 100644 --- a/QUICK_DEMO.md +++ b/QUICK_DEMO.md @@ -141,3 +141,9 @@ 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 +``` diff --git a/README.md b/README.md index e1d54b2..7e77460 100644 --- a/README.md +++ b/README.md @@ -451,6 +451,12 @@ When comparing both runs, start with: - `reports/aggregate_summary.json` - `targets/.../reports/report.summary.json` +For a repeatable PowerShell flow on Windows: + +```powershell +.\scripts\validate_labs.ps1 -Labs juice-shop +``` + --- ## Output Structure diff --git a/scripts/validate_labs.ps1 b/scripts/validate_labs.ps1 new file mode 100644 index 0000000..900e3f1 --- /dev/null +++ b/scripts/validate_labs.ps1 @@ -0,0 +1,145 @@ +[CmdletBinding()] +param( + [string[]]$Labs = @('juice-shop', 'dvwa'), + [string[]]$Profiles = @('passive-stealth', 'passive-recon-safe'), + [switch]$IncludeEnum, + [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' + } +} + +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 + ) + + $innerCommand = "pip install -q -r requirements.txt && python main.py --profile $Profile --run-name $RunName $Target" + Invoke-Docker -Arguments @( + 'run', '--rm', + '-v', "${workspace}:/workspace", + '-w', '/workspace', + $scannerImage, + 'sh', '-lc', $innerCommand + ) +} + +$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 + $executedRuns.Add((Join-Path 'scans' $runName)) | Out-Null + } + } +} finally { + if (-not $KeepLabsRunning) { + foreach ($containerName in $startedLabs) { + Stop-LabContainer -Name $containerName + } + } +} + +Write-Host '' +Write-Host 'Validacion completada. Runs generados:' -ForegroundColor Green +foreach ($run in $executedRuns) { + Write-Host " - $run" +} From b405e3e19147c34543c6054cb17d434cc2109783 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 20:10:33 +0200 Subject: [PATCH 05/24] Harden report schema and lab validation --- CHANGELOG.md | 3 + QUICK_DEMO.md | 4 + README.md | 4 + main.py | 137 ++++++++++--- scripts/validate_labs.ps1 | 79 ++++++++ src/attack_surface_mapper/batch/aggregate.py | 32 ++- .../reporting/generator.py | 81 +++++--- .../validators/api_validator.py | 15 +- ...0_contract_and_false_positive_hardening.py | 184 ++++++++++++++++++ 9 files changed, 481 insertions(+), 58 deletions(-) create mode 100644 tests/test_v10_22_10_contract_and_false_positive_hardening.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 162de1d..e871b8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - 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`. ## v10.22.6 false-positive tuning diff --git a/QUICK_DEMO.md b/QUICK_DEMO.md index e6cbde6..6c6858b 100644 --- a/QUICK_DEMO.md +++ b/QUICK_DEMO.md @@ -147,3 +147,7 @@ On Windows/PowerShell, the same flow can be repeated with: ```powershell .\scripts\validate_labs.ps1 -Labs juice-shop ``` + +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. diff --git a/README.md b/README.md index 7e77460..cbd51e9 100644 --- a/README.md +++ b/README.md @@ -457,6 +457,10 @@ For a repeatable PowerShell flow on Windows: .\scripts\validate_labs.ps1 -Labs juice-shop ``` +The script now validates the generated `run_manifest.json`, `aggregate_summary.json` +and `report.summary.json`, and it accepts `-MinFindings` to require a minimum number +of correlated findings per run. + --- ## Output Structure diff --git a/main.py b/main.py index 5cff0b6..fe97980 100644 --- a/main.py +++ b/main.py @@ -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]: @@ -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(get_setting(args, config, '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/scripts/validate_labs.ps1 b/scripts/validate_labs.ps1 index 900e3f1..0ff0e71 100644 --- a/scripts/validate_labs.ps1 +++ b/scripts/validate_labs.ps1 @@ -3,6 +3,7 @@ param( [string[]]$Labs = @('juice-shop', 'dvwa'), [string[]]$Profiles = @('passive-stealth', 'passive-recon-safe'), [switch]$IncludeEnum, + [int]$MinFindings = 1, [switch]$KeepLabsRunning ) @@ -113,6 +114,83 @@ function Invoke-Scan { ) } +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] @@ -127,6 +205,7 @@ try { $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 } } diff --git a/src/attack_surface_mapper/batch/aggregate.py b/src/attack_surface_mapper/batch/aggregate.py index e15f0a9..4d34730 100644 --- a/src/attack_surface_mapper/batch/aggregate.py +++ b/src/attack_surface_mapper/batch/aggregate.py @@ -11,6 +11,8 @@ 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} _IPV4_RE = re.compile(r'^(?:\d{1,3}\.){3}\d{1,3}$') @@ -25,6 +27,10 @@ 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)) @@ -112,7 +118,11 @@ def _build_finding_record(result_target: str, vuln, target_asset_hosts: dict[str 'priority': vuln.priority, 'severity': vuln.severity, 'category': vuln.category, + 'kind': vuln.kind, + 'confidence': vuln.confidence, 'verification_status': vuln.verification_status, + 'source_count': vuln.source_count, + 'evidence_summary': vuln.evidence_summary, 'recommendation': vuln.recommendation, 'targets': [result_target], 'target_host_original': vuln.target_host_original, @@ -130,6 +140,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] = {} @@ -137,6 +148,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: @@ -158,6 +170,10 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: record['recommendation'] = vuln.recommendation record['finding_id'] = vuln.finding_id record['correlation_id'] = vuln.correlation_id + record['kind'] = vuln.kind + 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) @@ -181,6 +197,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]) @@ -198,14 +215,25 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: reverse=True, ) + 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'] return { + 'schema_version': '1.0', + 'summary': { + 'total_targets': total_targets, + 'raw_total_findings': raw_total_findings, + 'total_findings': len(top_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), + '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], diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index 6b2b5cb..73a9c77 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -13,11 +13,25 @@ SEVERITY_LABELS = ['critical', 'high', 'medium', 'low', 'info', 'unknown'] PRIORITY_LABELS = ['critical', 'high', 'medium', 'low'] +COMPARISON_KEYS = ('new_findings', 'resolved_findings', 'changed_findings') 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 {} + return {key: list(payload.get(key, []) or []) for key in COMPARISON_KEYS} + @dataclass(slots=True) class ReportPaths: markdown: str | None = None @@ -72,8 +86,8 @@ 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, @@ -96,8 +110,8 @@ def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: 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.' @@ -119,37 +133,42 @@ 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 { + 'title': vulnerability.title, + 'target': vulnerability.target, + 'priority': vulnerability.priority, + 'severity': vulnerability.severity, + 'category': vulnerability.category, + 'finding_id': vulnerability.finding_id, + 'correlation_id': vulnerability.correlation_id, + 'kind': vulnerability.kind, + 'confidence': vulnerability.confidence, + 'verification_status': vulnerability.verification_status, + 'source_count': vulnerability.source_count, + 'target_host_original': vulnerability.target_host_original, + 'asset_host': vulnerability.asset_host, + 'asset_host_resolved': vulnerability.asset_host_resolved, + 'asset_port': vulnerability.asset_port, + 'evidence_summary': vulnerability.evidence_summary, + 'recommendation': vulnerability.recommendation, + } + 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) + comparison_payload = _normalise_comparison(comparison) return { + 'schema_version': '1.0', '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, - 'finding_id': v.finding_id, - 'correlation_id': v.correlation_id, - 'kind': v.kind, - 'confidence': v.confidence, - 'verification_status': v.verification_status, - 'source_count': v.source_count, - 'target_host_original': v.target_host_original, - 'asset_host': v.asset_host, - 'asset_host_resolved': v.asset_host_resolved, - 'asset_port': v.asset_port, - 'evidence_summary': v.evidence_summary, - 'recommendation': v.recommendation, - } - for v in sorted_vulns[:10] - ], + 'comparison': comparison_payload, + 'comparison_summary': {key: len(comparison_payload[key]) for key in COMPARISON_KEYS}, + 'top_finding_count': min(len(sorted_vulns), 10), + 'top_findings': [self._serialize_top_finding(v) for v in sorted_vulns[:10]], } def generate_summary_json(self, vulnerabilities: Iterable[Vulnerability], target: str, output_path: str, comparison: dict | None = None) -> str: @@ -157,7 +176,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) @@ -224,10 +243,10 @@ 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(): @@ -304,7 +323,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/validators/api_validator.py b/src/attack_surface_mapper/validators/api_validator.py index f8b6974..af90c8e 100644 --- a/src/attack_surface_mapper/validators/api_validator.py +++ b/src/attack_surface_mapper/validators/api_validator.py @@ -5,7 +5,7 @@ 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: @@ -145,6 +145,19 @@ 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) + 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): 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..1362d1b --- /dev/null +++ b/tests/test_v10_22_10_contract_and_false_positive_hardening.py @@ -0,0 +1,184 @@ +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'}], + 'resolved_findings': [], + 'changed_findings': [], + } + assert payload['comparison_summary'] == { + 'new_findings': 1, + 'resolved_findings': 0, + 'changed_findings': 0, + } + assert payload['stats']['priority_counts'] == {'critical': 0, 'high': 1, 'medium': 0, 'low': 0} + assert payload['stats']['severity_counts']['info'] == 0 + assert payload['top_finding_count'] == 1 + + +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['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 == [] From af99c723b8901afda50e3cfeca3b9cdda290bd27 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 20:21:25 +0200 Subject: [PATCH 06/24] Refine comparison reporting and validation consistency --- CHANGELOG.md | 2 + .../analysis/comparison.py | 116 ++++++++++-- .../analysis/enrichment.py | 73 ++++++-- .../reporting/generator.py | 44 +++-- ...0_contract_and_false_positive_hardening.py | 15 +- ...1_comparison_and_validation_consistency.py | 170 ++++++++++++++++++ 6 files changed, 376 insertions(+), 44 deletions(-) create mode 100644 tests/test_v10_22_11_comparison_and_validation_consistency.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e871b8e..f615f49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ - 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. ## v10.22.6 false-positive tuning diff --git a/src/attack_surface_mapper/analysis/comparison.py b/src/attack_surface_mapper/analysis/comparison.py index 53b26cb..6d3abe0 100644 --- a/src/attack_surface_mapper/analysis/comparison.py +++ b/src/attack_surface_mapper/analysis/comparison.py @@ -5,6 +5,11 @@ 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} + def load_previous_scan(path: str | None) -> list[Vulnerability]: if not path: @@ -26,37 +31,118 @@ 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 '', + 'severity': vulnerability.severity or '', + 'confidence': vulnerability.confidence or '', + 'verification_status': vulnerability.verification_status or '', + 'kind': vulnerability.kind 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) + 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) + combined_delta = priority_delta + severity_delta + confidence_delta + verification_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, + 'severity': 0, + 'confidence': 0, + 'verification_status': 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 (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 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_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 '', + '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/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 334163a..5a9c83e 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -107,7 +107,7 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: 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 confidence == 'low': @@ -150,6 +150,34 @@ def compute_confidence(vulnerability: Vulnerability) -> str: return 'low' +def _normalise_validation_state(vulnerability: Vulnerability) -> None: + verification = (vulnerability.verification_status or '').lower() + confidence = (vulnerability.confidence or '').lower() + category = (vulnerability.category or '').lower() + + 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 category in {'authentication', 'api', 'secret'} and verification != 'confirmed': + vulnerability.needs_manual_validation = True + + if 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 compute_recommendation(vulnerability: Vulnerability) -> str: if vulnerability.title.startswith('Technology Fingerprint Detected'): return 'Usar el fingerprint como contexto para priorizar rutas, hardening y validaciones específicas del stack detectado.' @@ -183,15 +211,40 @@ def _stable_identifier(namespace: str, *parts: object) -> str: 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() + + 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 category in {'authentication', 'api', 'secret'} and verification != 'confirmed': + vulnerability.needs_manual_validation = True + + if 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 + _normalise_validation_state(vulnerability) vulnerability.priority, vulnerability.priority_reason = compute_priority(vulnerability) vulnerability.recommendation = vulnerability.recommendation or compute_recommendation(vulnerability) vulnerability.evidence_summary = clean_evidence(vulnerability.evidence_summary or build_evidence_summary(vulnerability)) @@ -208,15 +261,7 @@ def enrich_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnera 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' + _normalise_validation_state(vulnerability) vulnerability.finding_id = _stable_identifier( 'finding', *(vulnerability.dedup_key()), diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index 73a9c77..04a3d43 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -14,6 +14,7 @@ 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') @@ -30,7 +31,21 @@ def _nonzero_count_items(counts: dict[str, int]) -> list[tuple[str, int]]: def _normalise_comparison(comparison: dict | None) -> dict[str, list[dict]]: payload = comparison or {} - return {key: list(payload.get(key, []) or []) for key in COMPARISON_KEYS} + 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 @dataclass(slots=True) class ReportPaths: @@ -158,6 +173,8 @@ def _serialize_top_finding(vulnerability: Vulnerability) -> dict: 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) + comparison_payload = _normalise_comparison(comparison) if comparison else {} + comparison_payload = _normalise_comparison(comparison) if comparison else {} comparison_payload = _normalise_comparison(comparison) return { 'schema_version': '1.0', @@ -166,7 +183,7 @@ def build_summary_payload(self, vulnerabilities: Iterable[Vulnerability], target 'executive_summary': self.executive_summary(stats), 'stats': asdict(stats), 'comparison': comparison_payload, - 'comparison_summary': {key: len(comparison_payload[key]) for key in COMPARISON_KEYS}, + '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]], } @@ -227,6 +244,7 @@ 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) + comparison_payload = _normalise_comparison(comparison) if comparison else {} 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'}] @@ -251,11 +269,14 @@ def generate_markdown(self, vulnerabilities: Iterable[Vulnerability], target: st 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: @@ -286,6 +307,7 @@ 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( @@ -299,12 +321,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""" 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 index 1362d1b..7ba581e 100644 --- a/tests/test_v10_22_10_contract_and_false_positive_hardening.py +++ b/tests/test_v10_22_10_contract_and_false_positive_hardening.py @@ -43,15 +43,20 @@ def test_summary_payload_exposes_stable_schema_and_comparison_counts() -> None: 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'}], - 'resolved_findings': [], - 'changed_findings': [], - } + 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 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..b3e5f64 --- /dev/null +++ b/tests/test_v10_22_11_comparison_and_validation_consistency.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +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'} From de3ae3b806f4f1574832ebc990d0dd8e63087dfb Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 20:31:07 +0200 Subject: [PATCH 07/24] Tune correlated priority toward confirmed evidence --- .../analysis/correlation.py | 14 ++++ .../analysis/enrichment.py | 12 +++- ...1_comparison_and_validation_consistency.py | 69 +++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) 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 5a9c83e..4fb4eed 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -98,9 +98,17 @@ 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 verification == 'confirmed': + score += 1 + reasons.append('hallazgo confirmado') + if vulnerability.source_count > 1 and verification == 'confirmed': + score += 1 + 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') + 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') diff --git a/tests/test_v10_22_11_comparison_and_validation_consistency.py b/tests/test_v10_22_11_comparison_and_validation_consistency.py index b3e5f64..c3a59f9 100644 --- a/tests/test_v10_22_11_comparison_and_validation_consistency.py +++ b/tests/test_v10_22_11_comparison_and_validation_consistency.py @@ -1,5 +1,6 @@ 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 @@ -168,3 +169,71 @@ def test_likely_application_findings_still_require_manual_validation() -> None: 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] From ed151a61f0798d76ec6e7473a89c05bb50bc0457 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 20:46:42 +0200 Subject: [PATCH 08/24] Add false-positive review matrix workflow --- .gitignore | 1 + CHANGELOG.md | 2 + QUICK_DEMO.md | 2 + README.md | 2 + scripts/export_review_matrix.py | 39 ++++++ scripts/validate_labs.ps1 | 30 +++++ .../reporting/__init__.py | 3 +- .../reporting/review_matrix.py | 113 ++++++++++++++++++ tests/test_v10_22_12_review_matrix.py | 106 ++++++++++++++++ 9 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 scripts/export_review_matrix.py create mode 100644 src/attack_surface_mapper/reporting/review_matrix.py create mode 100644 tests/test_v10_22_12_review_matrix.py 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 f615f49..08dbe4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - 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`. ## v10.22.6 false-positive tuning diff --git a/QUICK_DEMO.md b/QUICK_DEMO.md index 6c6858b..a602f4e 100644 --- a/QUICK_DEMO.md +++ b/QUICK_DEMO.md @@ -151,3 +151,5 @@ On Windows/PowerShell, the same flow can be repeated with: 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. diff --git a/README.md b/README.md index cbd51e9..cabbce5 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,8 @@ For a repeatable PowerShell flow on Windows: The script now validates the generated `run_manifest.json`, `aggregate_summary.json` and `report.summary.json`, and it accepts `-MinFindings` to require a minimum number of correlated findings per run. +It also exports `reviews/lab_findings_review.csv` so you can label findings as +`verdadero`, `falso` or `dudoso` during false-positive review. --- 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 index 0ff0e71..b82ff3d 100644 --- a/scripts/validate_labs.ps1 +++ b/scripts/validate_labs.ps1 @@ -4,6 +4,7 @@ param( [string[]]$Profiles = @('passive-stealth', 'passive-recon-safe'), [switch]$IncludeEnum, [int]$MinFindings = 1, + [string]$ReviewMatrixOutput = 'reviews/lab_findings_review.csv', [switch]$KeepLabsRunning ) @@ -114,6 +115,33 @@ function Invoke-Scan { ) } +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-RequiredJsonProperty { param( $Object, @@ -217,6 +245,8 @@ try { } } +Export-ReviewMatrix -Runs $executedRuns -OutputPath $ReviewMatrixOutput + Write-Host '' Write-Host 'Validacion completada. Runs generados:' -ForegroundColor Green foreach ($run in $executedRuns) { diff --git a/src/attack_surface_mapper/reporting/__init__.py b/src/attack_surface_mapper/reporting/__init__.py index db25f48..ea93fc2 100644 --- a/src/attack_surface_mapper/reporting/__init__.py +++ b/src/attack_surface_mapper/reporting/__init__.py @@ -1,3 +1,4 @@ from .generator import ReportGenerator, ReportPaths, ReportStats +from .review_matrix import build_review_rows, review_bucket_for_finding, write_review_matrix -__all__ = ['ReportGenerator', 'ReportPaths', 'ReportStats'] +__all__ = ['ReportGenerator', 'ReportPaths', 'ReportStats', 'build_review_rows', 'review_bucket_for_finding', 'write_review_matrix'] 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..2651367 --- /dev/null +++ b/src/attack_surface_mapper/reporting/review_matrix.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import csv +import json +from pathlib import Path +from typing import Iterable + + +def review_bucket_for_finding(finding: dict) -> str: + kind = str(finding.get('kind') 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() + + if kind == 'discovery' or category == 'discovery': + return 'descubrimiento' + if 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 _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 ''), + 'priority': str(finding.get('priority') 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', + 'priority', + '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/tests/test_v10_22_12_review_matrix.py b/tests/test_v10_22_12_review_matrix.py new file mode 100644 index 0000000..2f68905 --- /dev/null +++ b/tests/test_v10_22_12_review_matrix.py @@ -0,0 +1,106 @@ +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' + + +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'] == 'priorizar' + 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': 'priorizar', + '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'] == 'priorizar' + assert rows[0]['etiqueta_analista'] == '' From 47068d0dfdceb0ca318df54bc51e2b8316842a67 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 21:24:27 +0200 Subject: [PATCH 09/24] Stabilize lab enum validation and header priority --- CHANGELOG.md | 3 +++ QUICK_DEMO.md | 3 +++ README.md | 4 ++++ config/examples/lab-passive-recon-enum.yml | 18 ++++++++++++++++++ scripts/validate_labs.ps1 | 10 +++++++++- .../analysis/enrichment.py | 2 +- ...11_comparison_and_validation_consistency.py | 17 +++++++++++++++++ 7 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 config/examples/lab-passive-recon-enum.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 08dbe4e..519dc93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ - 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. + ## 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. diff --git a/QUICK_DEMO.md b/QUICK_DEMO.md index a602f4e..a5b71b4 100644 --- a/QUICK_DEMO.md +++ b/QUICK_DEMO.md @@ -146,6 +146,7 @@ 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 @@ -153,3 +154,5 @@ The script also validates the generated manifest and summary artifacts. Use 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. diff --git a/README.md b/README.md index cabbce5..213cd8e 100644 --- a/README.md +++ b/README.md @@ -455,6 +455,7 @@ For a repeatable PowerShell flow on Windows: ```powershell .\scripts\validate_labs.ps1 -Labs juice-shop +.\scripts\validate_labs.ps1 -Labs juice-shop -IncludeEnum ``` The script now validates the generated `run_manifest.json`, `aggregate_summary.json` @@ -462,6 +463,9 @@ and `report.summary.json`, and it accepts `-MinFindings` to require a minimum nu of correlated findings per run. It also exports `reviews/lab_findings_review.csv` so you can label findings as `verdadero`, `falso` or `dudoso` during false-positive review. +When `-IncludeEnum` is enabled, the script uses +`config/examples/lab-passive-recon-enum.yml` so the enum profile stays reproducible +in Docker without depending on a host-side Nuclei install. --- 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/scripts/validate_labs.ps1 b/scripts/validate_labs.ps1 index b82ff3d..bea79f7 100644 --- a/scripts/validate_labs.ps1 +++ b/scripts/validate_labs.ps1 @@ -36,6 +36,10 @@ $labDefinitions = @{ } } +$profileConfigOverrides = @{ + 'passive-recon-enum' = 'config/examples/lab-passive-recon-enum.yml' +} + function Invoke-Docker { param([string[]]$Arguments) Write-Host "docker $($Arguments -join ' ')" -ForegroundColor DarkGray @@ -105,7 +109,11 @@ function Invoke-Scan { [string]$RunName ) - $innerCommand = "pip install -q -r requirements.txt && python main.py --profile $Profile --run-name $RunName $Target" + $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", diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 4fb4eed..0b5ec21 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -98,7 +98,7 @@ 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 verification == 'confirmed': + if verification == 'confirmed' and category not in {'headers', 'discovery'} and (vulnerability.severity or '').lower() not in {'low', 'info'}: score += 1 reasons.append('hallazgo confirmado') if vulnerability.source_count > 1 and verification == 'confirmed': diff --git a/tests/test_v10_22_11_comparison_and_validation_consistency.py b/tests/test_v10_22_11_comparison_and_validation_consistency.py index c3a59f9..9f2a1ba 100644 --- a/tests/test_v10_22_11_comparison_and_validation_consistency.py +++ b/tests/test_v10_22_11_comparison_and_validation_consistency.py @@ -237,3 +237,20 @@ def test_unique_confirmed_finding_keeps_higher_priority_than_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' From 272f6f2388c376a846e4ecf5bb8a92612f92f4cd Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 21:43:17 +0200 Subject: [PATCH 10/24] Refine false-positive heuristics and report ranking --- CHANGELOG.md | 2 + src/attack_surface_mapper/batch/aggregate.py | 59 ++++- .../reporting/generator.py | 67 ++++-- .../validators/api_validator.py | 36 ++- .../validators/panels_validator.py | 2 +- .../validators/sensitive_files_validator.py | 6 + tests/test_v10_22_13_precision_iteration.py | 215 ++++++++++++++++++ 7 files changed, 362 insertions(+), 25 deletions(-) create mode 100644 tests/test_v10_22_13_precision_iteration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 519dc93..160e4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ - 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. ## v10.22.6 false-positive tuning diff --git a/src/attack_surface_mapper/batch/aggregate.py b/src/attack_surface_mapper/batch/aggregate.py index 4d34730..8443367 100644 --- a/src/attack_surface_mapper/batch/aggregate.py +++ b/src/attack_surface_mapper/batch/aggregate.py @@ -14,6 +14,20 @@ 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}$') @@ -94,6 +108,42 @@ 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() + return ( + 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 str(item.get('verification_status') or '').lower() == 'confirmed': + return 0 + return 1 + + +def _top_finding_sort_key(item: dict) -> tuple: + return ( + _PRIORITY_SORT.get(str(item.get('priority') or '').lower(), 4), + _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' @@ -206,14 +256,7 @@ 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'] diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index 04a3d43..c9b17e3 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -15,6 +15,18 @@ 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, +} @@ -47,6 +59,46 @@ def _normalise_comparison(comparison: dict | None) -> dict[str, list[dict]]: normalised.setdefault('schema_version', '1.0') return normalised + +def _is_inventory_like(vulnerability: Vulnerability) -> bool: + title = (vulnerability.title or '').lower() + category = (vulnerability.category or '').lower() + return ( + 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 (vulnerability.verification_status or '').lower() == 'confirmed': + 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), + _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, + ) + @dataclass(slots=True) class ReportPaths: markdown: str | None = None @@ -76,18 +128,7 @@ 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: @@ -173,8 +214,6 @@ def _serialize_top_finding(vulnerability: Vulnerability) -> dict: 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) - comparison_payload = _normalise_comparison(comparison) if comparison else {} - comparison_payload = _normalise_comparison(comparison) if comparison else {} comparison_payload = _normalise_comparison(comparison) return { 'schema_version': '1.0', diff --git a/src/attack_surface_mapper/validators/api_validator.py b/src/attack_surface_mapper/validators/api_validator.py index af90c8e..b2cf6cd 100644 --- a/src/attack_surface_mapper/validators/api_validator.py +++ b/src/attack_surface_mapper/validators/api_validator.py @@ -16,6 +16,27 @@ def _header_value(headers: dict[str, str], name: str) -> str: 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): DEFAULT_PATHS: tuple[str, ...] = ( '/swagger', @@ -145,6 +166,7 @@ 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.' @@ -195,13 +217,23 @@ 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 score >= 6: diff --git a/src/attack_surface_mapper/validators/panels_validator.py b/src/attack_surface_mapper/validators/panels_validator.py index e57a827..40ff6ac 100644 --- a/src/attack_surface_mapper/validators/panels_validator.py +++ b/src/attack_surface_mapper/validators/panels_validator.py @@ -43,7 +43,7 @@ def run(self, target: str, baseline=None) -> 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: diff --git a/src/attack_surface_mapper/validators/sensitive_files_validator.py b/src/attack_surface_mapper/validators/sensitive_files_validator.py index c64cccb..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 ' 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' 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..c035b96 --- /dev/null +++ b/tests/test_v10_22_13_precision_iteration.py @@ -0,0 +1,215 @@ +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.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_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)', + ] + + +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)', + ] From 068d416688108c02ddef8925b589d058333ff5c0 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 21:56:42 +0200 Subject: [PATCH 11/24] Tune API priority and review buckets --- CHANGELOG.md | 3 ++ .../analysis/enrichment.py | 12 +++++ .../reporting/review_matrix.py | 14 ++++- ...1_comparison_and_validation_consistency.py | 54 +++++++++++++++++++ tests/test_v10_22_12_review_matrix.py | 2 + 5 files changed, 84 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 160e4db..ed2b926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ - 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. + ## 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. diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 0b5ec21..e9b33d6 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -83,6 +83,7 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: 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() @@ -127,6 +128,17 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: 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'}: + max_score = 4 if verification == 'confirmed' and confidence == 'high' else 3 + score = min(score, max_score) + reasons.append('documentación o superficie api: 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 category == 'discovery': score = 1 reasons.append('hallazgo de descubrimiento, impacto limitado') diff --git a/src/attack_surface_mapper/reporting/review_matrix.py b/src/attack_surface_mapper/reporting/review_matrix.py index 2651367..6c164e9 100644 --- a/src/attack_surface_mapper/reporting/review_matrix.py +++ b/src/attack_surface_mapper/reporting/review_matrix.py @@ -7,14 +7,26 @@ def review_bucket_for_finding(finding: dict) -> str: + title = str(finding.get('title') or '').lower() kind = str(finding.get('kind') 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() - if kind == 'discovery' or category == 'discovery': + inventory_like = ( + 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 category == 'headers': + return 'revisar' if verification == 'confirmed' and confidence == 'high' and priority in {'medium', 'high', 'critical'}: return 'priorizar' if verification in {'confirmed', 'likely', 'needs_manual_validation'}: diff --git a/tests/test_v10_22_11_comparison_and_validation_consistency.py b/tests/test_v10_22_11_comparison_and_validation_consistency.py index 9f2a1ba..5d18c6d 100644 --- a/tests/test_v10_22_11_comparison_and_validation_consistency.py +++ b/tests/test_v10_22_11_comparison_and_validation_consistency.py @@ -254,3 +254,57 @@ def test_confirmed_medium_header_does_not_escalate_to_high_priority_by_default() 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 == 'high' + + +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 index 2f68905..197e7ce 100644 --- a/tests/test_v10_22_12_review_matrix.py +++ b/tests/test_v10_22_12_review_matrix.py @@ -12,6 +12,8 @@ def test_review_bucket_prefers_discovery_and_confirmed_validation() -> None: 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': '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: From 6408029293d8019e49a3e10ff502aa9a40c98830 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 22:04:34 +0200 Subject: [PATCH 12/24] Split report risk and hygiene summaries --- CHANGELOG.md | 1 + .../reporting/generator.py | 52 +++++++++++++++++-- ...0_contract_and_false_positive_hardening.py | 6 +++ tests/test_v10_22_13_precision_iteration.py | 37 +++++++++++++ 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2b926..0f7c0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - 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. ## v10.22.6 false-positive tuning diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index c9b17e3..3b1e317 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -99,6 +99,25 @@ def _report_sort_key(vulnerability: Vulnerability) -> tuple: 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 _split_report_groups(vulnerabilities: Iterable[Vulnerability]) -> tuple[list[Vulnerability], list[Vulnerability], list[Vulnerability], list[Vulnerability]]: + sorted_vulns = list(vulnerabilities) + application = [v for v in sorted_vulns if _is_application_finding(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'] + return application, hygiene, network, discovery + @dataclass(slots=True) class ReportPaths: markdown: str | None = None @@ -120,6 +139,9 @@ class ReportStats: unique_cwes: list[str] needs_manual_validation: int confirmed_high_or_critical: int + application_findings: int + hygiene_findings: int + discovery_findings: int class ReportGenerator: @@ -133,6 +155,7 @@ def sort_vulnerabilities(vulnerabilities: Iterable[Vulnerability]) -> list[Vulne @staticmethod def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: vulns = list(vulnerabilities) + application_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) @@ -160,6 +183,9 @@ def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: if (v.verification_status or '').lower() == 'confirmed' and (v.priority or v.severity or '').lower() in {'critical', 'high'} ), + application_findings=len(application_findings), + hygiene_findings=len(hygiene_findings), + discovery_findings=len(discovery_findings), ) @staticmethod @@ -177,6 +203,7 @@ def executive_summary(stats: ReportStats) -> str: 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'Aplicación: {stats.application_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}. ' f'{impact_note}' @@ -214,6 +241,7 @@ def _serialize_top_finding(vulnerability: Vulnerability) -> dict: 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, hygiene_findings, _, discovery_findings = _split_report_groups(sorted_vulns) comparison_payload = _normalise_comparison(comparison) return { 'schema_version': '1.0', @@ -225,6 +253,12 @@ def build_summary_payload(self, vulnerabilities: Iterable[Vulnerability], target '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(application_findings), 5), + 'top_risk_findings': [self._serialize_top_finding(v) for v in application_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: @@ -284,11 +318,11 @@ def generate_markdown(self, vulnerabilities: Iterable[Vulnerability], target: st sorted_vulns = self.sort_vulnerabilities(vulnerabilities) stats = self.compute_stats(sorted_vulns) comparison_payload = _normalise_comparison(comparison) if comparison else {} - 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'] + application_findings, hygiene_findings, network_services, discovery_like = _split_report_groups(sorted_vulns) + confirmed = [v for v in application_findings if (v.verification_status or '').lower() == 'confirmed'] + plausible = [v for v in application_findings if (v.verification_status or '').lower() != 'confirmed'] + hygiene_confirmed = [v for v in hygiene_findings if (v.verification_status or '').lower() == 'confirmed'] + hygiene_plausible = [v for v in hygiene_findings if (v.verification_status or '').lower() != 'confirmed'] lines = [ f'# {self.title}', @@ -329,6 +363,14 @@ 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 += ['', '## 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.') 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 index 7ba581e..ec2161b 100644 --- a/tests/test_v10_22_10_contract_and_false_positive_hardening.py +++ b/tests/test_v10_22_10_contract_and_false_positive_hardening.py @@ -60,7 +60,13 @@ def test_summary_payload_exposes_stable_schema_and_comparison_counts() -> None: } assert payload['stats']['priority_counts'] == {'critical': 0, 'high': 1, 'medium': 0, 'low': 0} assert payload['stats']['severity_counts']['info'] == 0 + assert payload['stats']['application_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_hygiene_finding_count'] == 0 + assert payload['top_discovery_finding_count'] == 0 def test_aggregate_payload_exposes_stable_summary_and_finding_fields() -> None: diff --git a/tests/test_v10_22_13_precision_iteration.py b/tests/test_v10_22_13_precision_iteration.py index c035b96..0718d78 100644 --- a/tests/test_v10_22_13_precision_iteration.py +++ b/tests/test_v10_22_13_precision_iteration.py @@ -137,6 +137,43 @@ def test_summary_top_findings_prioritise_application_risk_over_headers_and_inven 'Multiple API Endpoints Exposed (10)', 'Technology Fingerprint Detected (Angular)', ] + assert payload['top_risk_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'] == 'Technology Fingerprint Detected (Angular)' + + +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: From d74900f281dd61757dcbff35730d3ad9891523cc Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 22:22:47 +0200 Subject: [PATCH 13/24] Soften auth findings for public API surface --- CHANGELOG.md | 1 + .../analysis/enrichment.py | 4 ++ .../validators/auth_validator.py | 62 +++++++++++++++++++ tests/test_v10_22_13_precision_iteration.py | 25 ++++++++ 4 files changed, 92 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7c0ab..c392f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - 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. ## v10.22.6 false-positive tuning diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index e9b33d6..93d490d 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -132,6 +132,10 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: max_score = 4 if verification == 'confirmed' and confidence == 'high' else 3 score = min(score, max_score) 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') diff --git a/src/attack_surface_mapper/validators/auth_validator.py b/src/attack_surface_mapper/validators/auth_validator.py index ad0727d..66e9311 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', @@ -152,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', @@ -253,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) diff --git a/tests/test_v10_22_13_precision_iteration.py b/tests/test_v10_22_13_precision_iteration.py index 0718d78..92a605f 100644 --- a/tests/test_v10_22_13_precision_iteration.py +++ b/tests/test_v10_22_13_precision_iteration.py @@ -3,6 +3,7 @@ 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 @@ -46,6 +47,30 @@ def test_api_validator_discards_baseline_like_graphql_without_strong_signature() assert 'sin firma graphql fuerte' 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 = "
" From 000f732b518a43c1f0cd508b6280ec289b107cf3 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Wed, 8 Apr 2026 22:40:32 +0200 Subject: [PATCH 14/24] Filter low-signal items from top risk summary --- CHANGELOG.md | 1 + .../reporting/generator.py | 16 ++++++++-- tests/test_v10_22_13_precision_iteration.py | 31 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c392f1c..0d0fb3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - 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 diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index 3b1e317..9855164 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -118,6 +118,17 @@ def _split_report_groups(vulnerabilities: Iterable[Vulnerability]) -> tuple[list discovery = [v for v in sorted_vulns if (v.category or '').lower() == 'discovery'] return application, hygiene, network, discovery + +def _headline_risk_findings(vulnerabilities: Iterable[Vulnerability]) -> list[Vulnerability]: + items = list(vulnerabilities) + preferred = [ + vuln + for vuln in items + if (vuln.priority or '').lower() in {'critical', 'high', 'medium'} + or (vuln.verification_status or '').lower() == 'confirmed' + ] + return preferred or items + @dataclass(slots=True) class ReportPaths: markdown: str | None = None @@ -242,6 +253,7 @@ def build_summary_payload(self, vulnerabilities: Iterable[Vulnerability], target sorted_vulns = self.sort_vulnerabilities(vulnerabilities) stats = self.compute_stats(sorted_vulns) application_findings, hygiene_findings, _, discovery_findings = _split_report_groups(sorted_vulns) + headline_risk_findings = _headline_risk_findings(application_findings) comparison_payload = _normalise_comparison(comparison) return { 'schema_version': '1.0', @@ -253,8 +265,8 @@ def build_summary_payload(self, vulnerabilities: Iterable[Vulnerability], target '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(application_findings), 5), - 'top_risk_findings': [self._serialize_top_finding(v) for v in application_findings[:5]], + '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_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), diff --git a/tests/test_v10_22_13_precision_iteration.py b/tests/test_v10_22_13_precision_iteration.py index 92a605f..463dd6b 100644 --- a/tests/test_v10_22_13_precision_iteration.py +++ b/tests/test_v10_22_13_precision_iteration.py @@ -167,6 +167,37 @@ def test_summary_top_findings_prioritise_application_risk_over_headers_and_inven assert payload['top_discovery_findings'][0]['title'] == 'Technology Fingerprint Detected (Angular)' +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_markdown_has_dedicated_hygiene_section(tmp_path) -> None: findings = [ Vulnerability( From 93c569718c66d603215c9d2a9bcf817131d49746 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Fri, 10 Apr 2026 19:59:03 +0200 Subject: [PATCH 15/24] Soften Swagger documentation prioritization --- src/attack_surface_mapper/analysis/enrichment.py | 3 +-- src/attack_surface_mapper/reporting/review_matrix.py | 7 +++++++ ...test_v10_22_11_comparison_and_validation_consistency.py | 2 +- tests/test_v10_22_12_review_matrix.py | 1 + 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 93d490d..0d6fc27 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -129,8 +129,7 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: score = min(score, 1) reasons.append('cabecera de bajo impacto') if title in {'swagger ui exposed', 'openapi specification exposed', 'api surface exposed'}: - max_score = 4 if verification == 'confirmed' and confidence == 'high' else 3 - score = min(score, max_score) + 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 diff --git a/src/attack_surface_mapper/reporting/review_matrix.py b/src/attack_surface_mapper/reporting/review_matrix.py index 6c164e9..40f0859 100644 --- a/src/attack_surface_mapper/reporting/review_matrix.py +++ b/src/attack_surface_mapper/reporting/review_matrix.py @@ -14,6 +14,11 @@ def review_bucket_for_finding(finding: dict) -> str: confidence = str(finding.get('confidence') or '').lower() priority = str(finding.get('priority') or '').lower() + documentation_like = title in { + 'swagger ui exposed', + 'openapi specification exposed', + 'api surface exposed', + } inventory_like = ( kind == 'discovery' or category == 'discovery' @@ -25,6 +30,8 @@ def review_bucket_for_finding(finding: dict) -> str: ) if inventory_like: return 'descubrimiento' + if documentation_like: + return 'revisar' if category == 'headers': return 'revisar' if verification == 'confirmed' and confidence == 'high' and priority in {'medium', 'high', 'critical'}: diff --git a/tests/test_v10_22_11_comparison_and_validation_consistency.py b/tests/test_v10_22_11_comparison_and_validation_consistency.py index 5d18c6d..d73f7b4 100644 --- a/tests/test_v10_22_11_comparison_and_validation_consistency.py +++ b/tests/test_v10_22_11_comparison_and_validation_consistency.py @@ -270,7 +270,7 @@ def test_confirmed_swagger_documentation_does_not_escalate_to_critical_priority( enrich_vulnerabilities([vuln]) - assert vuln.priority == 'high' + assert vuln.priority == 'medium' def test_likely_graphql_without_auth_stays_below_high_priority() -> None: diff --git a/tests/test_v10_22_12_review_matrix.py b/tests/test_v10_22_12_review_matrix.py index 197e7ce..143a1ff 100644 --- a/tests/test_v10_22_12_review_matrix.py +++ b/tests/test_v10_22_12_review_matrix.py @@ -13,6 +13,7 @@ def test_review_bucket_prefers_discovery_and_confirmed_validation() -> None: 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': 'Multiple API Endpoints Exposed (10)', 'verification_status': 'likely', 'confidence': 'medium', 'priority': 'medium'}) == 'descubrimiento' From 1cedb2044f795cb8788119b365255312a1aaf5bf Mon Sep 17 00:00:00 2001 From: GabriVG Date: Fri, 10 Apr 2026 20:08:52 +0200 Subject: [PATCH 16/24] Refine API surface triage and report focus --- .../reporting/generator.py | 15 +++++ .../reporting/review_matrix.py | 6 ++ .../validators/api_validator.py | 8 +++ tests/test_v10_22_12_review_matrix.py | 8 ++- tests/test_v10_22_13_precision_iteration.py | 65 +++++++++++++++++++ 5 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index 9855164..f771a24 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -10,6 +10,7 @@ from typing import Iterable from attack_surface_mapper.models.vulnerability import 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'] @@ -121,6 +122,20 @@ def _split_report_groups(vulnerabilities: Iterable[Vulnerability]) -> tuple[list 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, + '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 diff --git a/src/attack_surface_mapper/reporting/review_matrix.py b/src/attack_surface_mapper/reporting/review_matrix.py index 40f0859..4f095e6 100644 --- a/src/attack_surface_mapper/reporting/review_matrix.py +++ b/src/attack_surface_mapper/reporting/review_matrix.py @@ -19,6 +19,10 @@ def review_bucket_for_finding(finding: dict) -> str: 'openapi specification exposed', 'api surface exposed', } + surface_like = title in { + 'graphql surface exposed', + 'broad cors policy observed', + } inventory_like = ( kind == 'discovery' or category == 'discovery' @@ -32,6 +36,8 @@ def review_bucket_for_finding(finding: dict) -> str: return 'descubrimiento' if documentation_like: return 'revisar' + if surface_like: + return 'revisar' if category == 'headers': return 'revisar' if verification == 'confirmed' and confidence == 'high' and priority in {'medium', 'high', 'critical'}: diff --git a/src/attack_surface_mapper/validators/api_validator.py b/src/attack_surface_mapper/validators/api_validator.py index b2cf6cd..34df6f7 100644 --- a/src/attack_surface_mapper/validators/api_validator.py +++ b/src/attack_surface_mapper/validators/api_validator.py @@ -236,6 +236,14 @@ def _classify_path(self, path: str, response, preview: str, content_type: str, b 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/tests/test_v10_22_12_review_matrix.py b/tests/test_v10_22_12_review_matrix.py index 143a1ff..0ba3ec0 100644 --- a/tests/test_v10_22_12_review_matrix.py +++ b/tests/test_v10_22_12_review_matrix.py @@ -14,6 +14,8 @@ def test_review_bucket_prefers_discovery_and_confirmed_validation() -> None: 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' @@ -69,7 +71,7 @@ def test_build_review_rows_reads_run_manifest_and_vulnerabilities(tmp_path: Path 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'] == 'priorizar' + assert rows[0]['bucket_revision'] == 'revisar' assert rows[0]['etiqueta_analista'] == '' @@ -94,7 +96,7 @@ def test_write_review_matrix_creates_csv_with_expected_columns(tmp_path: Path) - 'asset_host': 'target.example', 'asset_port': '443', 'evidence_summary': 'graphql marker', - 'bucket_revision': 'priorizar', + 'bucket_revision': 'revisar', 'etiqueta_analista': '', 'notas': '', } @@ -105,5 +107,5 @@ def test_write_review_matrix_creates_csv_with_expected_columns(tmp_path: Path) - rows = list(reader) assert rows[0]['run_name'] == 'lab_demo' - assert rows[0]['bucket_revision'] == 'priorizar' + 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 index 463dd6b..f8008f1 100644 --- a/tests/test_v10_22_13_precision_iteration.py +++ b/tests/test_v10_22_13_precision_iteration.py @@ -47,6 +47,29 @@ def test_api_validator_discards_baseline_like_graphql_without_strong_signature() 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': @@ -198,6 +221,48 @@ def test_top_risk_findings_skip_low_likely_application_noise_when_stronger_items 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( From 5db792b3e7450f17a8688add45aacf980c196adc Mon Sep 17 00:00:00 2001 From: GabriVG Date: Fri, 10 Apr 2026 20:39:48 +0200 Subject: [PATCH 17/24] Freeze finding contract and add lab golden set --- scripts/check_review_golden_set.py | 43 ++++ scripts/validate_labs.ps1 | 25 +++ src/attack_surface_mapper/batch/aggregate.py | 61 +++++- .../models/vulnerability.py | 61 +++++- .../reporting/__init__.py | 21 +- .../reporting/generator.py | 70 ++++--- .../reporting/review_matrix.py | 48 +++++ .../validators/panels_validator.py | 11 +- tests/data/lab_review_golden_set.json | 44 +++++ ...0_contract_and_false_positive_hardening.py | 7 +- tests/test_v10_22_13_precision_iteration.py | 3 +- ...14_golden_set_and_operational_coherence.py | 186 ++++++++++++++++++ 12 files changed, 537 insertions(+), 43 deletions(-) create mode 100644 scripts/check_review_golden_set.py create mode 100644 tests/data/lab_review_golden_set.json create mode 100644 tests/test_v10_22_14_golden_set_and_operational_coherence.py 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/validate_labs.ps1 b/scripts/validate_labs.ps1 index bea79f7..a55f818 100644 --- a/scripts/validate_labs.ps1 +++ b/scripts/validate_labs.ps1 @@ -5,6 +5,7 @@ param( [switch]$IncludeEnum, [int]$MinFindings = 1, [string]$ReviewMatrixOutput = 'reviews/lab_findings_review.csv', + [string]$ReviewGoldenSet = 'tests/data/lab_review_golden_set.json', [switch]$KeepLabsRunning ) @@ -150,6 +151,29 @@ function Export-ReviewMatrix { 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, @@ -254,6 +278,7 @@ try { } Export-ReviewMatrix -Runs $executedRuns -OutputPath $ReviewMatrixOutput +Assert-ReviewGoldenSet -MatrixPath $ReviewMatrixOutput -GoldenSetPath $ReviewGoldenSet Write-Host '' Write-Host 'Validacion completada. Runs generados:' -ForegroundColor Green diff --git a/src/attack_surface_mapper/batch/aggregate.py b/src/attack_surface_mapper/batch/aggregate.py index 8443367..4097de7 100644 --- a/src/attack_surface_mapper/batch/aggregate.py +++ b/src/attack_surface_mapper/batch/aggregate.py @@ -8,6 +8,8 @@ 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'} @@ -131,6 +133,33 @@ def _top_finding_bucket(item: dict) -> int: 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), @@ -261,12 +290,18 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: 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', + 'finding_contract': Vulnerability.contract_metadata(), 'summary': { 'total_targets': total_targets, 'raw_total_findings': raw_total_findings, 'total_findings': len(top_findings), + '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]))), @@ -280,6 +315,10 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: '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, } @@ -295,6 +334,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', '', @@ -310,9 +354,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', ''] @@ -322,6 +363,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.') diff --git a/src/attack_surface_mapper/models/vulnerability.py b/src/attack_surface_mapper/models/vulnerability.py index 39b1c48..dcaec45 100644 --- a/src/attack_surface_mapper/models/vulnerability.py +++ b/src/attack_surface_mapper/models/vulnerability.py @@ -3,6 +3,27 @@ from dataclasses import asdict, dataclass, field from typing import Any +FINDING_SCHEMA_VERSION = '1.0' +FINDING_REQUIRED_FIELDS = ( + 'finding_id', + 'correlation_id', + 'title', + 'target', + '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 = ('priority', 'severity', 'category', 'kind', 'confidence', 'verification_status') +FINDING_ASSET_FIELDS = ('asset_host', 'asset_host_resolved', 'asset_port') + @dataclass(slots=True) class Vulnerability: @@ -90,9 +111,9 @@ def correlation_key(self) -> tuple[str, str, str]: return 'exposure', 'api-docs-exposure', '/api-docs' if route_groupable and '/graphql' in location: return 'exposure', 'graphql-exposure', '/graphql' - if route_groupable and '/actuator' in location: - return 'exposure', 'actuator-exposure', '/actuator' - if route_groupable and any(token in location for token in ('/admin', '/dashboard', '/management')): + 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' @@ -106,3 +127,37 @@ 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), + '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, + '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/reporting/__init__.py b/src/attack_surface_mapper/reporting/__init__.py index ea93fc2..b2d8e53 100644 --- a/src/attack_surface_mapper/reporting/__init__.py +++ b/src/attack_surface_mapper/reporting/__init__.py @@ -1,4 +1,21 @@ from .generator import ReportGenerator, ReportPaths, ReportStats -from .review_matrix import build_review_rows, review_bucket_for_finding, write_review_matrix +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', 'build_review_rows', 'review_bucket_for_finding', 'write_review_matrix'] +__all__ = [ + 'ReportGenerator', + 'ReportPaths', + 'ReportStats', + '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/generator.py b/src/attack_surface_mapper/reporting/generator.py index f771a24..600383c 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -9,7 +9,7 @@ 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'] @@ -111,13 +111,26 @@ def _is_application_finding(vulnerability: Vulnerability) -> bool: return category not in NETWORK_DISCOVERY_CATEGORIES | {'discovery'} and not _is_hygiene_finding(vulnerability) -def _split_report_groups(vulnerabilities: Iterable[Vulnerability]) -> tuple[list[Vulnerability], list[Vulnerability], list[Vulnerability], list[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)] + 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'] - return application, hygiene, network, discovery + 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]: @@ -166,6 +179,7 @@ class ReportStats: needs_manual_validation: int confirmed_high_or_critical: int application_findings: int + review_surface_findings: int hygiene_findings: int discovery_findings: int @@ -181,7 +195,7 @@ def sort_vulnerabilities(vulnerabilities: Iterable[Vulnerability]) -> list[Vulne @staticmethod def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: vulns = list(vulnerabilities) - application_findings, hygiene_findings, _, discovery_findings = _split_report_groups(vulns) + 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) @@ -210,6 +224,7 @@ def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: and (v.priority or v.severity or '').lower() in {'critical', 'high'} ), application_findings=len(application_findings), + review_surface_findings=len(review_surface_findings), hygiene_findings=len(hygiene_findings), discovery_findings=len(discovery_findings), ) @@ -229,7 +244,7 @@ def executive_summary(stats: ReportStats) -> str: 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'Aplicación: {stats.application_findings}; higiene/endurecimiento: {stats.hygiene_findings}; descubrimiento: {stats.discovery_findings}. ' + 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}. ' f'{impact_note}' @@ -244,34 +259,19 @@ def _write(path: str | Path, content: str) -> str: @staticmethod def _serialize_top_finding(vulnerability: Vulnerability) -> dict: - return { - 'title': vulnerability.title, - 'target': vulnerability.target, - 'priority': vulnerability.priority, - 'severity': vulnerability.severity, - 'category': vulnerability.category, - 'finding_id': vulnerability.finding_id, - 'correlation_id': vulnerability.correlation_id, - 'kind': vulnerability.kind, - 'confidence': vulnerability.confidence, - 'verification_status': vulnerability.verification_status, - 'source_count': vulnerability.source_count, - 'target_host_original': vulnerability.target_host_original, - 'asset_host': vulnerability.asset_host, - 'asset_host_resolved': vulnerability.asset_host_resolved, - 'asset_port': vulnerability.asset_port, - 'evidence_summary': vulnerability.evidence_summary, - 'recommendation': vulnerability.recommendation, - } + 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, hygiene_findings, _, discovery_findings = _split_report_groups(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': '1.0', + 'schema_version': FINDING_SCHEMA_VERSION, + 'finding_contract': Vulnerability.contract_metadata(), 'title': self.title, 'target': target, 'executive_summary': self.executive_summary(stats), @@ -282,6 +282,8 @@ def build_summary_payload(self, vulnerabilities: Iterable[Vulnerability], target '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), @@ -345,9 +347,11 @@ def generate_markdown(self, vulnerabilities: Iterable[Vulnerability], target: st sorted_vulns = self.sort_vulnerabilities(vulnerabilities) stats = self.compute_stats(sorted_vulns) comparison_payload = _normalise_comparison(comparison) if comparison else {} - application_findings, hygiene_findings, network_services, discovery_like = _split_report_groups(sorted_vulns) + application_findings, review_surface_findings, hygiene_findings, network_services, discovery_like = _split_report_groups(sorted_vulns) confirmed = [v for v in application_findings if (v.verification_status or '').lower() == 'confirmed'] plausible = [v for v in application_findings if (v.verification_status or '').lower() != 'confirmed'] + review_confirmed = [v for v in review_surface_findings if (v.verification_status or '').lower() == 'confirmed'] + review_plausible = [v for v in review_surface_findings if (v.verification_status or '').lower() != 'confirmed'] hygiene_confirmed = [v for v in hygiene_findings if (v.verification_status or '').lower() == 'confirmed'] hygiene_plausible = [v for v in hygiene_findings if (v.verification_status or '').lower() != 'confirmed'] @@ -390,6 +394,14 @@ 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.') diff --git a/src/attack_surface_mapper/reporting/review_matrix.py b/src/attack_surface_mapper/reporting/review_matrix.py index 4f095e6..dddd1cb 100644 --- a/src/attack_surface_mapper/reporting/review_matrix.py +++ b/src/attack_surface_mapper/reporting/review_matrix.py @@ -51,6 +51,54 @@ 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(): diff --git a/src/attack_surface_mapper/validators/panels_validator.py b/src/attack_surface_mapper/validators/panels_validator.py index 40ff6ac..2407957 100644 --- a/src/attack_surface_mapper/validators/panels_validator.py +++ b/src/attack_surface_mapper/validators/panels_validator.py @@ -13,6 +13,7 @@ class PanelsValidator(BaseValidator): '/admin', '/login', '/dashboard', + '/management', '/swagger', '/swagger-ui', '/actuator', @@ -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': diff --git a/tests/data/lab_review_golden_set.json b/tests/data/lab_review_golden_set.json new file mode 100644 index 0000000..cab2939 --- /dev/null +++ b/tests/data/lab_review_golden_set.json @@ -0,0 +1,44 @@ +[ + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Metrics Endpoint Accessible Without Authentication", + "bucket_revision": "priorizar", + "priority": "critical", + "verification_status": "confirmed" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Swagger UI Exposed", + "bucket_revision": "revisar", + "priority": "medium", + "verification_status": "likely" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "GraphQL Surface Exposed", + "bucket_revision": "revisar", + "priority": "medium", + "verification_status": "likely" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Multiple API Endpoints Exposed (10)", + "bucket_revision": "descubrimiento", + "priority": "medium", + "verification_status": "likely" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "Broad CORS Policy Observed", + "bucket_revision": "revisar", + "priority": "low", + "verification_status": "likely" + }, + { + "run_name": "lab_dvwa_passive_recon_safe", + "title": "Missing Content-Security-Policy Header", + "bucket_revision": "revisar", + "priority": "medium", + "verification_status": "confirmed" + } +] 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 index ec2161b..70530b8 100644 --- a/tests/test_v10_22_10_contract_and_false_positive_hardening.py +++ b/tests/test_v10_22_10_contract_and_false_positive_hardening.py @@ -60,11 +60,15 @@ def test_summary_payload_exposes_stable_schema_and_comparison_counts() -> None: } assert payload['stats']['priority_counts'] == {'critical': 0, 'high': 1, 'medium': 0, 'low': 0} assert payload['stats']['severity_counts']['info'] == 0 - assert payload['stats']['application_findings'] == 1 + 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 @@ -107,6 +111,7 @@ def test_aggregate_payload_exposes_stable_summary_and_finding_fields() -> None: 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] diff --git a/tests/test_v10_22_13_precision_iteration.py b/tests/test_v10_22_13_precision_iteration.py index f8008f1..aa5fd48 100644 --- a/tests/test_v10_22_13_precision_iteration.py +++ b/tests/test_v10_22_13_precision_iteration.py @@ -186,8 +186,9 @@ def test_summary_top_findings_prioritise_application_risk_over_headers_and_inven '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'] == 'Technology Fingerprint Detected (Angular)' + 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: 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..38b731b --- /dev/null +++ b/tests/test_v10_22_14_golden_set_and_operational_coherence.py @@ -0,0 +1,186 @@ +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_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', + 'priority': 'critical', + 'verification_status': 'confirmed', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Swagger UI Exposed', + 'bucket_revision': 'revisar', + 'priority': 'medium', + 'verification_status': 'likely', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'GraphQL Surface Exposed', + 'bucket_revision': 'revisar', + 'priority': 'medium', + 'verification_status': 'likely', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Multiple API Endpoints Exposed (10)', + 'bucket_revision': 'descubrimiento', + 'priority': 'medium', + 'verification_status': 'likely', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'Broad CORS Policy Observed', + 'bucket_revision': 'revisar', + 'priority': 'low', + 'verification_status': 'likely', + }, + { + 'run_name': 'lab_dvwa_passive_recon_safe', + 'title': 'Missing Content-Security-Policy Header', + 'bucket_revision': 'revisar', + 'priority': 'medium', + 'verification_status': 'confirmed', + }, + ] + + 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) From 2bce25dbf2482ef015ecd4f7be581b16cf5292ab Mon Sep 17 00:00:00 2001 From: GabriVG Date: Fri, 10 Apr 2026 20:57:18 +0200 Subject: [PATCH 18/24] Reduce manual-validation noise for discovery inventory --- .../analysis/enrichment.py | 50 ++++++-------- .../reporting/generator.py | 2 +- tests/data/lab_review_golden_set.json | 50 ++++++++++++-- ...14_golden_set_and_operational_coherence.py | 68 +++++++++++++++++++ 4 files changed, 132 insertions(+), 38 deletions(-) diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 0d6fc27..5f98a04 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -61,13 +61,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 @@ -173,34 +186,6 @@ def compute_confidence(vulnerability: Vulnerability) -> str: return 'low' -def _normalise_validation_state(vulnerability: Vulnerability) -> None: - verification = (vulnerability.verification_status or '').lower() - confidence = (vulnerability.confidence or '').lower() - category = (vulnerability.category or '').lower() - - 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 category in {'authentication', 'api', 'secret'} and verification != 'confirmed': - vulnerability.needs_manual_validation = True - - if 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 compute_recommendation(vulnerability: Vulnerability) -> str: if vulnerability.title.startswith('Technology Fingerprint Detected'): return 'Usar el fingerprint como contexto para priorizar rutas, hardening y validaciones específicas del stack detectado.' @@ -238,6 +223,7 @@ 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 @@ -247,10 +233,12 @@ def _normalise_validation_state(vulnerability: Vulnerability) -> None: elif verification in {'likely', 'needs_manual_validation', 'heuristic'}: vulnerability.needs_manual_validation = True - if category in {'authentication', 'api', 'secret'} and verification != 'confirmed': + if inventory_like: + vulnerability.needs_manual_validation = False + elif category in {'authentication', 'api', 'secret'} and verification != 'confirmed': vulnerability.needs_manual_validation = True - if confidence in {'low', 'medium'} and category in {'authentication', 'api', 'panel-exposure', 'sensitive-file'} and verification != 'confirmed': + if not inventory_like 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: diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index 600383c..2b2a4de 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -215,7 +215,7 @@ def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: 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 diff --git a/tests/data/lab_review_golden_set.json b/tests/data/lab_review_golden_set.json index cab2939..600b264 100644 --- a/tests/data/lab_review_golden_set.json +++ b/tests/data/lab_review_golden_set.json @@ -4,41 +4,79 @@ "title": "Metrics Endpoint Accessible Without Authentication", "bucket_revision": "priorizar", "priority": "critical", - "verification_status": "confirmed" + "verification_status": "confirmed", + "needs_manual_validation": "false" }, { "run_name": "lab_juice_shop_passive_recon_enum", "title": "Swagger UI Exposed", "bucket_revision": "revisar", "priority": "medium", - "verification_status": "likely" + "verification_status": "likely", + "needs_manual_validation": "true" }, { "run_name": "lab_juice_shop_passive_recon_enum", "title": "GraphQL Surface Exposed", "bucket_revision": "revisar", "priority": "medium", - "verification_status": "likely" + "verification_status": "likely", + "needs_manual_validation": "true" }, { "run_name": "lab_juice_shop_passive_recon_enum", "title": "Multiple API Endpoints Exposed (10)", "bucket_revision": "descubrimiento", "priority": "medium", - "verification_status": "likely" + "verification_status": "likely", + "needs_manual_validation": "false" }, { "run_name": "lab_juice_shop_passive_recon_enum", "title": "Broad CORS Policy Observed", "bucket_revision": "revisar", "priority": "low", - "verification_status": "likely" + "verification_status": "likely", + "needs_manual_validation": "true" }, { "run_name": "lab_dvwa_passive_recon_safe", "title": "Missing Content-Security-Policy Header", "bucket_revision": "revisar", "priority": "medium", - "verification_status": "confirmed" + "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", + "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", + "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", + "priority": "low", + "verification_status": "likely", + "needs_manual_validation": "false" + }, + { + "run_name": "lab_juice_shop_passive_recon_enum", + "title": "robots.txt Exposed", + "bucket_revision": "descubrimiento", + "priority": "low", + "verification_status": "likely", + "needs_manual_validation": "false" } ] 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 index 38b731b..6d05e28 100644 --- a/tests/test_v10_22_14_golden_set_and_operational_coherence.py +++ b/tests/test_v10_22_14_golden_set_and_operational_coherence.py @@ -83,6 +83,36 @@ def test_summary_payload_separates_review_surface_from_actionable_risk() -> None 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( @@ -141,6 +171,7 @@ def test_review_golden_set_matches_expected_rows() -> None: 'bucket_revision': 'priorizar', 'priority': 'critical', 'verification_status': 'confirmed', + 'needs_manual_validation': 'false', }, { 'run_name': 'lab_juice_shop_passive_recon_enum', @@ -148,6 +179,7 @@ def test_review_golden_set_matches_expected_rows() -> None: 'bucket_revision': 'revisar', 'priority': 'medium', 'verification_status': 'likely', + 'needs_manual_validation': 'true', }, { 'run_name': 'lab_juice_shop_passive_recon_enum', @@ -155,6 +187,7 @@ def test_review_golden_set_matches_expected_rows() -> None: 'bucket_revision': 'revisar', 'priority': 'medium', 'verification_status': 'likely', + 'needs_manual_validation': 'true', }, { 'run_name': 'lab_juice_shop_passive_recon_enum', @@ -162,6 +195,7 @@ def test_review_golden_set_matches_expected_rows() -> None: 'bucket_revision': 'descubrimiento', 'priority': 'medium', 'verification_status': 'likely', + 'needs_manual_validation': 'false', }, { 'run_name': 'lab_juice_shop_passive_recon_enum', @@ -169,6 +203,7 @@ def test_review_golden_set_matches_expected_rows() -> None: 'bucket_revision': 'revisar', 'priority': 'low', 'verification_status': 'likely', + 'needs_manual_validation': 'true', }, { 'run_name': 'lab_dvwa_passive_recon_safe', @@ -176,6 +211,39 @@ def test_review_golden_set_matches_expected_rows() -> None: 'bucket_revision': 'revisar', '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', + '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', + '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', + 'priority': 'low', + 'verification_status': 'likely', + 'needs_manual_validation': 'false', + }, + { + 'run_name': 'lab_juice_shop_passive_recon_enum', + 'title': 'robots.txt Exposed', + 'bucket_revision': 'descubrimiento', + 'priority': 'low', + 'verification_status': 'likely', + 'needs_manual_validation': 'false', }, ] From f0919455b21401f864001da0423cad96cbb120f8 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Fri, 10 Apr 2026 21:25:02 +0200 Subject: [PATCH 19/24] Make validation semantics explicit across findings --- CHANGELOG.md | 5 + .../analysis/comparison.py | 13 +- .../analysis/enrichment.py | 74 +++++- src/attack_surface_mapper/batch/aggregate.py | 47 +++- .../models/vulnerability.py | 22 +- .../reporting/generator.py | 61 ++++- .../reporting/review_matrix.py | 15 +- tests/data/lab_review_golden_set.json | 20 ++ ...14_golden_set_and_operational_coherence.py | 20 ++ tests/test_v10_22_15_validation_layer.py | 219 ++++++++++++++++++ 10 files changed, 469 insertions(+), 27 deletions(-) create mode 100644 tests/test_v10_22_15_validation_layer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0fb3d..a8ec5d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## v10.22.8 polish +- 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. diff --git a/src/attack_surface_mapper/analysis/comparison.py b/src/attack_surface_mapper/analysis/comparison.py index 6d3abe0..9e2dbcd 100644 --- a/src/attack_surface_mapper/analysis/comparison.py +++ b/src/attack_surface_mapper/analysis/comparison.py @@ -9,6 +9,7 @@ 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]: @@ -40,6 +41,9 @@ def _serialise_finding(vulnerability: Vulnerability) -> dict[str, str]: '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 '', @@ -51,7 +55,8 @@ def _change_direction(previous: Vulnerability, current: Vulnerability) -> str: 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) - combined_delta = priority_delta + severity_delta + confidence_delta + verification_delta + 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: return 'promoted' if combined_delta < 0: @@ -75,6 +80,7 @@ def compare_scans(current: list[Vulnerability], previous: list[Vulnerability]) - 'severity': 0, 'confidence': 0, 'verification_status': 0, + 'finding_role': 0, } for key, vulnerability in current_index.items(): @@ -95,6 +101,9 @@ def compare_scans(current: list[Vulnerability], previous: list[Vulnerability]) - 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 @@ -110,6 +119,8 @@ def compare_scans(current: list[Vulnerability], previous: list[Vulnerability]) - '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, diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 5f98a04..968acd4 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -91,6 +91,49 @@ def _base_priority(vulnerability: Vulnerability) -> int: return SEVERITY_SCORE.get((vulnerability.severity or 'unknown').lower(), 0) +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(vulnerability: Vulnerability) -> tuple[str, str]: score = _base_priority(vulnerability) reasons: list[str] = [f'severidad base={vulnerability.severity.lower()}'] @@ -99,6 +142,7 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: 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 @@ -112,9 +156,9 @@ 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 verification == 'confirmed' and category not in {'headers', 'discovery'} and (vulnerability.severity or '').lower() not in {'low', 'info'}: + if finding_role == 'validated' and category not in {'headers', 'discovery'} and (vulnerability.severity or '').lower() not in {'low', 'info'}: score += 1 - reasons.append('hallazgo confirmado') + reasons.append('hallazgo validado') if vulnerability.source_count > 1 and verification == 'confirmed': score += 1 reasons.append('múltiples fuentes correlacionadas con confirmación') @@ -132,6 +176,9 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: 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') @@ -155,9 +202,14 @@ def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: 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 category == 'discovery': - score = 1 - reasons.append('hallazgo de descubrimiento, impacto limitado') + 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) @@ -238,7 +290,13 @@ def _normalise_validation_state(vulnerability: Vulnerability) -> None: elif category in {'authentication', 'api', 'secret'} and verification != 'confirmed': vulnerability.needs_manual_validation = True - if not inventory_like and confidence in {'low', 'medium'} and category in {'authentication', 'api', 'panel-exposure', 'sensitive-file'} and verification != 'confirmed': + 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: @@ -256,11 +314,12 @@ def enrich_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnera if vulnerability.category == 'discovery' and (vulnerability.verification_status or '').lower() == 'confirmed': vulnerability.confidence = 'high' _normalise_validation_state(vulnerability) + vulnerability.kind = infer_kind(vulnerability) + _apply_validation_model(vulnerability) vulnerability.priority, vulnerability.priority_reason = compute_priority(vulnerability) 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') @@ -272,7 +331,6 @@ def enrich_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnera vulnerability.asset_host_resolved = str(vulnerability.host).lower() if vulnerability.port and not vulnerability.asset_port: vulnerability.asset_port = vulnerability.port - _normalise_validation_state(vulnerability) vulnerability.finding_id = _stable_identifier( 'finding', *(vulnerability.dedup_key()), diff --git a/src/attack_surface_mapper/batch/aggregate.py b/src/attack_surface_mapper/batch/aggregate.py index 4097de7..88ffccc 100644 --- a/src/attack_surface_mapper/batch/aggregate.py +++ b/src/attack_surface_mapper/batch/aggregate.py @@ -51,6 +51,31 @@ 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: @@ -113,8 +138,10 @@ def _network_title_key(vuln) -> str: 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 ( - category == 'discovery' + 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') @@ -128,7 +155,7 @@ def _top_finding_bucket(item: dict) -> int: return 3 if str(item.get('category') or '').lower() == 'headers': return 2 - if str(item.get('verification_status') or '').lower() == 'confirmed': + if bool(item.get('validated')) or _finding_role_record(item) == 'validated': return 0 return 1 @@ -198,6 +225,9 @@ def _build_finding_record(result_target: str, vuln, target_asset_hosts: dict[str '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, @@ -250,6 +280,9 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: record['finding_id'] = vuln.finding_id record['correlation_id'] = vuln.correlation_id record['kind'] = vuln.kind + record['finding_role'] = vuln.finding_role + record['validated'] = vuln.validated + record['validation_basis'] = vuln.validation_basis record['confidence'] = vuln.confidence record['source_count'] = vuln.source_count record['evidence_summary'] = vuln.evidence_summary @@ -298,6 +331,8 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: 'total_targets': total_targets, 'raw_total_findings': raw_total_findings, 'total_findings': len(top_findings), + '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), @@ -309,6 +344,8 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: 'total_targets': total_targets, 'raw_total_findings': raw_total_findings, 'total_findings': len(top_findings), + '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]))), @@ -404,7 +441,7 @@ 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', 'target_host_original', 'asset_host', 'asset_host_resolved', 'asset_port', 'finding_id', 'correlation_id', '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', 'severity', 'category', 'kind', 'finding_role', 'validated', 'validation_basis', 'verification_status', 'recommendation']) for finding in payload['top_findings']: writer.writerow([ finding.get('scope', ''), @@ -421,6 +458,10 @@ def write_aggregate_reports(results: Iterable[ScanResult], output_dir: str) -> d finding['priority'], 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/models/vulnerability.py b/src/attack_surface_mapper/models/vulnerability.py index dcaec45..ac1041c 100644 --- a/src/attack_surface_mapper/models/vulnerability.py +++ b/src/attack_surface_mapper/models/vulnerability.py @@ -9,6 +9,8 @@ 'correlation_id', 'title', 'target', + 'finding_role', + 'validated', 'priority', 'severity', 'category', @@ -21,8 +23,19 @@ 'asset_port', ) FINDING_IDENTITY_FIELDS = ('finding_id', 'correlation_id', 'title', 'target', 'target_host_original') -FINDING_CLASSIFICATION_FIELDS = ('priority', 'severity', 'category', 'kind', 'confidence', 'verification_status') +FINDING_CLASSIFICATION_FIELDS = ( + 'finding_role', + 'validated', + 'validation_basis', + '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') @dataclass(slots=True) @@ -60,6 +73,9 @@ 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 target_host_original: str | None = None asset_host: str | None = None asset_host_resolved: str | None = None @@ -135,6 +151,7 @@ def contract_metadata() -> dict[str, Any]: 'required_fields': list(FINDING_REQUIRED_FIELDS), 'identity_fields': list(FINDING_IDENTITY_FIELDS), 'classification_fields': list(FINDING_CLASSIFICATION_FIELDS), + 'assessment_fields': list(FINDING_ASSESSMENT_FIELDS), 'asset_fields': list(FINDING_ASSET_FIELDS), } @@ -144,6 +161,9 @@ def to_summary_record(self) -> dict[str, Any]: 'correlation_id': self.correlation_id, 'title': self.title, 'target': self.target, + 'finding_role': self.finding_role, + 'validated': self.validated, + 'validation_basis': self.validation_basis, 'priority': self.priority, 'severity': self.severity, 'category': self.category, diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index 2b2a4de..93b7469 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -61,11 +61,38 @@ def _normalise_comparison(comparison: dict | None) -> dict[str, list[dict]]: 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 ( - category == 'discovery' + 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') @@ -79,7 +106,7 @@ def _sort_bucket(vulnerability: Vulnerability) -> int: return 3 if (vulnerability.category or '').lower() == 'headers': return 2 - if (vulnerability.verification_status or '').lower() == 'confirmed': + if _finding_role(vulnerability) == 'validated' or bool(vulnerability.validated): return 0 return 1 @@ -141,6 +168,8 @@ def _headline_risk_findings(vulnerabilities: Iterable[Vulnerability]) -> list[Vu 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, @@ -153,7 +182,7 @@ def _headline_risk_findings(vulnerabilities: Iterable[Vulnerability]) -> list[Vu vuln for vuln in items if (vuln.priority or '').lower() in {'critical', 'high', 'medium'} - or (vuln.verification_status or '').lower() == 'confirmed' + or _finding_role(vuln) == 'validated' ] return preferred or items @@ -178,6 +207,8 @@ class ReportStats: unique_cwes: list[str] 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 @@ -220,9 +251,11 @@ def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: confirmed_high_or_critical=sum( 1 for v in vulns - if (v.verification_status or '').lower() == 'confirmed' + 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), @@ -244,6 +277,7 @@ def executive_summary(stats: ReportStats) -> str: 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}. ' @@ -304,12 +338,12 @@ 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([ - 'finding_id', 'correlation_id', 'source', 'severity', 'priority', 'priority_reason', 'category', 'kind', 'confidence', 'verification_status', 'title', 'target', 'target_host_original', 'asset_host', 'asset_host_resolved', 'asset_port', 'description', + 'finding_id', 'correlation_id', 'source', 'severity', 'priority', '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.finding_id or '', vuln.correlation_id or '', vuln.source, vuln.severity, vuln.priority or '', vuln.priority_reason or '', vuln.category or '', vuln.kind or '', vuln.confidence or '', vuln.verification_status or '', + vuln.finding_id or '', vuln.correlation_id or '', vuln.source, vuln.severity, vuln.priority 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 '' @@ -322,6 +356,9 @@ def _append_markdown_finding(self, lines: list[str], vuln: Vulnerability, label: '', f'- **Prioridad:** {vuln.priority or "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"}', @@ -348,12 +385,12 @@ def generate_markdown(self, vulnerabilities: Iterable[Vulnerability], target: st stats = self.compute_stats(sorted_vulns) 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 (v.verification_status or '').lower() == 'confirmed'] - plausible = [v for v in application_findings if (v.verification_status or '').lower() != 'confirmed'] - review_confirmed = [v for v in review_surface_findings if (v.verification_status or '').lower() == 'confirmed'] - review_plausible = [v for v in review_surface_findings if (v.verification_status or '').lower() != 'confirmed'] - hygiene_confirmed = [v for v in hygiene_findings if (v.verification_status or '').lower() == 'confirmed'] - hygiene_plausible = [v for v in hygiene_findings if (v.verification_status or '').lower() != 'confirmed'] + 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}', diff --git a/src/attack_surface_mapper/reporting/review_matrix.py b/src/attack_surface_mapper/reporting/review_matrix.py index dddd1cb..85e6dbc 100644 --- a/src/attack_surface_mapper/reporting/review_matrix.py +++ b/src/attack_surface_mapper/reporting/review_matrix.py @@ -9,10 +9,12 @@ 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', @@ -24,7 +26,8 @@ def review_bucket_for_finding(finding: dict) -> str: 'broad cors policy observed', } inventory_like = ( - kind == 'discovery' + finding_role == 'discovery' + or kind == 'discovery' or category == 'discovery' or title.startswith('multiple api endpoints exposed') or title.startswith('protected api surface discovered') @@ -40,7 +43,9 @@ def review_bucket_for_finding(finding: dict) -> str: return 'revisar' if category == 'headers': return 'revisar' - if verification == 'confirmed' and confidence == 'high' and priority in {'medium', 'high', 'critical'}: + 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' @@ -135,6 +140,9 @@ def build_review_rows(run_dirs: Iterable[str | Path]) -> list[dict[str, str]]: '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 ''), 'severity': str(finding.get('severity') or ''), 'confidence': str(finding.get('confidence') or ''), @@ -163,6 +171,9 @@ def write_review_matrix(rows: Iterable[dict[str, str]], output_path: str | Path) 'title', 'category', 'kind', + 'finding_role', + 'validated', + 'validation_basis', 'priority', 'severity', 'confidence', diff --git a/tests/data/lab_review_golden_set.json b/tests/data/lab_review_golden_set.json index 600b264..3e1b7a6 100644 --- a/tests/data/lab_review_golden_set.json +++ b/tests/data/lab_review_golden_set.json @@ -3,6 +3,8 @@ "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" @@ -11,6 +13,8 @@ "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" @@ -19,6 +23,8 @@ "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" @@ -27,6 +33,8 @@ "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" @@ -35,6 +43,8 @@ "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" @@ -43,6 +53,8 @@ "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" @@ -51,6 +63,8 @@ "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" @@ -59,6 +73,8 @@ "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" @@ -67,6 +83,8 @@ "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" @@ -75,6 +93,8 @@ "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_v10_22_14_golden_set_and_operational_coherence.py b/tests/test_v10_22_14_golden_set_and_operational_coherence.py index 6d05e28..5616974 100644 --- a/tests/test_v10_22_14_golden_set_and_operational_coherence.py +++ b/tests/test_v10_22_14_golden_set_and_operational_coherence.py @@ -169,6 +169,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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', @@ -177,6 +179,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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', @@ -185,6 +189,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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', @@ -193,6 +199,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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', @@ -201,6 +209,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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', @@ -209,6 +219,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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', @@ -217,6 +229,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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', @@ -225,6 +239,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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', @@ -233,6 +249,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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', @@ -241,6 +259,8 @@ def test_review_golden_set_matches_expected_rows() -> None: '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_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' From ce9d440981cb439d31fdf651b2e537884ec1f865 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Fri, 10 Apr 2026 22:00:31 +0200 Subject: [PATCH 20/24] Add structured priority scoring model --- CHANGELOG.md | 4 + .../analysis/comparison.py | 10 ++ .../analysis/enrichment.py | 136 +++++++++++++++++- src/attack_surface_mapper/batch/aggregate.py | 15 +- .../models/vulnerability.py | 9 ++ .../reporting/generator.py | 10 +- .../reporting/review_matrix.py | 4 + tests/test_v10_22_16_scoring_model.py | 135 +++++++++++++++++ 8 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 tests/test_v10_22_16_scoring_model.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ec5d8..f1d1750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## v10.22.8 polish +- 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. diff --git a/src/attack_surface_mapper/analysis/comparison.py b/src/attack_surface_mapper/analysis/comparison.py index 9e2dbcd..0b53b58 100644 --- a/src/attack_surface_mapper/analysis/comparison.py +++ b/src/attack_surface_mapper/analysis/comparison.py @@ -37,6 +37,7 @@ def _serialise_finding(vulnerability: Vulnerability) -> dict[str, str]: '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 '', @@ -52,11 +53,14 @@ def _serialise_finding(vulnerability: Vulnerability) -> dict[str, str]: 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: @@ -77,6 +81,7 @@ def compare_scans(current: list[Vulnerability], previous: list[Vulnerability]) - unchanged_count = 0 change_type_counts = { 'priority': 0, + 'priority_score': 0, 'severity': 0, 'confidence': 0, 'verification_status': 0, @@ -92,6 +97,9 @@ def compare_scans(current: list[Vulnerability], previous: list[Vulnerability]) - 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 @@ -113,6 +121,8 @@ def compare_scans(current: list[Vulnerability], previous: list[Vulnerability]) - '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 '', diff --git a/src/attack_surface_mapper/analysis/enrichment.py b/src/attack_surface_mapper/analysis/enrichment.py index 968acd4..b418a61 100644 --- a/src/attack_surface_mapper/analysis/enrichment.py +++ b/src/attack_surface_mapper/analysis/enrichment.py @@ -24,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] = { @@ -91,6 +120,16 @@ def _base_priority(vulnerability: Vulnerability) -> int: return SEVERITY_SCORE.get((vulnerability.severity or 'unknown').lower(), 0) +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() @@ -134,7 +173,7 @@ def _apply_validation_model(vulnerability: Vulnerability) -> None: vulnerability.validated = False -def compute_priority(vulnerability: Vulnerability) -> tuple[str, str]: +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() @@ -226,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 @@ -316,7 +447,8 @@ def enrich_vulnerabilities(vulnerabilities: list[Vulnerability]) -> list[Vulnera _normalise_validation_state(vulnerability) vulnerability.kind = infer_kind(vulnerability) _apply_validation_model(vulnerability) - vulnerability.priority, vulnerability.priority_reason = compute_priority(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) diff --git a/src/attack_surface_mapper/batch/aggregate.py b/src/attack_surface_mapper/batch/aggregate.py index 88ffccc..5ef6e70 100644 --- a/src/attack_surface_mapper/batch/aggregate.py +++ b/src/attack_surface_mapper/batch/aggregate.py @@ -190,6 +190,7 @@ def _split_aggregate_findings(items: list[dict]) -> tuple[list[dict], list[dict] 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), @@ -221,7 +222,10 @@ def _build_finding_record(result_target: str, vuln, target_asset_hosts: dict[str '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, @@ -279,10 +283,13 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: record['recommendation'] = vuln.recommendation 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 @@ -326,11 +333,13 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: 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), @@ -344,6 +353,7 @@ def build_aggregate_payload(results: Iterable[ScanResult]) -> dict: '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'), 'priority_counts': _stable_counts(priority_counter, PRIORITY_LABELS), @@ -441,7 +451,7 @@ 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', 'target_host_original', 'asset_host', 'asset_host_resolved', 'asset_port', 'finding_id', 'correlation_id', 'title', 'priority', 'severity', 'category', 'kind', 'finding_role', 'validated', 'validation_basis', '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', ''), @@ -456,6 +466,9 @@ def write_aggregate_reports(results: Iterable[ScanResult], output_dir: str) -> d 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', ''), diff --git a/src/attack_surface_mapper/models/vulnerability.py b/src/attack_surface_mapper/models/vulnerability.py index ac1041c..27733d6 100644 --- a/src/attack_surface_mapper/models/vulnerability.py +++ b/src/attack_surface_mapper/models/vulnerability.py @@ -27,6 +27,8 @@ 'finding_role', 'validated', 'validation_basis', + 'scoring_version', + 'priority_score', 'priority', 'severity', 'category', @@ -36,6 +38,7 @@ ) 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) @@ -76,6 +79,8 @@ class Vulnerability: 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 @@ -152,6 +157,7 @@ def contract_metadata() -> dict[str, Any]: '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), } @@ -164,6 +170,9 @@ def to_summary_record(self) -> dict[str, Any]: '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, diff --git a/src/attack_surface_mapper/reporting/generator.py b/src/attack_surface_mapper/reporting/generator.py index 93b7469..0d7c667 100644 --- a/src/attack_surface_mapper/reporting/generator.py +++ b/src/attack_surface_mapper/reporting/generator.py @@ -116,6 +116,7 @@ def _report_sort_key(vulnerability: Vulnerability) -> tuple: 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), @@ -205,6 +206,7 @@ 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 @@ -241,6 +243,7 @@ def compute_stats(vulnerabilities: Iterable[Vulnerability]) -> ReportStats: 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( @@ -305,6 +308,7 @@ def build_summary_payload(self, vulnerabilities: Iterable[Vulnerability], target 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, @@ -338,12 +342,12 @@ 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([ - 'finding_id', 'correlation_id', 'source', 'severity', 'priority', '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', + '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.finding_id or '', vuln.correlation_id or '', vuln.source, vuln.severity, vuln.priority 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.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 '' @@ -355,6 +359,7 @@ 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"}', @@ -470,6 +475,7 @@ def generate_html(self, vulnerabilities: Iterable[Vulnerability], target: str, o 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)}

" diff --git a/src/attack_surface_mapper/reporting/review_matrix.py b/src/attack_surface_mapper/reporting/review_matrix.py index 85e6dbc..b213851 100644 --- a/src/attack_surface_mapper/reporting/review_matrix.py +++ b/src/attack_surface_mapper/reporting/review_matrix.py @@ -144,6 +144,8 @@ def build_review_rows(run_dirs: Iterable[str | Path]) -> list[dict[str, str]]: '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 ''), @@ -175,6 +177,8 @@ def write_review_matrix(rows: Iterable[dict[str, str]], output_path: str | Path) 'validated', 'validation_basis', 'priority', + 'priority_score', + 'scoring_version', 'severity', 'confidence', 'verification_status', 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'] From 07df8ea6acd987ee678e97e4eb88b521d2571ab3 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Fri, 17 Apr 2026 23:55:16 +0200 Subject: [PATCH 21/24] Add Elasticsearch export bundle output --- CHANGELOG.md | 4 + QUICK_DEMO.md | 108 +++- README.md | 42 ++ scripts/export_elasticsearch_bundle.py | 46 ++ .../reporting/__init__.py | 2 + .../reporting/elasticsearch_export.py | 603 ++++++++++++++++++ tests/test_v10_22_17_elasticsearch_export.py | 229 +++++++ 7 files changed, 1033 insertions(+), 1 deletion(-) create mode 100644 scripts/export_elasticsearch_bundle.py create mode 100644 src/attack_surface_mapper/reporting/elasticsearch_export.py create mode 100644 tests/test_v10_22_17_elasticsearch_export.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d1750..fee140a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 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. diff --git a/QUICK_DEMO.md b/QUICK_DEMO.md index a5b71b4..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 @@ -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 @@ -156,3 +215,50 @@ 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 213cd8e..dbd6c7f 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ Finally, the tool generates: - aggregate run reports - JSON summaries - Markdown / CSV / HTML outputs +- optional Elasticsearch export bundles from an existing run --- @@ -505,6 +506,7 @@ Available at the root of each run: - `reports/aggregate_summary.json` - `reports/aggregate_report.md` - `reports/aggregate_findings.csv` +- `elasticsearch/` after running the export helper ### Target-level outputs Available per target: @@ -544,6 +546,46 @@ Useful to understand: ### 5. console logs Very useful to compare how noisy each profile is. +### 6. `elasticsearch/` +Optional post-scan bundle for Elasticsearch ingestion. +It is generated from an existing run and includes: +- index mappings +- `_bulk` NDJSON exports +- helper flows for manual/Kibana Dev Tools, `curl` and Python + +Example: + +```bash +python scripts/export_elasticsearch_bundle.py --run-dir scans/lab_juice_shop_passive_recon_enum +``` + +This creates: + +```text +scans//elasticsearch/ + findings_mapping.json + summaries_mapping.json + runs_mapping.json + findings_bulk.ndjson + summaries_bulk.ndjson + runs_bulk.ndjson + manual_kibana_devtools.md + ingest_with_curl.sh + ingest_with_python.py + export_manifest.json +``` + +Default indices: +- `attack-surface-mapper-findings` +- `attack-surface-mapper-summaries` +- `attack-surface-mapper-runs` + +You can change the prefix with: + +```bash +python scripts/export_elasticsearch_bundle.py --run-dir scans/ --index-prefix asm-demo +``` + --- ## What Makes the Project Valuable 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/src/attack_surface_mapper/reporting/__init__.py b/src/attack_surface_mapper/reporting/__init__.py index b2d8e53..646bee3 100644 --- a/src/attack_surface_mapper/reporting/__init__.py +++ b/src/attack_surface_mapper/reporting/__init__.py @@ -1,4 +1,5 @@ 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, @@ -12,6 +13,7 @@ 'ReportGenerator', 'ReportPaths', 'ReportStats', + 'export_elasticsearch_bundle', 'build_review_rows', 'evaluate_review_rows_against_golden_set', 'load_review_golden_set', 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/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' From 69d3f865e819d8301d4f8ab57f4956890328d004 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Sun, 23 Aug 2026 17:42:38 +0200 Subject: [PATCH 22/24] Harden Attack Surface Mapper release quality --- .github/workflows/ci.yml | 24 ++++++++++ CHANGELOG.md | 48 +++++++++++-------- pyproject.toml | 11 +++-- .../validators/api_validator.py | 8 ++-- .../validators/auth_validator.py | 2 +- tests/test_source_encoding.py | 19 ++++++++ 6 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_source_encoding.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f51236 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index fee140a..ae3b652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ -# attack_surface_mapper_project_v10.22 +# Attack Surface Mapper + +## 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 @@ -6,14 +14,14 @@ - 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. +- 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. +- 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. @@ -27,21 +35,21 @@ - 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`. +- 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. +- 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 diff --git a/pyproject.toml b/pyproject.toml index 8faad3d..22a3b41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ 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.9" +description = "Pipeline-based attack-surface discovery, validation, correlation, and reporting." requires-python = ">=3.11" dependencies = [ "requests>=2.31.0", @@ -14,6 +14,11 @@ dependencies = [ "playwright>=1.52.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0,<9", +] + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] diff --git a/src/attack_surface_mapper/validators/api_validator.py b/src/attack_surface_mapper/validators/api_validator.py index 34df6f7..a548bf2 100644 --- a/src/attack_surface_mapper/validators/api_validator.py +++ b/src/attack_surface_mapper/validators/api_validator.py @@ -169,17 +169,17 @@ def _classify_path(self, path: str, response, preview: str, content_type: str, b graphql_signature = '' if looks_like_login_surface(response, preview): title = 'API Surface Exposed' - description = 'Se ha detectado una superficie de API accesible públicamente.' + 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.' + 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.' + 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' + 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): diff --git a/src/attack_surface_mapper/validators/auth_validator.py b/src/attack_surface_mapper/validators/auth_validator.py index 66e9311..fae6bd8 100644 --- a/src/attack_surface_mapper/validators/auth_validator.py +++ b/src/attack_surface_mapper/validators/auth_validator.py @@ -173,7 +173,7 @@ def _check_protected_paths(self, session, target: str, host: str | None, port: s description=description, severity=severity, target=response.url, - evidence=f'GET {response.url} devolvió {response.status_code}; validación={reason}', + 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'}", 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 == [] From 02d3620fe5f8be62a6cd9537b5d3a4c38fc55f32 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Sat, 29 Aug 2026 19:41:04 +0200 Subject: [PATCH 23/24] Prepare Attack Surface Mapper for public release --- .github/ISSUE_TEMPLATE/bug_report.md | 26 + .github/ISSUE_TEMPLATE/feature_request.md | 18 + .github/pull_request_template.md | 22 + .github/workflows/ci.yml | 3 + .github/workflows/security.yml | 30 + CHANGELOG.md | 8 + CODE_OF_CONDUCT.md | 7 + CONTRIBUTING.md | 31 + LICENSE | 21 + README.md | 697 +++++----------------- SECURITY.md | 10 +- docs/ARCHITECTURE.md | 45 ++ pyproject.toml | 19 +- 13 files changed, 372 insertions(+), 565 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/security.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 docs/ARCHITECTURE.md 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 index 2f51236..fb4fec8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,6 @@ jobs: 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..8f26528 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,30 @@ +name: Dependency security + +on: + 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/CHANGELOG.md b/CHANGELOG.md index ae3b652..5e70e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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. +- 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. 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/README.md b/README.md index dbd6c7f..50b8958 100644 --- a/README.md +++ b/README.md @@ -1,641 +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 -- optional Elasticsearch export bundles from an existing run - ---- - -## 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 -``` - -### 2. Optional: install Playwright browsers - -Only needed for the **active dynamic profile**: - -```bash -python -m playwright install -``` +Pipeline-based attack-surface discovery, validation, correlation and reporting for authorised security testing and controlled labs. -### 3. External tools required +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. -#### Required -- **Python 3.11+** -- **Nuclei** available in `PATH` -- updated Nuclei templates +> **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. -#### Optional -- **Nmap** available in `PATH` if you want network reconnaissance -- **Playwright browsers** for dynamic crawling in the active profile +## Why this project is interesting ---- +The engineering focus is the pipeline around the scanners: -## Configuration Model +- 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. -The tool can be configured in two ways: +## Architecture -### 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: +Execution is orchestrated as: -> If a target is passed via CLI, it is added to the targets defined inside the YAML profile and the final list is deduplicated. +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. -This is useful because the tutor can execute the same profile against another target without modifying the YAML. +See [the pipeline guide](docs/PIPELINE.md) and [the architecture note](docs/ARCHITECTURE.md) for the detailed contracts. ---- +## Quick start -## Main Profiles +### Requirements -The project includes several operational profiles. +- 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. -### 1. `passive-stealth` -Designed for **very low-noise observation**. - -Characteristics: -- no Nuclei -- no Nmap -- no invented baseline probes -- no probing of common hardcoded paths -- only observed navigation and linked resources - -Recommended when the goal is to minimise visible activity in logs. - -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 ``` -### Add a CLI target to YAML/profile targets - -```bash -python main.py --profile passive-recon-safe https://proba-despregamento.onrender.com/ -``` - -### Run with Nmap enabled - -```bash -python main.py https://example.com/ --profile deep --use-nmap --nmap-top-ports 200 --debug -``` - ---- +## Output contract -## Simple Demo Flow +Each run is written below `scans//`: -A good way to evaluate the project is to execute these three profiles against the same 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/ +```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/ ``` -Then compare: +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. -- 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 +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). -This is one of the most important values of the project: -**the ability to control the trade-off between stealth, coverage and signal quality**. +## Local lab validation -### Repeatable local validation - -For quick regression checks of the passive profiles, a local Juice Shop container is a practical baseline: +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 -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 +python -m pytest -q +python -m compileall -q main.py src ``` -When comparing both runs, start with: - -- `run_manifest.json` -- `reports/aggregate_summary.json` -- `targets/.../reports/report.summary.json` - -For a repeatable PowerShell flow on Windows: +On Windows, the PowerShell validation helper is: ```powershell -.\scripts\validate_labs.ps1 -Labs juice-shop -.\scripts\validate_labs.ps1 -Labs juice-shop -IncludeEnum +.\scripts\validate_labs.ps1 ``` -The script now validates the generated `run_manifest.json`, `aggregate_summary.json` -and `report.summary.json`, and it accepts `-MinFindings` to require a minimum number -of correlated findings per run. -It also exports `reviews/lab_findings_review.csv` so you can label findings as -`verdadero`, `falso` or `dudoso` during false-positive review. -When `-IncludeEnum` is enabled, the script uses -`config/examples/lab-passive-recon-enum.yml` so the enum profile stays reproducible -in Docker without depending on a host-side Nuclei install. - ---- +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. -## Output Structure - -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` -- `elasticsearch/` after running the export helper - -### 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. - -### 3. `targets/.../findings/vulnerabilities.json` -Contains the final structured findings per target. - -### 4. `run_manifest.json` -Useful to understand: -- which stages ran -- which collectors were used -- what was actually observed - -### 5. console logs -Very useful to compare how noisy each profile is. - -### 6. `elasticsearch/` -Optional post-scan bundle for Elasticsearch ingestion. -It is generated from an existing run and includes: -- index mappings -- `_bulk` NDJSON exports -- helper flows for manual/Kibana Dev Tools, `curl` and Python - -Example: +The fastest review path is: -```bash -python scripts/export_elasticsearch_bundle.py --run-dir scans/lab_juice_shop_passive_recon_enum -``` +1. [Architecture](docs/ARCHITECTURE.md) +2. [Profiles](docs/PROFILES.md) +3. [Pipeline](docs/PIPELINE.md) +4. [Output contract](docs/OUTPUTS.md) +5. [Tests](tests) -This creates: +## Quality and security posture -```text -scans//elasticsearch/ - findings_mapping.json - summaries_mapping.json - runs_mapping.json - findings_bulk.ndjson - summaries_bulk.ndjson - runs_bulk.ndjson - manual_kibana_devtools.md - ingest_with_curl.sh - ingest_with_python.py - export_manifest.json -``` - -Default indices: -- `attack-surface-mapper-findings` -- `attack-surface-mapper-summaries` -- `attack-surface-mapper-runs` +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`. -You can change the prefix with: - -```bash -python scripts/export_elasticsearch_bundle.py --run-dir scans/ --index-prefix asm-demo -``` +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. ---- +## Roadmap -## What Makes the Project Valuable +- 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. -This project is valuable because it is not limited to raw scanner execution. +## License -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 +Released under the [MIT License](LICENSE). Use of the tool remains subject to the authorised-testing requirements in [SECURITY.md](SECURITY.md). -In practice, this makes it useful both as: -- a technical project with real engineering depth -- an operational prototype for attack-surface analysis +## 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 is combined with YAML targets and deduplicated. -- The active YAML profile file is called `active-aggressive.yml`; the CLI accepts both `active` and `active-aggressive`. - ---- - -## 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/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/pyproject.toml b/pyproject.toml index 22a3b41..8fc8c19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,20 @@ build-backend = "setuptools.build_meta" [project] name = "attack-surface-mapper" -version = "10.22.9" +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", @@ -19,6 +31,11 @@ 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 = ["."] From e3618bd066981383b230e713ed2358ddc80fd038 Mon Sep 17 00:00:00 2001 From: GabriVG Date: Sat, 29 Aug 2026 19:42:23 +0200 Subject: [PATCH 24/24] Run dependency audit on every push --- .github/workflows/security.yml | 1 + CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 8f26528..c855510 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,6 +1,7 @@ name: Dependency security on: + push: pull_request: schedule: - cron: "17 5 * * 1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e70e83..fa658ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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