@dozimple/abap-adt 1.0.0-rc.1 → 1.0.1

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,59 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/). V
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.0.1] - 2026-09-22
8
+
9
+ Versión de mantenimiento: seguimiento de la auditoría de seguridad, procedimiento de commit y pruebas por propiedades.
10
+
11
+ ### Añadido
12
+ - **Pruebas por propiedades de la entrada hostil** (`test/property.test.ts`, fast-check): 15 invariantes sobre el
13
+ filtro de `table_contents`, el saneado de errores, el HTML de documentación y feeds, los nombres que acaban en una
14
+ ruta ADT, `SAP_USER_RE` y la huella de los tokens de confirmación. En vez de comprobar casos conocidos, cada prueba
15
+ afirma lo que la función garantiza para **cualquier** entrada y el generador busca el contraejemplo (miles por
16
+ ejecución; `FC_RUNS=5000 npm test` para una pasada profunda). Validadas rompiendo el código a propósito: cada
17
+ propiedad detecta la regresión que le toca.
18
+
19
+ ### Cambiado
20
+ - La release de GitHub adjunta el bundle de la atestación también como `<paquete>.intoto.jsonl`, además de
21
+ `.sigstore.json`. Es el mismo bundle in-toto con los dos nombres: uno es el que verifica `gh attestation verify` y el
22
+ otro el que las herramientas de cadena de suministro reconocen como procedencia (OpenSSF Scorecard entre ellas).
23
+
24
+ ### Seguridad
25
+ - El veto de datos sensibles incluye `ICF_PASSWD` (contraseña del usuario de inicio de sesión fijo de un servicio ICF)
26
+ y las columnas genéricas `PASSWD` / `PASSWORD`, en cualquier tabla, también cuando llegan por un `SELECT *` (se
27
+ comprueban las columnas del resultado y, si aparece una, no se muestra nada). Detectado al revisar la configuración
28
+ ICF de un servicio.
29
+ - Seguimiento de la auditoría (prioridades 1 y 2 del auditor y DZ-30): **permisos de `systems.json` en Windows**
30
+ comprobados por ACL y SID (antes no se comprobaba nada en esa plataforma), fail-closed si no se pueden leer;
31
+ **registro de auditoría firmado con HMAC-SHA256** con clave en el llavero del SO (quitar firmas se detecta), y
32
+ `audit:verify` comprueba cadena y firmas; el escáner falla si `package.json` declara scripts de instalación.
33
+ - **Auditoría de seguridad del 21-09-2026** (30 hallazgos, ninguno alto ni crítico): corregidos todos los accionables.
34
+ Los más relevantes: la escritura ya no aplica un diff aprobado sobre un objeto que cambió en SAP después de la vista
35
+ previa (huella comprobada bajo el bloqueo); filtro de `table_contents` por lista blanca; usuarios SAP validados en
36
+ filtros de feeds; nombres validados en rutas ADT; `allowSelfSigned` solo en DEV y avisado al arrancar; sin
37
+ redirecciones en el servicio de riesgo; errores saneados antes de llegar al modelo; contraseñas en memoria con
38
+ caducidad; toda tool `exec` decide expresamente si pide confirmación; tope de filas también en `jobs` y
39
+ `application_log`; columnas personales propias por cliente (`piiColumns`); registro de auditoría con bloqueo entre
40
+ procesos y lectura de la cola; marcado neutralizado tras decodificar HTML; ids online de `docs_fetch` validados ya
41
+ decodificados. Documentado lo que cada control garantiza y lo que no (token frente a elicitación, cadena de
42
+ auditoría sin secreto, aviso de «dato» probabilístico). Tests de regresión en `test/audit-2026-09.test.ts`.
43
+ - **Procedimiento de seguridad en cada commit** ([docs/COMMIT_SECURITY.md](docs/COMMIT_SECURITY.md)): el `pre-commit`
44
+ revisa el código completo y el contenido exacto preparado; un `pre-push` nuevo y el CI revisan todo el historial de
45
+ todas las ramas. El escáner detecta además direcciones (IPs reales, hosts con puertos de SAP, dominios internos),
46
+ más tipos de credenciales (Google, JWT, tokens de GitHub de grano fino, GitLab, cadenas de conexión, secretos
47
+ asignados, URLs con contraseña, webhooks) y archivos peligrosos por su nombre (`.env`, certificados, claves SSH, el
48
+ `systems.json` real). 21 tests adversariales.
49
+
50
+ ### Corregido
51
+ - Workflow de release: la comprobación de `latest` fallaba porque npm tarda unos segundos en propagar la etiqueta
52
+ (la 1.0.0 se publicó bien, pero la release de GitHub no se creó y se completó a mano con los artefactos firmados del
53
+ mismo run). Ahora reintenta durante 2 minutos, no republica una versión existente y actualiza la release si ya
54
+ existe: reintentar un run es seguro.
55
+
56
+ ## [1.0.0] - 2026-09-21
57
+
58
+ Primera versión estable, publicada en npm como `@dozimple/abap-adt` con procedencia.
59
+
7
60
  ### Seguridad
8
61
  - El escáner de datos sensibles detecta tokens de npm y cualquier `_authToken` (p. ej. un `npm login` que escribe en
9
62
  el `.npmrc` del proyecto).
@@ -55,6 +108,9 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/). V
55
108
  smoke test no aborta ante una llamada lenta.
56
109
 
57
110
  ### Añadido
111
+ - Workflow de release (`release.yml`): al crear un tag `vX.Y.Z`, tests, auditoría, escáner, paquete con atestación de
112
+ procedencia (Sigstore) y SBOM; tras la aprobación manual del environment `release`, publicación en npm con
113
+ procedencia por trusted publishing (OIDC, sin tokens guardados) y release de GitHub con paquete, atestación y SBOM.
58
114
  - **`function_modules`**: módulos de función de un grupo (texto en el idioma de la conexión, tipo RFC / actualización),
