modelos-secure 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -29,6 +29,8 @@ npx modelos-secure login --url <address> --client-id <client ID> --client-secret
29
29
 
30
30
  Paste the workspace token when it asks. The token is never typed on the command line. Your browser then opens for you to sign in with Google. The address, the token and the Google sign-in are kept in `~/.modelos-secure/credentials.json`, readable only by you, and the sign-in is renewed as it runs out. Without `--client-id`, as on a local stack, there is no Google sign-in.
31
31
 
32
+ When the app is reached at an IP address and its certificate is issued for a name, the login command also has `--tls-name` and that name. The CLI then connects to the address and checks the certificate against the name: its chain, its dates and the name are all still checked. `MODELOS_SECURE_TLS_NAME` does the same for one run. When something cannot be reached, the CLI says why in plain words.
33
+
32
34
  `MODELOS_SECURE_URL` and `MODELOS_SECURE_TOKEN` take the place of the saved address and token. `modelos-secure doctor` says who is signed in to Google, and why a request was turned away.
33
35
 
34
36
  ## In CI
@@ -0,0 +1 @@
1
+ export { t as loginCommand } from "./login-JPkrnvZC.js";
@@ -5,6 +5,8 @@ import { execFile, spawn } from "node:child_process";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { createServer } from "node:http";
7
7
  import { emitKeypressEvents } from "node:readline";
8
+ import { request } from "node:https";
9
+ import { checkServerIdentity } from "node:tls";
8
10
  // src/ui.ts
9
11
  var color = process.stdout.isTTY && !process.env.NO_COLOR;
10
12
  var unicode = process.env.TERM !== "dumb" && !process.env.MODELOS_SECURE_ASCII;
@@ -276,6 +278,61 @@ function gcloudIdentityToken(audience) {
276
278
  });
277
279
  });
278
280
  }
281
+ // src/tls.ts
282
+ /** A fetch that connects to the URL's host and checks its certificate against `name`. */
283
+ function fetchCheckingName(name) {
284
+ return (url, init = {}) => new Promise((resolve, reject) => {
285
+ const outgoing = request(new URL(url), {
286
+ method: init.method ?? "GET",
287
+ headers: init.headers,
288
+ servername: name,
289
+ checkServerIdentity: (_host, certificate) => checkServerIdentity(name, certificate),
290
+ signal: init.signal
291
+ }, (incoming) => {
292
+ const chunks = [];
293
+ incoming.on("data", (chunk) => chunks.push(chunk));
294
+ incoming.on("error", reject);
295
+ incoming.on("end", () => {
296
+ const headers = new Headers();
297
+ for (const [key, value] of Object.entries(incoming.headers)) if (value !== void 0) headers.set(key, Array.isArray(value) ? value.join(", ") : value);
298
+ const status = incoming.statusCode ?? 502;
299
+ resolve(new Response(status === 204 || status === 304 ? null : Buffer.concat(chunks), {
300
+ status,
301
+ headers
302
+ }));
303
+ });
304
+ });
305
+ outgoing.on("error", reject);
306
+ if (init.body !== void 0) outgoing.write(init.body);
307
+ outgoing.end();
308
+ });
309
+ }
310
+ /** What went wrong reaching an address, in plain words, from the error Node gave. */
311
+ function networkMessage(error, url, tlsName) {
312
+ const cause = error?.cause;
313
+ const code = (typeof cause?.code === "string" ? cause.code : void 0) ?? error?.code;
314
+ let host = url;
315
+ try {
316
+ host = new URL(url).hostname;
317
+ } catch {}
318
+ switch (code) {
319
+ case "ERR_TLS_CERT_ALTNAME_INVALID": return tlsName ? `The certificate at ${host} is not issued for ${tlsName}. Copy the login command from the tokens page again.` : `The certificate at ${host} is not issued for that address. If it is issued for a name, such as one in your company's domain, log in again with --tls-name and that name.`;
320
+ case "DEPTH_ZERO_SELF_SIGNED_CERT":
321
+ case "SELF_SIGNED_CERT_IN_CHAIN":
322
+ case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
323
+ case "UNABLE_TO_GET_ISSUER_CERT_LOCALLY": return `This machine does not trust the certificate at ${host}. If your company adds its own certificates to this machine, set NODE_OPTIONS=--use-system-ca and try again.`;
324
+ case "CERT_HAS_EXPIRED": return `The certificate at ${host} has expired. Tell whoever runs that address.`;
325
+ case "ENOTFOUND":
326
+ case "EAI_AGAIN": return `${host} cannot be found from this machine. Check the address.`;
327
+ case "ECONNREFUSED":
328
+ case "ECONNRESET":
329
+ case "ETIMEDOUT":
330
+ case "EHOSTUNREACH":
331
+ case "ENETUNREACH":
332
+ case "UND_ERR_CONNECT_TIMEOUT": return `${host} cannot be reached from this machine. It may only be reachable on the company network.`;
333
+ default: return `The request to ${host} failed${code ? ` (${code})` : ""}.`;
334
+ }
335
+ }
279
336
  // src/iap.ts
