pdgseg-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdgseg/__init__.py +8 -0
- pdgseg/cli.py +172 -0
- pdgseg/client.py +158 -0
- pdgseg/config.py +195 -0
- pdgseg/errors.py +21 -0
- pdgseg/flow.py +160 -0
- pdgseg/output.py +115 -0
- pdgseg/packager.py +141 -0
- pdgseg/schema/pdgseg.schema.v1.json +63 -0
- pdgseg/summary.py +90 -0
- pdgseg_cli-1.0.0.dist-info/METADATA +380 -0
- pdgseg_cli-1.0.0.dist-info/RECORD +15 -0
- pdgseg_cli-1.0.0.dist-info/WHEEL +5 -0
- pdgseg_cli-1.0.0.dist-info/entry_points.txt +2 -0
- pdgseg_cli-1.0.0.dist-info/top_level.txt +1 -0
pdgseg/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""pdgseg: CLI de SegSoft para ejecutar el análisis de cumplimiento desde pipelines CI/CD."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
|
|
6
|
+
__version__ = version("pdgseg-cli")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree without installing
|
|
8
|
+
__version__ = "0.0.0.dev0"
|
pdgseg/cli.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Punto de entrada: «pdgseg analyze»."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .client import ApiClient
|
|
15
|
+
from .config import CONFIG_FILE_NAME, SERVER_ENV, example_config, load_config
|
|
16
|
+
from .errors import PdgsegError
|
|
17
|
+
from .flow import Progress, run_analysis
|
|
18
|
+
from .output import error_json, render_text, to_json
|
|
19
|
+
from .summary import EXIT_ERROR, EXIT_OK, exit_code_for, summarize
|
|
20
|
+
|
|
21
|
+
TOKEN_ENV = "PDGSEG_API_TOKEN"
|
|
22
|
+
DEFAULT_SARIF_PATH = "results.sarif"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
26
|
+
@click.version_option(__version__, "-V", "--version", prog_name="pdgseg")
|
|
27
|
+
def main() -> None:
|
|
28
|
+
"""CLI de SegSoft: análisis de cumplimiento de seguridad para pipelines CI/CD."""
|
|
29
|
+
_utf8_when_redirected()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _utf8_when_redirected() -> None:
|
|
33
|
+
"""
|
|
34
|
+
On Windows, output redirected to a pipe or file (a CI log) uses the ANSI code page, so accents
|
|
35
|
+
show up garbled and a character outside it would crash. CI logs are read as UTF-8: write UTF-8
|
|
36
|
+
there. An interactive console is left alone (click writes to it through the Unicode console API).
|
|
37
|
+
"""
|
|
38
|
+
for stream in (sys.stdout, sys.stderr):
|
|
39
|
+
if not hasattr(stream, "reconfigure"):
|
|
40
|
+
continue
|
|
41
|
+
try:
|
|
42
|
+
if stream.isatty():
|
|
43
|
+
stream.reconfigure(errors="replace")
|
|
44
|
+
else:
|
|
45
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
46
|
+
except (ValueError, OSError, AttributeError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@main.command()
|
|
51
|
+
@click.option("--path", "path", type=click.Path(file_okay=False, dir_okay=True, path_type=Path), default=".",
|
|
52
|
+
show_default=True, help="Directorio del proyecto a analizar.")
|
|
53
|
+
@click.option("--config", "config_path", type=click.Path(dir_okay=False, path_type=Path), default=None,
|
|
54
|
+
help=f"Archivo de configuración. Por defecto, {CONFIG_FILE_NAME} dentro de --path.")
|
|
55
|
+
@click.option("--output", "output_format", type=click.Choice(["text", "json", "sarif"]), default="text",
|
|
56
|
+
show_default=True, help="text: resumen legible; json: para scripts; sarif: archivo SARIF 2.1.0 "
|
|
57
|
+
"(para GitHub Code Scanning) más el resumen en texto.")
|
|
58
|
+
@click.option("--output-path", type=click.Path(dir_okay=False, path_type=Path), default=None,
|
|
59
|
+
help=f"Dónde escribir la salida json o sarif (sarif: {DEFAULT_SARIF_PATH} por defecto; "
|
|
60
|
+
"json: la salida estándar por defecto).")
|
|
61
|
+
@click.option("--dry-run", is_flag=True, help="Analiza e informa, pero devuelve 0 aunque haya hallazgos "
|
|
62
|
+
"(los errores técnicos siguen devolviendo 10).")
|
|
63
|
+
@click.option("--quiet", "-q", is_flag=True, help="Sin mensajes de progreso ni colores.")
|
|
64
|
+
@click.option("--verbose", "-v", is_flag=True, help="Muestra cada petición HTTP y el detalle de los errores.")
|
|
65
|
+
@click.option("--token", envvar=TOKEN_ENV, default=None, show_envvar=True,
|
|
66
|
+
help=f"Token de API (pdgseg_...). Mejor por la variable {TOKEN_ENV}: lo que va en la línea de "
|
|
67
|
+
"comandos puede quedar en el historial y en los logs.")
|
|
68
|
+
@click.option("--server", envvar=SERVER_ENV, default=None, show_envvar=True,
|
|
69
|
+
help="URL del backend; reemplaza «server» de la configuración.")
|
|
70
|
+
def analyze(path: Path, config_path: Optional[Path], output_format: str, output_path: Optional[Path],
|
|
71
|
+
dry_run: bool, quiet: bool, verbose: bool, token: Optional[str], server: Optional[str]) -> None:
|
|
72
|
+
"""Sube el proyecto, ejecuta el análisis y termina con un código de salida según los hallazgos.
|
|
73
|
+
|
|
74
|
+
\b
|
|
75
|
+
Códigos de salida:
|
|
76
|
+
0 sin hallazgos de la severidad failOnSeverity (por defecto high) o superior
|
|
77
|
+
1 hallazgos de esa severidad o superior, sin CRITICAL
|
|
78
|
+
2 al menos un hallazgo CRITICAL
|
|
79
|
+
10 error técnico (configuración, token, red, análisis fallido)
|
|
80
|
+
"""
|
|
81
|
+
if quiet and verbose:
|
|
82
|
+
raise click.UsageError("--quiet y --verbose no se pueden usar juntos.")
|
|
83
|
+
|
|
84
|
+
json_to_stdout = output_format == "json" and output_path is None
|
|
85
|
+
color = not quiet and output_format != "json" and sys.stdout.isatty() and "NO_COLOR" not in os.environ
|
|
86
|
+
|
|
87
|
+
def info(message: str) -> None:
|
|
88
|
+
if not quiet:
|
|
89
|
+
click.echo(message, err=True)
|
|
90
|
+
|
|
91
|
+
def debug(message: str) -> None:
|
|
92
|
+
if verbose:
|
|
93
|
+
click.echo(click.style(message, dim=True) if color else message, err=True)
|
|
94
|
+
|
|
95
|
+
def fail(message: str) -> None:
|
|
96
|
+
if json_to_stdout:
|
|
97
|
+
click.echo(error_json(message, EXIT_ERROR))
|
|
98
|
+
click.echo(("Error: " if not message.startswith("Error") else "") + message, err=True)
|
|
99
|
+
sys.exit(EXIT_ERROR)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
config = load_config(config_path or (path / CONFIG_FILE_NAME), server_override=server)
|
|
103
|
+
if not token:
|
|
104
|
+
raise PdgsegError(f"Falta el token de API: defínelo en la variable {TOKEN_ENV} (en CI, como secreto) "
|
|
105
|
+
f"o con --token. Se crea en SegSoft, ícono de llave, «Tokens de API».")
|
|
106
|
+
token = token.strip()
|
|
107
|
+
if dry_run:
|
|
108
|
+
click.echo(click.style("DRY-RUN: el resultado no hará fallar el pipeline.", fg="yellow", bold=True)
|
|
109
|
+
if color else "DRY-RUN: el resultado no hará fallar el pipeline.", err=True)
|
|
110
|
+
|
|
111
|
+
info(f"pdgseg {__version__} -> {config.server}")
|
|
112
|
+
client = ApiClient(config.server, token, log=debug)
|
|
113
|
+
outcome = run_analysis(client, config, path, want_sarif=output_format == "sarif", progress=Progress(info))
|
|
114
|
+
except PdgsegError as e:
|
|
115
|
+
fail(str(e))
|
|
116
|
+
return
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
fail("Interrumpido.")
|
|
119
|
+
return
|
|
120
|
+
except Exception as e: # noqa: BLE001 - any bug must still end in exit code 10, not a traceback
|
|
121
|
+
if verbose:
|
|
122
|
+
traceback.print_exc()
|
|
123
|
+
fail(f"Error inesperado: {e}")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
summary = summarize(outcome.results)
|
|
127
|
+
real_code = exit_code_for(summary, config.fail_on_severity)
|
|
128
|
+
code = EXIT_OK if dry_run else real_code
|
|
129
|
+
|
|
130
|
+
sarif_path = None
|
|
131
|
+
if output_format == "sarif":
|
|
132
|
+
sarif_path = output_path or Path(DEFAULT_SARIF_PATH)
|
|
133
|
+
try:
|
|
134
|
+
sarif_path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
sarif_path.write_bytes(outcome.sarif or b"")
|
|
136
|
+
except OSError as e:
|
|
137
|
+
fail(f"No se pudo escribir {sarif_path}: {e}")
|
|
138
|
+
info(f"SARIF escrito en {sarif_path}")
|
|
139
|
+
|
|
140
|
+
if output_format == "json":
|
|
141
|
+
document = to_json(outcome, summary, exit_code=code, real_exit_code=real_code,
|
|
142
|
+
fail_on_severity=config.fail_on_severity, dry_run=dry_run)
|
|
143
|
+
if output_path:
|
|
144
|
+
try:
|
|
145
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
output_path.write_text(document + "\n", encoding="utf-8")
|
|
147
|
+
except OSError as e:
|
|
148
|
+
fail(f"No se pudo escribir {output_path}: {e}")
|
|
149
|
+
info(f"JSON escrito en {output_path}")
|
|
150
|
+
else:
|
|
151
|
+
click.echo(document)
|
|
152
|
+
else:
|
|
153
|
+
click.echo(render_text(outcome, summary, exit_code=code, real_exit_code=real_code,
|
|
154
|
+
fail_on_severity=config.fail_on_severity, dry_run=dry_run,
|
|
155
|
+
server=config.server, color=color))
|
|
156
|
+
sys.exit(code)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@main.command("init")
|
|
160
|
+
@click.option("--path", "path", type=click.Path(file_okay=False, path_type=Path), default=".", show_default=True)
|
|
161
|
+
def init(path: Path) -> None:
|
|
162
|
+
"""Crea un .pdgseg.yml de ejemplo (no sobrescribe uno existente)."""
|
|
163
|
+
target = path / CONFIG_FILE_NAME
|
|
164
|
+
if target.exists():
|
|
165
|
+
click.echo(f"{target} ya existe; no se modificó.", err=True)
|
|
166
|
+
sys.exit(EXIT_ERROR)
|
|
167
|
+
target.write_text(example_config(), encoding="utf-8")
|
|
168
|
+
click.echo(f"Creado {target}. Ajusta «server» y «policySet».")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__": # pragma: no cover
|
|
172
|
+
main()
|
pdgseg/client.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Cliente HTTP del backend de SegSoft: TLS verificado y reintentos con backoff exponencial."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, Dict, Optional, Sequence, Tuple
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .errors import ApiError
|
|
13
|
+
|
|
14
|
+
RETRY_DELAYS: Tuple[float, ...] = (1, 2, 4)
|
|
15
|
+
# Gateways that answer while the backend restarts or is unreachable (Caddy, Vercel).
|
|
16
|
+
RETRYABLE_STATUSES = {502, 503, 504}
|
|
17
|
+
MAX_RETRY_AFTER_SECONDS = 60
|
|
18
|
+
TIMEOUT = (10, 120) # (connect, read) seconds; uploads of large ZIPs need the long read
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApiClient:
|
|
22
|
+
"""
|
|
23
|
+
Envuelve requests.Session. Reintenta, con esperas de 1s, 2s y 4s:
|
|
24
|
+
- fallos de conexión (DNS, conexión rechazada o cortada) en cualquier método: la petición no llegó;
|
|
25
|
+
- timeouts de lectura y respuestas 502/503/504 solo en peticiones idempotentes (GET, DELETE),
|
|
26
|
+
para no duplicar una subida o un análisis que el servidor sí recibió;
|
|
27
|
+
- 429, en cualquier método, esperando lo que indique Retry-After (máximo 60s).
|
|
28
|
+
TLS siempre se verifica (requests usa certifi, o REQUESTS_CA_BUNDLE si está definida).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, base_url: str, token: str, *, retry_delays: Sequence[float] = RETRY_DELAYS,
|
|
32
|
+
sleep: Optional[Callable[[float], None]] = None, log: Optional[Callable[[str], None]] = None,
|
|
33
|
+
session: Optional[requests.Session] = None, timeout: Tuple[float, float] = TIMEOUT):
|
|
34
|
+
self.base_url = base_url.rstrip("/")
|
|
35
|
+
self._retry_delays = tuple(retry_delays)
|
|
36
|
+
self._sleep = sleep or (lambda seconds: time.sleep(seconds))
|
|
37
|
+
self._log = log or (lambda _msg: None)
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
self._session = session or requests.Session()
|
|
40
|
+
self._session.verify = True
|
|
41
|
+
self._session.headers.update({
|
|
42
|
+
"Authorization": f"Bearer {token}",
|
|
43
|
+
"Accept": "application/json",
|
|
44
|
+
"User-Agent": f"pdgseg-cli/{__version__}",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
# ---- public API ------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def get_json(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
50
|
+
return self._request("GET", path, params=params).json()
|
|
51
|
+
|
|
52
|
+
def get_bytes(self, path: str) -> bytes:
|
|
53
|
+
return self._request("GET", path, accept="application/sarif+json, application/json").content
|
|
54
|
+
|
|
55
|
+
def post_json(self, path: str, body: Any) -> Any:
|
|
56
|
+
response = self._request("POST", path, json=body)
|
|
57
|
+
return response.json() if response.content else None
|
|
58
|
+
|
|
59
|
+
def upload(self, path: str, file_path: Path, field: str = "file") -> Any:
|
|
60
|
+
def files() -> Dict[str, Any]:
|
|
61
|
+
return {field: (file_path.name, file_path.open("rb"), "application/zip")}
|
|
62
|
+
return self._request("POST", path, files_factory=files).json()
|
|
63
|
+
|
|
64
|
+
def delete(self, path: str) -> None:
|
|
65
|
+
self._request("DELETE", path)
|
|
66
|
+
|
|
67
|
+
# ---- internals -------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
|
|
70
|
+
json: Any = None, files_factory: Optional[Callable[[], Dict[str, Any]]] = None,
|
|
71
|
+
accept: Optional[str] = None) -> requests.Response:
|
|
72
|
+
url = self.base_url + path
|
|
73
|
+
idempotent = method in ("GET", "HEAD", "DELETE", "PUT")
|
|
74
|
+
headers = {"Accept": accept} if accept else None
|
|
75
|
+
attempts = len(self._retry_delays) + 1
|
|
76
|
+
|
|
77
|
+
for attempt in range(1, attempts + 1):
|
|
78
|
+
files = files_factory() if files_factory else None
|
|
79
|
+
try:
|
|
80
|
+
self._log(f"{method} {url}" + (f" (intento {attempt}/{attempts})" if attempt > 1 else ""))
|
|
81
|
+
response = self._session.request(method, url, params=params, json=json, files=files,
|
|
82
|
+
headers=headers, timeout=self._timeout)
|
|
83
|
+
except requests.exceptions.SSLError as e:
|
|
84
|
+
# A certificate problem does not fix itself by retrying.
|
|
85
|
+
raise ApiError(f"No se pudo establecer una conexión segura con {self.base_url}: el certificado "
|
|
86
|
+
f"TLS no es válido ({_short(e)}).") from e
|
|
87
|
+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
|
|
88
|
+
# ConnectTimeout is a ConnectionError (nothing was sent); ReadTimeout is not (it may have been).
|
|
89
|
+
never_reached_server = (isinstance(e, requests.exceptions.ConnectionError)
|
|
90
|
+
and not isinstance(e, requests.exceptions.ReadTimeout))
|
|
91
|
+
retryable = never_reached_server or idempotent
|
|
92
|
+
if retryable and attempt < attempts:
|
|
93
|
+
self._wait(attempt, f"fallo de conexión ({_short(e)})")
|
|
94
|
+
continue
|
|
95
|
+
raise ApiError(f"No se pudo conectar con {self.base_url} ({method} {path}) después de "
|
|
96
|
+
f"{attempt} intento(s): {_short(e)}") from e
|
|
97
|
+
finally:
|
|
98
|
+
if files:
|
|
99
|
+
for value in files.values():
|
|
100
|
+
handle = value[1] if isinstance(value, tuple) else value
|
|
101
|
+
if hasattr(handle, "close"):
|
|
102
|
+
handle.close()
|
|
103
|
+
|
|
104
|
+
self._log(f" -> {response.status_code}")
|
|
105
|
+
if response.status_code == 429 and attempt < attempts:
|
|
106
|
+
self._sleep(_retry_after(response))
|
|
107
|
+
continue
|
|
108
|
+
if response.status_code in RETRYABLE_STATUSES and idempotent and attempt < attempts:
|
|
109
|
+
self._wait(attempt, f"el servidor respondió {response.status_code}")
|
|
110
|
+
continue
|
|
111
|
+
if response.ok:
|
|
112
|
+
return response
|
|
113
|
+
raise _error_for(response, method, path)
|
|
114
|
+
|
|
115
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
116
|
+
|
|
117
|
+
def _wait(self, attempt: int, reason: str) -> None:
|
|
118
|
+
delay = self._retry_delays[attempt - 1]
|
|
119
|
+
self._log(f" {reason}; reintentando en {delay:g}s")
|
|
120
|
+
self._sleep(delay)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _error_for(response: requests.Response, method: str, path: str) -> ApiError:
|
|
124
|
+
status = response.status_code
|
|
125
|
+
message = _server_message(response)
|
|
126
|
+
if status == 401:
|
|
127
|
+
return ApiError("El backend rechazó el token (401)" + (f": {message}" if message else "") +
|
|
128
|
+
". Revisa PDGSEG_API_TOKEN: debe ser un token de API vigente (pdgseg_...).", status)
|
|
129
|
+
if status == 403:
|
|
130
|
+
return ApiError("Sin permiso para esta operación (403)" + (f": {message}" if message else "."), status)
|
|
131
|
+
if status == 423:
|
|
132
|
+
return ApiError("La cuenta dueña del token está bloqueada (423)" + (f": {message}" if message else "."),
|
|
133
|
+
status)
|
|
134
|
+
detail = f": {message}" if message else ""
|
|
135
|
+
return ApiError(f"{method} {path} respondió {status}{detail}", status)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _server_message(response: requests.Response) -> Optional[str]:
|
|
139
|
+
try:
|
|
140
|
+
body = response.json()
|
|
141
|
+
except ValueError:
|
|
142
|
+
return None
|
|
143
|
+
if isinstance(body, dict):
|
|
144
|
+
message = body.get("message") or body.get("error")
|
|
145
|
+
return str(message) if message else None
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _retry_after(response: requests.Response) -> float:
|
|
150
|
+
try:
|
|
151
|
+
return min(max(float(response.headers.get("Retry-After", "1")), 1), MAX_RETRY_AFTER_SECONDS)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _short(error: Exception) -> str:
|
|
157
|
+
text = str(error)
|
|
158
|
+
return text if len(text) <= 200 else text[:200] + "..."
|
pdgseg/config.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Lectura y validación de .pdgseg.yml (schemaVersion 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from jsonschema import Draft202012Validator
|
|
15
|
+
from jsonschema.exceptions import ValidationError
|
|
16
|
+
|
|
17
|
+
from .errors import ConfigError
|
|
18
|
+
|
|
19
|
+
CONFIG_FILE_NAME = ".pdgseg.yml"
|
|
20
|
+
SERVER_ENV = "PDGSEG_SERVER_URL"
|
|
21
|
+
SEVERITY_THRESHOLDS = ("medium", "high", "critical")
|
|
22
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_schema() -> Dict[str, Any]:
|
|
26
|
+
text = files("pdgseg").joinpath("schema/pdgseg.schema.v1.json").read_text(encoding="utf-8")
|
|
27
|
+
return json.loads(text)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class PolicySource:
|
|
32
|
+
"""Exactamente una de las tres formas de elegir las políticas."""
|
|
33
|
+
|
|
34
|
+
policy_set: Optional[str] = None
|
|
35
|
+
policies: Tuple[str, ...] = ()
|
|
36
|
+
framework: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
def describe(self) -> str:
|
|
39
|
+
if self.policy_set:
|
|
40
|
+
return f"Policy Set «{self.policy_set}»"
|
|
41
|
+
if self.framework:
|
|
42
|
+
return f"framework {self.framework}"
|
|
43
|
+
return f"{len(self.policies)} políticas"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Config:
|
|
48
|
+
server: str
|
|
49
|
+
policy_source: PolicySource
|
|
50
|
+
fail_on_severity: str = "high"
|
|
51
|
+
exclude: Tuple[str, ...] = ()
|
|
52
|
+
timeout_minutes: int = 15
|
|
53
|
+
poll_interval_seconds: int = 5
|
|
54
|
+
path: Optional[Path] = field(default=None, compare=False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(path: Path, server_override: Optional[str] = None,
|
|
58
|
+
env: Optional[Mapping[str, str]] = None) -> Config:
|
|
59
|
+
"""Lee, valida y construye la configuración. Cualquier problema es un ConfigError (exit code 10)."""
|
|
60
|
+
env = os.environ if env is None else env
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
raise ConfigError(
|
|
63
|
+
f"No se encontró {path}. Crea un {CONFIG_FILE_NAME} en la raíz del repositorio "
|
|
64
|
+
f"(por ejemplo con «schemaVersion: 1», «server» y «policySet») o indica otro con --config.")
|
|
65
|
+
try:
|
|
66
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
67
|
+
except yaml.YAMLError as e:
|
|
68
|
+
raise ConfigError(f"{path} no es YAML válido: {e}") from e
|
|
69
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
70
|
+
raise ConfigError(f"No se pudo leer {path}: {e}") from e
|
|
71
|
+
|
|
72
|
+
if raw is None:
|
|
73
|
+
raise ConfigError(f"{path} está vacío.")
|
|
74
|
+
return parse_config(raw, path=path, server_override=server_override or env.get(SERVER_ENV))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_config(raw: Any, path: Optional[Path] = None, server_override: Optional[str] = None) -> Config:
|
|
78
|
+
where = str(path) if path else CONFIG_FILE_NAME
|
|
79
|
+
if not isinstance(raw, dict):
|
|
80
|
+
raise ConfigError(f"{where} debe ser un objeto YAML (clave: valor), no {_type_name(raw)}.")
|
|
81
|
+
|
|
82
|
+
errors = sorted(Draft202012Validator(load_schema()).iter_errors(raw), key=lambda e: list(e.absolute_path))
|
|
83
|
+
if errors:
|
|
84
|
+
details = "\n".join(f" - {_describe(e)}" for e in errors)
|
|
85
|
+
raise ConfigError(f"{where} no es válido:\n{details}")
|
|
86
|
+
|
|
87
|
+
sources = [k for k in ("policySet", "policies", "framework") if k in raw]
|
|
88
|
+
if len(sources) != 1:
|
|
89
|
+
found = ", ".join(sources) if sources else "ninguna"
|
|
90
|
+
raise ConfigError(
|
|
91
|
+
f"{where} no es válido:\n - indica exactamente una forma de elegir las políticas: "
|
|
92
|
+
f"policySet, policies o framework (encontradas: {found}).")
|
|
93
|
+
|
|
94
|
+
server = (server_override or raw.get("server") or "").strip()
|
|
95
|
+
if not server:
|
|
96
|
+
raise ConfigError(
|
|
97
|
+
f"{where} no es válido:\n - falta «server» (la URL del backend de SegSoft); "
|
|
98
|
+
f"también se puede dar con la variable {SERVER_ENV} o con --server.")
|
|
99
|
+
_require_secure_server(server, where)
|
|
100
|
+
|
|
101
|
+
return Config(
|
|
102
|
+
server=server.rstrip("/"),
|
|
103
|
+
policy_source=PolicySource(
|
|
104
|
+
policy_set=raw.get("policySet"),
|
|
105
|
+
policies=tuple(raw.get("policies", ())),
|
|
106
|
+
framework=raw.get("framework"),
|
|
107
|
+
),
|
|
108
|
+
fail_on_severity=raw.get("failOnSeverity", "high"),
|
|
109
|
+
exclude=tuple(raw.get("exclude", ())),
|
|
110
|
+
timeout_minutes=raw.get("timeoutMinutes", 15),
|
|
111
|
+
poll_interval_seconds=raw.get("pollIntervalSeconds", 5),
|
|
112
|
+
path=path,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _require_secure_server(server: str, where: str) -> None:
|
|
117
|
+
parsed = urlparse(server)
|
|
118
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
119
|
+
raise ConfigError(f"{where} no es válido:\n - server: «{server}» no es una URL http(s).")
|
|
120
|
+
if parsed.scheme == "http" and parsed.hostname not in _LOCAL_HOSTS:
|
|
121
|
+
raise ConfigError(
|
|
122
|
+
f"{where} no es válido:\n - server: usa https. El token viaja en cada petición; "
|
|
123
|
+
f"http solo se acepta para localhost.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _describe(error: ValidationError) -> str:
|
|
127
|
+
"""Un error del schema en una línea, nombrando el campo."""
|
|
128
|
+
location = ".".join(str(p) for p in error.absolute_path) or "(raíz)"
|
|
129
|
+
v = error.validator
|
|
130
|
+
if v == "required":
|
|
131
|
+
missing = error.message.split("'")[1] if "'" in error.message else error.message
|
|
132
|
+
return f"falta el campo obligatorio «{missing}»"
|
|
133
|
+
if v == "additionalProperties":
|
|
134
|
+
extra = sorted(set(error.instance) - set(error.schema.get("properties", {})))
|
|
135
|
+
allowed = ", ".join(error.schema.get("properties", {}))
|
|
136
|
+
return f"campo desconocido «{', '.join(extra)}»; los permitidos son: {allowed}"
|
|
137
|
+
if v == "const":
|
|
138
|
+
return f"{location}: debe ser {error.validator_value!r} (se encontró {error.instance!r})"
|
|
139
|
+
if v == "enum":
|
|
140
|
+
options = ", ".join(str(o) for o in error.validator_value)
|
|
141
|
+
return f"{location}: «{error.instance}» no es válido; usa uno de: {options}"
|
|
142
|
+
if v == "type":
|
|
143
|
+
return f"{location}: debe ser {_schema_type(error.validator_value)}, no {_type_name(error.instance)}"
|
|
144
|
+
if v in ("minimum", "maximum"):
|
|
145
|
+
word = "al menos" if v == "minimum" else "como máximo"
|
|
146
|
+
return f"{location}: debe ser {word} {error.validator_value} (se encontró {error.instance})"
|
|
147
|
+
if v == "pattern":
|
|
148
|
+
if location == "server":
|
|
149
|
+
return f"server: «{error.instance}» no es una URL http(s)"
|
|
150
|
+
return f"{location}: «{error.instance}» no es un id válido (UUID)"
|
|
151
|
+
if v == "minItems":
|
|
152
|
+
return f"{location}: no puede estar vacío"
|
|
153
|
+
if v == "minLength":
|
|
154
|
+
return f"{location}: no puede estar vacío"
|
|
155
|
+
if v == "uniqueItems":
|
|
156
|
+
return f"{location}: tiene elementos repetidos"
|
|
157
|
+
return f"{location}: {error.message}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_TYPE_NAMES = {"string": "un texto", "integer": "un número entero", "array": "una lista",
|
|
161
|
+
"object": "un objeto", "boolean": "true o false", "number": "un número"}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _schema_type(t: Any) -> str:
|
|
165
|
+
return _TYPE_NAMES.get(t, str(t)) if isinstance(t, str) else " o ".join(_TYPE_NAMES.get(x, x) for x in t)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _type_name(value: Any) -> str:
|
|
169
|
+
if isinstance(value, bool):
|
|
170
|
+
return "true/false"
|
|
171
|
+
if isinstance(value, int):
|
|
172
|
+
return "un número"
|
|
173
|
+
if isinstance(value, float):
|
|
174
|
+
return "un número decimal"
|
|
175
|
+
if isinstance(value, str):
|
|
176
|
+
return "un texto"
|
|
177
|
+
if isinstance(value, list):
|
|
178
|
+
return "una lista"
|
|
179
|
+
if isinstance(value, dict):
|
|
180
|
+
return "un objeto"
|
|
181
|
+
return "nada" if value is None else type(value).__name__
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def example_config() -> str:
|
|
185
|
+
return (
|
|
186
|
+
"schemaVersion: 1\n"
|
|
187
|
+
"server: https://segsoft-icesi.westus3.cloudapp.azure.com\n"
|
|
188
|
+
"policySet: \"Perfil PDG ICESI\"\n"
|
|
189
|
+
"failOnSeverity: high\n"
|
|
190
|
+
"exclude: [\"node_modules/\", \"dist/\"]\n"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__: List[str] = ["Config", "PolicySource", "load_config", "parse_config", "CONFIG_FILE_NAME",
|
|
195
|
+
"SERVER_ENV", "SEVERITY_THRESHOLDS", "example_config"]
|
pdgseg/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Errores técnicos: todos terminan el CLI con exit code 10."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PdgsegError(Exception):
|
|
9
|
+
"""Un error técnico con un mensaje para mostrar tal cual al usuario."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(PdgsegError):
|
|
13
|
+
""".pdgseg.yml no existe, no es YAML válido o no cumple el schema."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiError(PdgsegError):
|
|
17
|
+
"""El backend respondió con un error, o no se pudo conectar con él."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, status: Optional[int] = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status = status
|