ffp-sql-sandbox 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INTEGRATION.md +37 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/docker-executor.d.ts +6 -0
- package/dist/docker-executor.d.ts.map +1 -0
- package/dist/docker-executor.js +192 -0
- package/dist/docker-executor.js.map +1 -0
- package/dist/docker-types.d.ts +37 -0
- package/dist/docker-types.d.ts.map +1 -0
- package/dist/docker-types.js +50 -0
- package/dist/docker-types.js.map +1 -0
- package/dist/execute-sql.d.ts +9 -0
- package/dist/execute-sql.d.ts.map +1 -0
- package/dist/execute-sql.js +89 -0
- package/dist/execute-sql.js.map +1 -0
- package/dist/host-allowlist.d.ts +2 -0
- package/dist/host-allowlist.d.ts.map +1 -0
- package/dist/host-allowlist.js +9 -0
- package/dist/host-allowlist.js.map +1 -0
- package/dist/image.d.ts +16 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +19 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/limits.d.ts +11 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +25 -0
- package/dist/limits.js.map +1 -0
- package/dist/password-tar.d.ts +6 -0
- package/dist/password-tar.d.ts.map +1 -0
- package/dist/password-tar.js +29 -0
- package/dist/password-tar.js.map +1 -0
- package/dist/rewrite-host.d.ts +6 -0
- package/dist/rewrite-host.d.ts.map +1 -0
- package/dist/rewrite-host.js +16 -0
- package/dist/rewrite-host.js.map +1 -0
- package/dist/stream-consumer.d.ts +31 -0
- package/dist/stream-consumer.d.ts.map +1 -0
- package/dist/stream-consumer.js +139 -0
- package/dist/stream-consumer.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-sql.d.ts +8 -0
- package/dist/validate-sql.d.ts.map +1 -0
- package/dist/validate-sql.js +30 -0
- package/dist/validate-sql.js.map +1 -0
- package/package.json +45 -0
- package/sandbox/Dockerfile +13 -0
- package/sandbox/events.d.ts +12 -0
- package/sandbox/events.js +14 -0
- package/sandbox/execute.js +235 -0
- package/sandbox/package.json +10 -0
- package/sandbox/secrets.d.ts +5 -0
- package/sandbox/secrets.js +16 -0
- package/sandbox/session-setup.d.ts +4 -0
- package/sandbox/session-setup.js +20 -0
- package/sandbox/stream-limit.d.ts +16 -0
- package/sandbox/stream-limit.js +35 -0
package/INTEGRATION.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Integrating ffp-sql-sandbox with ChuTingzj/ai-bi
|
|
2
|
+
|
|
3
|
+
This repository does **not** wire the library into [ChuTingzj/ai-bi](https://github.com/ChuTingzj/ai-bi). Keep that as a follow-up PR in ai-bi after this package is a real dependency, tests are green, and there are no P0s.
|
|
4
|
+
|
|
5
|
+
## Adapter shape
|
|
6
|
+
|
|
7
|
+
Keep NestJS, `CryptoService`, and `DataSource` inside ai-bi. Do not leak those types into this library.
|
|
8
|
+
|
|
9
|
+
Suggested adapter (ai-bi `SandboxService`):
|
|
10
|
+
|
|
11
|
+
1. Decrypt `dataSource.password` with the existing `CryptoService`.
|
|
12
|
+
2. Call `validateSql` / `executeSql` from `ffp-sql-sandbox`.
|
|
13
|
+
3. Map `{ ok: true, data }` → the current `SandboxResult` / `QueryResult` shape (`success` / `columns`+`rows`).
|
|
14
|
+
4. Map `{ ok: false, code, error }` → `{ success: false, error }`.
|
|
15
|
+
|
|
16
|
+
## Host rewrite stays in ai-bi
|
|
17
|
+
|
|
18
|
+
`resolveSandboxDbHost` is **not** a public export of this package (on purpose).
|
|
19
|
+
|
|
20
|
+
ai-bi may keep its private loopback → `host.docker.internal` helper for its own tests and Docker-on-Linux setups. If the adapter rewrites the host *before* calling `executeSql`, put the rewritten name on `hostAllowlist` as well.
|
|
21
|
+
|
|
22
|
+
Alternatively, pass the original host (`localhost`, `db.internal`, …) in `connection.host`, allowlist **that** name, and let this library apply a private rewrite after the allowlist check. Either way, user-controlled hosts must not bypass `hostAllowlist`.
|
|
23
|
+
|
|
24
|
+
## What not to copy from the old sandbox
|
|
25
|
+
|
|
26
|
+
| Old ai-bi behavior | v1 library |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `DB_PASS` / password in container `Env` | Forbidden. Password goes to tmpfs `/run/secrets/db_password`. |
|
|
29
|
+
| `Cmd: [sql]` | SQL on stdin JSON. |
|
|
30
|
+
| Floating `SANDBOX_IMAGE=ai-bi-sandbox:latest` | Default image digest-pinned; custom `image` is untrusted. |
|
|
31
|
+
| Configurable validator prefixes/patterns | Not exposed. `validateSql(sql)` only. |
|
|
32
|
+
| Wait for container + `logs()` then parse/truncate | Streaming attach; `maxRows` / `maxBytes` applied as data arrives. |
|
|
33
|
+
| Regex as the security proof | Documented as fail-fast only. Proof is RO role + limits + allowlist. |
|
|
34
|
+
|
|
35
|
+
## Image
|
|
36
|
+
|
|
37
|
+
Build `sandbox/Dockerfile` from this repo (or pull the published digest) and configure ai-bi to use `DEFAULT_SANDBOX_IMAGE` or an explicit digest-pinned ref. Do not default to `:latest`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FFP-Tech-Lab (From First Principle)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# ffp-sql-sandbox
|
|
2
|
+
|
|
3
|
+
Read-only SQL sandbox primitives for Node.js: fail-fast `validateSql` plus one-shot Docker `executeSql` with hard resource, row, byte, timeout, and host-allowlist limits.
|
|
4
|
+
|
|
5
|
+
This is the v1 library extracted from the sandbox ideas in [ChuTingzj/ai-bi](https://github.com/ChuTingzj/ai-bi) (`validateSql` + dockerode one-shot container) and redesigned around a stricter security model. It is **not** wired into ai-bi in this repository — see [INTEGRATION.md](./INTEGRATION.md).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add ffp-sql-sandbox
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Docker is a **hard dependency** of `executeSql`. If the engine cannot be pinged, the call fails with `DOCKER_UNAVAILABLE` rather than falling back to in-process SQL.
|
|
14
|
+
|
|
15
|
+
Build the runner image from `sandbox/Dockerfile` (digest-pinned `FROM`) and either publish it at the default ref below or pass `image` (untrusted override).
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
docker build -t ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1 ./sandbox
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Public API
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import {
|
|
25
|
+
validateSql,
|
|
26
|
+
executeSql,
|
|
27
|
+
DEFAULT_SANDBOX_LIMITS,
|
|
28
|
+
DEFAULT_SANDBOX_IMAGE,
|
|
29
|
+
} from 'ffp-sql-sandbox'
|
|
30
|
+
|
|
31
|
+
validateSql(sql: string): { ok: true } | { ok: false; code: string; reason: string }
|
|
32
|
+
|
|
33
|
+
executeSql(input: {
|
|
34
|
+
sql: string
|
|
35
|
+
connection: {
|
|
36
|
+
type: 'postgres' | 'mysql'
|
|
37
|
+
host: string
|
|
38
|
+
port: number
|
|
39
|
+
user: string
|
|
40
|
+
password: string // never placed in container Env
|
|
41
|
+
database: string
|
|
42
|
+
}
|
|
43
|
+
hostAllowlist: string[] // required
|
|
44
|
+
limits?: Partial<SandboxLimits>
|
|
45
|
+
image?: string // untrusted override; default image is digest-pinned
|
|
46
|
+
}): Promise<
|
|
47
|
+
| { ok: true; data: { columns: string[]; rows: unknown[][]; truncated?: boolean } }
|
|
48
|
+
| { ok: false; code: string; error: string }
|
|
49
|
+
>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`validateSql` is fail-fast only. There are no `allowPrefixes` / `forbidPatterns` options.
|
|
53
|
+
|
|
54
|
+
`resolveSandboxDbHost` is **not** exported. Loopback rewrite for container DNS, if needed, stays private (and, for ai-bi, in the adapter — see INTEGRATION.md).
|
|
55
|
+
|
|
56
|
+
## Non-goals
|
|
57
|
+
|
|
58
|
+
- Natural language → SQL, query guidance, schema sync, or an AST parser as a required v1 component
|
|
59
|
+
- A multi-tenant auth / connection-pool product
|
|
60
|
+
- Claiming absolute network isolation (the runner uses Docker `bridge` so it can reach the allowlisted database)
|
|
61
|
+
- Exporting `resolveSandboxDbHost` as public API
|
|
62
|
+
- Making `validateSql` a proof of safety (it is a cheap reject, not a guarantee)
|
|
63
|
+
|
|
64
|
+
## Threat model
|
|
65
|
+
|
|
66
|
+
Proof that a query cannot mutate data or exfiltrate unbounded results does **not** come from regex. v1 proof is the combination of:
|
|
67
|
+
|
|
68
|
+
| Guard | What it actually does |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| 1. Read-only DB role | **Caller must connect as a read-only role** (and/or a `READ ONLY` transaction). The library also sets `statement_timeout` and *prefers* a read-only session (`SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY` on Postgres, `SET SESSION TRANSACTION READ ONLY` on MySQL) when the dialect allows it. If that `SET` is ignored or the role can override it, the database role is still the real write barrier. |
|
|
71
|
+
| 2. Container limits + dual timeout | Memory, nano-CPUs, PID cap, dropped capabilities. **Dual timeout**: the runner sets DB `statement_timeout` / `MAX_EXECUTION_TIME` to `timeoutMs`, and the host kills the container after `timeoutMs + 2000ms` if it is still running. |
|
|
72
|
+
| 3. Streaming `maxRows` / `maxBytes` | The runner applies limits **as rows arrive** and stops/cancels instead of buffering the full result. The host consumer is a backstop on the live Docker attach stream. **Demux-then-truncate of a completed log buffer is not the limits implementation.** |
|
|
73
|
+
| 4. `connection.host` allowlist | SSRF defense. `hostAllowlist` is required. Hosts not on the list are rejected **before** any container is created. No arbitrary hosts, no glob, no CIDR in v1 — exact match after trim + case-insensitive compare. |
|
|
74
|
+
|
|
75
|
+
Passwords are written to a **tmpfs** file at `/run/secrets/db_password` inside the container (mode/uid for the `node` user). They are **never** placed in container `Env` (no `DB_PASS`, `PGPASSWORD`, or `MYSQL_PWD`). SQL is sent on stdin JSON, not `Cmd`, so `docker inspect` does not show the password.
|
|
76
|
+
|
|
77
|
+
### Default image vs custom image
|
|
78
|
+
|
|
79
|
+
| Image | Trust |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `DEFAULT_SANDBOX_IMAGE` (`…@sha256:…`) | Digest-pinned default. Treat as the supported runner. |
|
|
82
|
+
| `image` override | **Untrusted.** Allowed so callers can build locally, but a custom image can ignore streaming caps, log secrets, or exfiltrate. Pin and review your own image if you override. |
|
|
83
|
+
|
|
84
|
+
The Dockerfile `FROM` line is also digest-pinned (`node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5`).
|
|
85
|
+
|
|
86
|
+
Until GHCR publish, `DEFAULT_SANDBOX_IMAGE` is a **content digest** of the v1 runner files, not a registry-pullable image. `executeSql` without `image` will return `IMAGE_UNAVAILABLE` if that digest is not present locally. Build `sandbox/Dockerfile` and pass `image` (untrusted override), or `docker load` / retag the digest-pinned image. After the first registry push, replace the constant with `docker buildx imagetools inspect` output.
|
|
87
|
+
|
|
88
|
+
## Default limits
|
|
89
|
+
|
|
90
|
+
| Limit | Default | Role |
|
|
91
|
+
| --- | --- | --- |
|
|
92
|
+
| `timeoutMs` | `10000` | DB `statement_timeout` / `MAX_EXECUTION_TIME`, plus container kill at `timeoutMs + 2000` |
|
|
93
|
+
| `memoryMb` | `128` | Container memory (and memory-swap) cap |
|
|
94
|
+
| `nanoCpus` | `500000000` (0.5 CPU) | Container CFS quota |
|
|
95
|
+
| `maxRows` | `1000` | Streaming row cap; result may set `truncated: true` |
|
|
96
|
+
| `maxBytes` | `1000000` | Streaming serialized-row / stdout byte cap; result may set `truncated: true` |
|
|
97
|
+
|
|
98
|
+
## `hostAllowlist` contract
|
|
99
|
+
|
|
100
|
+
- Required on every `executeSql` call.
|
|
101
|
+
- Empty list → `HOST_NOT_ALLOWED` (deny all).
|
|
102
|
+
- Compared against the **caller-supplied** `connection.host` (trimmed, case-insensitive). The library may rewrite loopback (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`) to `host.docker.internal` *after* the allowlist check so the container can reach a DB on the Docker host. That rewrite is private and not a public API.
|
|
103
|
+
- Put the host you actually pass in on the list (e.g. `db.internal` or `localhost`). Do not accept user-controlled hosts without your own allowlist.
|
|
104
|
+
|
|
105
|
+
## `validateSql` is fail-fast only (known bypasses)
|
|
106
|
+
|
|
107
|
+
`validateSql` is a cheap prefix + keyword + multi-statement reject. **Do not treat a `{ ok: true }` as authorization to run SQL outside this sandbox.** Known classes:
|
|
108
|
+
|
|
109
|
+
| Class | Example | Notes |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| Writes that still look like `SELECT` | `SELECT … INTO …`, `SELECT pg_file_write(...)` | Prefix allowlist does not model Postgres side effects. **Read-only role** is the guard. |
|
|
112
|
+
| False positives | `SELECT * FROM t WHERE action = 'UPDATE'` | Keyword scan is not an AST. Fail-fast, not completeness. |
|
|
113
|
+
| Comment / encoding tricks | Leading `--` comments, Unicode homoglyphs | Rejected or missed; not a parser. |
|
|
114
|
+
| Expensive reads | `SELECT * FROM huge_table` | Allowed by regex; stopped by timeout + `maxRows`/`maxBytes` + role. |
|
|
115
|
+
| Multi-statement via protocol | Driver-level stacked queries if the runner used a naive exec | Runner sends a single query text; DB role + `READ ONLY` still apply. |
|
|
116
|
+
| Network | `dblink`, `http` FDW, `LOAD_FILE` | Allowlist is on `connection.host`, not on SQL-level network functions. Use a locked-down role. |
|
|
117
|
+
|
|
118
|
+
## Error codes
|
|
119
|
+
|
|
120
|
+
| Code | Where |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `NOT_READ_ONLY_PREFIX` | `validateSql` / `executeSql` |
|
|
123
|
+
| `FORBIDDEN_KEYWORD` | `validateSql` / `executeSql` |
|
|
124
|
+
| `MULTI_STATEMENT` | `validateSql` / `executeSql` |
|
|
125
|
+
| `HOST_NOT_ALLOWED` | `executeSql` |
|
|
126
|
+
| `DOCKER_UNAVAILABLE` | `executeSql` |
|
|
127
|
+
| `TIMEOUT` | `executeSql` |
|
|
128
|
+
| `INVALID_LIMITS` | `executeSql` |
|
|
129
|
+
| `IMAGE_UNPINNED` | `executeSql` (default image missing digest) |
|
|
130
|
+
| `IMAGE_UNAVAILABLE` | `executeSql` (image missing locally / not pullable; default digest is unpublished until GHCR) |
|
|
131
|
+
| `UNSUPPORTED_DIALECT` | `executeSql` |
|
|
132
|
+
| `EXECUTION_FAILED` | `executeSql` |
|
|
133
|
+
|
|
134
|
+
## Scripts
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pnpm test # node:test via tsx
|
|
138
|
+
pnpm typecheck
|
|
139
|
+
pnpm build
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT. The GitHub repository may stay private until the org publishes it; that does not change the source license.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type DockerLike, type DockerRunRequest } from './docker-types.js';
|
|
2
|
+
import type { ExecuteSqlResult } from './types.js';
|
|
3
|
+
export type { DockerLike, DockerRunRequest } from './docker-types.js';
|
|
4
|
+
export declare function createDefaultDocker(): DockerLike;
|
|
5
|
+
export declare function runInSandbox(docker: DockerLike, request: DockerRunRequest): Promise<ExecuteSqlResult>;
|
|
6
|
+
//# sourceMappingURL=docker-executor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"docker-executor.d.ts","sourceRoot":"","sources":["../src/docker-executor.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,UAAU,EACf,KAAK,gBAAgB,EACtB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAKtE,wBAAgB,mBAAmB,IAAI,UAAU,CAEhD;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,gBAAgB,CAAC,CA0K3B"}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import Docker from 'dockerode';
|
|
2
|
+
import { createPasswordTar } from './password-tar.js';
|
|
3
|
+
import { consumeNdjsonResult } from './stream-consumer.js';
|
|
4
|
+
import { demuxStdout, } from './docker-types.js';
|
|
5
|
+
const CONTAINER_KILL_GRACE_MS = 2_000;
|
|
6
|
+
const SECRET_MOUNT = '/run/secrets';
|
|
7
|
+
export function createDefaultDocker() {
|
|
8
|
+
return new Docker();
|
|
9
|
+
}
|
|
10
|
+
export async function runInSandbox(docker, request) {
|
|
11
|
+
let container = null;
|
|
12
|
+
let timedOut = false;
|
|
13
|
+
let killedForLimit = false;
|
|
14
|
+
let attachStream = null;
|
|
15
|
+
const containerTimeoutMs = request.timeoutMs + CONTAINER_KILL_GRACE_MS;
|
|
16
|
+
let killTimer;
|
|
17
|
+
try {
|
|
18
|
+
container = await docker.createContainer({
|
|
19
|
+
Image: request.image,
|
|
20
|
+
AttachStdin: true,
|
|
21
|
+
AttachStdout: true,
|
|
22
|
+
AttachStderr: true,
|
|
23
|
+
OpenStdin: true,
|
|
24
|
+
StdinOnce: true,
|
|
25
|
+
Tty: false,
|
|
26
|
+
Env: request.env,
|
|
27
|
+
HostConfig: {
|
|
28
|
+
Memory: request.memoryBytes,
|
|
29
|
+
MemorySwap: request.memoryBytes,
|
|
30
|
+
NanoCpus: request.nanoCpus,
|
|
31
|
+
NetworkMode: 'bridge',
|
|
32
|
+
ExtraHosts: ['host.docker.internal:host-gateway'],
|
|
33
|
+
AutoRemove: false,
|
|
34
|
+
CapDrop: ['ALL'],
|
|
35
|
+
SecurityOpt: ['no-new-privileges:true'],
|
|
36
|
+
PidsLimit: 256,
|
|
37
|
+
Tmpfs: {
|
|
38
|
+
[SECRET_MOUNT]: 'rw,noexec,nosuid,size=1m,mode=0700,uid=1000,gid=1000',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
Labels: {
|
|
42
|
+
'ffp.sql-sandbox': 'v1',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
await container.start();
|
|
46
|
+
killTimer = setTimeout(() => {
|
|
47
|
+
timedOut = true;
|
|
48
|
+
void container?.kill().catch(() => undefined);
|
|
49
|
+
destroyStream(attachStream);
|
|
50
|
+
}, containerTimeoutMs);
|
|
51
|
+
await container.putArchive(createPasswordTar(request.password), {
|
|
52
|
+
path: SECRET_MOUNT,
|
|
53
|
+
});
|
|
54
|
+
attachStream = await container.attach({
|
|
55
|
+
stream: true,
|
|
56
|
+
stdin: true,
|
|
57
|
+
stdout: true,
|
|
58
|
+
stderr: true,
|
|
59
|
+
hijack: true,
|
|
60
|
+
});
|
|
61
|
+
const writable = attachStream;
|
|
62
|
+
if (typeof writable.write === 'function') {
|
|
63
|
+
writable.write(request.stdinJson);
|
|
64
|
+
if (typeof writable.end === 'function') {
|
|
65
|
+
writable.end();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const stdout = demuxStdout(attachStream);
|
|
69
|
+
const consumed = consumeNdjsonResult(stdout, {
|
|
70
|
+
maxRows: request.maxRows,
|
|
71
|
+
maxBytes: request.maxBytes,
|
|
72
|
+
abort: () => {
|
|
73
|
+
killedForLimit = true;
|
|
74
|
+
destroyStream(attachStream);
|
|
75
|
+
void container?.kill().catch(() => undefined);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const wait = container.wait();
|
|
79
|
+
let hangTimer;
|
|
80
|
+
const hang = new Promise((_, reject) => {
|
|
81
|
+
hangTimer = setTimeout(() => {
|
|
82
|
+
timedOut = true;
|
|
83
|
+
reject(Object.assign(new Error('timeout'), { code: 'TIMEOUT' }));
|
|
84
|
+
}, containerTimeoutMs + 50);
|
|
85
|
+
});
|
|
86
|
+
let result;
|
|
87
|
+
try {
|
|
88
|
+
result = await Promise.race([
|
|
89
|
+
Promise.all([consumed, wait]).then(([data, status]) => ({
|
|
90
|
+
data,
|
|
91
|
+
status,
|
|
92
|
+
})),
|
|
93
|
+
hang,
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (hangTimer !== undefined) {
|
|
98
|
+
clearTimeout(hangTimer);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (timedOut) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
code: 'TIMEOUT',
|
|
105
|
+
error: `SQL execution timed out after ${request.timeoutMs}ms (container kill + statement_timeout)`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (result.data.error) {
|
|
109
|
+
return { ok: false, code: 'EXECUTION_FAILED', error: result.data.error };
|
|
110
|
+
}
|
|
111
|
+
if (result.status.StatusCode !== 0 && !result.data.truncated && !killedForLimit) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
code: 'EXECUTION_FAILED',
|
|
115
|
+
error: result.data.error ?? 'Sandbox runner exited with a non-zero status',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const data = {
|
|
119
|
+
columns: result.data.columns,
|
|
120
|
+
rows: result.data.rows,
|
|
121
|
+
};
|
|
122
|
+
if (result.data.truncated) {
|
|
123
|
+
data.truncated = true;
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, data };
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (timedOut) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
code: 'TIMEOUT',
|
|
132
|
+
error: `SQL execution timed out after ${request.timeoutMs}ms (container kill + statement_timeout)`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (isImageUnavailableError(err)) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
code: 'IMAGE_UNAVAILABLE',
|
|
139
|
+
error: imageUnavailableMessage(request),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
code: 'EXECUTION_FAILED',
|
|
145
|
+
error: err instanceof Error ? err.message : String(err),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
if (killTimer !== undefined) {
|
|
150
|
+
clearTimeout(killTimer);
|
|
151
|
+
}
|
|
152
|
+
destroyStream(attachStream);
|
|
153
|
+
if (container) {
|
|
154
|
+
try {
|
|
155
|
+
await container.kill();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// already exited
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await container.remove({ force: true });
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// already removed
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function destroyStream(stream) {
|
|
170
|
+
if (!stream) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if ('destroy' in stream && typeof stream.destroy === 'function') {
|
|
174
|
+
stream.destroy();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function isImageUnavailableError(err) {
|
|
178
|
+
const status = typeof err === 'object' && err !== null && 'statusCode' in err
|
|
179
|
+
? Number(err.statusCode)
|
|
180
|
+
: undefined;
|
|
181
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
182
|
+
return (status === 404 ||
|
|
183
|
+
/no such image/i.test(msg) ||
|
|
184
|
+
/\(HTTP code 404\).*image/i.test(msg));
|
|
185
|
+
}
|
|
186
|
+
function imageUnavailableMessage(request) {
|
|
187
|
+
if (request.usingDefaultImage) {
|
|
188
|
+
return `Default sandbox image is not pullable (${request.image}). Until GHCR publish, build sandbox/Dockerfile locally and pass image: '<tag-or-id>' (untrusted override), or docker load/tag the digest-pinned image.`;
|
|
189
|
+
}
|
|
190
|
+
return `Sandbox image not found: ${request.image}. Build or pull it before calling executeSql.`;
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=docker-executor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"docker-executor.js","sourceRoot":"","sources":["../src/docker-executor.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PassThrough, type Readable } from 'node:stream';
|
|
2
|
+
export type ContainerLike = {
|
|
3
|
+
start: () => Promise<unknown>;
|
|
4
|
+
putArchive: (tar: Buffer | NodeJS.ReadableStream, opts: {
|
|
5
|
+
path: string;
|
|
6
|
+
}) => Promise<unknown>;
|
|
7
|
+
attach: (opts: unknown) => Promise<NodeJS.ReadWriteStream | Readable>;
|
|
8
|
+
wait: () => Promise<{
|
|
9
|
+
StatusCode: number;
|
|
10
|
+
}>;
|
|
11
|
+
kill: () => Promise<unknown>;
|
|
12
|
+
remove: (opts?: unknown) => Promise<unknown>;
|
|
13
|
+
};
|
|
14
|
+
export type DockerLike = {
|
|
15
|
+
ping: () => Promise<unknown>;
|
|
16
|
+
createContainer: (opts: Record<string, unknown>) => Promise<ContainerLike>;
|
|
17
|
+
};
|
|
18
|
+
export type DockerRunRequest = {
|
|
19
|
+
image: string;
|
|
20
|
+
usingDefaultImage: boolean;
|
|
21
|
+
env: string[];
|
|
22
|
+
password: string;
|
|
23
|
+
stdinJson: string;
|
|
24
|
+
memoryBytes: number;
|
|
25
|
+
nanoCpus: number;
|
|
26
|
+
timeoutMs: number;
|
|
27
|
+
maxRows: number;
|
|
28
|
+
maxBytes: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Incrementally demux Docker's 8-byte multiplexed attach stream.
|
|
32
|
+
* Tty:false attach is always multiplexed — buffer until a full 8-byte header.
|
|
33
|
+
* Type-1 (stdout) and type-2 (stderr) payloads are forwarded to the NDJSON
|
|
34
|
+
* consumer so runner `{type:"error"}` events are not dropped.
|
|
35
|
+
*/
|
|
36
|
+
export declare function demuxStdout(stream: AsyncIterable<Buffer | string>): PassThrough;
|
|
37
|
+
//# sourceMappingURL=docker-types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"docker-types.d.ts","sourceRoot":"","sources":["../src/docker-types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEzD,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9B,UAAU,EAAE,CACV,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC,cAAc,EACnC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,KACnB,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB,MAAM,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,eAAe,GAAG,QAAQ,CAAC,CAAC;IACtE,IAAI,EAAE,MAAM,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5C,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9C,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;CAC5E,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,iBAAiB,EAAE,OAAO,CAAC;IAC3B,GAAG,EAAE,MAAM,EAAE,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,GAAG,MAAM,CAAC,GAAG,WAAW,CAsB/E"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { PassThrough } from 'node:stream';
|
|
2
|
+
/**
|
|
3
|
+
* Incrementally demux Docker's 8-byte multiplexed attach stream.
|
|
4
|
+
* Tty:false attach is always multiplexed — buffer until a full 8-byte header.
|
|
5
|
+
* Type-1 (stdout) and type-2 (stderr) payloads are forwarded to the NDJSON
|
|
6
|
+
* consumer so runner `{type:"error"}` events are not dropped.
|
|
7
|
+
*/
|
|
8
|
+
export function demuxStdout(stream) {
|
|
9
|
+
const stdout = new PassThrough();
|
|
10
|
+
let carry = Buffer.alloc(0);
|
|
11
|
+
void (async () => {
|
|
12
|
+
try {
|
|
13
|
+
for await (const chunk of stream) {
|
|
14
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
15
|
+
const joined = Buffer.concat([carry, incoming]);
|
|
16
|
+
const extracted = extractMuxed(joined);
|
|
17
|
+
carry = Buffer.from(extracted.rest);
|
|
18
|
+
if (extracted.stdout.length > 0) {
|
|
19
|
+
stdout.write(extracted.stdout);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
stdout.end();
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
stdout.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
return stdout;
|
|
29
|
+
}
|
|
30
|
+
function extractMuxed(buffer) {
|
|
31
|
+
const stdoutChunks = [];
|
|
32
|
+
let offset = 0;
|
|
33
|
+
while (offset + 8 <= buffer.length) {
|
|
34
|
+
const size = buffer.readUInt32BE(offset + 4);
|
|
35
|
+
if (offset + 8 + size > buffer.length) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
const streamType = buffer[offset];
|
|
39
|
+
const payload = buffer.subarray(offset + 8, offset + 8 + size);
|
|
40
|
+
if (streamType === 1 || streamType === 2) {
|
|
41
|
+
stdoutChunks.push(payload);
|
|
42
|
+
}
|
|
43
|
+
offset += 8 + size;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
stdout: Buffer.concat(stdoutChunks),
|
|
47
|
+
rest: buffer.subarray(offset),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=docker-types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"docker-types.js","sourceRoot":"","sources":["../src/docker-types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAiB,MAAM,aAAa,CAAC;AAgCzD;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,MAAsC;IAChE,MAAM,MAAM,GAAG,IAAI,WAAW,EAAE,CAAC;IACjC,IAAI,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAE5B,KAAK,CAAC,KAAK,IAAI,EAAE;QACf,IAAI,CAAC;YACH,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;gBACjC,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;gBACrE,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC;gBAChD,MAAM,SAAS,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;gBACvC,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;gBACpC,IAAI,SAAS,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAChC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;gBACjC,CAAC;YACH,CAAC;YACD,MAAM,CAAC,GAAG,EAAE,CAAC;QACf,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,CAAC,OAAO,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACtE,CAAC;IACH,CAAC,CAAC,EAAE,CAAC;IAEL,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,YAAY,CAAC,MAAc;IAClC,MAAM,YAAY,GAAa,EAAE,CAAC;IAClC,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,OAAO,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;QACnC,MAAM,IAAI,GAAG,MAAM,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QAC7C,IAAI,MAAM,GAAG,CAAC,GAAG,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;YACtC,MAAM;QACR,CAAC;QACD,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;QAClC,MAAM,OAAO,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;QAC/D,IAAI,UAAU,KAAK,CAAC,IAAI,UAAU,KAAK,CAAC,EAAE,CAAC;YACzC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC7B,CAAC;QACD,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;IACrB,CAAC;IACD,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC;QACnC,IAAI,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;KAC9B,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DockerLike } from './docker-types.js';
|
|
2
|
+
import type { ExecuteSqlInput, ExecuteSqlResult } from './types.js';
|
|
3
|
+
export type { ExecuteSqlInput, ExecuteSqlResult } from './types.js';
|
|
4
|
+
export type { DockerLike } from './docker-types.js';
|
|
5
|
+
export type ExecuteSqlDeps = {
|
|
6
|
+
docker?: DockerLike;
|
|
7
|
+
};
|
|
8
|
+
export declare function executeSql(input: ExecuteSqlInput, deps?: ExecuteSqlDeps): Promise<ExecuteSqlResult>;
|
|
9
|
+
//# sourceMappingURL=execute-sql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"execute-sql.d.ts","sourceRoot":"","sources":["../src/execute-sql.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAEjB,MAAM,YAAY,CAAC;AAEpB,YAAY,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AACpE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,MAAM,MAAM,cAAc,GAAG;IAC3B,MAAM,CAAC,EAAE,UAAU,CAAC;CACrB,CAAC;AAEF,wBAAsB,UAAU,CAC9B,KAAK,EAAE,eAAe,EACtB,IAAI,GAAE,cAAmB,GACxB,OAAO,CAAC,gBAAgB,CAAC,CAwE3B"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { validateSql } from './validate-sql.js';
|
|
2
|
+
import { hostIsAllowlisted } from './host-allowlist.js';
|
|
3
|
+
import { mergeLimits } from './limits.js';
|
|
4
|
+
import { DEFAULT_SANDBOX_IMAGE, isDigestPinnedImage } from './image.js';
|
|
5
|
+
import { rewriteLoopbackHost } from './rewrite-host.js';
|
|
6
|
+
import { createDefaultDocker, runInSandbox, } from './docker-executor.js';
|
|
7
|
+
export async function executeSql(input, deps = {}) {
|
|
8
|
+
const validation = validateSql(input.sql);
|
|
9
|
+
if (!validation.ok) {
|
|
10
|
+
return {
|
|
11
|
+
ok: false,
|
|
12
|
+
code: validation.code,
|
|
13
|
+
error: validation.reason,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (!hostIsAllowlisted(input.connection.host, input.hostAllowlist)) {
|
|
17
|
+
return {
|
|
18
|
+
ok: false,
|
|
19
|
+
code: 'HOST_NOT_ALLOWED',
|
|
20
|
+
error: `connection.host ${JSON.stringify(input.connection.host)} is not on hostAllowlist`,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const dialect = parseDialect(input.connection.type);
|
|
24
|
+
if (!dialect.ok) {
|
|
25
|
+
return dialect;
|
|
26
|
+
}
|
|
27
|
+
const merged = mergeLimits(input.limits);
|
|
28
|
+
if (!merged.ok) {
|
|
29
|
+
return { ok: false, code: 'INVALID_LIMITS', error: merged.error };
|
|
30
|
+
}
|
|
31
|
+
const image = input.image ?? DEFAULT_SANDBOX_IMAGE;
|
|
32
|
+
if (input.image === undefined && !isDigestPinnedImage(image)) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
code: 'IMAGE_UNPINNED',
|
|
36
|
+
error: 'Default sandbox image must be pinned by sha256 digest',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const docker = deps.docker ?? createDefaultDocker();
|
|
40
|
+
try {
|
|
41
|
+
await docker.ping();
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
code: 'DOCKER_UNAVAILABLE',
|
|
47
|
+
error: `Docker is required to execute SQL. Could not ping the Docker engine: ${err instanceof Error ? err.message : String(err)}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const dbHost = rewriteLoopbackHost(input.connection.host);
|
|
51
|
+
const env = [
|
|
52
|
+
`DB_TYPE=${dialect.type}`,
|
|
53
|
+
`DB_HOST=${dbHost}`,
|
|
54
|
+
`DB_PORT=${input.connection.port}`,
|
|
55
|
+
`DB_USER=${input.connection.user}`,
|
|
56
|
+
`DB_NAME=${input.connection.database}`,
|
|
57
|
+
`QUERY_TIMEOUT=${merged.limits.timeoutMs}`,
|
|
58
|
+
`MAX_ROWS=${merged.limits.maxRows}`,
|
|
59
|
+
`MAX_BYTES=${merged.limits.maxBytes}`,
|
|
60
|
+
];
|
|
61
|
+
return runInSandbox(docker, {
|
|
62
|
+
image,
|
|
63
|
+
usingDefaultImage: input.image === undefined,
|
|
64
|
+
env,
|
|
65
|
+
password: input.connection.password,
|
|
66
|
+
stdinJson: JSON.stringify({ sql: input.sql }) + '\n',
|
|
67
|
+
memoryBytes: merged.limits.memoryMb * 1024 * 1024,
|
|
68
|
+
nanoCpus: merged.limits.nanoCpus,
|
|
69
|
+
timeoutMs: merged.limits.timeoutMs,
|
|
70
|
+
maxRows: merged.limits.maxRows,
|
|
71
|
+
maxBytes: merged.limits.maxBytes,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function parseDialect(type) {
|
|
75
|
+
switch (type) {
|
|
76
|
+
case 'postgres':
|
|
77
|
+
case 'mysql':
|
|
78
|
+
return { ok: true, type };
|
|
79
|
+
default: {
|
|
80
|
+
const unexpected = type;
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
code: 'UNSUPPORTED_DIALECT',
|
|
84
|
+
error: `Unsupported dialect: ${String(unexpected)}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=execute-sql.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"execute-sql.js","sourceRoot":"","sources":["../src/execute-sql.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACxE,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EACL,mBAAmB,EACnB,YAAY,GACb,MAAM,sBAAsB,CAAC;AAe9B,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,KAAsB,EACtB,OAAuB,EAAE;IAEzB,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC1C,IAAI,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC;QACnB,OAAO;YACL,EAAE,EAAE,KAAK;YACT,IAAI,EAAE,UAAU,CAAC,IAAI;YACrB,KAAK,EAAE,UAAU,CAAC,MAAM;SACzB,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,EAAE,KAAK,CAAC,aAAa,CAAC,EAAE,CAAC;QACnE,OAAO;YACL,EAAE,EAAE,KAAK;YACT,IAAI,EAAE,kBAAkB;YACxB,KAAK,EAAE,mBAAmB,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,0BAA0B;SAC1F,CAAC;IACJ,CAAC;IAED,MAAM,OAAO,GAAG,YAAY,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IACpD,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC;QAChB,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,MAAM,MAAM,GAAG,WAAW,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACzC,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;QACf,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC;IACpE,CAAC;IAED,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,IAAI,qBAAqB,CAAC;IACnD,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,IAAI,CAAC,mBAAmB,CAAC,KAAK,CAAC,EAAE,CAAC;QAC7D,OAAO;YACL,EAAE,EAAE,KAAK;YACT,IAAI,EAAE,gBAAgB;YACtB,KAAK,EAAE,uDAAuD;SAC/D,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,mBAAmB,EAAE,CAAC;IACpD,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACtB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO;YACL,EAAE,EAAE,KAAK;YACT,IAAI,EAAE,oBAAoB;YAC1B,KAAK,EAAE,wEAAwE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE;SAClI,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,mBAAmB,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IAC1D,MAAM,GAAG,GAAG;QACV,WAAW,OAAO,CAAC,IAAI,EAAE;QACzB,WAAW,MAAM,EAAE;QACnB,WAAW,KAAK,CAAC,UAAU,CAAC,IAAI,EAAE;QAClC,WAAW,KAAK,CAAC,UAAU,CAAC,IAAI,EAAE;QAClC,WAAW,KAAK,CAAC,UAAU,CAAC,QAAQ,EAAE;QACtC,iBAAiB,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE;QAC1C,YAAY,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE;QACnC,aAAa,MAAM,CAAC,MAAM,CAAC,QAAQ,EAAE;KACtC,CAAC;IAEF,OAAO,YAAY,CAAC,MAAM,EAAE;QAC1B,KAAK;QACL,iBAAiB,EAAE,KAAK,CAAC,KAAK,KAAK,SAAS;QAC5C,GAAG;QACH,QAAQ,EAAE,KAAK,CAAC,UAAU,CAAC,QAAQ;QACnC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI;QACpD,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,GAAG,IAAI,GAAG,IAAI;QACjD,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ;QAChC,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC,SAAS;QAClC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO;QAC9B,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ;KACjC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,YAAY,CACnB,IAAgB;IAEhB,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,UAAU,CAAC;QAChB,KAAK,OAAO;YACV,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;QAC5B,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,IAAI,CAAC;YAC/B,OAAO;gBACL,EAAE,EAAE,KAAK;gBACT,IAAI,EAAE,qBAAqB;gBAC3B,KAAK,EAAE,wBAAwB,MAAM,CAAC,UAAU,CAAC,EAAE;aACpD,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-allowlist.d.ts","sourceRoot":"","sources":["../src/host-allowlist.ts"],"names":[],"mappings":"AAAA,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,aAAa,EAAE,MAAM,EAAE,GACtB,OAAO,CAST"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function hostIsAllowlisted(host, hostAllowlist) {
|
|
2
|
+
const normalized = host.trim().toLowerCase();
|
|
3
|
+
if (normalized.length === 0) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const allowed = new Set(hostAllowlist.map((entry) => entry.trim().toLowerCase()).filter(Boolean));
|
|
7
|
+
return allowed.has(normalized);
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=host-allowlist.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-allowlist.js","sourceRoot":"","sources":["../src/host-allowlist.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,iBAAiB,CAC/B,IAAY,EACZ,aAAuB;IAEvB,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC7C,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5B,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,OAAO,GAAG,IAAI,GAAG,CACrB,aAAa,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CACzE,CAAC;IACF,OAAO,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AACjC,CAAC"}
|