280
337
  /**
281
338
  * The Google credential for one connection, in this order: MODELOS_SECURE_IAP_TOKEN as it is;
@@ -369,13 +426,27 @@ function refusalKind(answer) {
369
426
  if (!json && status < 500 && (status < 300 || page)) return "not-the-app";
370
427
  return null;
371
428
  }
429
+ /**
430
+ * IAP's own words for a refusal, when it gave them as plain text, such as "Invalid bearer token.
431
+ * Audience doesn't match the allowlisted oauth clients for this application." A page gives none.
432
+ */
433
+ function iapReason(headers, text) {
434
+ if ((headers.get("content-type") ?? "").includes("html")) return void 0;
435
+ const line = text.trim().split("\n")[0]?.trim() ?? "";
436
+ if (!(headers.get("x-goog-iap-generated-response") === "true" || line.startsWith("Invalid IAP credentials")) || !line || line.startsWith("<")) return void 0;
437
+ const words = line.replace(/^Invalid IAP credentials:\s*/i, "").slice(0, 240).trim();
438
+ if (!words) return void 0;
439
+ return /[.!?]$/.test(words) ? words : `${words}.`;
440
+ }
372
441
  /** What happened, why, and what to do, in the voice of the CLI's other failures. */
373
- function refusalMessage(kind, credential, url, ci = Boolean(process.env.CI)) {
442
+ function refusalMessage(kind, credential, url, ci = Boolean(process.env.CI), detail = {}) {
374
443
  if (kind === "not-the-app") return `${url} answered, but it is not ModelOS Secure. Check the address against the login command on the tokens page.`;
375
444
  if (kind === "no-sign-in") return ci ? "This address needs a Google sign-in, and none was found. In CI, set MODELOS_SECURE_CLIENT_ID with gcloud signed in as a service account, or set MODELOS_SECURE_IAP_TOKEN." : "This address needs a Google sign-in, and this machine has none yet. Run modelos-secure login with the command from the tokens page.";
376
445
  if (kind === "not-allowed") return credential.email ? `You are signed in to Google as ${credential.email}, but that account is not allowed through to ModelOS Secure. Ask whoever runs ModelOS Secure to give it access.` : "This Google account is not allowed through to ModelOS Secure. Ask whoever runs ModelOS Secure to give it access.";
377
- if (credential.source === "env") return "Google refused the token in MODELOS_SECURE_IAP_TOKEN: it has expired, or it was made for another client ID. Make a new one for the client ID on the tokens page.";
378
- if (credential.source === "gcloud") return "Google refused the identity token from gcloud: it was made for another client ID, or the account is not a service account. Check MODELOS_SECURE_CLIENT_ID against the tokens page.";
446
+ const said = detail.reason ? `It said: ${detail.reason}` : detail.redirected ? "It sent the request to its sign-in page instead." : "";
447
+ if (credential.source === "env") return `Google refused the token in MODELOS_SECURE_IAP_TOKEN: it has expired, or it was made for another client ID. Make a new one for the client ID on the tokens page.${said ? ` ${said}` : ""}`;
448
+ if (credential.source === "gcloud") return `Google refused the identity token from gcloud: it was made for another client ID, or the account is not a service account. Check MODELOS_SECURE_CLIENT_ID against the tokens page.${said ? ` ${said}` : ""}`;
449
+ if (said) return `Google did not let your sign-in through to ModelOS Secure. ${said} Run modelos-secure login again. If it says the same, send this message to whoever runs ModelOS Secure.`;
379
450
  return "Google refused your sign-in: it has expired or was revoked. Run modelos-secure login again.";
380
451
  }
381
452
  // src/api.ts
@@ -390,14 +461,14 @@ async function readConnection() {
390
461
  const url = process.env.MODELOS_SECURE_URL ?? saved?.url;
391
462
  const token = process.env.MODELOS_SECURE_TOKEN ?? saved?.token;
392
463
  if (!url || !token) return null;
393
- const google = saved?.google && saved.url && trim(saved.url) === trim(url) ? saved.google : void 0;
394
- return google ? {
464
+ const same = Boolean(saved?.url && trim(saved.url) === trim(url));
465
+ const google = same ? saved?.google : void 0;
466
+ const tlsName = process.env.MODELOS_SECURE_TLS_NAME ?? (same ? saved?.tlsName : void 0);
467
+ return {
395
468
  url: trim(url),
396
469
  token,
397
- google
398
- } : {
399
- url: trim(url),
400
- token
470
+ ...google ? { google } : {},
471
+ ...tlsName ? { tlsName } : {}
401
472
  };
402
473
  }
403
474
  /** The same connection at another address. The Google sign-in goes only to the same origin. */
@@ -431,7 +502,8 @@ async function saveRenewed(url, google) {
431
502
  if (saved?.url && saved.token && trim(saved.url) === url && saved.google?.clientId === google.clientId) await saveConnection({
432
503
  url: saved.url,
433
504
  token: saved.token,
434
- google
505
+ google,
506
+ ...saved.tlsName ? { tlsName: saved.tlsName } : {}
435
507
  });
436
508
  }
437
509
  var ApiError = class extends Error {
@@ -468,16 +540,26 @@ function apiClient(connection, options = {}) {
468
540
  };
469
541
  const call = async (method, path, body, signal) => {
470
542
  const sent = await credential();
471
- const response = await fetch(`${connection.url}/api/v1${path}`, {
543
+ const url = `${connection.url}/api/v1${path}`;
544
+ const headers = {
545
+ authorization: `Bearer ${sent.token ?? connection.token}`,
546
+ "x-modelos-secure-token": connection.token,
547
+ ...body ? { "content-type": body.type } : {}
548
+ };
549
+ const response = await (connection.tlsName ? fetchCheckingName(connection.tlsName)(url, {
550
+ method,
551
+ headers,
552
+ body: body?.text,
553
+ signal
554
+ }) : fetch(url, {
472
555
  method,
473
556
  redirect: "manual",
474
- headers: {
475
- authorization: `Bearer ${connection.token}`,
476
- ...sent.token ? { "proxy-authorization": `Bearer ${sent.token}` } : {},
477
- ...body ? { "content-type": body.type } : {}
478
- },
557
+ headers,
479
558
  body: body?.text,
480
559
  signal
560
+ })).catch((error) => {
561
+ if (signal?.aborted) throw error;
562
+ throw new ApiError(networkMessage(error, connection.url, connection.tlsName), 0);
481
563
  });
482
564
  const text = await response.text();
483
565
  let parsed;
@@ -492,7 +574,10 @@ function apiClient(connection, options = {}) {
492
574
  body: parsed,
493
575
  sent: Boolean(sent.token)
494
576
  });
495
- if (kind) throw new IapRefusal(kind, response.status, refusalMessage(kind, sent, connection.url));
577
+ if (kind) throw new IapRefusal(kind, response.status, refusalMessage(kind, sent, connection.url, void 0, {
578
+ reason: iapReason(response.headers, text),
579
+ redirected: response.status >= 300 && response.status < 400
580
+ }));
496
581
  if (!response.ok) {
497
582
  const error = typeof parsed === "object" && parsed !== null ? parsed.error : void 0;
498
583
  throw new ApiError(typeof error === "string" ? error : `The server answered ${response.status.toString()}.`, response.status);
@@ -524,25 +609,29 @@ function parseLoginLine(line) {
524
609
  return {
525
610
  url: after("url"),
526
611
  clientId: after("client-id"),
527
- clientSecret: after("client-secret")
612
+ clientSecret: after("client-secret"),
613
+ tlsName: after("tls-name")
528
614
  };
529
615
  }
530
616
  async function loginCommand(given = {}) {
531
617
  let url = given.url ?? process.env.MODELOS_SECURE_URL;
532
- let { clientId, clientSecret } = given;
618
+ let { clientId, clientSecret, tlsName } = given;
533
619
  if (!url) {
534
620
  const pasted = parseLoginLine(await askText("Where is ModelOS Secure? Paste the login command from the tokens page, or type the address:"));
535
621
  url = pasted.url;
536
622
  clientId ??= pasted.clientId;
537
623
  clientSecret ??= pasted.clientSecret;
624
+ tlsName ??= pasted.tlsName;
538
625
  }
626
+ tlsName ??= process.env.MODELOS_SECURE_TLS_NAME;
539
627
  if (!url) throw new Error("A login needs the address. Copy the login command from the tokens page.");
540
628
  if (clientId && !clientSecret) throw new Error("The client ID needs its client secret. Copy the whole login command from the tokens page.");
541
629
  const token = process.env.MODELOS_SECURE_TOKEN ?? await askText("Paste a workspace token from the tokens page:", true);
542
630
  if (!token) throw new Error("A login needs a workspace token. Create one on the tokens page.");
543
631
  const connection = {
544
632
  url: url.replace(/\/$/, ""),
545
- token
633
+ token,
634
+ ...tlsName ? { tlsName } : {}
546
635
  };
547
636
  if (clientId && clientSecret) connection.google = await signInWithGoogle(clientId, clientSecret);
548
637
  const me = await apiClient(connection).get("/whoami");
package/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { _ as step, a as apiClient, c as Interrupted, d as blue, f as bold, g as say, h as red, i as IapRefusal, l as ask, m as grey, o as atAddress, p as dash, r as ApiError$1, s as readConnection, t as loginCommand, u as askText } from "./assets/login-B21lxf7i.js";
2
+ import { _ as step, a as apiClient, c as Interrupted, d as blue, f as bold, g as say, h as red, i as IapRefusal, l as ask, m as grey, o as atAddress, p as dash, r as ApiError$1, s as readConnection, t as loginCommand, u as askText } from "./assets/login-JPkrnvZC.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
@@ -19485,7 +19485,7 @@ async function scanCommand(dir, options) {
19485
19485
  label: "Enter a token now",
19486
19486
  recommended: true,
19487
19487
  apply: async () => {
19488
- const { loginCommand } = await import("./assets/login-CVPBOTvx.js");
19488
+ const { loginCommand } = await import("./assets/login-BoqCvsTW.js");
19489
19489
  await loginCommand();
19490
19490
  const fresh = await readConnection();
19491
19491
  api = fresh ? apiClient(fresh) : null;
@@ -19678,7 +19678,7 @@ function flag(name) {
19678
19678
  const index = rest.indexOf(`--${name}`);
19679
19679
  return index >= 0 ? rest[index + 1] : void 0;
19680
19680
  }
19681
- var positional = rest.filter((arg, i) => !arg.startsWith("--") && !rest[i - 1]?.match(/^--(format|out|system|instances|against|budget|url|client-id|client-secret)$/));
19681
+ var positional = rest.filter((arg, i) => !arg.startsWith("--") && !rest[i - 1]?.match(/^--(format|out|system|instances|against|budget|url|client-id|client-secret|tls-name)$/));
19682
19682
  async function connection() {
19683
19683
  const found = await readConnection();
19684
19684
  if (!found) throw new Error("No workspace token found. Run modelos-secure login, or set MODELOS_SECURE_TOKEN.");
@@ -19687,7 +19687,8 @@ async function connection() {
19687
19687
  var USAGE = `modelos-secure: test an AI system for Security, Safety and Quality.
19688
19688
 
19689
19689
  login Saves a workspace token, and signs in with Google when the
19690
- address needs it. --url, --client-id, --client-secret.
19690
+ address needs it. --url, --client-id, --client-secret,
19691
+ --tls-name.
19691
19692
  scan . Connects this repository. --offline, --explain, --yes.
19692
19693
  review Starts a review of this system. --quick, --ci, --budget.
19693
19694
  replay F-031 Replays one finding.
@@ -19703,7 +19704,8 @@ try {
19703
19704
  if (command === "login") await loginCommand({
19704
19705
  url: flag("url"),
19705
19706
  clientId: flag("client-id"),
19706
- clientSecret: flag("client-secret")
19707
+ clientSecret: flag("client-secret"),
19708
+ tlsName: flag("tls-name")
19707
19709
  });
19708
19710
  else if (command === "scan") await scanCommand(resolve(positional[0] ?? "."), {
19709
19711
  offline: has("offline"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelos-secure",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Tests AI systems for Security, Safety and Quality. One command connects a system from its code.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1 +0,0 @@
1
- export { t as loginCommand } from "./login-B21lxf7i.js";