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 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