@appliance.sh/api-server 1.21.0 → 1.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appliance.sh/api-server",
3
- "version": "1.21.0",
3
+ "version": "1.22.1",
4
4
  "description": "",
5
5
  "author": "Eliot Lim",
6
6
  "repository": "https://github.com/appliance-sh/appliance.sh",
@@ -19,8 +19,8 @@
19
19
  "test:e2e": "vitest run --config vitest.e2e.config.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@appliance.sh/infra": "1.21.0",
23
- "@appliance.sh/sdk": "1.21.0",
22
+ "@appliance.sh/infra": "1.22.1",
23
+ "@appliance.sh/sdk": "1.22.1",
24
24
  "@aws-sdk/client-ecr": "^3.1005.0",
25
25
  "@aws-sdk/client-s3": "^3.750.0",
26
26
  "express": "^5.2.1"
@@ -1,11 +1,12 @@
1
1
  import { Router } from 'express';
2
- import { timingSafeEqual } from 'crypto';
2
+ import { createHash, timingSafeEqual } from 'crypto';
3
3
  import { apiKeyInput } from '@appliance.sh/sdk';
4
4
  import { apiKeyService } from '../../services/api-key.service';
5
5
 
6
6
  function constantTimeEqual(a: string, b: string): boolean {
7
- if (a.length !== b.length) return false;
8
- return timingSafeEqual(Buffer.from(a), Buffer.from(b));
7
+ const ha = createHash('sha256').update(a).digest();
8
+ const hb = createHash('sha256').update(b).digest();
9
+ return timingSafeEqual(ha, hb);
9
10
  }
10
11
 
11
12
  export const bootstrapRoutes = Router();
