@llm4ts/shell 2.27.0 → 2.28.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.
- package/kits/soap-ace/README.md +68 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +15 -0
- package/kits/soap-ace/flows/lib/soap/Auth.js +133 -11
- package/kits/soap-ace/flows/lib/soap/Call.js +17 -5
- package/kits/soap-ace/flows/lib/soap/Discover.js +31 -3
- package/kits/soap-ace/flows/lib/soap/Envelope.js +29 -4
- package/kits/soap-ace/flows/lib/soap/Explore.js +1 -0
- package/kits/soap-ace/flows/lib/soap/Setup.js +202 -0
- package/kits/soap-ace/flows/lib/soap/Sts.js +216 -0
- package/kits/soap-ace/flows/lib/soap/Stub.js +8 -1
- package/kits/soap-ace/flows/lib/soap/Transport.js +245 -75
- package/kits/soap-ace/flows/lib/soap/Xml.js +4 -2
- package/kits/soap-ace/flows/soap-discover.js +1 -1
- package/kits/soap-ace/flows/soap-explore.js +20 -3
- package/kits/soap-ace/flows/soap-sample.js +143 -12
- package/package.json +4 -4
package/kits/soap-ace/README.md
CHANGED
|
@@ -116,6 +116,10 @@ llm4ts run soap-sample --repo . "propose cercaMovimenti" # scena
|
|
|
116
116
|
llm4ts run soap-sample --repo . "call cercaMovimenti" # every request of the operation
|
|
117
117
|
llm4ts run soap-sample --repo . -- --allow-mutating revocaBonifico "call revocaBonifico/gia-eseguito"
|
|
118
118
|
llm4ts run soap-sample --repo . list
|
|
119
|
+
llm4ts run soap-sample --repo . "check" # the selected profile as decisions, no network
|
|
120
|
+
llm4ts run soap-sample --repo . "trust call" # capture and pin the chain the endpoint presents
|
|
121
|
+
llm4ts run soap-sample --repo . "sts init" # write the STS request template
|
|
122
|
+
llm4ts run soap-sample --repo . -- --env uat "call all" # with auth.uat.json (or LLM4TS_SOAP_ENV=uat)
|
|
119
123
|
```
|
|
120
124
|
|
|
121
125
|
A request is a YAML projection of the XSD, one file per scenario under
|
|
@@ -312,6 +316,70 @@ read, and errors name the reference, never its value.
|
|
|
312
316
|
Persisted exchanges drop `wsse:Security` headers and credential-bearing HTTP
|
|
313
317
|
headers (`authorization`, cookies, anything named like a token or secret).
|
|
314
318
|
|
|
319
|
+
## Environments, proxies, trust and STS tokens
|
|
320
|
+
|
|
321
|
+
Locked-down environments differ in where the proxy is, whose CA signed the
|
|
322
|
+
ESB, and which STS issues the token (ADR 0024). One file per environment
|
|
323
|
+
holds all of it: `auth.<env>.json` beside `auth.json`, selected by
|
|
324
|
+
`LLM4TS_SOAP_ENV=uat` or `-- --env uat`; a named environment without a
|
|
325
|
+
file is an error. Every flow prints the file it loaded.
|
|
326
|
+
|
|
327
|
+
```json
|
|
328
|
+
{
|
|
329
|
+
"environment": "uat",
|
|
330
|
+
"endpoint": "https://esb-uat.bank.internal/soap/DemoBank",
|
|
331
|
+
"call": {
|
|
332
|
+
"proxy": "none",
|
|
333
|
+
"tls": { "ca": "file:.llm4ts/soap/DemoBank/trust/esb-uat.bank.internal.pem" }
|
|
334
|
+
},
|
|
335
|
+
"fetch": {
|
|
336
|
+
"proxy": { "url": "http://proxy.bank.internal:3128", "noProxy": ["*.bank.internal"] }
|
|
337
|
+
},
|
|
338
|
+
"sts": {
|
|
339
|
+
"endpoint": "https://sts-uat.bank.internal/trust/13/usernamemixed",
|
|
340
|
+
"auth": { "user": "env:STS_USER", "password": "env:STS_PASSWORD" },
|
|
341
|
+
"appliesTo": "https://esb-uat.bank.internal/soap/DemoBank",
|
|
342
|
+
"token": { "element": "Assertion" },
|
|
343
|
+
"renewBeforeSeconds": 60,
|
|
344
|
+
"timestamp": true
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
- **Proxies** are explicit per side (`fetch`, `call`, `sts`): `{ "url",
|
|
350
|
+
"noProxy": ["host", "*.suffix"], "auth": {basic, references} }` or
|
|
351
|
+
`"none"` (the default). The transport opens an HTTP CONNECT tunnel; the
|
|
352
|
+
proxy environment variables are never read, and `check` lists them as
|
|
353
|
+
set-and-ignored. A refused tunnel is a `proxy` transport error.
|
|
354
|
+
- **CA material** adds to Node's system roots: `tls.ca` (a PEM file) and
|
|
355
|
+
`tls.caDir` (a directory of `.pem`/`.crt`). `tls.trust: "ca-only"` trusts
|
|
356
|
+
only the profile's. `tls.servername` names the host behind a front door.
|
|
357
|
+
Verification is never switched off.
|
|
358
|
+
- **`trust call|fetch|sts`** connects to that side's endpoint without
|
|
359
|
+
verifying, sends nothing, prints every certificate of the chain (subject,
|
|
360
|
+
issuer, validity, SHA-256 fingerprint) and whether the system already
|
|
361
|
+
trusts it, asks `trust this chain? [y/N]` (`-- --yes` headless), and on
|
|
362
|
+
yes writes `trust/<host>.pem` (gitignored) and sets that side's `tls.ca`
|
|
363
|
+
in the selected profile. A changed certificate is shown as a replacement
|
|
364
|
+
and asked again. A `tls` error that says "capture it with trust" is this
|
|
365
|
+
case.
|
|
366
|
+
- **STS**: with an `sts` section every `call` first obtains a SAML bearer
|
|
367
|
+
token. `sts init` writes `sts.request.xml`, a WS-Trust 1.3 Issue envelope
|
|
368
|
+
with `{{username}}`, `{{password}}`, `{{created}}`, `{{expires}}`,
|
|
369
|
+
`{{nonce}}`, `{{appliesTo}}`; edit it when the STS differs, the kit only
|
|
370
|
+
fills the placeholders. The first element named `token.element` (default
|
|
371
|
+
`Assertion`, any namespace) is taken from the response as its raw bytes,
|
|
372
|
+
so a signed assertion survives, and sent inside `wsse:Security` after the
|
|
373
|
+
`wsu:Timestamp` (`timestamp: true`) and the UsernameToken when
|
|
374
|
+
`wsSecurity` is also set. The token lives in memory for the run and is
|
|
375
|
+
renewed `renewBeforeSeconds` before its `NotOnOrAfter`; it is never
|
|
376
|
+
written anywhere. `StsError` names the fault, the status or the element
|
|
377
|
+
it looked for.
|
|
378
|
+
- **`check`** prints the selected file, each side's endpoint, proxy decision
|
|
379
|
+
and CA sources, the STS and its template's placeholders, the pinned
|
|
380
|
+
chains, and the ignored proxy variables. It makes no connection: run it
|
|
381
|
+
first, then `trust`, then `sts init`, then `call`.
|
|
382
|
+
|
|
315
383
|
## Masking
|
|
316
384
|
|
|
317
385
|
Every SOAP sample is masked before it is written anywhere or shown to a
|
|
@@ -59,6 +59,21 @@ and set `LLM4TS_SOAP_SERVICE`.
|
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
Per environment, `auth.test.json` / `auth.uat.json` next to it, picked by
|
|
63
|
+
`LLM4TS_SOAP_ENV` or `-- --env`. In a locked-down network run, in order:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
llm4ts run soap-sample --repo . "check" # the profile as decisions, no network
|
|
67
|
+
llm4ts run soap-sample --repo . "trust call" # pin the ESB's chain after reading its fingerprint
|
|
68
|
+
llm4ts run soap-sample --repo . "trust sts" # and the STS's, when the profile names one
|
|
69
|
+
llm4ts run soap-sample --repo . "sts init" # the WS-Trust template; edit it if the STS differs
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Proxies are explicit per side in the profile (`proxy`), never read from
|
|
73
|
+
the environment; CA PEMs add to the system roots; the SAML token the STS
|
|
74
|
+
issues is carried byte for byte and never persisted (README, "Environments,
|
|
75
|
+
proxies, trust and STS tokens").
|
|
76
|
+
|
|
62
77
|
## 3. Samples
|
|
63
78
|
|
|
64
79
|
```bash
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import * as Effect from "effect/Effect";
|
|
3
4
|
import * as Redacted from "effect/Redacted";
|
|
4
5
|
import * as Schema from "effect/Schema";
|
|
@@ -31,20 +32,75 @@ export class NoAuth extends Schema.Class("NoAuth")({
|
|
|
31
32
|
}) {
|
|
32
33
|
}
|
|
33
34
|
export const HttpAuth = Schema.Union([NoAuth, BasicAuth, BearerAuth]);
|
|
34
|
-
/**
|
|
35
|
+
/** How the profile's CA material combines with Node's system roots. */
|
|
36
|
+
export const TlsTrust = Schema.Literals(["system+ca", "ca-only"]);
|
|
37
|
+
/**
|
|
38
|
+
* Client TLS: PEM cert+key, or a PKCS#12 bundle; CA material as a PEM file
|
|
39
|
+
* (`ca`) and/or a directory of PEMs (`caDir`), added to the system roots
|
|
40
|
+
* unless `trust` is `ca-only`; `servername` for a host behind a front door.
|
|
41
|
+
*/
|
|
35
42
|
export class TlsConfig extends Schema.Class("TlsConfig")({
|
|
36
43
|
cert: Schema.optionalKey(SecretRef),
|
|
37
44
|
key: Schema.optionalKey(SecretRef),
|
|
38
45
|
pfx: Schema.optionalKey(SecretRef),
|
|
39
46
|
passphrase: Schema.optionalKey(SecretRef),
|
|
40
|
-
ca: Schema.optionalKey(SecretRef)
|
|
47
|
+
ca: Schema.optionalKey(SecretRef),
|
|
48
|
+
caDir: Schema.optionalKey(Schema.String),
|
|
49
|
+
trust: Schema.optionalKey(TlsTrust),
|
|
50
|
+
servername: Schema.optionalKey(Schema.String)
|
|
41
51
|
}) {
|
|
42
52
|
}
|
|
53
|
+
const ProxyUrl = Schema.String.check(Schema.isPattern(/^https?:\/\/[^/?#@]+\/?$/i, {
|
|
54
|
+
message: "an http(s) proxy origin without credentials or path; credentials go in auth"
|
|
55
|
+
}));
|
|
56
|
+
/**
|
|
57
|
+
* An explicit HTTP CONNECT proxy for one side, with the hosts that bypass
|
|
58
|
+
* it (`*.suffix` wildcards). Environment proxy variables are never read.
|
|
59
|
+
*/
|
|
60
|
+
export class ProxyConfig extends Schema.Class("ProxyConfig")({
|
|
61
|
+
url: ProxyUrl,
|
|
62
|
+
noProxy: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
63
|
+
auth: Schema.optionalKey(BasicAuth)
|
|
64
|
+
}) {
|
|
65
|
+
}
|
|
66
|
+
export const ProxySetting = Schema.Union([Schema.Literal("none"), ProxyConfig]);
|
|
43
67
|
export class HttpSide extends Schema.Class("HttpSide")({
|
|
44
68
|
auth: Schema.optionalKey(HttpAuth),
|
|
45
|
-
tls: Schema.optionalKey(TlsConfig)
|
|
69
|
+
tls: Schema.optionalKey(TlsConfig),
|
|
70
|
+
proxy: Schema.optionalKey(ProxySetting)
|
|
71
|
+
}) {
|
|
72
|
+
}
|
|
73
|
+
/** The credential the STS request template is filled with. */
|
|
74
|
+
export class StsCredential extends Schema.Class("StsCredential")({
|
|
75
|
+
user: Principal,
|
|
76
|
+
password: SecretRef
|
|
77
|
+
}) {
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A SAML bearer token from an STS: the template under the service folder
|
|
81
|
+
* is filled and posted to `endpoint` with this side's auth, TLS and proxy;
|
|
82
|
+
* the first element named `token.element` in the answer is carried, bytes
|
|
83
|
+
* unchanged, in every call's WS-Security header.
|
|
84
|
+
*/
|
|
85
|
+
export class StsConfig extends Schema.Class("StsConfig")({
|
|
86
|
+
endpoint: Schema.String.check(Schema.isPattern(/^https?:\/\/[^/?#@]+(?:[/?#][^@]*)?$/i, {
|
|
87
|
+
message: "an http(s) URL without credentials; secrets go in references"
|
|
88
|
+
})),
|
|
89
|
+
soapVersion: Schema.optionalKey(Schema.Literals(["1.1", "1.2"])),
|
|
90
|
+
/** Relative to the service folder; `sts init` writes the default. */
|
|
91
|
+
template: Schema.optionalKey(Schema.String),
|
|
92
|
+
auth: StsCredential,
|
|
93
|
+
appliesTo: Schema.optionalKey(Schema.String),
|
|
94
|
+
token: Schema.optionalKey(Schema.Struct({ element: Schema.optionalKey(Schema.String) })),
|
|
95
|
+
renewBeforeSeconds: Schema.optionalKey(Schema.Int),
|
|
96
|
+
timestamp: Schema.optionalKey(Schema.Boolean),
|
|
97
|
+
tls: Schema.optionalKey(TlsConfig),
|
|
98
|
+
proxy: Schema.optionalKey(ProxySetting),
|
|
99
|
+
timeoutSeconds: Schema.optionalKey(Schema.Int)
|
|
46
100
|
}) {
|
|
47
101
|
}
|
|
102
|
+
export const defaultStsTemplate = "sts.request.xml";
|
|
103
|
+
export const defaultTokenElement = "Assertion";
|
|
48
104
|
export class UsernameToken extends Schema.Class("UsernameToken")({
|
|
49
105
|
user: Principal,
|
|
50
106
|
password: SecretRef,
|
|
@@ -69,10 +125,14 @@ export class AuthProfile extends Schema.Class("AuthProfile")({
|
|
|
69
125
|
/** Calling operations. */
|
|
70
126
|
call: Schema.optionalKey(HttpSide),
|
|
71
127
|
wsSecurity: Schema.optionalKey(UsernameToken),
|
|
128
|
+
/** A SAML bearer token from an STS, carried by every call. */
|
|
129
|
+
sts: Schema.optionalKey(StsConfig),
|
|
72
130
|
mutating: Schema.optionalKey(MutatingPolicies),
|
|
73
131
|
timeoutSeconds: Schema.optionalKey(Schema.Int)
|
|
74
132
|
}) {
|
|
75
133
|
}
|
|
134
|
+
/** `auth.json`, or `auth.<env>.json` when an environment is selected. */
|
|
135
|
+
export const profileFileName = (env) => env === undefined || env.trim() === "" ? "auth.json" : `auth.${env.trim()}.json`;
|
|
76
136
|
export const defaultMutatingPolicy = (environment) => environment === "dev" ? "confirm" : "flag";
|
|
77
137
|
export const mutatingPolicy = (profile) => profile.mutating?.[profile.environment] ?? defaultMutatingPolicy(profile.environment);
|
|
78
138
|
export class AuthProfileError extends Schema.TaggedError()("AuthProfileError", {
|
|
@@ -84,11 +144,11 @@ export class AuthProfileError extends Schema.TaggedError()("AuthProfileError", {
|
|
|
84
144
|
return `auth profile: ${this.subject}: ${this.detail}`;
|
|
85
145
|
}
|
|
86
146
|
}
|
|
87
|
-
export const decodeAuthProfile = (text) => Schema.decodeUnknownEffect(Schema.fromJsonString(AuthProfile))(text).pipe(Effect.mapError((error) =>
|
|
147
|
+
export const decodeAuthProfile = (text, file = "auth.json") => Schema.decodeUnknownEffect(Schema.fromJsonString(AuthProfile))(text).pipe(Effect.mapError((error) =>
|
|
88
148
|
// Schema issues quote the offending input; a mistyped secret could be
|
|
89
149
|
// in it, so only the paths of the issue are kept.
|
|
90
150
|
new AuthProfileError({
|
|
91
|
-
subject:
|
|
151
|
+
subject: file,
|
|
92
152
|
detail: `invalid profile (${issuePaths(error.message)})`
|
|
93
153
|
})));
|
|
94
154
|
/** Keep only the `at ["a"]["b"]` locations of a schema message, dropping any quoted value. */
|
|
@@ -137,27 +197,81 @@ export const resolveTls = (source, tls) => tls === undefined
|
|
|
137
197
|
const cert = yield* optionalSecret(source, "cert", tls.cert);
|
|
138
198
|
const key = yield* optionalSecret(source, "key", tls.key);
|
|
139
199
|
const pfx = yield* optionalSecret(source, "pfx", tls.pfx);
|
|
140
|
-
const
|
|
200
|
+
const caFile = tls.ca === undefined ? undefined : Redacted.value(yield* resolveSecret(source, tls.ca));
|
|
201
|
+
let caDir;
|
|
202
|
+
if (tls.caDir !== undefined) {
|
|
203
|
+
if (source.readPemDirectory === undefined) {
|
|
204
|
+
return yield* new AuthProfileError({
|
|
205
|
+
subject: "tls.caDir",
|
|
206
|
+
detail: "this source cannot read a directory"
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
caDir = yield* source.readPemDirectory(tls.caDir);
|
|
210
|
+
}
|
|
211
|
+
const ca = caFile === undefined && caDir === undefined
|
|
212
|
+
? {}
|
|
213
|
+
: { ca: Redacted.make(concatPem(caFile, caDir)) };
|
|
141
214
|
const passphrase = tls.passphrase === undefined
|
|
142
215
|
? {}
|
|
143
216
|
: { passphrase: yield* resolveText(source, tls.passphrase) };
|
|
144
|
-
return {
|
|
217
|
+
return {
|
|
218
|
+
...cert,
|
|
219
|
+
...key,
|
|
220
|
+
...pfx,
|
|
221
|
+
...ca,
|
|
222
|
+
...passphrase,
|
|
223
|
+
...(tls.trust === undefined ? {} : { trust: tls.trust }),
|
|
224
|
+
...(tls.servername === undefined ? {} : { servername: tls.servername })
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
const concatPem = (...parts) => {
|
|
228
|
+
const present = parts.filter((part) => part !== undefined);
|
|
229
|
+
const joined = present.map((part) => new TextDecoder().decode(part).trimEnd()).join("\n");
|
|
230
|
+
return new TextEncoder().encode(`${joined}\n`);
|
|
231
|
+
};
|
|
232
|
+
/** Whether a host bypasses the proxy: exact match, or `*.suffix` / `.suffix`. */
|
|
233
|
+
export const bypassesProxy = (host, noProxy) => {
|
|
234
|
+
const lower = host.toLowerCase();
|
|
235
|
+
return noProxy.some((entry) => {
|
|
236
|
+
const pattern = entry.trim().toLowerCase();
|
|
237
|
+
if (pattern === "")
|
|
238
|
+
return false;
|
|
239
|
+
if (pattern === "*")
|
|
240
|
+
return true;
|
|
241
|
+
if (pattern.startsWith("*.") || pattern.startsWith(".")) {
|
|
242
|
+
const suffix = pattern.replace(/^\*/, "");
|
|
243
|
+
return lower.endsWith(suffix) && lower.length > suffix.length;
|
|
244
|
+
}
|
|
245
|
+
return lower === pattern;
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
export const resolveProxy = (source, proxy) => proxy === undefined || proxy === "none"
|
|
249
|
+
? Effect.succeed(undefined)
|
|
250
|
+
: Effect.gen(function* () {
|
|
251
|
+
const authorization = proxy.auth === undefined
|
|
252
|
+
? {}
|
|
253
|
+
: {
|
|
254
|
+
authorization: basicHeader(yield* resolvePrincipal(source, proxy.auth.user), yield* resolveText(source, proxy.auth.password))
|
|
255
|
+
};
|
|
256
|
+
return { url: proxy.url, noProxy: proxy.noProxy ?? [], ...authorization };
|
|
145
257
|
});
|
|
146
258
|
const basicHeader = (user, password) => Redacted.make(`Basic ${Buffer.from(`${user}:${Redacted.value(password)}`, "utf8").toString("base64")}`);
|
|
147
259
|
export const resolveSide = (source, side) => Effect.gen(function* () {
|
|
148
260
|
const auth = side?.auth;
|
|
149
261
|
const tls = yield* resolveTls(source, side?.tls);
|
|
262
|
+
const proxy = yield* resolveProxy(source, side?.proxy);
|
|
150
263
|
if (auth === undefined || auth.scheme === "none")
|
|
151
|
-
return { headers: [], tls };
|
|
264
|
+
return { headers: [], tls, proxy };
|
|
152
265
|
if (auth.scheme === "basic") {
|
|
153
266
|
const user = yield* resolvePrincipal(source, auth.user);
|
|
154
267
|
const password = yield* resolveText(source, auth.password);
|
|
155
|
-
return { headers: [["authorization", basicHeader(user, password)]], tls };
|
|
268
|
+
return { headers: [["authorization", basicHeader(user, password)]], tls, proxy };
|
|
156
269
|
}
|
|
157
270
|
const token = yield* resolveText(source, auth.token);
|
|
158
271
|
return {
|
|
159
272
|
headers: [["authorization", Redacted.make(`Bearer ${Redacted.value(token)}`)]],
|
|
160
|
-
tls
|
|
273
|
+
tls,
|
|
274
|
+
proxy
|
|
161
275
|
};
|
|
162
276
|
});
|
|
163
277
|
export const resolveUsernameToken = (source, token) => token === undefined
|
|
@@ -175,5 +289,13 @@ export const nodeSecretSource = (environment = process.env) => ({
|
|
|
175
289
|
readFile: (path) => Effect.tryPromise({
|
|
176
290
|
try: async () => new Uint8Array(await readFile(path)),
|
|
177
291
|
catch: () => new AuthProfileError({ subject: `file:${path}`, detail: "not readable" })
|
|
292
|
+
}),
|
|
293
|
+
readPemDirectory: (path) => Effect.tryPromise({
|
|
294
|
+
try: async () => {
|
|
295
|
+
const names = (await readdir(path)).filter((name) => /\.(pem|crt|cer)$/i.test(name)).sort();
|
|
296
|
+
const parts = await Promise.all(names.map((name) => readFile(join(path, name))));
|
|
297
|
+
return concatPem(...parts.map((part) => new Uint8Array(part)));
|
|
298
|
+
},
|
|
299
|
+
catch: () => new AuthProfileError({ subject: `tls.caDir ${path}`, detail: "not readable" })
|
|
178
300
|
})
|
|
179
301
|
});
|
|
@@ -7,7 +7,7 @@ import { resolveSide, resolveUsernameToken } from "./Auth.js";
|
|
|
7
7
|
import { checkCall } from "./CallPolicy.js";
|
|
8
8
|
import { elementByName, operationByName } from "./Catalog.js";
|
|
9
9
|
import { effectiveClass } from "./Classification.js";
|
|
10
|
-
import { buildEnvelope, readEnvelope, safeHeaders, soapHeaders,
|
|
10
|
+
import { buildEnvelope, readEnvelope, safeHeaders, soapHeaders, securityHeader, stripSecurity } from "./Envelope.js";
|
|
11
11
|
import { instanceFromXml, instanceToXml, validateInstance } from "./Instance.js";
|
|
12
12
|
import { kindsFromCatalog, maskDocument, MaskingReport, mergeReports } from "./Masking.js";
|
|
13
13
|
import { Exchange, issueRecords, loadMaskingKey, loadMaskingOverrides, readRequestFile, RecordedMessage, RecordedResponse, samplePaths, writeExchange } from "./Samples.js";
|
|
@@ -92,12 +92,23 @@ export const callOperation = (options) => Effect.gen(function* () {
|
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
94
|
const side = yield* resolveSide(options.secrets, profile.call);
|
|
95
|
-
const
|
|
95
|
+
const usernameToken = yield* resolveUsernameToken(options.secrets, profile.wsSecurity);
|
|
96
96
|
const now = options.now ?? (() => new Date());
|
|
97
97
|
const random = options.random ?? ((size) => new Uint8Array(randomBytes(size)));
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
if (profile.sts !== undefined && options.token === undefined) {
|
|
99
|
+
return yield* new CallSetupError({
|
|
100
|
+
operation: operation.name,
|
|
101
|
+
detail: "the profile names an STS but the call has no token source"
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const saml = options.token === undefined ? undefined : yield* options.token.current;
|
|
105
|
+
const created = now().toISOString();
|
|
106
|
+
const header = securityHeader({
|
|
107
|
+
...(usernameToken === undefined ? {} : { usernameToken }),
|
|
108
|
+
...(saml === undefined ? {} : { token: saml.token }),
|
|
109
|
+
...(profile.sts?.timestamp === false || saml === undefined ? {} : { timestamp: { created } }),
|
|
110
|
+
material: { nonce: random(16), created }
|
|
111
|
+
});
|
|
101
112
|
const bodyXml = instanceToXml(catalog, input, request.body);
|
|
102
113
|
const envelope = buildEnvelope(operation.soapVersion, bodyXml, header);
|
|
103
114
|
const headers = soapHeaders(operation.soapVersion, operation.soapAction);
|
|
@@ -108,6 +119,7 @@ export const callOperation = (options) => Effect.gen(function* () {
|
|
|
108
119
|
secretHeaders: side.headers,
|
|
109
120
|
body: envelope,
|
|
110
121
|
...(side.tls === undefined ? {} : { tls: side.tls }),
|
|
122
|
+
...(side.proxy === undefined ? {} : { proxy: side.proxy }),
|
|
111
123
|
timeout: Duration.seconds(profile.timeoutSeconds ?? 60)
|
|
112
124
|
});
|
|
113
125
|
const key = yield* loadMaskingKey(workspace, service, random);
|
|
@@ -2,7 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
import * as Effect from "effect/Effect";
|
|
3
3
|
import * as Schema from "effect/Schema";
|
|
4
4
|
import { ComplexTypeDef, elementByName, localName, splitClark, typeByName, WsdlCatalog } from "./Catalog.js";
|
|
5
|
-
import { decodeAuthProfile } from "./Auth.js";
|
|
5
|
+
import { AuthProfileError, decodeAuthProfile, profileFileName } from "./Auth.js";
|
|
6
6
|
import { classifyOperations, operationsDrift, parseOperationsFile, renderOperationsFile } from "./Classification.js";
|
|
7
7
|
import { readCatalog } from "./Wsdl.js";
|
|
8
8
|
// `soap-discover` as a library call: read the catalog, persist it with a
|
|
@@ -26,10 +26,14 @@ export const servicePaths = (service) => {
|
|
|
26
26
|
summary: `${directory}/catalog.md`,
|
|
27
27
|
operations: `${directory}/operations.md`,
|
|
28
28
|
auth: `${directory}/auth.json`,
|
|
29
|
+
/** Captured server chains, one PEM per host (`trust`). */
|
|
30
|
+
trustDir: `${directory}/trust`,
|
|
29
31
|
masking: `${directory}/masking.json`,
|
|
30
32
|
gitignore: `${directory}/.gitignore`
|
|
31
33
|
};
|
|
32
34
|
};
|
|
35
|
+
/** The profile file an environment selects: `auth.json`, or `auth.<env>.json`. */
|
|
36
|
+
export const profilePath = (service, env) => `${serviceDirectory(service)}/${profileFileName(env)}`;
|
|
33
37
|
/** A directory-safe service name: explicit, else the WSDL's service, else the file name. */
|
|
34
38
|
export const serviceName = (catalog, location, explicit) => {
|
|
35
39
|
const raw = explicit ??
|
|
@@ -39,8 +43,32 @@ export const serviceName = (catalog, location, explicit) => {
|
|
|
39
43
|
return safe === "" ? "service" : safe;
|
|
40
44
|
};
|
|
41
45
|
/** What never leaves the machine: credentials references, raw captures, the masking key. */
|
|
42
|
-
export const serviceGitignore = [
|
|
43
|
-
|
|
46
|
+
export const serviceGitignore = [
|
|
47
|
+
"auth.json",
|
|
48
|
+
"auth.*.json",
|
|
49
|
+
"trust/",
|
|
50
|
+
"raw/",
|
|
51
|
+
".masking-key",
|
|
52
|
+
""
|
|
53
|
+
].join("\n");
|
|
54
|
+
/**
|
|
55
|
+
* The profile of the selected environment (`LLM4TS_SOAP_ENV` / `--env`):
|
|
56
|
+
* `auth.<env>.json` when one is named, which must then exist; else `auth.json`.
|
|
57
|
+
*/
|
|
58
|
+
export const loadAuthProfile = (workspace, service, env) => Effect.gen(function* () {
|
|
59
|
+
const path = profilePath(service, env);
|
|
60
|
+
const text = yield* readIfPresent(workspace, path);
|
|
61
|
+
if (text === undefined) {
|
|
62
|
+
if (env !== undefined && env.trim() !== "") {
|
|
63
|
+
return yield* new AuthProfileError({
|
|
64
|
+
subject: path,
|
|
65
|
+
detail: `no profile for environment '${env.trim()}'; write it, or unset LLM4TS_SOAP_ENV`
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return yield* decodeAuthProfile(text, path.split("/").at(-1) ?? "auth.json");
|
|
71
|
+
});
|
|
44
72
|
export const readIfPresent = (workspace, path) => Effect.flatMap(workspace.discover(path), (found) => found.includes(path) ? workspace.read(path) : Effect.succeed(undefined));
|
|
45
73
|
export const encodeCatalog = (catalog) => `${JSON.stringify(Schema.encodeSync(WsdlCatalog)(catalog), null, 2)}\n`;
|
|
46
74
|
export const loadCatalog = (workspace, service) => {
|
|
@@ -25,21 +25,46 @@ export const passwordDigest = (material, password) => createHash("sha1")
|
|
|
25
25
|
.update(material.created, "utf8")
|
|
26
26
|
.update(password, "utf8")
|
|
27
27
|
.digest("base64");
|
|
28
|
-
|
|
28
|
+
const usernameTokenElement = (token, material) => {
|
|
29
29
|
const password = Redacted.value(token.password);
|
|
30
30
|
const passwordValue = token.passwordType === "digest" ? passwordDigest(material, password) : password;
|
|
31
31
|
const type = token.passwordType === "digest" ? "PasswordDigest" : "PasswordText";
|
|
32
|
-
return
|
|
33
|
-
`<wsse:Security xmlns:wsse="${WsseNamespace}" xmlns:wsu="${WsuNamespace}" soapenv:mustUnderstand="1">`,
|
|
32
|
+
return [
|
|
34
33
|
'<wsse:UsernameToken wsu:Id="UsernameToken-1">',
|
|
35
34
|
`<wsse:Username>${escapeText(token.user)}</wsse:Username>`,
|
|
36
35
|
`<wsse:Password Type="${TokenProfile}#${type}">${escapeText(passwordValue)}</wsse:Password>`,
|
|
37
36
|
`<wsse:Nonce EncodingType="${Base64Binary}">${Buffer.from(material.nonce).toString("base64")}</wsse:Nonce>`,
|
|
38
37
|
`<wsu:Created>${material.created}</wsu:Created>`,
|
|
39
|
-
"</wsse:UsernameToken>"
|
|
38
|
+
"</wsse:UsernameToken>"
|
|
39
|
+
].join("");
|
|
40
|
+
};
|
|
41
|
+
const fiveMinutesAfter = (created) => new Date(new Date(created).getTime() + 5 * 60_000).toISOString();
|
|
42
|
+
/**
|
|
43
|
+
* The WS-Security header, in the order ESBs expect: Timestamp, then the
|
|
44
|
+
* UsernameToken, then the bearer token. Empty parts are left out; no parts
|
|
45
|
+
* at all gives no header.
|
|
46
|
+
*/
|
|
47
|
+
export const securityHeader = (parts) => {
|
|
48
|
+
const inner = [];
|
|
49
|
+
if (parts.timestamp !== undefined) {
|
|
50
|
+
inner.push('<wsu:Timestamp wsu:Id="TS-1">', `<wsu:Created>${parts.timestamp.created}</wsu:Created>`, `<wsu:Expires>${parts.timestamp.expires ?? fiveMinutesAfter(parts.timestamp.created)}</wsu:Expires>`, "</wsu:Timestamp>");
|
|
51
|
+
}
|
|
52
|
+
if (parts.usernameToken !== undefined) {
|
|
53
|
+
inner.push(usernameTokenElement(parts.usernameToken, parts.material));
|
|
54
|
+
}
|
|
55
|
+
if (parts.token !== undefined) {
|
|
56
|
+
inner.push(Redacted.value(parts.token));
|
|
57
|
+
}
|
|
58
|
+
if (inner.length === 0) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return Redacted.make([
|
|
62
|
+
`<wsse:Security xmlns:wsse="${WsseNamespace}" xmlns:wsu="${WsuNamespace}" soapenv:mustUnderstand="1">`,
|
|
63
|
+
...inner,
|
|
40
64
|
"</wsse:Security>"
|
|
41
65
|
].join(""));
|
|
42
66
|
};
|
|
67
|
+
export const usernameTokenHeader = (token, material) => securityHeader({ usernameToken: token, material }) ?? Redacted.make("");
|
|
43
68
|
/**
|
|
44
69
|
* Wrap a body element (serialized XML, carrying its own namespace
|
|
45
70
|
* declarations) in an envelope. The header, when present, is secret
|
|
@@ -247,6 +247,7 @@ export const exploreService = (options) => Effect.gen(function* () {
|
|
|
247
247
|
operations: probeFile,
|
|
248
248
|
secrets: options.secrets,
|
|
249
249
|
transport: options.transport,
|
|
250
|
+
...(options.token === undefined ? {} : { token: options.token }),
|
|
250
251
|
allowMutating: undefined,
|
|
251
252
|
confirm: () => Effect.succeed(false),
|
|
252
253
|
...(body === undefined
|