59
115
  o el grupo y los hermanos de un módulo. Funciona con namespaces.
60
116
  - **`run_atc` sobre implementaciones de ampliación** (`object_type: ENHO`, cualquier subtipo).
package/README.es.md CHANGED
@@ -286,6 +286,33 @@ Modelo de amenazas con STRIDE y OWASP Top 10 para aplicaciones LLM: **[docs/THRE
286
286
 
287
287
  ## Instalación
288
288
 
289
+ ### Desde npm (recomendado)
290
+
291
+ Con la versión exacta y sin scripts de instalación, igual que las dependencias del propio proyecto:
292
+
293
+ ```sh
294
+ npm install -g --ignore-scripts @dozimple/abap-adt@1.0.1
295
+ PKG="$(npm root -g)/@dozimple/abap-adt"
296
+ mkdir -p ~/.config/abap-adt-dozimple && chmod 700 ~/.config/abap-adt-dozimple
297
+ cp "$PKG/config/systems.example.json" ~/.config/abap-adt-dozimple/systems.json # sistemas, roles y permisos
298
+ chmod 600 ~/.config/abap-adt-dozimple/systems.json
299
+ sh "$PKG/scripts/set-password.sh" MI_DEV # pide la clave; va al llavero
300
+ node "$PKG/dist/scripts/smoke.js" MI_DEV # validación de solo lectura
301
+ ```
302
+
303
+ Registro en el cliente MCP:
304
+
305
+ ```json
306
+ { "mcpServers": { "abap-adt-doZimple": { "command": "abap-adt-dozimple" } } }
307
+ ```
308
+
309
+ Cada versión se publica desde el CI con [procedencia de npm](https://docs.npmjs.com/generating-provenance-statements):
310
+ `npm view @dozimple/abap-adt@1.0.1 dist.attestations` muestra la atestación, y la release de GitHub incluye el
311
+ paquete, su bundle de Sigstore (`.sigstore.json`), el mismo bundle como procedencia in-toto (`.intoto.jsonl`)
312
+ y el SBOM. Para comprobarlo: `gh attestation verify dozimple-abap-adt-1.0.1.tgz --repo <owner>/abap-adt-dozimple`.
313
+
314
+ ### Desde el código fuente
315
+
289
316
  ```sh
290
317
  npm ci && npm run build
291
318
  mkdir -p ~/.config/abap-adt-dozimple
package/README.md CHANGED
@@ -286,6 +286,33 @@ Designed to pass a Security and Basis review without exceptions. Details: **[SEC
286
286
 
287
287
  ## Installation
288
288
 
289
+ ### From npm (recommended)
290
+
291
+ Pinned to an exact version, without install scripts, like the project's own dependencies:
292
+
293
+ ```sh
294
+ npm install -g --ignore-scripts @dozimple/abap-adt@1.0.1
295
+ PKG="$(npm root -g)/@dozimple/abap-adt"
296
+ mkdir -p ~/.config/abap-adt-dozimple && chmod 700 ~/.config/abap-adt-dozimple
297
+ cp "$PKG/config/systems.example.json" ~/.config/abap-adt-dozimple/systems.json # systems, roles and permissions
298
+ chmod 600 ~/.config/abap-adt-dozimple/systems.json
299
+ sh "$PKG/scripts/set-password.sh" MY_DEV # prompts; stored in the keychain
300
+ node "$PKG/dist/scripts/smoke.js" MY_DEV # read-only validation
301
+ ```
302
+
303
+ MCP client registration:
304
+
305
+ ```json
306
+ { "mcpServers": { "abap-adt-doZimple": { "command": "abap-adt-dozimple" } } }
307
+ ```
308
+
309
+ Every release is published from CI with [npm provenance](https://docs.npmjs.com/generating-provenance-statements):
310
+ `npm view @dozimple/abap-adt@1.0.1 dist.attestations` shows the attestation, and the GitHub release carries the
311
+ tarball, its Sigstore bundle (`.sigstore.json`), the same bundle as in-toto provenance (`.intoto.jsonl`) and the
312
+ SBOM. To check it: `gh attestation verify dozimple-abap-adt-1.0.1.tgz --repo <owner>/abap-adt-dozimple`.
313
+
314
+ ### From source
315
+
289
316
  ```sh
290
317
  npm ci && npm run build
291
318
  mkdir -p ~/.config/abap-adt-dozimple
package/SECURITY.md CHANGED
@@ -8,26 +8,26 @@ aplicaciones LLM, riesgos residuales): **[docs/THREAT_MODEL.md](docs/THREAT_MODE
8
8
 
9
9
  | Activo | Amenaza | Control |
10
10
  |---|---|---|
11
- | Credenciales SAP | Filtrado en archivos, logs, respuestas o procesos hijo | Llavero de macOS o Secret Service en Linux (`execFile` sin shell); opción `--strict` para que el llavero pregunte en cada lectura; la configuración no admite contraseñas; nunca en logs ni respuestas; los procesos hijo reciben un entorno explícito mínimo (`PATH`, `HOME`, `TMPDIR`, idioma y terminal) |
11
+ | Credenciales SAP | Filtrado en archivos, logs, respuestas o procesos hijo | Llavero de macOS o Secret Service en Linux (`execFile` sin shell); opción `--strict` para que el llavero pregunte en cada lectura; en memoria, caducan a los 15 min y se borran al cerrar; la configuración no admite contraseñas; nunca en logs ni respuestas; los procesos hijo reciben un entorno explícito mínimo (`PATH`, `HOME`, `TMPDIR`, idioma y terminal) |
12
12
  | Sistemas SAP | Cambios no deseados por un agente | `read` / `exec` / `write` por tool; `write` solo en DEV con `allowWrite`; QAS y PRD nunca; tools de escritura ocultas si ningún sistema la permite; sin liberar, importar ni borrar |
13
- | Escrituras | El agente escribe sin que la persona vea qué | **Confirmación obligatoria**: por elicitación MCP si el cliente la soporta; si no, en dos fases con vista previa (sintaxis de SAP + diff real) y token de un solo uso atado a tool, sistema y argumentos exactos (10 min). Anotación `destructiveHint` para que el cliente también pida permiso |
14
- | Trazabilidad | Una escritura sin rastro, o un rastro retocado | **Registro de auditoría** local encadenado por hash (`audit.jsonl`): intención, resultado y denegaciones de toda escritura y ejecución, con usuario SAP, sistema, forma de confirmación y huella sha256 del contenido (nunca el contenido). Sin registro no se escribe. `npm run audit:verify` detecta alteraciones |
13
+ | Escrituras | El agente escribe sin que la persona vea qué | **Confirmación obligatoria** con vista previa (sintaxis de SAP + diff real + avisos de la orden). Dos niveles de garantía, y no son equivalentes: **elicitación MCP** (fuerte: la pregunta la responde una persona en el cliente) si el cliente la soporta; si no, **token** de un solo uso atado a tool, sistema y argumentos exactos (10 min), que es una garantía **de aviso**: el servidor no puede distinguir si una persona vio la vista previa, por eso el cliente no debe autoaprobar estas tools (`destructiveHint`). `npm run audit:verify` cuenta cuántas escrituras se confirmaron de cada forma. Además, la huella de lo mostrado viaja con la confirmación y se comprueba **bajo el bloqueo** antes de escribir: si el objeto cambió en SAP tras la vista previa, no se escribe |
14
+ | Trazabilidad | Una escritura sin rastro, o un rastro retocado | **Registro de auditoría** local encadenado por hash y **firmado con HMAC-SHA256** (`audit.jsonl`): intención, resultado y denegaciones de toda escritura y ejecución, con usuario SAP, sistema, forma de confirmación y huella sha256 del contenido (nunca el contenido). La clave se genera al primer uso y vive en el llavero del SO (se guarda por stdin, nunca como argumento de un proceso); la fecha de creación va con ella, así que quitar firmas se detecta. Sin registro no se escribe; escrituras de varios servidores a la vez, serializadas con bloqueo. `npm run audit:verify` comprueba cadena y firmas. **Límite:** quien pueda leer tu llavero (tu propia sesión sin `--strict`) podría recalcularlo todo; para evidencia fuera de tu control, anota fuera del equipo el «último hash» que imprime `audit:verify`. Sin almacén de secretos (Windows), la cadena queda sin firma y se avisa |
15
15
  | ABAP Unit | Un test con efectos (BD, llamadas externas) | Solo tests `RISK LEVEL HARMLESS` y `DURATION SHORT`, pedido explícitamente; solo en DEV |
16
16
  | Orden de transporte | El cambio cae en una orden ajena | `decideTransport`: orden explícita; si hay bloqueo CTS de otra orden, se para sin escribir |
17
17
  | Material de seguridad en SAP | Lectura de hashes, almacén seguro, PSE, secretos OAuth o dumps | Veto de USR02/USH02/USRPWDHISTORY, RSECTAB/RSECACTB, SSF_PSE_*, USRACL, SNAP, OA2C_* y columnas de hash, **también a través de vistas (DD26S) y CDS (DDLDEPENDENCY + fuente DDL), hasta 3 niveles** |
18
18
  | Datos de personal | Infotipos y nómina | PA\*, PB\*, PCL1-5 y HRPY_\* vetados, también vía vistas y CDS |
19
- | Datos personales de negocio | Exposición masiva a un proveedor LLM | Clase de datos por sistema (`test` / `masked` / `prod`; por defecto DEV = test, QAS/PRD = prod). En masked/prod: **columnas personales enmascaradas** (nombres, direcciones, teléfonos, correos, identificadores fiscales, cuentas bancarias, fechas de nacimiento), prohibido usarlas en WHERE, alias o expresiones, y **tope de filas** por sistema (200 en prod, configurable) |
19
+ | Datos personales de negocio | Exposición masiva a un proveedor LLM | Clase de datos por sistema (`test` / `masked` / `prod`; por defecto DEV = test, QAS/PRD = prod). En masked/prod: **columnas personales enmascaradas** (nombres, direcciones, teléfonos, correos, identificadores fiscales, cuentas bancarias, fechas de nacimiento, y las columnas propias de cada cliente declaradas en `piiColumns`), prohibido usarlas en WHERE, alias o expresiones, y **tope de filas** por sistema (200 en prod, configurable), también en `jobs` y `application_log`. El enmascarado es por nombre de columna: un campo Z no declarado sale en claro |
20
20
  | Parámetros de las tools | Un parámetro mal escrito o inventado que se ignora en silencio (p. ej. la orden de transporte) | Esquemas estrictos: un parámetro no declarado es error y la tool no se ejecuta; el esquema publicado declara `additionalProperties: false` |
21
- | SQL | Escritura, varias sentencias o inyección en filtros | Solo `SELECT`/`WITH`, sin `;`; vista previa de ADT de solo lectura; literales escapados; en `table_contents` el filtro no admite subconsultas, UNION, comentarios ni comillas sin cerrar |
22
- | Configuración | Alguien redirige el servidor o habilita la escritura | `systems.json` se rechaza si lo pueden modificar grupo u otros usuarios, o si es de otro usuario; el sistema por defecto por variable de entorno se identifica como tal en cada respuesta, y si no existe es error |
21
+ | SQL | Escritura, varias sentencias o inyección en filtros | Solo `SELECT`/`WITH`, sin `;`; vista previa de ADT de solo lectura; literales escapados; en `table_contents` el filtro se valida por **lista blanca** (columnas, operadores, literales, AND/OR/NOT, LIKE, IN, BETWEEN, IS NULL/INITIAL, paréntesis equilibrados); usuarios SAP en filtros de feeds validados por formato; nombres en rutas ADT validados (sin `..`) |
22
+ | Configuración | Alguien redirige el servidor, habilita la escritura o cambia el comando de un componente | `systems.json` se rechaza si lo pueden modificar otros: en macOS/Linux por permisos y propietario; **en Windows por sus ACL**, leídas por SID (independiente del idioma): solo tu usuario, SYSTEM, Administradores y TrustedInstaller pueden escribirlo. Si la comprobación no puede hacerse, no se arranca (salvo `ABAP_DZ_SKIP_ACL_CHECK=1`, avisado en cada arranque). El sistema por defecto por variable de entorno se identifica en cada respuesta, y si no existe es error |
23
23
  | Red | Exposición de un puerto | Solo stdio; no se abre ningún puerto |
24
- | TLS | Intercepción (MitM) | Verificación por defecto; `caFile` para certificados propios; `allowSelfSigned` marcado con ⚠ |
24
+ | TLS | Intercepción (MitM) | Verificación por defecto; `caFile` para certificados propios; `allowSelfSigned` **solo en DEV** (rechazado en QAS/PRD), marcado con ⚠ y avisado en cada arranque. El servicio de riesgo no sigue redirecciones: las credenciales Basic no viajan a otra URL |
25
25
  | Datos de clientes | Salida a internet vía documentación | Online apagado por defecto; si se habilita, `assertPublicQuery` bloquea objetos Z/Y, namespaces, órdenes, sistemas, SID, usuarios, dominios y términos configurados; `docs_fetch` solo acepta ids con forma de documento, y los online pasan el mismo filtro |
26
- | Inyección de instrucciones | Fuente, textos o datos de SAP o de la web que intentan dirigir al agente | Toda respuesta de SAP y de documentación se marca como dato, nunca instrucciones; ninguna escritura sin confirmación humana con vista previa |
27
- | Dependencias | Vulnerabilidades o código malicioso | 4 dependencias de producción con versión exacta y lockfile; `ignore-scripts` (sin scripts de instalación); `npm audit` y `npm audit signatures` (firmas y procedencia) en CI; SBOM CycloneDX como artefacto de cada build; acciones de CI fijadas por commit; Dependabot |
26
+ | Inyección de instrucciones | Fuente, textos o datos de SAP o de la web que intentan dirigir al agente | Toda respuesta de SAP y de documentación se marca como dato, nunca instrucciones (control **probabilístico**: reduce, no elimina, que el modelo obedezca contenido hostil); el marcado que revive al decodificar HTML se neutraliza; la barrera efectiva es que ninguna escritura ocurre sin confirmación humana con vista previa. Los errores que llegan al modelo se sanean (sin credenciales en URLs, sin cabeceras, sin marcado, con longitud acotada) |
27
+ | Dependencias | Vulnerabilidades o código malicioso | 4 dependencias de producción con versión exacta y lockfile; `ignore-scripts` (sin scripts de instalación) en el desarrollo, y el paquete publicado **no declara ningún script de instalación** (el escáner falla si aparece uno: el `.npmrc` no protege a quien instala el paquete); `npm audit` y `npm audit signatures` (firmas y procedencia) en CI; SBOM CycloneDX como artefacto de cada build; acciones de CI fijadas por commit; Dependabot |
28
28
  | Terceros | Un MCP externo con acceso a credenciales | Solo como proceso hijo aislado, tras revisión, con entorno mínimo; sus tools pasan por la política de este servidor |
29
29
  | Estado local | Lectura por otros usuarios del equipo | Carpetas `700`, archivos `600`; el registro de uso no guarda argumentos |
30
- | Repositorio | Credenciales o datos de clientes en el historial | `scripts/scan-sensitive.mjs` en `pre-commit` y en CI; los términos prohibidos se leen de la configuración local, nunca se listan en el repo; secret scanning con push protection |
30
+ | Repositorio | Credenciales, direcciones (IPs, hosts internos o de SAP) o datos de clientes en el código o en el historial | [Procedimiento en cada commit](docs/COMMIT_SECURITY.md): `pre-commit` revisa el código completo más lo preparado; `pre-push` y CI revisan todo el historial; `main` exige el CI; secret scanning con push protection. Los términos de clientes se leen de la configuración local, nunca se listan en el repo |
31
31
 
32
32
  ## Recomendaciones de despliegue
33
33
 
@@ -46,7 +46,8 @@ aplicaciones LLM, riesgos residuales): **[docs/THREAT_MODEL.md](docs/THREAT_MODE
46
46
 
47
47
  ```sh
48
48
  npm run security # npm audit + firmas del registro + escáner de credenciales y datos de clientes
49
- npm test # incluye los tests de política y de seguridad (test/security.test.ts)
49
+ npm test # incluye los tests de política y de seguridad (test/security.test.ts) y las
50
+ # pruebas por propiedades de la entrada hostil (test/property.test.ts, fast-check)
50
51
  npm run audit:verify # integridad del registro de auditoría local
51
52
  npm run sbom # SBOM CycloneDX de las dependencias de producción
52
53
  ```
package/dist/core/atc.js CHANGED
@@ -31,7 +31,8 @@ export async function defaultVariant(c) {
31
31
  const v = cust.properties.find((p) => p.name === "systemCheckVariant")?.value;
32
32
  return typeof v === "string" && v ? v : "DEFAULT";
33
33
  }
34
- const xmlEsc = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
34
+ /** Escapado XML completo (texto y atributos con comillas simples o dobles). */
35
+ const xmlEsc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
35
36
  /**
36
37
  * Ejecución ATC sobre varios objetos a la vez. La librería solo admite una
37
38
  * URI; en NW 7.50 una orden no sirve como conjunto («No URI-Mapping defined
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { auditKey, macOf } from "./auditkey.js";
4
5
  import { stateDir } from "./telemetry.js";
5
6
  /**
6
7
  * Registro de auditoría de todo lo que modifica o ejecuta en SAP. Solo
@@ -15,7 +16,7 @@ export const AUDIT_FILE = "audit.jsonl";
15
16
  const GENESIS = "0".repeat(64);
16
17
  const INLINE_MAX = 120;
17
18
  /** E/S del registro, sustituible en tests para simular disco lleno o permisos. */
18
- export const auditIo = { appendFileSync, existsSync, mkdirSync, readFileSync };
19
+ export const auditIo = { appendFileSync, existsSync, mkdirSync, readFileSync, openSync, closeSync, fstatSync, readSync, statSync, rmSync };
19
20
  const sha256 = (s) => createHash("sha256").update(s).digest("hex");
20
21
  /** Argumentos aptos para el registro: valores cortos tal cual, el resto como huella. */
21
22
  export function auditArgs(args) {
@@ -39,14 +40,63 @@ export function auditArgs(args) {
39
40
  function entryHash(e) {
40
41
  return sha256(JSON.stringify(e));
41
42
  }
43
+ /** Última entrada leyendo solo la cola del archivo (el registro no se relee entero en cada escritura). */
42
44
  function lastEntry(path) {
43
45
  if (!auditIo.existsSync(path))
44
46
  return { seq: 0, hash: GENESIS };
45
- const lines = auditIo.readFileSync(path, "utf8").trimEnd().split("\n").filter(Boolean);
46
- if (!lines.length)
47
- return { seq: 0, hash: GENESIS };
48
- const last = JSON.parse(lines[lines.length - 1]);
49
- return { seq: last.seq, hash: last.hash };
47
+ const fd = auditIo.openSync(path, "r");
48
+ try {
49
+ const size = auditIo.fstatSync(fd).size;
50
+ if (!size)
51
+ return { seq: 0, hash: GENESIS };
52
+ for (let window = 16 * 1024;; window *= 4) {
53
+ const start = Math.max(0, size - window);
54
+ const buf = Buffer.alloc(size - start);
55
+ auditIo.readSync(fd, buf, 0, buf.length, start);
56
+ const lines = buf.toString("utf8").trimEnd().split("\n");
57
+ // Si la ventana empieza a mitad de una línea, la primera está cortada: vale la última si hay al menos dos.
58
+ if (lines.length >= 2 || start === 0) {
59
+ const last = JSON.parse(lines[lines.length - 1]);
60
+ return { seq: last.seq, hash: last.hash };
61
+ }
62
+ }
63
+ }
64
+ finally {
65
+ auditIo.closeSync(fd);
66
+ }
67
+ }
68
+ /**
69
+ * Bloqueo entre procesos (dos clientes MCP pueden tener cada uno su servidor): sin él, dos escrituras simultáneas
70
+ * leerían el mismo «prev» y romperían la cadena, que sería indistinguible de una manipulación.
71
+ */
72
+ function withLock(dir, fn) {
73
+ const lock = join(dir, AUDIT_FILE + ".lock");
74
+ const deadline = Date.now() + 5_000;
75
+ for (;;) {
76
+ try {
77
+ auditIo.closeSync(auditIo.openSync(lock, "wx", 0o600));
78
+ break;
79
+ }
80
+ catch (e) {
81
+ if (e.code !== "EEXIST")
82
+ throw e;
83
+ // Un bloqueo de más de 30 s es de un proceso que murió a mitad: se recupera.
84
+ try {
85
+ if (Date.now() - auditIo.statSync(lock).mtimeMs > 30_000)
86
+ auditIo.rmSync(lock, { force: true });
87
+ }
88
+ catch { }
89
+ if (Date.now() > deadline)
90
+ throw new Error("registro de auditoría bloqueado por otro proceso");
91
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
92
+ }
93
+ }
94
+ try {
95
+ return fn();
96
+ }
97
+ finally {
98
+ auditIo.rmSync(lock, { force: true });
99
+ }
50
100
  }
51
101
  /**
52
102
  * Añade una entrada. Lanza si no puede escribir: quien llama decide no
@@ -55,6 +105,12 @@ function lastEntry(path) {
55
105
  export function appendAudit(input, dir = stateDir()) {
56
106
  auditIo.mkdirSync(dir, { recursive: true, mode: 0o700 });
57
107
  const path = join(dir, AUDIT_FILE);
108
+ return withLock(dir, () => appendLocked(path, input));
109
+ }
110
+ function appendLocked(path, input) {
111
+ // La clave se obtiene (o se crea) ANTES de fechar la entrada: así la primera firmada nunca queda con una fecha
112
+ // anterior a la de la clave, y quitarle la firma no la hace pasar por una entrada antigua.
113
+ const key = auditKey(true);
58
114
  const prev = lastEntry(path);
59
115
  const base = {
60
116
  seq: prev.seq + 1,
@@ -63,14 +119,19 @@ export function appendAudit(input, dir = stateDir()) {
63
119
  args: auditArgs(input.args),
64
120
  prev: prev.hash,
65
121
  };
66
- const entry = { ...base, hash: entryHash(base) };
122
+ const hash = entryHash(base);
123
+ const entry = key ? { ...base, hash, mac: macOf(key.key, hash) } : { ...base, hash };
67
124
  auditIo.appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 0o600 });
68
125
  return entry;
69
126
  }
70
- /** Recorre la cadena completa: secuencia, enlace con la anterior y hash propio. */
71
- export function verifyAudit(text) {
127
+ /**
128
+ * Recorre la cadena completa: secuencia, enlace con la anterior y hash propio. Con la clave, además, la firma de cada
129
+ * entrada; y toda entrada posterior a la creación de la clave TIENE que estar firmada (quitar firmas no sirve).
130
+ */
131
+ export function verifyAudit(text, key) {
72
132
  const lines = text.split("\n").filter(Boolean);
73
133
  let prev = GENESIS;
134
+ let signed = 0;
74
135
  for (let i = 0; i < lines.length; i++) {
75
136
  let e;
76
137
  try {
@@ -79,15 +140,23 @@ export function verifyAudit(text) {
79
140
  catch {
80
141
  return { ok: false, entries: lines.length, brokenAt: i + 1, reason: "línea que no es JSON" };
81
142
  }
82
- const { hash, ...rest } = e;
143
+ const { hash, mac, ...rest } = e;
83
144
  if (e.seq !== i + 1)
84
145
  return { ok: false, entries: lines.length, brokenAt: i + 1, reason: `secuencia ${e.seq}, se esperaba ${i + 1}` };
85
146
  if (e.prev !== prev)
86
147
  return { ok: false, entries: lines.length, brokenAt: i + 1, reason: "no enlaza con la entrada anterior" };
87
148
  if (entryHash(rest) !== hash)
88
149
  return { ok: false, entries: lines.length, brokenAt: i + 1, reason: "contenido alterado" };
150
+ if (key) {
151
+ if (mac !== undefined && mac !== macOf(key.key, hash))
152
+ return { ok: false, entries: lines.length, brokenAt: i + 1, reason: "firma HMAC inválida (cadena recalculada sin la clave)" };
153
+ if (mac === undefined && e.ts >= key.created)
154
+ return { ok: false, entries: lines.length, brokenAt: i + 1, reason: "entrada sin firma posterior a la creación de la clave (firma quitada)" };
155
+ if (mac !== undefined)
156
+ signed++;
157
+ }
89
158
  prev = hash;
90
159
  }
91
- return { ok: true, entries: lines.length };
160
+ return key ? { ok: true, entries: lines.length, signed } : { ok: true, entries: lines.length };
92
161
  }
93
162
  //# sourceMappingURL=audit.js.map
@@ -0,0 +1,85 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHmac, randomBytes } from "node:crypto";
3
+ /**
4
+ * Clave HMAC del registro de auditoría, guardada en el almacén de secretos del sistema operativo (llavero de macOS
5
+ * o Secret Service en Linux). Con ella, reescribir el registro exige además poder leer el llavero del usuario: un
6
+ * proceso sin acceso al llavero (u otro usuario del equipo) ya no puede recalcular la cadena y que pase por buena.
7
+ *
8
+ * Formato guardado: «v1:<fecha ISO de creación>:<64 hex>». La fecha permite detectar que se QUITARON firmas:
9
+ * toda entrada posterior a ella tiene que llevarla.
10
+ *
11
+ * ABAP_DZ_AUDIT_KEYSTORE: «os» (por defecto), «memory» (tests) o «none» (sin firma, se avisa).
12
+ */
13
+ const SERVICE = "abap-adt-dozimple-audit";
14
+ const ACCOUNT = "hmac";
15
+ let cached;
16
+ let memory;
17
+ let warned = false;
18
+ const parse = (v) => {
19
+ const m = /^v1:([^:]+(?::[^:]+)*?):([0-9a-f]{64})$/.exec((v ?? "").trim());
20
+ return m ? { created: m[1], key: Buffer.from(m[2], "hex") } : null;
21
+ };
22
+ function readOs() {
23
+ try {
24
+ if (process.platform === "darwin") {
25
+ return execFileSync("security", ["find-generic-password", "-s", SERVICE, "-a", ACCOUNT, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
26
+ }
27
+ if (process.platform === "linux") {
28
+ return execFileSync("secret-tool", ["lookup", "service", SERVICE, "account", ACCOUNT], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
29
+ }
30
+ }
31
+ catch {
32
+ /* no existe todavía, o no hay almacén */
33
+ }
34
+ return undefined;
35
+ }
36
+ /** Guarda el valor sin pasarlo nunca como argumento de un proceso (visible en `ps`): siempre por stdin. */
37
+ function writeOs(value) {
38
+ try {
39
+ if (process.platform === "darwin") {
40
+ execFileSync("security", ["-i"], { input: `add-generic-password -U -s ${SERVICE} -a ${ACCOUNT} -w ${value}\n`, stdio: ["pipe", "ignore", "ignore"] });
41
+ return parse(readOs())?.key.toString("hex") === value.split(":").pop();
42
+ }
43
+ if (process.platform === "linux") {
44
+ execFileSync("secret-tool", ["store", `--label=${SERVICE}`, "service", SERVICE, "account", ACCOUNT], { input: value, stdio: ["pipe", "ignore", "ignore"] });
45
+ return !!parse(readOs());
46
+ }
47
+ }
48
+ catch {
49
+ /* sin almacén disponible */
50
+ }
51
+ return false;
52
+ }
53
+ /**
54
+ * La clave, creándola la primera vez si `create`. null = no hay almacén de secretos: el registro sigue encadenado
55
+ * pero sin firma (y se avisa una vez por stderr).
56
+ */
57
+ export function auditKey(create = true) {
58
+ if (cached !== undefined && (cached || !create))
59
+ return cached;
60
+ const mode = process.env.ABAP_DZ_AUDIT_KEYSTORE ?? "os";
61
+ if (mode === "none")
62
+ return (cached = null);
63
+ const read = () => (mode === "memory" ? memory : readOs());
64
+ let k = parse(read());
65
+ if (!k && create) {
66
+ const value = `v1:${new Date().toISOString()}:${randomBytes(32).toString("hex")}`;
67
+ if (mode === "memory")
68
+ memory = value;
69
+ else if (!writeOs(value)) {
70
+ if (!warned)
71
+ console.error("[abap-adt-doZimple] ⚠ Sin almacén de secretos: el registro de auditoría queda encadenado pero sin firma HMAC.");
72
+ warned = true;
73
+ return (cached = null);
74
+ }
75
+ k = parse(read());
76
+ }
77
+ return (cached = k);
78
+ }
79
+ /** Solo tests: olvida la clave en memoria (como si fuera otro equipo o se hubiera borrado). */
80
+ export function resetAuditKeyForTests() {
81
+ cached = undefined;
82
+ memory = undefined;
83
+ }
84
+ export const macOf = (key, hash) => createHmac("sha256", key).update(hash).digest("hex");
85
+ //# sourceMappingURL=auditkey.js.map
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { existsSync, readFileSync, statSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { join, resolve } from "node:path";
@@ -34,6 +35,11 @@ const SystemSchema = z.object({
34
35
  dataClass: z.enum(["test", "masked", "prod"]).optional(),
35
36
  /** Tope de filas por consulta; por defecto test 5000, masked 1000, prod 200. */
36
37
  maxRows: z.number().int().min(1).max(5000).optional(),
38
+ /**
39
+ * Columnas personales propias de este cliente (campos Z o alias de CDS) que se enmascaran igual que las estándar
40
+ * en sistemas masked/prod. El enmascarado es por nombre: lo que no esté aquí ni en la lista estándar sale en claro.
41
+ */
42
+ piiColumns: z.array(z.string().regex(/^[A-Za-z0-9_/]+$/)).default([]),
37
43
  password: z
38
44
  .string()
39
45
  .regex(/^(keychain|env:[A-Z0-9_]+)$/, 'password: "keychain" o "env:NOMBRE_VARIABLE"')
@@ -70,6 +76,12 @@ export function parseConfig(raw) {
70
76
  throw new Error(`Sistema duplicado en la configuración: ${s.id}`);
71
77
  ids.add(key);
72
78
  }
79
+ for (const s of cfg.systems) {
80
+ // Sin verificar TLS, quien intercepte la red recibe usuario y contraseña (Basic): nunca en calidad ni productivo.
81
+ if (s.allowSelfSigned && s.role !== "DEV") {
82
+ throw new Error(`${s.id}: allowSelfSigned solo se admite en sistemas DEV. Para ${s.role} usa caFile con el certificado de su CA.`);
83
+ }
84
+ }
73
85
  if (cfg.defaultSystem && !ids.has(cfg.defaultSystem.toUpperCase())) {
74
86
  throw new Error(`defaultSystem "${cfg.defaultSystem}" no está en systems[]`);
75
87
  }
@@ -88,8 +100,9 @@ export function loadConfig(path = configPath()) {
88
100
  * servidor y si puede escribir en él: tiene que ser solo del usuario.
89
101
  */
90
102
  export function assertPrivateFile(path, st = statSync(path)) {
103
+ // En Windows no hay bits de modo ni uid: se leen las ACL (assertPrivateFileWindows).
91
104
  if (process.platform === "win32")
92
- return;
105
+ return assertPrivateFileWindows(path);
93
106
  if (st.mode & 0o022) {
94
107
  throw new Error(`${path} lo pueden modificar otros usuarios (permisos ${(st.mode & 0o777).toString(8)}). Corrígelo con: chmod 600 "${path}"`);
95
108
  }
@@ -98,6 +111,69 @@ export function assertPrivateFile(path, st = statSync(path)) {
98
111
  throw new Error(`${path} pertenece a otro usuario (uid ${st.uid}). Debe ser tuyo y con permisos 600.`);
99
112
  }
100
113
  }
114
+ // ── Windows: ACL del archivo ────────────────────────────────────────────────
115
+ /** Quién puede escribir legítimamente, además del propio usuario: SYSTEM, Administradores, TrustedInstaller. */
116
+ const TRUSTED_SIDS = new Set(["S-1-5-18", "S-1-5-32-544", "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"]);
117
+ /** Derechos que permiten cambiar el contenido, los permisos o el propietario (nombres de .NET y genéricos numéricos). */
118
+ const WRITE_RIGHTS = /FullControl|Modify|Write|AppendData|CreateFiles|ChangePermissions|TakeOwnership|^(268435456|1073741824)$/;
119
+ /** Entradas que dan escritura a alguien que no es el usuario ni una identidad del sistema. */
120
+ export function aclViolations(entries, userSid) {
121
+ return entries.filter((e) => /allow/i.test(e.type) && e.sid !== userSid && !TRUSTED_SIDS.has(e.sid) && WRITE_RIGHTS.test(e.rights));
122
+ }
123
+ /** Salida del script de PowerShell: «USER|<sid>», «OWNER|<sid>» y «ACE|<sid>|<derechos>|<tipo>». */
124
+ export function parseAclOutput(out) {
125
+ let user = "";
126
+ let owner = "";
127
+ const entries = [];
128
+ for (const line of out.split(/\r?\n/)) {
129
+ const [kind, sid, rights, type] = line.trim().split("|");
130
+ if (kind === "USER")
131
+ user = sid;
132
+ else if (kind === "OWNER")
133
+ owner = sid;
134
+ else if (kind === "ACE" && sid)
135
+ entries.push({ sid, rights: rights ?? "", type: type ?? "" });
136
+ }
137
+ return { user, owner, entries };
138
+ }
139
+ // La ruta llega por variable de entorno, nunca dentro del comando: sin inyección posible.
140
+ const ACL_SCRIPT = [
141
+ "$ErrorActionPreference='Stop'",
142
+ "$a=Get-Acl -LiteralPath $env:ABAPDZ_ACL_PATH",
143
+ "'USER|'+[Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
144
+ "'OWNER|'+(New-Object Security.Principal.NTAccount($a.Owner)).Translate([Security.Principal.SecurityIdentifier]).Value",
145
+ "foreach($r in $a.Access){ $s=try{$r.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value}catch{$r.IdentityReference.Value}; 'ACE|'+$s+'|'+$r.FileSystemRights+'|'+$r.AccessControlType }",
146
+ ].join("; ");
147
+ /**
148
+ * systems.json define qué proceso lanza cada componente (sidecars.command): quien pueda escribirlo ejecuta código
149
+ * con tu identidad. En Windows se exige que solo tu usuario (y el sistema) pueda modificarlo. Si la comprobación no
150
+ * se puede hacer, no se arranca (fail-closed), salvo ABAP_DZ_SKIP_ACL_CHECK=1, que queda avisado al arrancar.
151
+ */
152
+ export function assertPrivateFileWindows(path, run = () => execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ACL_SCRIPT], {
153
+ encoding: "utf8",
154
+ timeout: 15_000,
155
+ env: { ...process.env, ABAPDZ_ACL_PATH: path },
156
+ })) {
157
+ if (process.env.ABAP_DZ_SKIP_ACL_CHECK === "1")
158
+ return;
159
+ let acl;
160
+ try {
161
+ acl = parseAclOutput(run());
162
+ }
163
+ catch (e) {
164
+ throw new Error(`No se pudieron leer los permisos de ${path} (${e.message.split("\n")[0]}). Sin esa comprobación no se arranca; si lo asumes, define ABAP_DZ_SKIP_ACL_CHECK=1.`);
165
+ }
166
+ if (!acl.user)
167
+ throw new Error(`No se pudo determinar el usuario actual para comprobar ${path}.`);
168
+ if (acl.owner && acl.owner !== acl.user && !TRUSTED_SIDS.has(acl.owner)) {
169
+ throw new Error(`${path} pertenece a otra identidad (${acl.owner}). Debe ser tuyo.`);
170
+ }
171
+ const bad = aclViolations(acl.entries, acl.user);
172
+ if (bad.length) {
173
+ throw new Error(`${path} lo pueden modificar otras identidades (${bad.map((b) => `${b.sid}: ${b.rights}`).join("; ")}). ` +
174
+ `Déjalo solo para tu usuario: icacls "${path}" /inheritance:r /grant:r "%USERNAME%":F`);
175
+ }
176
+ }
101
177
  /** Escritura efectiva: hace falta allowWrite y ser un sistema de desarrollo. */
102
178
  export function canWrite(s) {
103
179
  return s.allowWrite && s.role === "DEV";
@@ -133,4 +209,16 @@ export function resolveSystem(cfg, requested) {
133
209
  return { system: cfg.systems[0], source: "único configurado" };
134
210
  throw new Error(`Hay varios sistemas y ninguno por defecto: indica "system" (${cfg.systems.map((x) => x.id).join(", ")})`);
135
211
  }
212
+ /** Avisos que se escriben por stderr en cada arranque: lo que la configuración deja más débil de lo normal. */
213
+ export function startupWarnings(cfg, platform = process.platform) {
214
+ const out = [];
215
+ for (const s of cfg.systems) {
216
+ if (s.allowSelfSigned && !s.caFile)
217
+ out.push(`${s.id}: TLS SIN VERIFICAR (allowSelfSigned). Configura caFile con el certificado de su CA.`);
218
+ }
219
+ if (platform === "win32" && process.env.ABAP_DZ_SKIP_ACL_CHECK === "1") {
220
+ out.push("Windows: ABAP_DZ_SKIP_ACL_CHECK=1 desactiva la comprobación de permisos de systems.json (que define los procesos que se lanzan). Quítala en cuanto puedas.");
221
+ }
222
+ return out;
223
+ }
136
224
  //# sourceMappingURL=config.js.map
@@ -22,22 +22,34 @@ function canonical(v) {
22
22
  export function argsDigest(tool, system, args) {
23
23
  return createHash("sha256").update(JSON.stringify([tool, system.toUpperCase(), canonical(args)])).digest("hex");
24
24
  }
25
- export function issueToken(tool, system, args, now = Date.now()) {
25
+ /**
26
+ * `state` es la huella de lo que el usuario vio en la vista previa (p. ej. el sha256 de la fuente actual en SAP):
27
+ * la tool la compara al escribir para no aplicar un diff sobre algo que cambió entretanto.
28
+ */
29
+ export function issueToken(tool, system, args, now = Date.now(), state) {
26
30
  for (const [t, p] of pending)
27
31
  if (p.exp <= now)
28
32
  pending.delete(t);
29
33
  const token = randomBytes(16).toString("hex");
30
- pending.set(token, { digest: argsDigest(tool, system, args), exp: now + CONFIRM_TTL_MS });
34
+ pending.set(token, { digest: argsDigest(tool, system, args), exp: now + CONFIRM_TTL_MS, state });
31
35
  return token;
32
36
  }
33
- /** Consume el token: vale una sola vez, también cuando no coincide. */
34
- export function consumeToken(token, tool, system, args, now = Date.now()) {
37
+ /** Como consumeToken, y además devuelve la huella del estado que se vio en la vista previa. */
38
+ export function consumeTokenWithState(token, tool, system, args, now = Date.now()) {
35
39
  const p = pending.get(token);
36
40
  if (!p)
37
- return "unknown";
41
+ return { check: "unknown" };
38
42
  pending.delete(token);
39
43
  if (p.exp <= now)
40
- return "expired";
41
- return p.digest === argsDigest(tool, system, args) ? "ok" : "mismatch";
44
+ return { check: "expired" };
45
+ return p.digest === argsDigest(tool, system, args) ? { check: "ok", state: p.state } : { check: "mismatch" };
46
+ }
47
+ /** Consume el token: vale una sola vez, también cuando no coincide. */
48
+ export function consumeToken(token, tool, system, args, now = Date.now()) {
49
+ return consumeTokenWithState(token, tool, system, args, now).check;
50
+ }
51
+ /** Huella de un contenido tal como se mostró: sha256 con saltos de línea normalizados. */
52
+ export function stateOf(content) {
53
+ return createHash("sha256").update(content.replace(/\r\n?/g, "\n")).digest("hex");
42
54
  }
43
55
  //# sourceMappingURL=confirm.js.map
@@ -88,7 +88,11 @@ export class SapConnection {
88
88
  if (!this.basisRelease) {
89
89
  this.basisRelease = this.query("SELECT release FROM cvers WHERE component = 'SAP_BASIS'", 1)
90
90
  .then((r) => r.values[0]?.RELEASE?.trim())
91
- .catch(() => undefined);
91
+ .catch(() => {
92
+ // Un fallo pasajero (VPN caída un instante) no deja el dato en blanco para siempre: se reintenta después.
93
+ this.basisRelease = undefined;
94
+ return undefined;
95
+ });
92
96
  }
93
97
  return this.basisRelease;
94
98
  }