@@ -1,8 +1,8 @@
1
- import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
1
+ import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
2
2
  import { ECRClient, GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr';
3
3
  import { applianceBaseConfig, applianceInput } from '@appliance.sh/sdk';
4
4
  import type { ApplianceContainer, ApplianceFrameworkApp } from '@appliance.sh/sdk';
5
- import { execSync } from 'child_process';
5
+ import { execFileSync } from 'child_process';
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
8
  import * as os from 'node:os';
@@ -60,7 +60,8 @@ export class BuildService {
60
60
 
61
61
  try {
62
62
  // List zip contents and validate paths before extracting
63
- const entries = execSync(`zipinfo -1 "${zipPath}"`, { encoding: 'utf-8' }).trim().split('\n');
63
+ // Validate zip contents before extracting
64
+ const entries = execFileSync('zipinfo', ['-1', zipPath], { encoding: 'utf-8' }).trim().split('\n');
64
65
  for (const entryPath of entries) {
65
66
  const resolved = path.resolve(tmpDir, entryPath);
66
67
  if (!resolved.startsWith(tmpDir + path.sep) && resolved !== tmpDir) {
@@ -68,7 +69,15 @@ export class BuildService {
68
69
  }
69
70
  }
70
71
 
71
- execSync(`unzip -o -q "${zipPath}" -d "${tmpDir}"`, { stdio: 'pipe' });
72
+ execFileSync('unzip', ['-o', '-q', zipPath, '-d', tmpDir], { stdio: 'pipe' });
73
+
74
+ // Reject symlinks after extraction
75
+ for (const entryPath of entries) {
76
+ const fullPath = path.join(tmpDir, entryPath);
77
+ if (fs.existsSync(fullPath) && fs.lstatSync(fullPath).isSymbolicLink()) {
78
+ throw new Error(`Zip contains symlink: ${entryPath}`);
79
+ }
80
+ }
72
81
 
73
82
  // Read the manifest
74
83
  const manifestPath = path.join(tmpDir, 'appliance.json');
@@ -78,7 +87,7 @@ export class BuildService {
78
87
  if (manifest.type === 'container') {
79
88
  return this.resolveContainer(tmpDir, manifest, tag, config);
80
89
  } else if (manifest.type === 'framework') {
81
- return this.resolveFramework(tmpDir, manifest, tag, config);
90
+ return this.resolveFramework(manifest, s3Key, config);
82
91
  } else {
83
92
  return { codeS3Key: s3Key };
84
93
  }
@@ -87,53 +96,28 @@ export class BuildService {
87
96
  }
88
97
  }
89
98
 
90
- private async resolveFramework(
91
- tmpDir: string,
99
+ /**
100
+ * Framework builds are fully pre-processed by the CLI (dependencies installed,
101
+ * run.sh generated). The server just resolves Lambda-specific params from the
102
+ * manifest metadata and points at the original uploaded zip.
103
+ */
104
+ private resolveFramework(
92
105
  manifest: ApplianceFrameworkApp,
93
- tag: string,
106
+ s3Key: string,
94
107
  config: ReturnType<typeof getBaseConfig>
95
- ): Promise<ResolvedBuild> {
96
- if (!config.aws.dataBucketName) throw new Error('Data bucket not configured');
97
-
108
+ ): ResolvedBuild {
98
109
  const port = manifest.port ?? 8080;
99
110
  const framework = manifest.framework ?? 'auto';
100
- const detectedFramework = framework === 'auto' ? this.detectFramework(tmpDir) : framework;
101
- const runtime = FRAMEWORK_RUNTIMES[detectedFramework] ?? FRAMEWORK_RUNTIMES['node'];
102
-
103
- // Generate a run.sh that starts the web server
104
- const startCommand = manifest.scripts?.start ?? this.defaultStartCommand(detectedFramework, tmpDir);
105
- const runSh = ['#!/bin/bash', `export PORT=${port}`, `exec ${startCommand}`].join('\n');
106
- fs.writeFileSync(path.join(tmpDir, 'run.sh'), runSh, { mode: 0o755 });
111
+ const runtime = FRAMEWORK_RUNTIMES[framework] ?? FRAMEWORK_RUNTIMES['node'];
107
112
 
108
- // Repackage as a new zip (excluding the original zip and manifest-only artifacts)
109
- const repackagedKey = `builds/${tag}.zip`;
110
- const repackagedZip = path.join(tmpDir, 'repackaged.zip');
111
- execSync(`cd "${tmpDir}" && zip -r "${repackagedZip}" . -x "appliance.zip" -x "repackaged.zip"`, { stdio: 'pipe' });
112
-
113
- // Upload the repackaged zip
114
- const s3 = new S3Client({ region: config.aws.region });
115
- await s3.send(
116
- new PutObjectCommand({
117
- Bucket: config.aws.dataBucketName,
118
- Key: repackagedKey,
119
- Body: fs.readFileSync(repackagedZip),
120
- ContentType: 'application/zip',
121
- })
122
- );
123
-
124
- // Resolve the Lambda Web Adapter layer ARN and architecture for this region
125
113
  const platform = manifest.platform;
126
114
  const layerArn = LAMBDA_ADAPTER_LAYER[platform]?.replace('${region}', config.aws.region);
127
115
  if (!layerArn) throw new Error(`No Lambda Web Adapter layer for platform: ${platform}`);
128
116
  const architecture = FRAMEWORK_ARCHITECTURES[platform];
129
117
  if (!architecture) throw new Error(`No Lambda architecture for platform: ${platform}`);
130
118
 
131
- // The Web Adapter layer uses AWS_LAMBDA_EXEC_WRAPPER to intercept the
132
- // Lambda runtime bootstrap. It starts run.sh (the web server) and proxies
133
- // Lambda invocations to it as HTTP requests. The handler value is not
134
- // called directly but must point to a valid file to pass validation.
135
119
  return {
136
- codeS3Key: repackagedKey,
120
+ codeS3Key: s3Key,
137
121
  runtime,
138
122
  handler: 'run.sh',
139
123
  layers: [layerArn],
@@ -145,29 +129,14 @@ export class BuildService {
145
129
  };
146
130
  }
147
131
 
148
- private detectFramework(tmpDir: string): string {
149
- if (fs.existsSync(path.join(tmpDir, 'package.json'))) return 'node';
150
- if (fs.existsSync(path.join(tmpDir, 'requirements.txt'))) return 'python';
151
- if (fs.existsSync(path.join(tmpDir, 'Pipfile'))) return 'python';
152
- if (fs.existsSync(path.join(tmpDir, 'pyproject.toml'))) return 'python';
153
- return 'node';
154
- }
155
-
156
- private defaultStartCommand(framework: string, tmpDir: string): string {
157
- if (framework === 'python') {
158
- return 'python app.py';
159
- }
160
- // Node default
161
- if (fs.existsSync(path.join(tmpDir, 'package.json'))) {
162
- const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf-8'));
163
- if (pkg.scripts?.start) return 'npm start';
164
- }
165
- return 'node index.js';
166
- }
167
-
132
+ /**
133
+ * Container builds are fully pre-processed by the CLI (Lambda Web Adapter
134
+ * already injected). The server just loads the image and pushes it to ECR.
135
+ * All subprocess calls use execFileSync (array args) to prevent shell injection.
136
+ */
168
137
  private async resolveContainer(
169
138
  tmpDir: string,
170
- manifest: ApplianceContainer,
139
+ _manifest: ApplianceContainer,
171
140
  tag: string,
172
141
  config: ReturnType<typeof getBaseConfig>
173
142
  ): Promise<ResolvedBuild> {
@@ -175,33 +144,17 @@ export class BuildService {
175
144
  if (!ecrRepositoryUrl) throw new Error('ECR repository not configured');
176
145
 
177
146
  const imageTarPath = path.join(tmpDir, 'image.tar');
178
- if (!fs.existsSync(imageTarPath)) throw new Error('Build missing image.tar');
147
+ if (!fs.existsSync(imageTarPath)) {
148
+ const extracted = fs.readdirSync(tmpDir);
149
+ throw new Error(`Build missing image.tar. Extracted contents: ${extracted.join(', ')}`);
150
+ }
179
151
 
180
- // Load the user's original image into Docker and capture the loaded image reference
181
- const loadOutput = execSync(`docker load -i "${imageTarPath}"`, { encoding: 'utf-8' });
182
- // Output is like "Loaded image: name:tag" or "Loaded image ID: sha256:abc..."
152
+ // Load the pre-built Lambda-ready image
153
+ const loadOutput = execFileSync('docker', ['load', '-i', imageTarPath], { encoding: 'utf-8' });
183
154
  const loadedMatch = loadOutput.match(/Loaded image(?: ID)?:\s*(.+)/);
184
155
  if (!loadedMatch) throw new Error(`Failed to parse docker load output: ${loadOutput}`);
185
156
  const loadedImage = loadedMatch[1].trim();
186
157
 
187
- // Wrap the image with the Lambda Web Adapter so the same plain HTTP
188
- // container works on both Lambda and ECS/Fargate without any changes.
189
- const lambdaImageName = `${manifest.name}-lambda`;
190
- const wrapperDockerfile = path.join(tmpDir, 'Dockerfile.lambda');
191
- fs.writeFileSync(
192
- wrapperDockerfile,
193
- [
194
- `FROM --platform=${manifest.platform} public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 AS adapter`,
195
- `FROM ${loadedImage}`,
196
- `COPY --from=adapter /lambda-adapter /opt/extensions/lambda-adapter`,
197
- `ENV AWS_LWA_PORT=${manifest.port}`,
198
- ].join('\n')
199
- );
200
- execSync(
201
- `docker build --platform ${manifest.platform} --provenance=false -f "${wrapperDockerfile}" -t "${lambdaImageName}" "${tmpDir}"`,
202
- { stdio: 'pipe' }
203
- );
204
-
205
158
  // Auth with ECR
206
159
  const ecr = new ECRClient({ region: config.aws.region });
207
160
  const authResult = await ecr.send(new GetAuthorizationTokenCommand({}));
@@ -212,20 +165,20 @@ export class BuildService {
212
165
 
213
166
  const decoded = Buffer.from(authData.authorizationToken, 'base64').toString();
214
167
  const [username, password] = decoded.split(':');
215
- execSync(`docker login --username ${username} --password-stdin ${authData.proxyEndpoint}`, {
168
+ execFileSync('docker', ['login', '--username', username, '--password-stdin', authData.proxyEndpoint], {
216
169
  input: password,
217
170
  stdio: ['pipe', 'pipe', 'pipe'],
218
171
  });
219
172
 
220
- // Tag and push the Lambda-wrapped image
173
+ // Tag and push
221
174
  const remoteTag = `${ecrRepositoryUrl}:${tag}`;
222
- execSync(`docker tag ${lambdaImageName} ${remoteTag}`, { stdio: 'pipe' });
223
- execSync(`docker push ${remoteTag}`, { stdio: 'pipe' });
175
+ execFileSync('docker', ['tag', loadedImage, remoteTag], { stdio: 'pipe' });
176
+ execFileSync('docker', ['push', remoteTag], { stdio: 'pipe' });
224
177
 
225
178
  // Get digest
226
179
  let imageUri: string;
227
180
  try {
228
- imageUri = execSync(`docker inspect --format='{{index .RepoDigests 0}}' ${remoteTag}`, {
181
+ imageUri = execFileSync('docker', ['inspect', '--format={{index .RepoDigests 0}}', remoteTag], {
229
182
  encoding: 'utf-8',
230
183
  }).trim();
231
184
  } catch {