@dozimple/abap-adt 1.0.0 → 1.1.0

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.es.md +8 -6
  3. package/README.md +8 -6
  4. package/SECURITY.md +12 -11
  5. package/dist/core/atc.js +2 -1
  6. package/dist/core/audit.js +81 -12
  7. package/dist/core/auditkey.js +85 -0
  8. package/dist/core/catalog.en.js +1 -0
  9. package/dist/core/catalog.js +1 -0
  10. package/dist/core/config.js +89 -1
  11. package/dist/core/confirm.js +19 -7
  12. package/dist/core/connection.js +46 -1
  13. package/dist/core/credentials.js +10 -3
  14. package/dist/core/datapolicy.js +15 -7
  15. package/dist/core/errors.js +34 -6
  16. package/dist/core/feeds.js +11 -1
  17. package/dist/core/objects.js +10 -0
  18. package/dist/core/policy.js +10 -1
  19. package/dist/core/registry.js +97 -15
  20. package/dist/index.js +8 -2
  21. package/dist/scripts/audit-verify.js +25 -6
  22. package/dist/tools/core/api_release_state.js +5 -0
  23. package/dist/tools/core/atc_quickfix.js +2 -2
  24. package/dist/tools/core/ddic_type_info.js +2 -0
  25. package/dist/tools/core/dumps.js +2 -1
  26. package/dist/tools/core/gateway_errors.js +2 -1
  27. package/dist/tools/core/get_source.js +2 -2
  28. package/dist/tools/core/revert_source.js +111 -0
  29. package/dist/tools/core/run_atc.js +9 -4
  30. package/dist/tools/core/run_unit_tests.js +6 -1
  31. package/dist/tools/core/sql_query.js +18 -2
  32. package/dist/tools/core/support.js +6 -3
  33. package/dist/tools/core/syntax_check.js +17 -2
  34. package/dist/tools/core/table_contents.js +43 -7
  35. package/dist/tools/core/text_elements.js +11 -3
  36. package/dist/tools/core/transport_diff.js +10 -1
  37. package/dist/tools/core/where_used.js +6 -1
  38. package/dist/tools/core/write_source.js +14 -2
  39. package/dist/tools/docs/docs.js +8 -0
  40. package/dist/tools/local/sap_systems.js +4 -0
  41. package/dist/tools/transport-risk/_service.js +7 -2
  42. package/docs/THREAT_MODEL.md +4 -3
  43. package/docs/TOOLS.md +44 -2
  44. package/package.json +5 -3
@@ -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
@@ -8,6 +8,10 @@ import { normalizeValue, wrapSql } from "./sql.js";
8
8
  const CACHE_DIR = join(homedir(), ".cache", "abap-adt-dozimple");
9
9
  const DISCOVERY_TTL_MS = 7 * 24 * 3600 * 1000;
10
10
  const REQUEST_TIMEOUT_MS = 120_000;
