modelos-secure 0.0.0-stage → 0.1.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/README.md +40 -2
- package/assets/login-BcHFyX6z.js +1 -0
- package/assets/login-WwE-Gunj.js +625 -0
- package/cli.js +19746 -0
- package/hook/clients.mjs +270 -0
- package/hook/hook.mjs +306 -0
- package/hook/loader.mjs +12 -0
- package/hook/rewrite.mjs +95 -0
- package/package.json +17 -3
- package/python/modelos_secure/__init__.py +0 -0
- package/python/modelos_secure/clients.py +444 -0
- package/python/modelos_secure/hook.py +542 -0
- package/python/requirements.txt +0 -0
- package/python/sitecustomize.py +13 -0
- package/wasm/tree-sitter-javascript.wasm +0 -0
- package/wasm/tree-sitter-python.wasm +0 -0
- package/wasm/tree-sitter-tsx.wasm +0 -0
- package/wasm/tree-sitter-typescript.wasm +0 -0
- package/wasm/web-tree-sitter.wasm +0 -0
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# modelos-secure
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tests an AI system for Security, Safety and Quality. One command in the system's repository connects it, with no code changes:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx modelos-secure scan .
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It reads the code, draws the system's map and asks only what it cannot work out. Everything it decides goes into `modelos-secure.yaml`. Before it sends anything it says exactly what leaves the machine, and `--explain` prints the outline without sending it. `--offline` keeps everything on your machine.
|
|
10
|
+
|
|
11
|
+
| Command | Does |
|
|
12
|
+
| --------------------------------------------- | -------------------------------------------------------------- |
|
|
13
|
+
| `modelos-secure login` | Saves a workspace token, and signs in with Google if needed. |
|
|
14
|
+
| `modelos-secure scan .` | Connects this repository. `--offline`, `--explain`, `--yes`. |
|
|
15
|
+
| `modelos-secure review` | Starts a review of this system. `--quick`, `--ci`, `--budget`. |
|
|
16
|
+
| `modelos-secure replay F-031` | Replays one finding. |
|
|
17
|
+
| `modelos-secure doctor` | Checks the setup and fixes what it can. |
|
|
18
|
+
| `modelos-secure export R-1179 --format sarif` | Writes a review's findings as SARIF or JSON. |
|
|
19
|
+
|
|
20
|
+
During a review, actions are held and never run. Outside a review the hooks do nothing. Requires Node 20 or newer. Python apps need Python 3.10 or newer.
|
|
21
|
+
|
|
22
|
+
## Signing in
|
|
23
|
+
|
|
24
|
+
Copy the login command from the workspace tokens page in the app. It has the app's address and, when the app is behind Google sign-in, the Google client to sign in with:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx modelos-secure login --url <address> --client-id <client ID> --client-secret <client secret>
|
|
28
|
+
```
|
|
29
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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.
|
|
35
|
+
|
|
36
|
+
## In CI
|
|
37
|
+
|
|
38
|
+
CI has no browser, so it signs in to Google as a service account. The service account needs IAP-secured Web App User on the app, and the client ID must be the one on the tokens page. Set `MODELOS_SECURE_URL` and `MODELOS_SECURE_TOKEN`, then one of these:
|
|
39
|
+
|
|
40
|
+
- `MODELOS_SECURE_CLIENT_ID`, the client ID from the tokens page, with gcloud signed in as the service account. The CLI runs `gcloud auth print-identity-token --audiences=<client ID>` and runs it again as the token runs out, so it suits reviews of any length. It does not work with a person's own gcloud sign-in.
|
|
41
|
+
- `MODELOS_SECURE_IAP_TOKEN`, an ID token for the service account with the client ID as its audience. It is sent as it is and never renewed, and it lasts about an hour. In GitHub Actions, `google-github-actions/auth` makes one with `token_format: id_token`, `id_token_audience` set to the client ID and `id_token_include_email: true`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as loginCommand } from "./login-WwE-Gunj.js";
|
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { emitKeypressEvents } from "node:readline";
|
|
8
|
+
import { request } from "node:https";
|
|
9
|
+
import { checkServerIdentity } from "node:tls";
|
|
10
|
+
// src/ui.ts
|
|
11
|
+
var color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
12
|
+
var unicode = process.env.TERM !== "dumb" && !process.env.MODELOS_SECURE_ASCII;
|
|
13
|
+
var paint = (code, text) => color ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
14
|
+
var red = (t) => paint("31", t);
|
|
15
|
+
var blue = (t) => paint("34", t);
|
|
16
|
+
var grey = (t) => paint("90", t);
|
|
17
|
+
var bold = (t) => paint("1", t);
|
|
18
|
+
var interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY) && !process.env.CI;
|
|
19
|
+
var pointer = unicode ? "›" : ">";
|
|
20
|
+
var dash = unicode ? "–" : "-";
|
|
21
|
+
function say(line = "") {
|
|
22
|
+
console.log(line);
|
|
23
|
+
}
|
|
24
|
+
var STEP_WIDTH = 13;
|
|
25
|
+
function step(name, detail, state = "done") {
|
|
26
|
+
const right = state === "done" ? "done" : "";
|
|
27
|
+
say(` ${bold(name.padEnd(STEP_WIDTH))}${detail}${right ? ` ${grey(right)}` : ""}`);
|
|
28
|
+
}
|
|
29
|
+
/** One question, one reason, a default. Arrows, Enter and the first letter of each choice. */
|
|
30
|
+
async function ask(question, why, choices) {
|
|
31
|
+
const start = Math.max(0, choices.findIndex((c) => c.recommended));
|
|
32
|
+
say();
|
|
33
|
+
say(` ${question}`);
|
|
34
|
+
say();
|
|
35
|
+
if (!interactive()) {
|
|
36
|
+
const chosen = choices[start];
|
|
37
|
+
say(` ${pointer} ${chosen.label}`);
|
|
38
|
+
return chosen.value;
|
|
39
|
+
}
|
|
40
|
+
let index = start;
|
|
41
|
+
const render = (first) => {
|
|
42
|
+
if (!first) process.stdout.write(`\u001b[${choices.length.toString()}A`);
|
|
43
|
+
for (const [i, choice] of choices.entries()) {
|
|
44
|
+
const mark = i === index ? blue(pointer) : " ";
|
|
45
|
+
process.stdout.write(`\u001b[2K ${mark} ${choice.label}${choice.recommended ? grey(" (recommended)") : ""}\n`);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
render(true);
|
|
49
|
+
if (why) say(`\n ${grey(`Why we ask: ${why}`)}`);
|
|
50
|
+
if (why) process.stdout.write(`\u001b[2A`);
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
emitKeypressEvents(process.stdin);
|
|
53
|
+
process.stdin.setRawMode(true);
|
|
54
|
+
process.stdin.resume();
|
|
55
|
+
const done = () => {
|
|
56
|
+
process.stdin.off("keypress", onKey);
|
|
57
|
+
process.stdin.setRawMode(false);
|
|
58
|
+
process.stdin.pause();
|
|
59
|
+
if (why) process.stdout.write(`\u001b[2B\n`);
|
|
60
|
+
};
|
|
61
|
+
const onKey = (text, key) => {
|
|
62
|
+
if (key.ctrl && key.name === "c") {
|
|
63
|
+
done();
|
|
64
|
+
reject(new Interrupted());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (key.name === "up") index = (index - 1 + choices.length) % choices.length;
|
|
68
|
+
else if (key.name === "down") index = (index + 1) % choices.length;
|
|
69
|
+
else if (key.name === "return") {
|
|
70
|
+
done();
|
|
71
|
+
resolve(choices[index].value);
|
|
72
|
+
return;
|
|
73
|
+
} else if (text) {
|
|
74
|
+
const match = choices.findIndex((c) => c.label.toLowerCase().startsWith(text.toLowerCase()));
|
|
75
|
+
if (match >= 0) index = match;
|
|
76
|
+
}
|
|
77
|
+
render(false);
|
|
78
|
+
};
|
|
79
|
+
process.stdin.on("keypress", onKey);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Reads a line. Used only when a person has to type something, such as a command. */
|
|
83
|
+
async function askText(question, secret = false) {
|
|
84
|
+
process.stdout.write(` ${question} `);
|
|
85
|
+
if (!interactive()) return "";
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
let value = "";
|
|
88
|
+
emitKeypressEvents(process.stdin);
|
|
89
|
+
process.stdin.setRawMode(true);
|
|
90
|
+
process.stdin.resume();
|
|
91
|
+
const onKey = (text, key) => {
|
|
92
|
+
if (key.ctrl && key.name === "c") {
|
|
93
|
+
process.stdin.setRawMode(false);
|
|
94
|
+
process.exit(130);
|
|
95
|
+
}
|
|
96
|
+
if (key.name === "return") {
|
|
97
|
+
process.stdin.off("keypress", onKey);
|
|
98
|
+
process.stdin.setRawMode(false);
|
|
99
|
+
process.stdin.pause();
|
|
100
|
+
process.stdout.write("\n");
|
|
101
|
+
resolve(value.trim());
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (key.name === "backspace") value = value.slice(0, -1);
|
|
105
|
+
else if (text && !key.ctrl) {
|
|
106
|
+
value += text;
|
|
107
|
+
if (!secret) process.stdout.write(text);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
process.stdin.on("keypress", onKey);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
var Interrupted = class extends Error {};
|
|
114
|
+
// src/google.ts
|
|
115
|
+
var AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
116
|
+
var TOKEN = "https://oauth2.googleapis.com/token";
|
|
117
|
+
/** A person has this long to finish signing in in the browser. */
|
|
118
|
+
var SIGN_IN_WAIT_MS = 3e5;
|
|
119
|
+
/** The sign-in was refused by Google: it expired, or was revoked. Only login can mend it. */
|
|
120
|
+
var SignInRefused = class extends Error {};
|
|
121
|
+
/** Reads a JWT's claims without checking its signature. Only for showing who and when. */
|
|
122
|
+
function claims(token) {
|
|
123
|
+
try {
|
|
124
|
+
const payload = token.split(".")[1] ?? "";
|
|
125
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
126
|
+
return {
|
|
127
|
+
email: typeof parsed.email === "string" ? parsed.email : void 0,
|
|
128
|
+
exp: typeof parsed.exp === "number" ? parsed.exp : void 0
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
var expiresAt = (token) => {
|
|
135
|
+
const exp = claims(token).exp;
|
|
136
|
+
return exp === void 0 ? void 0 : exp * 1e3;
|
|
137
|
+
};
|
|
138
|
+
var fresh = (expires, now = Date.now()) => expires !== void 0 && expires - 6e4 > now;
|
|
139
|
+
async function tokenRequest(form) {
|
|
140
|
+
const response = await fetch(TOKEN, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
143
|
+
body: new URLSearchParams(form).toString()
|
|
144
|
+
}).catch((error) => {
|
|
145
|
+
throw new Error(`I can't reach Google to sign in: ${error instanceof Error ? error.message : String(error)}.`);
|
|
146
|
+
});
|
|
147
|
+
const body = await response.json().catch(() => ({}));
|
|
148
|
+
return {
|
|
149
|
+
status: response.status,
|
|
150
|
+
body
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function signedIn(previous, body) {
|
|
154
|
+
if (!body.id_token) throw new Error("Google answered without an ID token. Check that the client ID on the tokens page is a Desktop app client.");
|
|
155
|
+
return {
|
|
156
|
+
...previous,
|
|
157
|
+
refreshToken: body.refresh_token ?? previous.refreshToken,
|
|
158
|
+
idToken: body.id_token,
|
|
159
|
+
expiresAt: expiresAt(body.id_token),
|
|
160
|
+
email: claims(body.id_token).email ?? previous.email
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Renews the ID token with the refresh token. Google needs the client secret for this too. */
|
|
164
|
+
async function renew(signIn) {
|
|
165
|
+
if (!signIn.refreshToken) throw new SignInRefused();
|
|
166
|
+
const { status, body } = await tokenRequest({
|
|
167
|
+
client_id: signIn.clientId,
|
|
168
|
+
client_secret: signIn.clientSecret,
|
|
169
|
+
refresh_token: signIn.refreshToken,
|
|
170
|
+
grant_type: "refresh_token"
|
|
171
|
+
});
|
|
172
|
+
if (body.error === "invalid_grant" || status === 401) throw new SignInRefused();
|
|
173
|
+
if (status !== 200) throw new Error(`Google did not renew the sign-in: ${body.error_description ?? body.error ?? `it answered ${status.toString()}`}.`);
|
|
174
|
+
return signedIn(signIn, body);
|
|
175
|
+
}
|
|
176
|
+
/** The address the browser is sent to. Exported so the request can be checked. */
|
|
177
|
+
function authorizeUrl(input) {
|
|
178
|
+
return `${AUTHORIZE}?${new URLSearchParams({
|
|
179
|
+
client_id: input.clientId,
|
|
180
|
+
redirect_uri: input.redirectUri,
|
|
181
|
+
response_type: "code",
|
|
182
|
+
scope: "openid email",
|
|
183
|
+
code_challenge: input.challenge,
|
|
184
|
+
code_challenge_method: "S256",
|
|
185
|
+
state: input.state,
|
|
186
|
+
access_type: "offline",
|
|
187
|
+
prompt: "consent"
|
|
188
|
+
}).toString()}`;
|
|
189
|
+
}
|
|
190
|
+
function openBrowser(url) {
|
|
191
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
|
|
192
|
+
try {
|
|
193
|
+
const child = spawn(command, args, {
|
|
194
|
+
stdio: "ignore",
|
|
195
|
+
detached: true
|
|
196
|
+
});
|
|
197
|
+
child.on("error", () => void 0);
|
|
198
|
+
child.unref();
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
var PAGE = (text) => `<!doctype html><meta charset="utf-8"><title>ModelOS Secure</title><body style="font: 14px/20px system-ui, sans-serif; margin: 48px">${text}</body>`;
|
|
202
|
+
/**
|
|
203
|
+
* Signs a person in with Google in their browser: the authorization code flow with PKCE and a
|
|
204
|
+
* loopback address on this machine, as Google asks of desktop apps.
|
|
205
|
+
*/
|
|
206
|
+
async function signInWithGoogle(clientId, clientSecret) {
|
|
207
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
208
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
209
|
+
const state = randomBytes(16).toString("base64url");
|
|
210
|
+
const server = createServer();
|
|
211
|
+
await new Promise((resolve, reject) => {
|
|
212
|
+
server.once("error", reject);
|
|
213
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
214
|
+
});
|
|
215
|
+
const address = server.address();
|
|
216
|
+
const redirectUri = `http://127.0.0.1:${(typeof address === "object" && address ? address.port : 0).toString()}`;
|
|
217
|
+
const { status, body } = await tokenRequest({
|
|
218
|
+
client_id: clientId,
|
|
219
|
+
client_secret: clientSecret,
|
|
220
|
+
code: await new Promise((resolve, reject) => {
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
server.close();
|
|
223
|
+
reject(/* @__PURE__ */ new Error("The Google sign-in was not finished within 5 minutes. Run modelos-secure login again."));
|
|
224
|
+
}, SIGN_IN_WAIT_MS);
|
|
225
|
+
server.on("request", (request, response) => {
|
|
226
|
+
const url = new URL(request.url ?? "/", redirectUri);
|
|
227
|
+
if (url.pathname !== "/" || url.searchParams.get("state") !== state) {
|
|
228
|
+
response.writeHead(404).end();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const error = url.searchParams.get("error");
|
|
232
|
+
const got = url.searchParams.get("code");
|
|
233
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
234
|
+
response.end(PAGE(got ? "Signed in. Go back to the terminal to finish." : "The sign-in did not finish. Go back to the terminal."));
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
server.close();
|
|
237
|
+
if (got) resolve(got);
|
|
238
|
+
else reject(/* @__PURE__ */ new Error(error === "access_denied" ? "The Google sign-in was cancelled. Run modelos-secure login again to sign in." : `Google did not sign you in: ${error ?? "no code came back"}.`));
|
|
239
|
+
});
|
|
240
|
+
const url = authorizeUrl({
|
|
241
|
+
clientId,
|
|
242
|
+
redirectUri,
|
|
243
|
+
challenge,
|
|
244
|
+
state
|
|
245
|
+
});
|
|
246
|
+
say(" Sign in with Google in your browser. If it does not open, open:");
|
|
247
|
+
say(` ${url}`);
|
|
248
|
+
openBrowser(url);
|
|
249
|
+
}),
|
|
250
|
+
code_verifier: verifier,
|
|
251
|
+
redirect_uri: redirectUri,
|
|
252
|
+
grant_type: "authorization_code"
|
|
253
|
+
});
|
|
254
|
+
if (status !== 200) throw new Error(`Google did not finish the sign-in: ${body.error_description ?? body.error ?? `it answered ${status.toString()}`}. Check the client ID and secret against the tokens page.`);
|
|
255
|
+
return signedIn({
|
|
256
|
+
clientId,
|
|
257
|
+
clientSecret
|
|
258
|
+
}, body);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* An ID token from gcloud for the given audience. This works when gcloud is signed in as a
|
|
262
|
+
* service account, as in CI, and returns null otherwise.
|
|
263
|
+
*/
|
|
264
|
+
function gcloudIdentityToken(audience) {
|
|
265
|
+
if (!/^[\w.-]+$/.test(audience)) return Promise.resolve(null);
|
|
266
|
+
return new Promise((resolve) => {
|
|
267
|
+
execFile("gcloud", [
|
|
268
|
+
"auth",
|
|
269
|
+
"print-identity-token",
|
|
270
|
+
`--audiences=${audience}`
|
|
271
|
+
], {
|
|
272
|
+
timeout: 3e4,
|
|
273
|
+
shell: process.platform === "win32",
|
|
274
|
+
windowsHide: true
|
|
275
|
+
}, (error, stdout) => {
|
|
276
|
+
const token = String(stdout).trim();
|
|
277
|
+
resolve(!error && token.split(".").length === 3 ? token : null);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
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
|
+
}
|
|
336
|
+
// src/iap.ts
|
|
337
|
+
/**
|
|
338
|
+
* The Google credential for one connection, in this order: MODELOS_SECURE_IAP_TOKEN as it is;
|
|
339
|
+
* the person signed in with login, renewed near its expiry; an ID token from gcloud when a
|
|
340
|
+
* client ID is known, which works for a service account; or nothing, as on a local stack.
|
|
341
|
+
*/
|
|
342
|
+
function proxyCredentials(google, options = {}) {
|
|
343
|
+
let signIn = google;
|
|
344
|
+
let refused = false;
|
|
345
|
+
let fromGcloud = null;
|
|
346
|
+
let pending = null;
|
|
347
|
+
const find = async () => {
|
|
348
|
+
const env = options.env ?? process.env;
|
|
349
|
+
const now = (options.now ?? Date.now)();
|
|
350
|
+
const given = env.MODELOS_SECURE_IAP_TOKEN?.trim();
|
|
351
|
+
if (given) return {
|
|
352
|
+
token: given,
|
|
353
|
+
source: "env",
|
|
354
|
+
email: claims(given).email
|
|
355
|
+
};
|
|
356
|
+
if (signIn?.refreshToken || signIn?.idToken) {
|
|
357
|
+
if (refused) throw new SignInRefused();
|
|
358
|
+
if (!signIn.idToken || !fresh(signIn.expiresAt, now)) {
|
|
359
|
+
try {
|
|
360
|
+
signIn = await (options.renew ?? renew)(signIn);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error instanceof SignInRefused) refused = true;
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
await options.saved?.(signIn).catch(() => void 0);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
token: signIn.idToken,
|
|
369
|
+
source: "signed-in",
|
|
370
|
+
email: signIn.email
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const audience = env.MODELOS_SECURE_CLIENT_ID?.trim() || signIn?.clientId;
|
|
374
|
+
if (audience) {
|
|
375
|
+
if (!fromGcloud || fromGcloud.token && !fresh(fromGcloud.expires, now)) {
|
|
376
|
+
const token = await (options.gcloud ?? gcloudIdentityToken)(audience);
|
|
377
|
+
fromGcloud = {
|
|
378
|
+
token,
|
|
379
|
+
expires: token ? expiresAt(token) : void 0
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (fromGcloud.token) return {
|
|
383
|
+
token: fromGcloud.token,
|
|
384
|
+
source: "gcloud",
|
|
385
|
+
email: claims(fromGcloud.token).email
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return { source: "none" };
|
|
389
|
+
};
|
|
390
|
+
return {
|
|
391
|
+
/** One lookup at a time, so many runners waiting at once renew the sign-in once. */
|
|
392
|
+
get() {
|
|
393
|
+
pending ??= find().finally(() => {
|
|
394
|
+
pending = null;
|
|
395
|
+
});
|
|
396
|
+
return pending;
|
|
397
|
+
} };
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Whether an answer came from IAP, or from something that is not the app, rather than from our
|
|
401
|
+
* API. Our API always answers JSON, with an `error` field when it says no. IAP answers with a
|
|
402
|
+
* page or plain text and marks it with x-goog-iap-generated-response, and may send a request
|
|
403
|
+
* with no credential to Google's sign-in page.
|
|
404
|
+
*/
|
|
405
|
+
function refusalKind(answer) {
|
|
406
|
+
const { status, headers, body, sent } = answer;
|
|
407
|
+
if (status >= 300 && status < 400) {
|
|
408
|
+
const location = headers.get("location") ?? "";
|
|
409
|
+
if (!(() => {
|
|
410
|
+
try {
|
|
411
|
+
return new URL(location).hostname === "accounts.google.com";
|
|
412
|
+
} catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
})()) return "not-the-app";
|
|
416
|
+
return sent ? "refused" : "no-sign-in";
|
|
417
|
+
}
|
|
418
|
+
const json = typeof body === "object" && body !== null;
|
|
419
|
+
const ours = json && typeof body.error === "string";
|
|
420
|
+
const fromIap = headers.get("x-goog-iap-generated-response") === "true";
|
|
421
|
+
if ((status === 401 || status === 403) && (fromIap || !ours)) {
|
|
422
|
+
if (!sent) return "no-sign-in";
|
|
423
|
+
return status === 401 ? "refused" : "not-allowed";
|
|
424
|
+
}
|
|
425
|
+
const page = (headers.get("content-type") ?? "").includes("text/html");
|
|
426
|
+
if (!json && status < 500 && (status < 300 || page)) return "not-the-app";
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
/** What happened, why, and what to do, in the voice of the CLI's other failures. */
|
|
430
|
+
function refusalMessage(kind, credential, url, ci = Boolean(process.env.CI)) {
|
|
431
|
+
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.`;
|
|
432
|
+
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.";
|
|
433
|
+
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.";
|
|
434
|
+
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.";
|
|
435
|
+
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.";
|
|
436
|
+
return "Google refused your sign-in: it has expired or was revoked. Run modelos-secure login again.";
|
|
437
|
+
}
|
|
438
|
+
// src/api.ts
|
|
439
|
+
var folder = () => join(homedir(), ".modelos-secure");
|
|
440
|
+
var credentials = () => join(folder(), "credentials.json");
|
|
441
|
+
var trim = (url) => url.replace(/\/$/, "");
|
|
442
|
+
async function readSaved() {
|
|
443
|
+
return readFile(credentials(), "utf8").then((t) => JSON.parse(t)).catch(() => null);
|
|
444
|
+
}
|
|
445
|
+
async function readConnection() {
|
|
446
|
+
const saved = await readSaved();
|
|
447
|
+
const url = process.env.MODELOS_SECURE_URL ?? saved?.url;
|
|
448
|
+
const token = process.env.MODELOS_SECURE_TOKEN ?? saved?.token;
|
|
449
|
+
if (!url || !token) return null;
|
|
450
|
+
const same = Boolean(saved?.url && trim(saved.url) === trim(url));
|
|
451
|
+
const google = same ? saved?.google : void 0;
|
|
452
|
+
const tlsName = process.env.MODELOS_SECURE_TLS_NAME ?? (same ? saved?.tlsName : void 0);
|
|
453
|
+
return {
|
|
454
|
+
url: trim(url),
|
|
455
|
+
token,
|
|
456
|
+
...google ? { google } : {},
|
|
457
|
+
...tlsName ? { tlsName } : {}
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/** The same connection at another address. The Google sign-in goes only to the same origin. */
|
|
461
|
+
function atAddress(connection, url) {
|
|
462
|
+
const origin = (u) => {
|
|
463
|
+
try {
|
|
464
|
+
return new URL(u).origin;
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
return origin(url) !== null && origin(url) === origin(connection.url) ? {
|
|
470
|
+
...connection,
|
|
471
|
+
url: trim(url)
|
|
472
|
+
} : {
|
|
473
|
+
url: trim(url),
|
|
474
|
+
token: connection.token
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
async function saveConnection(connection) {
|
|
478
|
+
await mkdir(folder(), {
|
|
479
|
+
recursive: true,
|
|
480
|
+
mode: 448
|
|
481
|
+
});
|
|
482
|
+
await writeFile(credentials(), JSON.stringify(connection), { mode: 384 });
|
|
483
|
+
await chmod(credentials(), 384);
|
|
484
|
+
}
|
|
485
|
+
/** Keeps a renewed Google sign-in, if the saved one is still the one that was renewed. */
|
|
486
|
+
async function saveRenewed(url, google) {
|
|
487
|
+
const saved = await readSaved();
|
|
488
|
+
if (saved?.url && saved.token && trim(saved.url) === url && saved.google?.clientId === google.clientId) await saveConnection({
|
|
489
|
+
url: saved.url,
|
|
490
|
+
token: saved.token,
|
|
491
|
+
google,
|
|
492
|
+
...saved.tlsName ? { tlsName: saved.tlsName } : {}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
var ApiError = class extends Error {
|
|
496
|
+
status;
|
|
497
|
+
constructor(message, status) {
|
|
498
|
+
super(message);
|
|
499
|
+
this.status = status;
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
/** IAP, or something that is not the app, answered instead of ModelOS Secure. */
|
|
503
|
+
var IapRefusal = class extends ApiError {
|
|
504
|
+
kind;
|
|
505
|
+
constructor(kind, status, message) {
|
|
506
|
+
super(message, status);
|
|
507
|
+
this.kind = kind;
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
function apiClient(connection, options = {}) {
|
|
511
|
+
const proxy = proxyCredentials(connection.google, {
|
|
512
|
+
saved: (google) => saveRenewed(connection.url, google),
|
|
513
|
+
...options
|
|
514
|
+
});
|
|
515
|
+
/** The Google credential this client sends. A refused sign-in is an IAP refusal. */
|
|
516
|
+
const credential = async () => {
|
|
517
|
+
try {
|
|
518
|
+
return await proxy.get();
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (!(error instanceof SignInRefused)) throw error;
|
|
521
|
+
throw new IapRefusal("refused", 401, refusalMessage("refused", {
|
|
522
|
+
source: "signed-in",
|
|
523
|
+
email: connection.google?.email
|
|
524
|
+
}, connection.url));
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
const call = async (method, path, body, signal) => {
|
|
528
|
+
const sent = await credential();
|
|
529
|
+
const url = `${connection.url}/api/v1${path}`;
|
|
530
|
+
const headers = {
|
|
531
|
+
authorization: `Bearer ${connection.token}`,
|
|
532
|
+
...sent.token ? { "proxy-authorization": `Bearer ${sent.token}` } : {},
|
|
533
|
+
...body ? { "content-type": body.type } : {}
|
|
534
|
+
};
|
|
535
|
+
const response = await (connection.tlsName ? fetchCheckingName(connection.tlsName)(url, {
|
|
536
|
+
method,
|
|
537
|
+
headers,
|
|
538
|
+
body: body?.text,
|
|
539
|
+
signal
|
|
540
|
+
}) : fetch(url, {
|
|
541
|
+
method,
|
|
542
|
+
redirect: "manual",
|
|
543
|
+
headers,
|
|
544
|
+
body: body?.text,
|
|
545
|
+
signal
|
|
546
|
+
})).catch((error) => {
|
|
547
|
+
if (signal?.aborted) throw error;
|
|
548
|
+
throw new ApiError(networkMessage(error, connection.url, connection.tlsName), 0);
|
|
549
|
+
});
|
|
550
|
+
const text = await response.text();
|
|
551
|
+
let parsed;
|
|
552
|
+
try {
|
|
553
|
+
parsed = JSON.parse(text);
|
|
554
|
+
} catch {
|
|
555
|
+
parsed = void 0;
|
|
556
|
+
}
|
|
557
|
+
const kind = refusalKind({
|
|
558
|
+
status: response.status,
|
|
559
|
+
headers: response.headers,
|
|
560
|
+
body: parsed,
|
|
561
|
+
sent: Boolean(sent.token)
|
|
562
|
+
});
|
|
563
|
+
if (kind) throw new IapRefusal(kind, response.status, refusalMessage(kind, sent, connection.url));
|
|
564
|
+
if (!response.ok) {
|
|
565
|
+
const error = typeof parsed === "object" && parsed !== null ? parsed.error : void 0;
|
|
566
|
+
throw new ApiError(typeof error === "string" ? error : `The server answered ${response.status.toString()}.`, response.status);
|
|
567
|
+
}
|
|
568
|
+
return parsed;
|
|
569
|
+
};
|
|
570
|
+
return {
|
|
571
|
+
credential,
|
|
572
|
+
postText: (path, text) => call("POST", path, {
|
|
573
|
+
text,
|
|
574
|
+
type: "text/csv"
|
|
575
|
+
}),
|
|
576
|
+
get: (path) => call("GET", path),
|
|
577
|
+
post: (path, body, signal) => call("POST", path, body === void 0 ? void 0 : {
|
|
578
|
+
text: JSON.stringify(body),
|
|
579
|
+
type: "application/json"
|
|
580
|
+
}, signal)
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
// src/commands/login.ts
|
|
584
|
+
/** Reads a login command pasted at the address prompt, or a bare address. */
|
|
585
|
+
function parseLoginLine(line) {
|
|
586
|
+
const words = line.trim().split(/\s+/).filter(Boolean);
|
|
587
|
+
const after = (name) => {
|
|
588
|
+
const index = words.indexOf(`--${name}`);
|
|
589
|
+
return index >= 0 ? words[index + 1] : void 0;
|
|
590
|
+
};
|
|
591
|
+
if (!words.some((w) => w.startsWith("--"))) return words[0] ? { url: words[0] } : {};
|
|
592
|
+
return {
|
|
593
|
+
url: after("url"),
|
|
594
|
+
clientId: after("client-id"),
|
|
595
|
+
clientSecret: after("client-secret"),
|
|
596
|
+
tlsName: after("tls-name")
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
async function loginCommand(given = {}) {
|
|
600
|
+
let url = given.url ?? process.env.MODELOS_SECURE_URL;
|
|
601
|
+
let { clientId, clientSecret, tlsName } = given;
|
|
602
|
+
if (!url) {
|
|
603
|
+
const pasted = parseLoginLine(await askText("Where is ModelOS Secure? Paste the login command from the tokens page, or type the address:"));
|
|
604
|
+
url = pasted.url;
|
|
605
|
+
clientId ??= pasted.clientId;
|
|
606
|
+
clientSecret ??= pasted.clientSecret;
|
|
607
|
+
tlsName ??= pasted.tlsName;
|
|
608
|
+
}
|
|
609
|
+
tlsName ??= process.env.MODELOS_SECURE_TLS_NAME;
|
|
610
|
+
if (!url) throw new Error("A login needs the address. Copy the login command from the tokens page.");
|
|
611
|
+
if (clientId && !clientSecret) throw new Error("The client ID needs its client secret. Copy the whole login command from the tokens page.");
|
|
612
|
+
const token = process.env.MODELOS_SECURE_TOKEN ?? await askText("Paste a workspace token from the tokens page:", true);
|
|
613
|
+
if (!token) throw new Error("A login needs a workspace token. Create one on the tokens page.");
|
|
614
|
+
const connection = {
|
|
615
|
+
url: url.replace(/\/$/, ""),
|
|
616
|
+
token,
|
|
617
|
+
...tlsName ? { tlsName } : {}
|
|
618
|
+
};
|
|
619
|
+
if (clientId && clientSecret) connection.google = await signInWithGoogle(clientId, clientSecret);
|
|
620
|
+
const me = await apiClient(connection).get("/whoami");
|
|
621
|
+
await saveConnection(connection);
|
|
622
|
+
const who = connection.google?.email;
|
|
623
|
+
say(`Saved. This machine now works in ${me.workspace}${who ? `, signed in to Google as ${who}` : ""}.`);
|
|
624
|
+
}
|
|
625
|
+
export { step as _, apiClient as a, Interrupted as c, blue as d, bold as f, say as g, red as h, IapRefusal as i, ask as l, grey as m, parseLoginLine as n, atAddress as o, dash as p, ApiError as r, readConnection as s, loginCommand as t, askText as u };
|