@memnox/interceptors 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/LICENSE +201 -0
- package/README.md +54 -0
- package/dist/chunk-SB27OD2T.js +206 -0
- package/dist/egress-cli.js +275 -0
- package/dist/git-credential-cli.js +74 -0
- package/dist/index.d.ts +521 -0
- package/dist/index.js +1123 -0
- package/dist/interceptor-cli.js +530 -0
- package/dist/shell-cli.js +258 -0
- package/package.json +54 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
EGRESS_DEFAULT_PORT,
|
|
4
|
+
EGRESS_MAX_BODY_BYTES,
|
|
5
|
+
buildAuthorizer,
|
|
6
|
+
buildHold,
|
|
7
|
+
log
|
|
8
|
+
} from "./chunk-SB27OD2T.js";
|
|
9
|
+
|
|
10
|
+
// src/egress-cli.ts
|
|
11
|
+
import { connect } from "net";
|
|
12
|
+
import { createServer, request as httpRequest } from "http";
|
|
13
|
+
|
|
14
|
+
// src/egress-seam.ts
|
|
15
|
+
import {
|
|
16
|
+
DECISION_EFFECT,
|
|
17
|
+
describeEgress,
|
|
18
|
+
digest,
|
|
19
|
+
inspectEgress,
|
|
20
|
+
isAllowed as holdAllowed
|
|
21
|
+
} from "@memnox/core";
|
|
22
|
+
var EGRESS_REQUEST_ACTION = "http.request";
|
|
23
|
+
var EGRESS_CONNECT_ACTION = "http.connect";
|
|
24
|
+
var EGRESS_BLIND_SPOTS = [
|
|
25
|
+
"the payload inside an HTTPS tunnel \u2014 the destination is gated, the body is not",
|
|
26
|
+
"any connection that does not go through this proxy",
|
|
27
|
+
"a protocol that is not HTTP or CONNECT"
|
|
28
|
+
];
|
|
29
|
+
var CARRIED_HEADERS = [
|
|
30
|
+
"authorization",
|
|
31
|
+
"cookie",
|
|
32
|
+
"x-api-key",
|
|
33
|
+
"proxy-authorization"
|
|
34
|
+
];
|
|
35
|
+
var EgressSeam = class {
|
|
36
|
+
constructor(deps) {
|
|
37
|
+
this.deps = deps;
|
|
38
|
+
}
|
|
39
|
+
deps;
|
|
40
|
+
async gateRequest(attempt) {
|
|
41
|
+
const fields = fieldsOf(attempt);
|
|
42
|
+
const inspection = inspectEgress({ destination: attempt.url, fields });
|
|
43
|
+
if (inspection.findings.length > 0) {
|
|
44
|
+
return { allowed: false, message: describeEgress(inspection) };
|
|
45
|
+
}
|
|
46
|
+
return this.rule({
|
|
47
|
+
action: EGRESS_REQUEST_ACTION,
|
|
48
|
+
target: attempt.url,
|
|
49
|
+
arguments: fields,
|
|
50
|
+
...this.deps.sessionId === void 0 ? {} : { sessionId: this.deps.sessionId }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* All that is knowable about a tunnel is where it goes. Ruling on the destination and
|
|
55
|
+
* saying plainly that the body is unseen beats pretending to inspect it.
|
|
56
|
+
*/
|
|
57
|
+
async gateConnect(authority) {
|
|
58
|
+
return this.rule({
|
|
59
|
+
action: EGRESS_CONNECT_ACTION,
|
|
60
|
+
target: authority,
|
|
61
|
+
...this.deps.sessionId === void 0 ? {} : { sessionId: this.deps.sessionId }
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async rule(request) {
|
|
65
|
+
const verdict = await this.deps.authorizer.authorize(request);
|
|
66
|
+
if (verdict.effect === DECISION_EFFECT.ALLOW) return { allowed: true };
|
|
67
|
+
if (verdict.effect === DECISION_EFFECT.ASK) {
|
|
68
|
+
const asked = await this.ask(request, verdict);
|
|
69
|
+
if (asked === null) return { allowed: true };
|
|
70
|
+
return asked;
|
|
71
|
+
}
|
|
72
|
+
return { allowed: false, message: describe(verdict) };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Puts an ask to a person. Null when it was allowed and the request may go.
|
|
76
|
+
*
|
|
77
|
+
* Without this an `ask` rule refused the request outright and told the reader "you
|
|
78
|
+
* chose to be asked about this" while nobody had been asked — the rule's own words
|
|
79
|
+
* arguing with what had just happened to them.
|
|
80
|
+
*/
|
|
81
|
+
async ask(request, verdict) {
|
|
82
|
+
const hold = this.deps.hold;
|
|
83
|
+
if (hold === void 0) {
|
|
84
|
+
return {
|
|
85
|
+
allowed: false,
|
|
86
|
+
message: `${describe(verdict)} Nobody could be asked, so it did not go.`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const result = await hold.hold({
|
|
90
|
+
sessionId: this.deps.sessionId ?? "ses_local",
|
|
91
|
+
agent: "an agent",
|
|
92
|
+
operation: request.action,
|
|
93
|
+
fingerprint: digest(`${request.action}:${request.target ?? ""}`),
|
|
94
|
+
reason: verdict.reason,
|
|
95
|
+
...request.target === void 0 ? {} : { target: request.target }
|
|
96
|
+
});
|
|
97
|
+
return holdAllowed(result) ? null : { allowed: false, message: describe(verdict) };
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
function fieldsOf(attempt) {
|
|
101
|
+
const fields = { method: attempt.method, url: attempt.url };
|
|
102
|
+
for (const name of CARRIED_HEADERS) {
|
|
103
|
+
const value = attempt.headers === void 0 ? void 0 : attempt.headers[name];
|
|
104
|
+
if (value !== void 0 && value.length > 0) fields[name] = value;
|
|
105
|
+
}
|
|
106
|
+
if (attempt.body !== void 0 && attempt.body.length > 0)
|
|
107
|
+
fields["body"] = attempt.body;
|
|
108
|
+
return fields;
|
|
109
|
+
}
|
|
110
|
+
function describe(verdict) {
|
|
111
|
+
const parts = [verdict.reason];
|
|
112
|
+
if (verdict.alternative !== void 0) {
|
|
113
|
+
const target = verdict.alternative.resource === void 0 ? "" : ` ${verdict.alternative.resource}`;
|
|
114
|
+
parts.push(
|
|
115
|
+
`Instead: ${verdict.alternative.action}${target} \u2014 ${verdict.alternative.note}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (verdict.approvalId !== void 0) {
|
|
119
|
+
parts.push(`Ask a person: memnox approvals resolve ${verdict.approvalId} --by <you>`);
|
|
120
|
+
}
|
|
121
|
+
if (verdict.decisionId !== void 0) {
|
|
122
|
+
parts.push(`Why: memnox why ${verdict.decisionId}`);
|
|
123
|
+
}
|
|
124
|
+
return parts.join(" ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/egress-cli.ts
|
|
128
|
+
var REFUSED_STATUS = 403;
|
|
129
|
+
var TUNNEL_OK = "HTTP/1.1 200 Connection Established\r\n\r\n";
|
|
130
|
+
var USAGE = `Usage: memnox-egress [--port <port>]
|
|
131
|
+
|
|
132
|
+
An HTTP forward proxy that rules on what leaves this machine. Point an agent at it:
|
|
133
|
+
|
|
134
|
+
HTTP_PROXY=http://127.0.0.1:${EGRESS_DEFAULT_PORT} HTTPS_PROXY=http://127.0.0.1:${EGRESS_DEFAULT_PORT} <your agent>
|
|
135
|
+
|
|
136
|
+
Blind to:
|
|
137
|
+
${EGRESS_BLIND_SPOTS.map((spot) => ` ${spot}`).join("\n")}`;
|
|
138
|
+
function portFrom(argv) {
|
|
139
|
+
const index = argv.indexOf("--port");
|
|
140
|
+
if (index === -1) return EGRESS_DEFAULT_PORT;
|
|
141
|
+
const raw = argv[index + 1];
|
|
142
|
+
if (raw === void 0) return null;
|
|
143
|
+
const port = Number(raw);
|
|
144
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
145
|
+
}
|
|
146
|
+
async function readBody(message) {
|
|
147
|
+
const chunks = [];
|
|
148
|
+
let size = 0;
|
|
149
|
+
for await (const chunk of message) {
|
|
150
|
+
const buffer = chunk;
|
|
151
|
+
size += buffer.length;
|
|
152
|
+
if (size > EGRESS_MAX_BODY_BYTES) return void 0;
|
|
153
|
+
chunks.push(buffer);
|
|
154
|
+
}
|
|
155
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
156
|
+
}
|
|
157
|
+
function refuse(response, message) {
|
|
158
|
+
response.writeHead(REFUSED_STATUS, { "content-type": "text/plain" });
|
|
159
|
+
response.end(`Memnox denied this request.
|
|
160
|
+
${message}
|
|
161
|
+
`);
|
|
162
|
+
}
|
|
163
|
+
function buildServer(seam) {
|
|
164
|
+
const server = createServer((request, response) => {
|
|
165
|
+
void (async () => {
|
|
166
|
+
const url = request.url;
|
|
167
|
+
const method = request.method;
|
|
168
|
+
if (url === void 0 || method === void 0)
|
|
169
|
+
return refuse(response, "no request");
|
|
170
|
+
const body = await readBody(request);
|
|
171
|
+
const outcome = await seam.gateRequest({
|
|
172
|
+
method,
|
|
173
|
+
url,
|
|
174
|
+
headers: headersOf(request),
|
|
175
|
+
// Undefined means it was larger than this seam reads, not that it was empty.
|
|
176
|
+
...body === void 0 ? {} : { body }
|
|
177
|
+
});
|
|
178
|
+
if (!outcome.allowed) {
|
|
179
|
+
log(`denied ${method} ${url}: ${outcome.message ?? ""}`);
|
|
180
|
+
return refuse(response, outcome.message ?? "no reason recorded");
|
|
181
|
+
}
|
|
182
|
+
forward(request, response, url, method, body);
|
|
183
|
+
})();
|
|
184
|
+
});
|
|
185
|
+
server.on("connect", (request, socket, head) => {
|
|
186
|
+
void (async () => {
|
|
187
|
+
const authority = request.url;
|
|
188
|
+
if (authority === void 0) return socket.destroy();
|
|
189
|
+
const outcome = await seam.gateConnect(authority);
|
|
190
|
+
if (!outcome.allowed) {
|
|
191
|
+
log(`denied CONNECT ${authority}: ${outcome.message ?? ""}`);
|
|
192
|
+
socket.end(
|
|
193
|
+
`HTTP/1.1 ${REFUSED_STATUS} Forbidden\r
|
|
194
|
+
\r
|
|
195
|
+
${outcome.message ?? ""}`
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
tunnel(authority, socket, head);
|
|
200
|
+
})();
|
|
201
|
+
});
|
|
202
|
+
return server;
|
|
203
|
+
}
|
|
204
|
+
function headersOf(request) {
|
|
205
|
+
const headers = {};
|
|
206
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
207
|
+
if (typeof value === "string") headers[name] = value;
|
|
208
|
+
}
|
|
209
|
+
return headers;
|
|
210
|
+
}
|
|
211
|
+
function forward(request, response, url, method, body) {
|
|
212
|
+
let target;
|
|
213
|
+
try {
|
|
214
|
+
target = new URL(url);
|
|
215
|
+
} catch {
|
|
216
|
+
return refuse(response, "this proxy takes absolute-form requests only");
|
|
217
|
+
}
|
|
218
|
+
const upstream = httpRequest(
|
|
219
|
+
{
|
|
220
|
+
protocol: target.protocol,
|
|
221
|
+
hostname: target.hostname,
|
|
222
|
+
port: target.port,
|
|
223
|
+
path: `${target.pathname}${target.search}`,
|
|
224
|
+
method,
|
|
225
|
+
headers: request.headers
|
|
226
|
+
},
|
|
227
|
+
(answer) => {
|
|
228
|
+
response.writeHead(answer.statusCode ?? 502, answer.headers);
|
|
229
|
+
answer.pipe(response);
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
upstream.on("error", (err) => {
|
|
233
|
+
log(`upstream failed for ${url}: ${String(err)}`);
|
|
234
|
+
response.writeHead(502).end();
|
|
235
|
+
});
|
|
236
|
+
if (body !== void 0) upstream.write(body);
|
|
237
|
+
upstream.end();
|
|
238
|
+
}
|
|
239
|
+
function tunnel(authority, socket, head) {
|
|
240
|
+
const [host, rawPort] = authority.split(":");
|
|
241
|
+
if (host === void 0) {
|
|
242
|
+
socket.destroy();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const upstream = connect(Number(rawPort ?? "443"), host, () => {
|
|
246
|
+
socket.write(TUNNEL_OK);
|
|
247
|
+
upstream.write(head);
|
|
248
|
+
upstream.pipe(socket);
|
|
249
|
+
socket.pipe(upstream);
|
|
250
|
+
});
|
|
251
|
+
upstream.on("error", () => socket.destroy());
|
|
252
|
+
socket.on("error", () => upstream.destroy());
|
|
253
|
+
}
|
|
254
|
+
async function main() {
|
|
255
|
+
const port = portFrom(process.argv.slice(2));
|
|
256
|
+
if (port === null) {
|
|
257
|
+
process.stderr.write(`${USAGE}
|
|
258
|
+
`);
|
|
259
|
+
process.exitCode = 1;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const seam = new EgressSeam({
|
|
263
|
+
authorizer: await buildAuthorizer(),
|
|
264
|
+
/* An ask on the network seam has to reach a person, or `ask` is a slower `deny`. */
|
|
265
|
+
hold: buildHold()
|
|
266
|
+
});
|
|
267
|
+
buildServer(seam).listen(port, "127.0.0.1", () => {
|
|
268
|
+
log(`egress seam on 127.0.0.1:${port}`);
|
|
269
|
+
for (const spot of EGRESS_BLIND_SPOTS) log(`blind to: ${spot}`);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
main().catch((err) => {
|
|
273
|
+
log(`egress seam failed to start, ruling on nothing: ${String(err)}`);
|
|
274
|
+
process.exitCode = 1;
|
|
275
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
buildAuthorizer,
|
|
4
|
+
log,
|
|
5
|
+
readStdin
|
|
6
|
+
} from "./chunk-SB27OD2T.js";
|
|
7
|
+
|
|
8
|
+
// src/git-credential-seam.ts
|
|
9
|
+
import { DECISION_EFFECT } from "@memnox/core";
|
|
10
|
+
var GIT_CREDENTIAL_ACTION = "git.credential";
|
|
11
|
+
var QUIT = "quit=1\n";
|
|
12
|
+
var GitCredentialSeam = class {
|
|
13
|
+
constructor(deps) {
|
|
14
|
+
this.deps = deps;
|
|
15
|
+
}
|
|
16
|
+
deps;
|
|
17
|
+
async gate(input) {
|
|
18
|
+
const fields = parseGitInput(input);
|
|
19
|
+
const target = remoteOf(fields);
|
|
20
|
+
const request = {
|
|
21
|
+
action: GIT_CREDENTIAL_ACTION,
|
|
22
|
+
...target === void 0 ? {} : { target },
|
|
23
|
+
// LOCAL ONLY, and it never contains the credential — git has not issued one yet.
|
|
24
|
+
arguments: { ...fields },
|
|
25
|
+
...this.deps.sessionId === void 0 ? {} : { sessionId: this.deps.sessionId }
|
|
26
|
+
};
|
|
27
|
+
const verdict = await this.deps.authorizer.authorize(request);
|
|
28
|
+
if (verdict.effect === DECISION_EFFECT.ALLOW) return { stdout: "" };
|
|
29
|
+
const where = target === void 0 ? "this remote" : target;
|
|
30
|
+
if (verdict.unreachable === true) {
|
|
31
|
+
return {
|
|
32
|
+
stdout: "",
|
|
33
|
+
message: `could not rule on ${where} \u2014 the runtime is unreachable, so git was left alone. A denied remote is reachable until it is back.`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
stdout: QUIT,
|
|
38
|
+
message: `no credential for ${where}: ${verdict.reason}`
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
function parseGitInput(input) {
|
|
43
|
+
const fields = {};
|
|
44
|
+
for (const line of input.split("\n")) {
|
|
45
|
+
if (line.length === 0) continue;
|
|
46
|
+
const separator = line.indexOf("=");
|
|
47
|
+
if (separator <= 0) continue;
|
|
48
|
+
const key = line.slice(0, separator).trim();
|
|
49
|
+
if (key === "password" || key === "credential") continue;
|
|
50
|
+
fields[key] = line.slice(separator + 1).trim();
|
|
51
|
+
}
|
|
52
|
+
return fields;
|
|
53
|
+
}
|
|
54
|
+
function remoteOf(fields) {
|
|
55
|
+
const host = fields["host"];
|
|
56
|
+
if (host === void 0 || host.length === 0) return void 0;
|
|
57
|
+
const protocol = fields["protocol"] ?? "https";
|
|
58
|
+
const path = fields["path"];
|
|
59
|
+
return `${protocol}://${host}${path === void 0 ? "" : `/${path}`}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/git-credential-cli.ts
|
|
63
|
+
var OPERATION_GET = "get";
|
|
64
|
+
async function main() {
|
|
65
|
+
const operation = process.argv[2];
|
|
66
|
+
if (operation !== OPERATION_GET) return;
|
|
67
|
+
const seam = new GitCredentialSeam({ authorizer: await buildAuthorizer() });
|
|
68
|
+
const outcome = await seam.gate(await readStdin());
|
|
69
|
+
if (outcome.message !== void 0) log(outcome.message);
|
|
70
|
+
if (outcome.stdout.length > 0) process.stdout.write(outcome.stdout);
|
|
71
|
+
}
|
|
72
|
+
main().catch((err) => {
|
|
73
|
+
log(`git seam failed, ruling on nothing: ${String(err)}`);
|
|
74
|
+
});
|