11
+ /** Circuit breaker: N fallos de red en la ventana abren el circuito durante CIRCUIT_OPEN_MS. */
12
+ const CIRCUIT_FAILURES = 3;
13
+ const CIRCUIT_WINDOW_MS = 60_000;
14
+ const CIRCUIT_OPEN_MS = 60_000;
11
15
  /**
12
16
  * TLS: con caFile se verifica contra ese certificado (lo correcto para un
13
17
  * sistema con certificado propio); allowSelfSigned desactiva la verificación
@@ -30,9 +34,44 @@ export class SapConnection {
30
34
  reader;
31
35
  caps;
32
36
  basisRelease;
37
+ networkFailures = [];
38
+ openUntil = 0;
33
39
  constructor(system) {
34
40
  this.system = system;
35
41
  }
42
+ /** Olvida el cliente de lectura (sesión caducada): la siguiente llamada vuelve a entrar. */
43
+ resetReader() {
44
+ const old = this.reader;
45
+ this.reader = undefined;
46
+ old?.then((c) => c.logout()).catch(() => undefined);
47
+ }
48
+ /**
49
+ * Tres fallos de red en un minuto abren el circuito: durante un minuto ninguna tool intenta nada contra este
50
+ * sistema y responde al instante. Un sistema caído (VPN, host) dejaba de responder 120 s por cada llamada.
51
+ */
52
+ noteNetworkFailure(now = Date.now()) {
53
+ this.networkFailures = this.networkFailures.filter((t) => now - t < CIRCUIT_WINDOW_MS);
54
+ this.networkFailures.push(now);
55
+ if (this.networkFailures.length >= CIRCUIT_FAILURES) {
56
+ this.openUntil = now + CIRCUIT_OPEN_MS;
57
+ this.networkFailures = [];
58
+ }
59
+ }
60
+ /** Segundos que faltan para volver a intentar, o 0 si el circuito está cerrado. */
61
+ circuitOpenFor(now = Date.now()) {
62
+ return this.openUntil > now ? Math.ceil((this.openUntil - now) / 1000) : 0;
63
+ }
64
+ /** Cierra el circuito a mano (sap_systems con check): se vuelve a intentar ya. */
65
+ resetCircuit() {
66
+ this.openUntil = 0;
67
+ this.networkFailures = [];
68
+ }
69
+ assertCircuitClosed() {
70
+ const s = this.circuitOpenFor();
71
+ if (s) {
72
+ throw new ToolError("NETWORK", `${this.system.id} no respondió ${CIRCUIT_FAILURES} veces seguidas: no se vuelve a intentar durante ${s} s.`, "sap_systems(check=true) lo reintenta ahora mismo.");
73
+ }
74
+ }
36
75
  async newClient() {
37
76
  const s = this.system;
38
77
  const password = await getPassword(s);
@@ -53,6 +92,7 @@ export class SapConnection {
53
92
  }
54
93
  /** Cliente de lectura, reutilizado entre llamadas. */
55
94
  async adt() {
95
+ this.assertCircuitClosed();
56
96
  if (!this.reader) {
57
97
  this.reader = this.newClient().then((c) => this.login(c));
58
98
  this.reader.catch(() => (this.reader = undefined));
@@ -61,6 +101,7 @@ export class SapConnection {
61
101
  }
62
102
  /** Ejecuta fn con una sesión stateful propia y la cierra siempre. */
63
103
  async stateful(fn) {
104
+ this.assertCircuitClosed();
64
105
  const c = await this.login(await this.newClient());
65
106
  c.stateful = session_types.stateful;
66
107
  try {
@@ -88,7 +129,11 @@ export class SapConnection {
88
129
  if (!this.basisRelease) {
89
130
  this.basisRelease = this.query("SELECT release FROM cvers WHERE component = 'SAP_BASIS'", 1)
90
131
  .then((r) => r.values[0]?.RELEASE?.trim())
91
- .catch(() => undefined);
132
+ .catch(() => {
133
+ // Un fallo pasajero (VPN caída un instante) no deja el dato en blanco para siempre: se reintenta después.
134
+ this.basisRelease = undefined;
135
+ return undefined;
136
+ });
92
137
  }
93
138
  return this.basisRelease;
94
139
  }
@@ -4,6 +4,8 @@ import { ToolError } from "./errors.js";
4
4
  const run = promisify(execFile);
5
5
  /** Servicio del llavero de macOS bajo el que se guardan las contraseñas. */
6
6
  export const KEYCHAIN_SERVICE = "abap-adt-dozimple";
7
+ /** Contraseñas en memoria con caducidad: sin caché el llavero se leería en cada petición; sin límite, vivirían siempre. */
8
+ const CACHE_TTL_MS = 15 * 60_000;
7
9
  const cache = new Map();
8
10
  /**
9
11
  * Devuelve la contraseña del sistema. Nunca se escribe en logs ni en la
@@ -11,8 +13,9 @@ const cache = new Map();
11
13
  */
12
14
  export async function getPassword(s) {
13
15
  const hit = cache.get(s.id);
14
- if (hit)
15
- return hit;
16
+ if (hit && hit.exp > Date.now())
17
+ return hit.pwd;
18
+ cache.delete(s.id);
16
19
  let pwd;
17
20
  if (s.password.startsWith("env:")) {
18
21
  pwd = process.env[s.password.slice(4)];
@@ -34,11 +37,15 @@ export async function getPassword(s) {
34
37
  throw new ToolError("AUTH", `No hay contraseña para ${s.id} en el llavero. Guárdala con: scripts/set-password.sh ${s.id}`);
35
38
  }
36
39
  }
37
- cache.set(s.id, pwd);
40
+ cache.set(s.id, { pwd, exp: Date.now() + CACHE_TTL_MS });
38
41
  return pwd;
39
42
  }
40
43
  /** Tras un 401 conviene olvidar la contraseña cacheada por si se cambió. */
41
44
  export function forgetPassword(id) {
42
45
  cache.delete(id);
43
46
  }
47
+ /** Al cerrar el servidor: ninguna contraseña queda en memoria más de lo necesario. */
48
+ export function clearPasswords() {
49
+ cache.clear();
50
+ }
44
51
  //# sourceMappingURL=credentials.js.map
@@ -1,6 +1,6 @@
1
1
  import { normalizeError, ToolError } from "./errors.js";
2
- import { sqlLiteral } from "./objects.js";
3
- import { assertNotSensitive, assertSelectOnly, rejectSensitive, sensitiveHits, sqlWords } from "./policy.js";
2
+ import { adtPathName, sqlLiteral } from "./objects.js";
3
+ import { assertNotSensitive, assertSelectOnly, rejectSensitive, SENSITIVE_COLUMNS, sensitiveHits, sqlWords } from "./policy.js";
4
4
  export const dataClassOf = (s) => s.dataClass ?? (s.role === "DEV" ? "test" : "prod");
5
5
  const DEFAULT_CAP = { test: 5000, masked: 1000, prod: 200 };
6
6
  export const rowCap = (s) => s.maxRows ?? DEFAULT_CAP[dataClassOf(s)];
@@ -24,17 +24,18 @@ export const MASK = "‹oculto›";
24
24
  * simple, para enmascararla. Usarla en un WHERE, un alias o una expresión
25
25
  * dejaría deducir su valor sin que aparezca en el resultado.
26
26
  */
27
- export function maskPii(cls, sql, columns, rows) {
27
+ export function maskPii(cls, sql, columns, rows, extra = []) {
28
28
  if (cls === "test")
29
29
  return [];
30
+ const pii = extra.length ? new Set([...PII_COLUMNS, ...extra.map((c) => c.toUpperCase())]) : PII_COLUMNS;
30
31
  const cols = new Set(columns.map((c) => c.toUpperCase()));
31
- const mentioned = new Set(sqlWords(sql).flatMap((w) => w.split("~")).filter((w) => PII_COLUMNS.has(w)));
32
+ const mentioned = new Set(sqlWords(sql).flatMap((w) => w.split("~")).filter((w) => pii.has(w)));
32
33
  const hidden = [...mentioned].filter((w) => !cols.has(w));
33
34
  if (hidden.length) {
34
35
  throw new ToolError("POLICY", `En un sistema con datos ${cls === "prod" ? "productivos" : "enmascarados"}, las columnas personales (${hidden.join(", ")}) ` +
35
36
  `solo pueden pedirse como columna simple del SELECT: no en WHERE, alias ni expresiones.`, "Filtra por la clave (KUNNR, LIFNR, PARTNER…) y deja que la columna salga enmascarada, o consulta un sistema de test.");
36
37
  }
37
- const masked = columns.filter((c) => PII_COLUMNS.has(c.toUpperCase()));
38
+ const masked = columns.filter((c) => pii.has(c.toUpperCase()));
38
39
  for (const row of rows)
39
40
  for (const c of masked)
40
41
  if (row[c] !== "" && row[c] !== null && row[c] !== undefined)
@@ -116,7 +117,7 @@ export async function assertNoSensitiveViews(sap, sql, depth = 3) {
116
117
  for (const [ddl, objs] of byDdl) {
117
118
  let src;
118
119
  try {
119
- src = await c.getObjectSource(`/sap/bc/adt/ddic/ddl/sources/${encodeURIComponent(ddl.toLowerCase())}/source/main`);
120
+ src = await c.getObjectSource(`/sap/bc/adt/ddic/ddl/sources/${adtPathName(ddl)}/source/main`);
120
121
  }
121
122
  catch (e) {
122
123
  throw new ToolError("POLICY", `No se pudo comprobar qué tablas lee la CDS ${ddl} (${normalizeError(e).message}): la consulta no se ejecuta sin esa comprobación.`);
@@ -160,8 +161,15 @@ export async function guardedQuery(sap, system, sql, requestedRows) {
160
161
  const cap = Math.min(requestedRows, rowCap(system));
161
162
  const r = await sap.query(q, cap);
162
163
  const columns = r.columns.map((c) => c.name);
164
+ // «SELECT *» no nombra las columnas: el veto se aplica también a lo que DEVUELVE SAP. Si llega alguna vetada, se
165
+ // descarta el resultado entero antes de mostrar nada.
166
+ const vetoed = columns.filter((c) => SENSITIVE_COLUMNS.includes(c.toUpperCase()));
167
+ if (vetoed.length) {
168
+ r.values.length = 0;
169
+ throw new ToolError("POLICY", `Consulta bloqueada: el resultado incluye columnas de credenciales (${vetoed.join(", ")}). Pide las columnas concretas que necesitas, sin esas.`);
170
+ }
163
171
  const notes = [];
164
- const masked = maskPii(cls, q, columns, r.values);
172
+ const masked = maskPii(cls, q, columns, r.values, system.piiColumns ?? []);
165
173
  if (masked.length)
166
174
  notes.push(`Columnas personales enmascaradas (sistema con datos ${cls === "prod" ? "productivos" : "enmascarados"}): ${masked.join(", ")}.`);
167
175
  const capped = r.values.length >= cap;
@@ -19,26 +19,29 @@ export function normalizeError(e, systemId) {
19
19
  if (e instanceof ToolError)
20
20
  return e;
21
21
  const any = e;
22
+ // signal.throwIfAborted() entre pasos de una tool larga: cancelación del cliente, no fallo del servidor.
23
+ if (any?.name === "AbortError")
24
+ return new ToolError("CANCELLED", "Cancelado por el cliente antes de terminar.");
22
25
  const code = errCode(any);
23
26
  if (code && NETWORK_CODES.has(code)) {
24
27
  return new ToolError("NETWORK", `No se llega a ${systemId ?? "SAP"} (${code}).`, "¿Está levantada la VPN de ese cliente?");
25
28
  }
26
29
  if (isCsrfError(e)) {
27
- return new ToolError("AUTH", `Sesión o token CSRF rechazado por ${systemId ?? "SAP"}: ${any.message}`);
30
+ return new ToolError("AUTH", `Sesión o token CSRF rechazado por ${systemId ?? "SAP"}: ${sanitizeMessage(any.message)}`);
28
31
  }
29
32
  if (isHttpError(e)) {
30
33
  const st = any.status;
31
34
  if (st === 401)
32
35
  return new ToolError("AUTH", `Usuario o contraseña rechazados por ${systemId ?? "SAP"} (401).`);
33
36
  if (st === 403)
34
- return new ToolError("AUTH", `Sin autorización (403): ${any.message}`);
37
+ return new ToolError("AUTH", `Sin autorización (403): ${sanitizeMessage(any.message)}`);
35
38
  if (st === 404)
36
- return new ToolError("NOT_FOUND", `No encontrado (404): ${any.message}`);
37
- return new ToolError("SAP", `HTTP ${st ?? "?"}: ${any.message}`);
39
+ return new ToolError("NOT_FOUND", `No encontrado (404): ${sanitizeMessage(any.message)}`);
40
+ return new ToolError("SAP", `HTTP ${st ?? "?"}: ${sanitizeMessage(any.message)}`);
38
41
  }
39
42
  if (isAdtError(e)) {
40
43
  const st = any.err;
41
- const msg = any.localizedMessage || any.message || "sin mensaje";
44
+ const msg = sanitizeMessage(any.localizedMessage || any.message || "sin mensaje", 1500);
42
45
  if (st === 401)
43
46
  return new ToolError("AUTH", `Usuario o contraseña rechazados (401): ${msg}`);
44
47
  if (st === 403)
@@ -47,9 +50,19 @@ export function normalizeError(e, systemId) {
47
50
  return new ToolError("NOT_FOUND", msg);
48
51
  return new ToolError("SAP", `${msg}${st ? ` (HTTP ${st})` : ""}`);
49
52
  }
50
- const message = any?.message ? String(any.message) : String(e);
53
+ const message = sanitizeMessage(any?.message ? String(any.message) : String(e));
51
54
  return new ToolError("INTERNAL", message || "Error sin mensaje");
52
55
  }
56
+ /**
57
+ * Sesión caducada a mitad de una lectura: token CSRF rechazado, o 401 en una sesión que ya había entrado. El registro
58
+ * la renueva y repite la lectura UNA vez; nunca una escritura (el bloqueo se perdió con la sesión).
59
+ */
60
+ export function isSessionExpired(e) {
61
+ if (isCsrfError(e))
62
+ return true;
63
+ const st = e;
64
+ return (isHttpError(e) && st?.status === 401) || (isAdtError(e) && st?.err === 401);
65
+ }
53
66
  export function renderError(te) {
54
67
  const labels = {
55
68
  NETWORK: "Sin conexión",
@@ -60,9 +73,24 @@ export function renderError(te) {
60
73
  CAPABILITY: "No disponible en este sistema",
61
74
  MODULE: "Módulo no habilitado",
62
75
  INPUT: "Parámetros inválidos",
76
+ CANCELLED: "Cancelado",
63
77
  INTERNAL: "Error interno",
64
78
  };
65
79
  return `${labels[te.kind]}: ${te.message}${te.hint ? `\n${te.hint}` : ""}\n` +
66
80
  `(No se completó la operación: no interpretes esto como un resultado vacío.)`;
67
81
  }
82
+ /**
83
+ * Texto de error apto para llegar al modelo (y por tanto al proveedor LLM): sin credenciales incrustadas en URLs,
84
+ * sin cabeceras Authorization, sin marcado y con longitud acotada. Los errores de SAP o de HTTP pueden traer rutas,
85
+ * hosts o páginas de error enteras; aquí se reducen a lo útil para diagnosticar.
86
+ */
87
+ export function sanitizeMessage(msg, max = 500) {
88
+ const clean = String(msg)
89
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s:@/]+:[^\s@/]+@/gi, "$1[credenciales]@")
90
+ .replace(/(authorization["']?\s*[:=]\s*["']?)(basic|bearer)\s+[A-Za-z0-9+/=._-]+/gi, "$1$2 [oculto]")
91
+ .replace(/<[^>]{0,200}>/g, " ")
92
+ .replace(/\s+/g, " ")
93
+ .trim();
94
+ return clean.length > max ? clean.slice(0, max) + "…" : clean;
95
+ }
68
96
  //# sourceMappingURL=errors.js.map
@@ -39,7 +39,17 @@ export function parseAtom(xml) {
39
39
  };
40
40
  });
41
41
  }
42
+ /** Tope de entrada antes de sanear: el saneado repite pasadas y el HTML de un dump puede ser enorme. */
43
+ export const MAX_HTML_INPUT = 2 * 1024 * 1024;
44
+ /**
45
+ * Tras decodificar entidades, «&lt;script&gt;» vuelve a ser «<script>». Este servidor no renderiza HTML, pero un
46
+ * cliente MCP podría, y el contenido de SAP podría imitar así el formato de las respuestas del servidor: se
47
+ * neutraliza todo «<» que abra algo con forma de etiqueta. Un «a < b» del texto se conserva.
48
+ */
49
+ export const neutralizeMarkup = (s) => s.replace(/<(?=[A-Za-z!/?])/g, "‹");
42
50
  export function htmlToText(html) {
51
+ if (html.length > MAX_HTML_INPUT)
52
+ html = html.slice(0, MAX_HTML_INPUT) + "\n[… contenido recortado]";
43
53
  let noScripts = html;
44
54
  for (let prev = ""; prev !== noScripts;) {
45
55
  prev = noScripts;
@@ -49,7 +59,7 @@ export function htmlToText(html) {
49
59
  .replace(/<br\s*\/?>/gi, "\n")
50
60
  .replace(/<\/(p|div|h\d|li|tr|table)>/gi, "\n")
51
61
  .replace(/<\/t[dh]>/gi, "\t");
52
- return decodeEntities(stripTags(withBreaks))
62
+ return neutralizeMarkup(decodeEntities(stripTags(withBreaks)))
53
63
  .replace(/\r\n?/g, "\n")
54
64
  .replace(/[ \t]+\n/g, "\n")
55
65
  .replace(/\n[ \t]+/g, "\n")
@@ -136,6 +136,16 @@ export async function resolveByTypePrefix(c, name, prefixes) {
136
136
  const h = hits.find((x) => x["adtcore:name"].toUpperCase() === name.toUpperCase() && prefixes.some((t) => x["adtcore:type"].startsWith(t)));
137
137
  return h ? { uri: h["adtcore:uri"], type: h["adtcore:type"] } : undefined;
138
138
  }
139
+ /**
140
+ * Nombre de objeto apto para una ruta ADT. encodeURIComponent no codifica «.», así que «..» sobreviviría y
141
+ * recorrería la ruta; un nombre de repositorio ABAP solo tiene letras, dígitos, «_», «$» y namespaces «/X/».
142
+ */
143
+ export function adtPathName(name) {
144
+ const n = name.trim();
145
+ if (!/^(\/[A-Za-z0-9_]+\/)?[A-Za-z0-9_$]{1,40}$/.test(n))
146
+ throw new ToolError("INPUT", `Nombre de objeto inválido para una ruta ADT: ${name}`);
147
+ return encodeURIComponent(n.toLowerCase());
148
+ }
139
149
  /** Escapa un literal para ABAP SQL. */
140
150
  export function sqlLiteral(v) {
141
151
  return `'${v.replace(/'/g, "''")}'`;
@@ -41,7 +41,11 @@ export const SENSITIVE_TABLES = [
41
41
  "USRACL", "SNAP",
42
42
  ];
43
43
  export const SENSITIVE_PREFIXES = ["OA2C_"];
44
- export const SENSITIVE_COLUMNS = ["PWDSALTEDHASH", "BCODE", "PASSCODE"];
44
+ /**
45
+ * Columnas con contraseñas o sus hashes, en cualquier tabla: además de las de USR02, la contraseña del usuario de
46
+ * inicio de sesión fijo de un servicio ICF (ICFSERVICE-ICF_PASSWD) y nombres genéricos de columna de contraseña.
47
+ */
48
+ export const SENSITIVE_COLUMNS = ["PWDSALTEDHASH", "BCODE", "PASSCODE", "ICF_PASSWD", "PASSWD", "PASSWORD"];
45
49
  /** Datos de personal (infotipos, clusters de nómina): categoría especial, nunca por este servidor. */
46
50
  export const HR_TABLE_RE = /^(PA\d{4}|PB\d{4}|PCL[1-5]|HRPY_[A-Z0-9_]+)$/;
47
51
  /** Qué palabras de una consulta (o de una definición de vista) son material vetado. */
@@ -116,6 +120,11 @@ export function assertPublicQuery(query, cfg, blockTerms = []) {
116
120
  throw new ToolError("POLICY", `La consulta saldría a internet con datos del cliente o del sistema (${[...new Set(hits)].join(", ")}).`, "Reformúlala con el concepto técnico (p. ej. «MATNR length extension BAPI»), sin nombres Z, órdenes, sistemas ni clientes; o búscala sin online.");
117
121
  }
118
122
  }
123
+ /**
124
+ * Usuario SAP: hasta 12 caracteres (USR02-BNAME). Se interpola en filtros de feeds ADT (dumps, errores de Gateway):
125
+ * paréntesis, comas o espacios podrían reescribir la expresión del filtro, así que solo se admite esta forma.
126
+ */
127
+ export const SAP_USER_RE = /^[A-Za-z0-9_.@-]{1,12}$/;
119
128
  export const TRKORR_RE = /^[A-Z0-9]{3}K\d{6}$/;
120
129
  export function assertTrkorr(t, label = "transport") {
121
130
  const up = t.trim().toUpperCase();