@enterpriseai/cli 3.19.1 → 3.19.3
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 +28 -0
- package/dist/commands/eai-managed-deploy-customer-source.d.ts.map +1 -1
- package/dist/commands/eai-managed-deploy-customer-source.js +24 -0
- package/dist/commands/eai-managed-deploy-customer-source.js.map +1 -1
- package/dist/commands/eai-managed-deploy-github-dispatch.d.ts.map +1 -1
- package/dist/commands/eai-managed-deploy-github-dispatch.js +30 -0
- package/dist/commands/eai-managed-deploy-github-dispatch.js.map +1 -1
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +33 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/provision.js +2 -5
- package/dist/commands/provision.js.map +1 -1
- package/dist/commands/support.d.ts +18 -0
- package/dist/commands/support.d.ts.map +1 -0
- package/dist/commands/support.js +110 -0
- package/dist/commands/support.js.map +1 -0
- package/dist/commands/types.d.ts +5 -1
- package/dist/commands/types.d.ts.map +1 -1
- package/dist/commands/types.js +55 -12
- package/dist/commands/types.js.map +1 -1
- package/dist/index.js +34 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/agent-guide.d.ts.map +1 -1
- package/dist/lib/agent-guide.js +25 -4
- package/dist/lib/agent-guide.js.map +1 -1
- package/dist/lib/api.d.ts +4 -2
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +17 -5
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/config.d.ts +2 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +8 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/eai-managed-deploy-contract.d.ts +4 -0
- package/dist/lib/eai-managed-deploy-contract.d.ts.map +1 -1
- package/dist/lib/eai-managed-deploy-contract.js.map +1 -1
- package/dist/lib/eai-managed-deploy-operation.d.ts.map +1 -1
- package/dist/lib/eai-managed-deploy-operation.js +14 -0
- package/dist/lib/eai-managed-deploy-operation.js.map +1 -1
- package/dist/lib/eai-managed-deploy-state.d.ts.map +1 -1
- package/dist/lib/eai-managed-deploy-state.js +15 -1
- package/dist/lib/eai-managed-deploy-state.js.map +1 -1
- package/dist/lib/error-codes.d.ts.map +1 -1
- package/dist/lib/error-codes.js +6 -3
- package/dist/lib/error-codes.js.map +1 -1
- package/dist/lib/output.d.ts +1 -0
- package/dist/lib/output.d.ts.map +1 -1
- package/dist/lib/output.js +3 -0
- package/dist/lib/output.js.map +1 -1
- package/dist/lib/profile.d.ts +2 -0
- package/dist/lib/profile.d.ts.map +1 -1
- package/dist/lib/profile.js.map +1 -1
- package/dist/lib/support-context.d.ts +31 -0
- package/dist/lib/support-context.d.ts.map +1 -0
- package/dist/lib/support-context.js +193 -0
- package/dist/lib/support-context.js.map +1 -0
- package/dist/lib/support-redaction.d.ts +10 -0
- package/dist/lib/support-redaction.d.ts.map +1 -0
- package/dist/lib/support-redaction.js +44 -0
- package/dist/lib/support-redaction.js.map +1 -0
- package/dist/lib/support-report.d.ts +68 -0
- package/dist/lib/support-report.d.ts.map +1 -0
- package/dist/lib/support-report.js +165 -0
- package/dist/lib/support-report.js.map +1 -0
- package/package.json +5 -4
- package/resources/deploy/eai-app-template/.github/workflows/eai-app.yml +46 -13
- package/resources/deploy/eai-app-template/producer-pin.json +8 -8
- package/resources/deploy/eai-app-template/scripts/source-unknown-deployment-evidence.mjs +29 -2
- package/resources/deploy/eai-app-template/tests/source-unknown-deployment-evidence.test.mjs +2239 -1
- package/resources/gofer/agents-skills/eai/SKILL.md +20 -0
- package/resources/gofer/claude-commands/eai.md +20 -0
- package/resources/gofer/claude-skills/eai/SKILL.md +20 -0
- package/resources/gofer/commands/0_gofer_start.md +18 -0
- package/resources/gofer/commands/gofer_eai_first_run.md +18 -0
- package/resources/gofer/copilot-prompts/eai.prompt.md +20 -0
- package/resources/gofer/gemini/commands/gofer/eai.md +20 -0
- package/resources/gofer/github-skills/eai/SKILL.md +20 -0
- package/resources/gofer/grok-skills/eai/SKILL.md +20 -0
- package/resources/gofer/instruction-templates/base/agents-base.md +18 -0
- package/resources/gofer/instruction-templates/base/claude-base.md +18 -0
- package/resources/gofer/instruction-templates/base/copilot-base.md +18 -0
- package/resources/gofer/instruction-templates/gofer/gofer-claude.md +4 -0
- package/resources/gofer/node-scripts/generate-commands.mjs +26 -0
- package/resources/gofer/references/platform/eai-support.md +19 -0
- package/resources/gofer/specify-commands/0_gofer_start.md +18 -0
- package/resources/gofer/specify-commands/gofer_eai_first_run.md +18 -0
- package/resources/gofer/system-skills/eai/SKILL.md +20 -0
- package/resources/linked-sources.json +2 -2
|
@@ -3,12 +3,18 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import {
|
|
5
5
|
cpSync,
|
|
6
|
+
chmodSync,
|
|
7
|
+
closeSync,
|
|
8
|
+
constants as fsConstants,
|
|
9
|
+
fstatSync,
|
|
10
|
+
openSync,
|
|
6
11
|
existsSync,
|
|
7
12
|
linkSync,
|
|
8
13
|
lstatSync,
|
|
9
14
|
mkdirSync,
|
|
10
15
|
mkdtempSync,
|
|
11
16
|
readFileSync,
|
|
17
|
+
readSync,
|
|
12
18
|
realpathSync,
|
|
13
19
|
rmSync,
|
|
14
20
|
symlinkSync,
|
|
@@ -19,13 +25,797 @@ import { tmpdir } from 'node:os';
|
|
|
19
25
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
20
26
|
import { fileURLToPath } from 'node:url';
|
|
21
27
|
import test from 'node:test';
|
|
28
|
+
import { runInNewContext } from 'node:vm';
|
|
29
|
+
import { gunzipSync } from 'node:zlib';
|
|
30
|
+
|
|
31
|
+
const canonicalWorkflowRelativePath = '.github/workflows/eai-app.yml';
|
|
32
|
+
const fixtureDirectory = 'tests/fixtures/source-unknown';
|
|
33
|
+
const canonicalDigest =
|
|
34
|
+
'bce4e34fe5c47377e6a679966a37257d944cd00ccf3c15b4ba437f144ed559ed';
|
|
35
|
+
const generatedDigest =
|
|
36
|
+
'77d4951b3e6852ead73cef8b9e8901e3de774f9aca70f71cc0c932c13903ff32';
|
|
37
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
38
|
+
const runtimeAppKeyPattern = /^[a-z][a-z0-9-]{1,62}$/;
|
|
39
|
+
const runtimeSafePathSegmentPattern =
|
|
40
|
+
/^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,254}[A-Za-z0-9])?$/;
|
|
41
|
+
const controlledRoutingProofs = new WeakMap();
|
|
42
|
+
const managedReviewPath =
|
|
43
|
+
'/api/platform/generated-apps/managed-review/workflow';
|
|
44
|
+
// SECURITY: TEST Portal currently exports through AU; the additional official TEST API pairs retain collector dispatch compatibility, not live regional adoption proof.
|
|
45
|
+
const trustedRoutingPairs = [
|
|
46
|
+
[
|
|
47
|
+
'https://dev-api.au.myenterprise.ai/public',
|
|
48
|
+
'https://dev-admin-portal.myenterprise.ai',
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
'https://test-api.au.myenterprise.ai/public',
|
|
52
|
+
'https://test-admin-portal.myenterprise.ai',
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
'https://test-api.ca.myenterprise.ai/public',
|
|
56
|
+
'https://test-admin-portal.myenterprise.ai',
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
'https://test-api.eu.myenterprise.ai/public',
|
|
60
|
+
'https://test-admin-portal.myenterprise.ai',
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
'https://api.au.myenterprise.ai/public',
|
|
64
|
+
'https://admin-portal.myenterprise.ai',
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
'https://api.eu.myenterprise.ai/public',
|
|
68
|
+
'https://admin-portal.eu.myenterprise.ai',
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
'https://api.ca.myenterprise.ai/public',
|
|
72
|
+
'https://admin-portal.ca.myenterprise.ai',
|
|
73
|
+
],
|
|
74
|
+
].map(([evidence, portal]) => [evidence, portal + managedReviewPath]);
|
|
75
|
+
const record = (value) =>
|
|
76
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
77
|
+
const boundedString = (value) =>
|
|
78
|
+
typeof value === 'string' &&
|
|
79
|
+
value.length > 0 &&
|
|
80
|
+
value.length <= 1024 &&
|
|
81
|
+
!/[\r\n\0]/.test(value);
|
|
82
|
+
const safePath = (value) =>
|
|
83
|
+
boundedString(value) &&
|
|
84
|
+
!value.startsWith('/') &&
|
|
85
|
+
!value.includes('\\') &&
|
|
86
|
+
value.split('/').every((part) => part && part !== '.' && part !== '..');
|
|
87
|
+
|
|
88
|
+
function regularBytes(path) {
|
|
89
|
+
const stat = lstatSync(path);
|
|
90
|
+
assert.ok(
|
|
91
|
+
stat.isFile() &&
|
|
92
|
+
stat.nlink === 1 &&
|
|
93
|
+
!stat.isSymbolicLink() &&
|
|
94
|
+
stat.size > 0 &&
|
|
95
|
+
stat.size <= 1024 * 1024,
|
|
96
|
+
'Workflow evidence must be a bounded regular file.',
|
|
97
|
+
);
|
|
98
|
+
return readFileSync(path);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function keys(value, allowed, label) {
|
|
102
|
+
assert.ok(
|
|
103
|
+
record(value) && Object.keys(value).every((key) => allowed.includes(key)),
|
|
104
|
+
`Unsupported ${label} fields.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateCliManifest(manifest) {
|
|
109
|
+
keys(
|
|
110
|
+
manifest,
|
|
111
|
+
['schemaVersion', 'cli', 'packages', 'template', 'gofer'],
|
|
112
|
+
'CLI manifest',
|
|
113
|
+
);
|
|
114
|
+
assert.equal(manifest.schemaVersion, 1);
|
|
115
|
+
if (manifest.cli !== undefined) {
|
|
116
|
+
keys(manifest.cli, ['version'], 'CLI version');
|
|
117
|
+
assert.ok(boundedString(manifest.cli.version));
|
|
118
|
+
}
|
|
119
|
+
if (manifest.packages !== undefined) {
|
|
120
|
+
keys(
|
|
121
|
+
manifest.packages,
|
|
122
|
+
['profile', 'source', 'recordedAt'],
|
|
123
|
+
'package profile',
|
|
124
|
+
);
|
|
125
|
+
assert.ok(
|
|
126
|
+
['external', 'internal', 'hybrid'].includes(manifest.packages.profile),
|
|
127
|
+
);
|
|
128
|
+
assert.ok(
|
|
129
|
+
manifest.packages.source === undefined ||
|
|
130
|
+
['eai-packages', 'enterpriseai-packages'].includes(
|
|
131
|
+
manifest.packages.source,
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
assert.ok(
|
|
135
|
+
manifest.packages.recordedAt === undefined ||
|
|
136
|
+
boundedString(manifest.packages.recordedAt),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (manifest.template !== undefined) {
|
|
140
|
+
keys(
|
|
141
|
+
manifest.template,
|
|
142
|
+
['repo', 'version', 'commit', 'displaySource', 'initializedAt'],
|
|
143
|
+
'CLI template',
|
|
144
|
+
);
|
|
145
|
+
assert.ok(Object.values(manifest.template).every(boundedString));
|
|
146
|
+
assert.ok(
|
|
147
|
+
manifest.template.commit === undefined ||
|
|
148
|
+
/^[a-f0-9]{40}$/.test(manifest.template.commit),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (manifest.gofer !== undefined) {
|
|
152
|
+
keys(
|
|
153
|
+
manifest.gofer,
|
|
154
|
+
['bundle', 'managedFiles', 'refreshedAt'],
|
|
155
|
+
'Gofer manifest',
|
|
156
|
+
);
|
|
157
|
+
assert.ok(record(manifest.gofer.managedFiles));
|
|
158
|
+
for (const [path, entry] of Object.entries(manifest.gofer.managedFiles)) {
|
|
159
|
+
keys(entry, ['sha256', 'source'], 'Gofer file');
|
|
160
|
+
assert.ok(
|
|
161
|
+
safePath(path) &&
|
|
162
|
+
/^[a-f0-9]{64}$/.test(entry.sha256) &&
|
|
163
|
+
['bundled', 'generated'].includes(entry.source),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (manifest.gofer.bundle !== undefined) {
|
|
167
|
+
keys(
|
|
168
|
+
manifest.gofer.bundle,
|
|
169
|
+
['commit', 'describe', 'syncedAt'],
|
|
170
|
+
'Gofer bundle',
|
|
171
|
+
);
|
|
172
|
+
assert.ok(Object.values(manifest.gofer.bundle).every(boundedString));
|
|
173
|
+
assert.ok(
|
|
174
|
+
manifest.gofer.bundle.commit === undefined ||
|
|
175
|
+
/^[a-f0-9]{40}$/.test(manifest.gofer.bundle.commit),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
assert.ok(
|
|
179
|
+
manifest.gofer.refreshedAt === undefined ||
|
|
180
|
+
boundedString(manifest.gofer.refreshedAt),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateGeneratedManifest(manifest, workflow) {
|
|
186
|
+
keys(
|
|
187
|
+
manifest,
|
|
188
|
+
[
|
|
189
|
+
'schemaVersion',
|
|
190
|
+
'sourceMode',
|
|
191
|
+
'appKey',
|
|
192
|
+
'appName',
|
|
193
|
+
'generatedAt',
|
|
194
|
+
'templateRepository',
|
|
195
|
+
'workflowPath',
|
|
196
|
+
'environment',
|
|
197
|
+
'codeownersPath',
|
|
198
|
+
'repositoryGuardrailsPath',
|
|
199
|
+
'requiredBeforeTenantAccess',
|
|
200
|
+
'runtimeBinding',
|
|
201
|
+
'theme',
|
|
202
|
+
'managedFiles',
|
|
203
|
+
'generatedFileScope',
|
|
204
|
+
],
|
|
205
|
+
'generated manifest',
|
|
206
|
+
);
|
|
207
|
+
assert.equal(manifest.schemaVersion, 'eai.generated_app_manifest.v1');
|
|
208
|
+
assert.equal(manifest.sourceMode, 'admin-portal-generated');
|
|
209
|
+
assert.ok(
|
|
210
|
+
typeof manifest.appKey === 'string' &&
|
|
211
|
+
runtimeAppKeyPattern.test(manifest.appKey),
|
|
212
|
+
);
|
|
213
|
+
assert.ok(
|
|
214
|
+
boundedString(manifest.appName) &&
|
|
215
|
+
boundedString(manifest.generatedAt) &&
|
|
216
|
+
Number.isFinite(Date.parse(manifest.generatedAt)),
|
|
217
|
+
);
|
|
218
|
+
assert.ok(
|
|
219
|
+
['eai-tools/eai-app-template', 'eai-support/eai-app-template'].includes(
|
|
220
|
+
manifest.templateRepository,
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
assert.equal(manifest.workflowPath, canonicalWorkflowRelativePath);
|
|
224
|
+
assert.equal(manifest.environment, 'eai-generated-preview');
|
|
225
|
+
assert.equal(manifest.codeownersPath, '.github/CODEOWNERS');
|
|
226
|
+
assert.equal(
|
|
227
|
+
manifest.repositoryGuardrailsPath,
|
|
228
|
+
'.github/eai-repository-guardrails.json',
|
|
229
|
+
);
|
|
230
|
+
assert.deepEqual(manifest.requiredBeforeTenantAccess, [
|
|
231
|
+
'branch_ruleset',
|
|
232
|
+
'workflow_path_protection',
|
|
233
|
+
'environment_protection',
|
|
234
|
+
'codeowners',
|
|
235
|
+
'publicapi_github_oidc_allowlist',
|
|
236
|
+
]);
|
|
237
|
+
assert.ok(
|
|
238
|
+
Array.isArray(manifest.managedFiles) &&
|
|
239
|
+
manifest.managedFiles.length > 0 &&
|
|
240
|
+
manifest.managedFiles.length <= 4096,
|
|
241
|
+
);
|
|
242
|
+
const paths = [];
|
|
243
|
+
for (const file of manifest.managedFiles) {
|
|
244
|
+
keys(file, ['path', 'checksum', 'encoding', 'owner'], 'generated file');
|
|
245
|
+
assert.ok(
|
|
246
|
+
safePath(file.path) && /^sha256:[a-f0-9]{64}$/.test(file.checksum),
|
|
247
|
+
);
|
|
248
|
+
assert.ok(['utf8', 'base64'].includes(file.encoding));
|
|
249
|
+
assert.equal(file.owner, 'admin-portal-generated');
|
|
250
|
+
assert.ok(!paths.includes(file.path), 'Duplicate generated file scope.');
|
|
251
|
+
paths.push(file.path);
|
|
252
|
+
}
|
|
253
|
+
assert.deepEqual(manifest.generatedFileScope, paths);
|
|
254
|
+
const declared = manifest.managedFiles.find(
|
|
255
|
+
(file) => file.path === canonicalWorkflowRelativePath,
|
|
256
|
+
);
|
|
257
|
+
assert.ok(
|
|
258
|
+
declared && declared.encoding === 'utf8',
|
|
259
|
+
'Managed workflow is missing.',
|
|
260
|
+
);
|
|
261
|
+
assert.equal(
|
|
262
|
+
declared.checksum,
|
|
263
|
+
`sha256:${digest(workflow)}`,
|
|
264
|
+
'Exported workflow checksum does not match its manifest.',
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function quotedBinding(workflow, name) {
|
|
269
|
+
const pattern = new RegExp(`^ ${name}: ("[^\\r\\n]*")$`, 'm');
|
|
270
|
+
const match = pattern.exec(workflow);
|
|
271
|
+
assert.ok(match, `Missing protected ${name}.`);
|
|
272
|
+
const value = JSON.parse(match[1]);
|
|
273
|
+
assert.ok(boundedString(value), `Malformed protected ${name}.`);
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function secureUrl(value, exactPath) {
|
|
278
|
+
assert.ok(boundedString(value));
|
|
279
|
+
const parsed = new URL(value);
|
|
280
|
+
assert.ok(
|
|
281
|
+
parsed.protocol === 'https:' &&
|
|
282
|
+
!parsed.username &&
|
|
283
|
+
!parsed.password &&
|
|
284
|
+
!parsed.search &&
|
|
285
|
+
!parsed.hash &&
|
|
286
|
+
!parsed.port &&
|
|
287
|
+
!value.slice('https://'.length).split('/')[0].includes(':'),
|
|
288
|
+
'Workflow destination must be credential-free HTTPS without an explicit port.',
|
|
289
|
+
);
|
|
290
|
+
if (exactPath)
|
|
291
|
+
assert.equal(
|
|
292
|
+
parsed.pathname,
|
|
293
|
+
exactPath,
|
|
294
|
+
'Wrong managed-review destination.',
|
|
295
|
+
);
|
|
296
|
+
return parsed;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function controlledReadFlags(constants = fsConstants) {
|
|
300
|
+
for (const name of ['O_NOFOLLOW', 'O_NONBLOCK']) {
|
|
301
|
+
assert.ok(
|
|
302
|
+
Number.isSafeInteger(constants[name]) && constants[name] > 0,
|
|
303
|
+
`Controlled secure reads require ${name} support.`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
assert.ok(
|
|
307
|
+
Number.isSafeInteger(constants.O_RDONLY) && constants.O_RDONLY >= 0,
|
|
308
|
+
'Controlled secure reads require O_RDONLY support.',
|
|
309
|
+
);
|
|
310
|
+
return constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function snapshotControlledDirectories(directory) {
|
|
314
|
+
const identities = [];
|
|
315
|
+
let current = resolve(directory);
|
|
316
|
+
while (true) {
|
|
317
|
+
const status = lstatSync(current);
|
|
318
|
+
assert.ok(
|
|
319
|
+
status.isDirectory() && !status.isSymbolicLink(),
|
|
320
|
+
'Controlled input ancestors must remain no-follow directories.',
|
|
321
|
+
);
|
|
322
|
+
identities.push({
|
|
323
|
+
path: current,
|
|
324
|
+
dev: status.dev,
|
|
325
|
+
ino: status.ino,
|
|
326
|
+
uid: status.uid,
|
|
327
|
+
mode: status.mode,
|
|
328
|
+
});
|
|
329
|
+
const parent = dirname(current);
|
|
330
|
+
if (parent === current) break;
|
|
331
|
+
current = parent;
|
|
332
|
+
}
|
|
333
|
+
return identities;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function assertControlledDirectories(identities) {
|
|
337
|
+
for (const identity of identities) {
|
|
338
|
+
const status = lstatSync(identity.path);
|
|
339
|
+
assert.ok(
|
|
340
|
+
status.isDirectory() &&
|
|
341
|
+
!status.isSymbolicLink() &&
|
|
342
|
+
status.dev === identity.dev &&
|
|
343
|
+
status.ino === identity.ino &&
|
|
344
|
+
status.uid === identity.uid &&
|
|
345
|
+
status.mode === identity.mode,
|
|
346
|
+
'Controlled input parent chain changed during its bound read.',
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function controlledFileMatches(actual, expected) {
|
|
352
|
+
return (
|
|
353
|
+
actual.isFile() &&
|
|
354
|
+
!actual.isSymbolicLink() &&
|
|
355
|
+
actual.nlink === 1 &&
|
|
356
|
+
actual.dev === expected.dev &&
|
|
357
|
+
actual.ino === expected.ino &&
|
|
358
|
+
actual.uid === expected.uid &&
|
|
359
|
+
actual.mode === expected.mode &&
|
|
360
|
+
actual.size === expected.size &&
|
|
361
|
+
actual.mtimeMs === expected.mtimeMs &&
|
|
362
|
+
actual.ctimeMs === expected.ctimeMs
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** SECURITY: the explicit local override is owner-run controlled qualification only, never repository-derived destination trust or live authorization. */
|
|
367
|
+
function readProtectedRoutingBytes(inputPath, candidateRoot, maxBytes = 8192) {
|
|
368
|
+
assert.ok(
|
|
369
|
+
process.platform !== 'win32' && typeof process.getuid === 'function',
|
|
370
|
+
'Controlled local routing requires POSIX owner protection.',
|
|
371
|
+
);
|
|
372
|
+
const flags = controlledReadFlags();
|
|
373
|
+
assert.ok(
|
|
374
|
+
Number.isSafeInteger(maxBytes) && maxBytes > 0 && maxBytes <= 512 * 1024,
|
|
375
|
+
'Controlled input byte limit is invalid.',
|
|
376
|
+
);
|
|
377
|
+
assert.ok(
|
|
378
|
+
isAbsolute(inputPath),
|
|
379
|
+
'Controlled routing input must be an absolute external path.',
|
|
380
|
+
);
|
|
381
|
+
const path = resolve(inputPath);
|
|
382
|
+
assert.equal(
|
|
383
|
+
realpathSync(path),
|
|
384
|
+
path,
|
|
385
|
+
'Controlled routing input must not traverse symlinks.',
|
|
386
|
+
);
|
|
387
|
+
const root = realpathSync(candidateRoot);
|
|
388
|
+
const within = relative(root, path);
|
|
389
|
+
assert.ok(
|
|
390
|
+
within === '..' || within.startsWith('../') || isAbsolute(within),
|
|
391
|
+
'Controlled routing input must be outside the candidate repository.',
|
|
392
|
+
);
|
|
393
|
+
const ancestors = snapshotControlledDirectories(dirname(path));
|
|
394
|
+
const parent = ancestors[0];
|
|
395
|
+
assert.ok(
|
|
396
|
+
parent.uid === process.getuid() && (parent.mode & 0o777) === 0o700,
|
|
397
|
+
'Controlled routing parent must be owner-protected 0700.',
|
|
398
|
+
);
|
|
399
|
+
const before = lstatSync(path);
|
|
400
|
+
const fd = openSync(path, flags);
|
|
401
|
+
try {
|
|
402
|
+
const opened = fstatSync(fd);
|
|
403
|
+
assertControlledDirectories(ancestors);
|
|
404
|
+
assert.ok(
|
|
405
|
+
opened.isFile() &&
|
|
406
|
+
opened.nlink === 1 &&
|
|
407
|
+
opened.uid === process.getuid() &&
|
|
408
|
+
(opened.mode & 0o777) === 0o600 &&
|
|
409
|
+
opened.size > 0 &&
|
|
410
|
+
opened.size <= maxBytes,
|
|
411
|
+
'Controlled routing input must be a bounded owner-only single-link file.',
|
|
412
|
+
);
|
|
413
|
+
assert.ok(
|
|
414
|
+
controlledFileMatches(opened, before) &&
|
|
415
|
+
controlledFileMatches(lstatSync(path), opened),
|
|
416
|
+
'Controlled routing leaf changed before its bound read.',
|
|
417
|
+
);
|
|
418
|
+
const bytes = Buffer.alloc(opened.size);
|
|
419
|
+
let offset = 0;
|
|
420
|
+
while (offset < bytes.length) {
|
|
421
|
+
const count = readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
422
|
+
assert.ok(
|
|
423
|
+
count > 0,
|
|
424
|
+
'Controlled routing input shrank during its bounded read.',
|
|
425
|
+
);
|
|
426
|
+
offset += count;
|
|
427
|
+
}
|
|
428
|
+
assert.equal(
|
|
429
|
+
readSync(fd, Buffer.alloc(1), 0, 1, offset),
|
|
430
|
+
0,
|
|
431
|
+
'Controlled routing input grew during its bounded read.',
|
|
432
|
+
);
|
|
433
|
+
const after = fstatSync(fd);
|
|
434
|
+
assertControlledDirectories(ancestors);
|
|
435
|
+
assert.ok(
|
|
436
|
+
controlledFileMatches(after, opened) &&
|
|
437
|
+
controlledFileMatches(lstatSync(path), opened),
|
|
438
|
+
'Controlled routing bytes changed during the protected read.',
|
|
439
|
+
);
|
|
440
|
+
return bytes;
|
|
441
|
+
} finally {
|
|
442
|
+
closeSync(fd);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function validateControlledConfigReceipts(proof, configPaths, candidateRoot) {
|
|
447
|
+
keys(
|
|
448
|
+
configPaths,
|
|
449
|
+
['publicApi', 'portal'],
|
|
450
|
+
'explicit independent source-config paths',
|
|
451
|
+
);
|
|
452
|
+
assert.deepEqual(Object.keys(configPaths).sort(), ['portal', 'publicApi']);
|
|
453
|
+
for (const name of ['publicApi', 'portal']) {
|
|
454
|
+
assert.ok(
|
|
455
|
+
boundedString(configPaths[name]),
|
|
456
|
+
'Controlled qualification requires explicit independent source-config paths.',
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
assert.notEqual(
|
|
460
|
+
resolve(configPaths.publicApi),
|
|
461
|
+
resolve(configPaths.portal),
|
|
462
|
+
'Independent source-config receipts require distinct resolved paths.',
|
|
463
|
+
);
|
|
464
|
+
for (const name of ['publicApi', 'portal']) {
|
|
465
|
+
const bytes = readProtectedRoutingBytes(
|
|
466
|
+
configPaths[name],
|
|
467
|
+
candidateRoot,
|
|
468
|
+
512 * 1024,
|
|
469
|
+
);
|
|
470
|
+
assert.equal(
|
|
471
|
+
digest(bytes),
|
|
472
|
+
proof.sourceConfigSha256[name],
|
|
473
|
+
'Independent source-config receipt digest does not match.',
|
|
474
|
+
);
|
|
475
|
+
let config;
|
|
476
|
+
try {
|
|
477
|
+
config = JSON.parse(bytes.toString('utf8'));
|
|
478
|
+
assert.ok(record(config));
|
|
479
|
+
const evidence = secureUrl(config.ADMIN_PORTAL_PUBLIC_API_BASE_URL);
|
|
480
|
+
const callback = secureUrl(
|
|
481
|
+
config.ADMIN_PORTAL_WORKFLOW_CALLBACK_BASE_URL,
|
|
482
|
+
);
|
|
483
|
+
assert.equal(callback.pathname, '/');
|
|
484
|
+
assert.ok(['/', '/public', '/public/'].includes(evidence.pathname));
|
|
485
|
+
} catch {
|
|
486
|
+
throw new Error(
|
|
487
|
+
'Independent source-config receipt has invalid preferred routing fields.',
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
assert.equal(
|
|
491
|
+
config.ADMIN_PORTAL_PUBLIC_API_BASE_URL,
|
|
492
|
+
proof.evidenceBaseUrl,
|
|
493
|
+
'Independent evidence destination does not match.',
|
|
494
|
+
);
|
|
495
|
+
assert.equal(
|
|
496
|
+
config.ADMIN_PORTAL_WORKFLOW_CALLBACK_BASE_URL.replace(/\/$/, '') +
|
|
497
|
+
managedReviewPath,
|
|
498
|
+
proof.managedReviewCallbackUrl,
|
|
499
|
+
'Independent callback destination does not match.',
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function readControlledRoutingProof(inputPath, candidateRoot, configPaths) {
|
|
505
|
+
const bytes = readProtectedRoutingBytes(inputPath, candidateRoot);
|
|
506
|
+
const proof = JSON.parse(bytes.toString('utf8'));
|
|
507
|
+
keys(
|
|
508
|
+
proof,
|
|
509
|
+
[
|
|
510
|
+
'schemaVersion',
|
|
511
|
+
'mode',
|
|
512
|
+
'appKey',
|
|
513
|
+
'tenantId',
|
|
514
|
+
'operationId',
|
|
515
|
+
'workflowSha256',
|
|
516
|
+
'generatedOperationSha256',
|
|
517
|
+
'evidenceBaseUrl',
|
|
518
|
+
'managedReviewCallbackUrl',
|
|
519
|
+
'sourceConfigSha256',
|
|
520
|
+
],
|
|
521
|
+
'controlled routing input',
|
|
522
|
+
);
|
|
523
|
+
assert.equal(
|
|
524
|
+
bytes.toString('utf8'),
|
|
525
|
+
JSON.stringify(proof, null, 2) + '\n',
|
|
526
|
+
'Controlled routing input must retain canonical owner-written JSON.',
|
|
527
|
+
);
|
|
528
|
+
assert.equal(proof.schemaVersion, 'eai.template_controlled_routing.v1');
|
|
529
|
+
assert.equal(proof.mode, 'owner-controlled-qualification-only');
|
|
530
|
+
assert.ok(
|
|
531
|
+
runtimeAppKeyPattern.test(proof.appKey) &&
|
|
532
|
+
boundedString(proof.tenantId) &&
|
|
533
|
+
boundedString(proof.operationId),
|
|
534
|
+
);
|
|
535
|
+
for (const field of ['workflowSha256', 'generatedOperationSha256'])
|
|
536
|
+
assert.ok(/^[a-f0-9]{64}$/.test(proof[field]));
|
|
537
|
+
keys(
|
|
538
|
+
proof.sourceConfigSha256,
|
|
539
|
+
['publicApi', 'portal'],
|
|
540
|
+
'independent source-config receipt digests',
|
|
541
|
+
);
|
|
542
|
+
assert.deepEqual(Object.keys(proof.sourceConfigSha256).sort(), [
|
|
543
|
+
'portal',
|
|
544
|
+
'publicApi',
|
|
545
|
+
]);
|
|
546
|
+
assert.ok(
|
|
547
|
+
Object.values(proof.sourceConfigSha256).every((x) =>
|
|
548
|
+
/^[a-f0-9]{64}$/.test(x),
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
const evidence = secureUrl(proof.evidenceBaseUrl);
|
|
552
|
+
const callback = secureUrl(proof.managedReviewCallbackUrl, managedReviewPath);
|
|
553
|
+
assert.ok(
|
|
554
|
+
/^[a-z0-9-]+-8000\.[a-z0-9-]+\.devtunnels\.ms$/.test(evidence.hostname) &&
|
|
555
|
+
['/', '/public', '/public/'].includes(evidence.pathname),
|
|
556
|
+
'Controlled evidence destination must be the local port-8000 HTTPS tunnel.',
|
|
557
|
+
);
|
|
558
|
+
assert.ok(
|
|
559
|
+
/^[a-z0-9-]+-3010\.[a-z0-9-]+\.devtunnels\.ms$/.test(callback.hostname),
|
|
560
|
+
'Controlled callback must be the local port-3010 HTTPS tunnel.',
|
|
561
|
+
);
|
|
562
|
+
validateControlledConfigReceipts(proof, configPaths, candidateRoot);
|
|
563
|
+
const handle = Object.freeze({});
|
|
564
|
+
controlledRoutingProofs.set(handle, {
|
|
565
|
+
proof,
|
|
566
|
+
path: inputPath,
|
|
567
|
+
candidateRoot,
|
|
568
|
+
configPaths: Object.freeze({ ...configPaths }),
|
|
569
|
+
bytesSha256: digest(bytes),
|
|
570
|
+
});
|
|
571
|
+
return handle;
|
|
572
|
+
}
|
|
22
573
|
|
|
574
|
+
function validateTrustedRouting(
|
|
575
|
+
evidenceUrl,
|
|
576
|
+
callbackUrl,
|
|
577
|
+
manifest,
|
|
578
|
+
workflowBytes,
|
|
579
|
+
operation,
|
|
580
|
+
operationBytes,
|
|
581
|
+
controlledProof,
|
|
582
|
+
) {
|
|
583
|
+
secureUrl(evidenceUrl);
|
|
584
|
+
secureUrl(callbackUrl, managedReviewPath);
|
|
585
|
+
if (controlledProof !== undefined) {
|
|
586
|
+
const captured = controlledRoutingProofs.get(controlledProof);
|
|
587
|
+
assert.ok(captured, 'Unrecognized controlled routing proof.');
|
|
588
|
+
assert.equal(
|
|
589
|
+
digest(readProtectedRoutingBytes(captured.path, captured.candidateRoot)),
|
|
590
|
+
captured.bytesSha256,
|
|
591
|
+
'Controlled routing proof changed after capture.',
|
|
592
|
+
);
|
|
593
|
+
const proof = captured.proof;
|
|
594
|
+
validateControlledConfigReceipts(
|
|
595
|
+
proof,
|
|
596
|
+
captured.configPaths,
|
|
597
|
+
captured.candidateRoot,
|
|
598
|
+
);
|
|
599
|
+
assert.equal(proof.appKey, manifest.appKey);
|
|
600
|
+
assert.equal(proof.tenantId, operation.tenantId);
|
|
601
|
+
assert.equal(proof.operationId, operation.operationId);
|
|
602
|
+
assert.equal(proof.workflowSha256, digest(workflowBytes));
|
|
603
|
+
assert.ok(
|
|
604
|
+
Buffer.isBuffer(operationBytes),
|
|
605
|
+
'Controlled routing requires raw generated-operation bytes.',
|
|
606
|
+
);
|
|
607
|
+
assert.equal(proof.generatedOperationSha256, digest(operationBytes));
|
|
608
|
+
assert.equal(proof.evidenceBaseUrl, evidenceUrl);
|
|
609
|
+
assert.equal(proof.managedReviewCallbackUrl, callbackUrl);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const normalizedEvidence = evidenceUrl.replace(/\/$/, '');
|
|
613
|
+
assert.ok(
|
|
614
|
+
trustedRoutingPairs.some(
|
|
615
|
+
([evidence, callback]) =>
|
|
616
|
+
evidence === normalizedEvidence && callback === callbackUrl,
|
|
617
|
+
),
|
|
618
|
+
'Generated workflow destinations must match one exact trusted Portal cell pair.',
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** SECURITY: only declared generator inputs may vary; every remaining workflow byte must retain the reviewed OIDC, validation and handoff contract. */
|
|
623
|
+
function validateGeneratedWorkflow(
|
|
624
|
+
manifest,
|
|
625
|
+
workflowBytes,
|
|
626
|
+
fixtureBytes,
|
|
627
|
+
operation,
|
|
628
|
+
controlledProof,
|
|
629
|
+
operationBytes,
|
|
630
|
+
) {
|
|
631
|
+
assert.equal(
|
|
632
|
+
digest(fixtureBytes),
|
|
633
|
+
generatedDigest,
|
|
634
|
+
'Generated workflow contract fixture drifted.',
|
|
635
|
+
);
|
|
636
|
+
validateGeneratedManifest(manifest, workflowBytes);
|
|
637
|
+
keys(
|
|
638
|
+
operation,
|
|
639
|
+
[
|
|
640
|
+
'appKey',
|
|
641
|
+
'configHash',
|
|
642
|
+
'githubInstallationId',
|
|
643
|
+
'operationId',
|
|
644
|
+
'schemaVersion',
|
|
645
|
+
'tenantId',
|
|
646
|
+
],
|
|
647
|
+
'generated source operation',
|
|
648
|
+
);
|
|
649
|
+
assert.equal(operation.schemaVersion, 'eai.generated_source_operation.v1');
|
|
650
|
+
assert.ok(runtimeAppKeyPattern.test(operation.appKey));
|
|
651
|
+
assert.equal(operation.appKey, manifest.appKey);
|
|
652
|
+
assert.ok(/^sha256:[a-f0-9]{64}$/.test(operation.configHash));
|
|
653
|
+
assert.ok(
|
|
654
|
+
typeof operation.githubInstallationId === 'string' &&
|
|
655
|
+
/^[1-9][0-9]*$/.test(operation.githubInstallationId),
|
|
656
|
+
);
|
|
657
|
+
assert.ok(
|
|
658
|
+
boundedString(operation.operationId) &&
|
|
659
|
+
runtimeSafePathSegmentPattern.test(operation.operationId) &&
|
|
660
|
+
boundedString(operation.tenantId) &&
|
|
661
|
+
runtimeSafePathSegmentPattern.test(operation.tenantId),
|
|
662
|
+
'Generated operation and tenant IDs must match collector safe path grammar.',
|
|
663
|
+
);
|
|
664
|
+
let workflow = workflowBytes.toString('utf8');
|
|
665
|
+
const branchMatch = /^ - ("[^\r\n]*")$/m.exec(workflow);
|
|
666
|
+
assert.ok(branchMatch, 'Missing protected default branch.');
|
|
667
|
+
const branch = JSON.parse(branchMatch[1]);
|
|
668
|
+
assert.ok(
|
|
669
|
+
typeof branch === 'string' &&
|
|
670
|
+
/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(branch) &&
|
|
671
|
+
!branch.includes('..'),
|
|
672
|
+
);
|
|
673
|
+
const ownerMatch =
|
|
674
|
+
/github\.actor == '([A-Za-z0-9-]+)\[bot\]' && github\.repository_owner == '\1'/.exec(
|
|
675
|
+
workflow,
|
|
676
|
+
);
|
|
677
|
+
assert.ok(
|
|
678
|
+
ownerMatch,
|
|
679
|
+
'Managed review must bind one exact repository owner and bot.',
|
|
680
|
+
);
|
|
681
|
+
const target = quotedBinding(workflow, 'EAI_MANAGED_REVIEW_TARGET_TENANT_ID');
|
|
682
|
+
assert.equal(
|
|
683
|
+
target,
|
|
684
|
+
operation.tenantId,
|
|
685
|
+
'Workflow target must match the generated operation tenant.',
|
|
686
|
+
);
|
|
687
|
+
const evidenceUrl = quotedBinding(workflow, 'EAI_WORKFLOW_EVIDENCE_BASE_URL');
|
|
688
|
+
const callbackUrl = quotedBinding(
|
|
689
|
+
workflow,
|
|
690
|
+
'EAI_MANAGED_REVIEW_CALLBACK_URL',
|
|
691
|
+
);
|
|
692
|
+
validateTrustedRouting(
|
|
693
|
+
evidenceUrl,
|
|
694
|
+
callbackUrl,
|
|
695
|
+
manifest,
|
|
696
|
+
workflowBytes,
|
|
697
|
+
operation,
|
|
698
|
+
operationBytes,
|
|
699
|
+
controlledProof,
|
|
700
|
+
);
|
|
701
|
+
assert.equal(
|
|
702
|
+
quotedBinding(workflow, 'EAI_GITHUB_OIDC_AUDIENCE'),
|
|
703
|
+
'api://enterprise-ai-publicapi/generated-app',
|
|
704
|
+
);
|
|
705
|
+
const replacements = [
|
|
706
|
+
[` - ${JSON.stringify(branch)}`, ' - "__DEFAULT_BRANCH__"'],
|
|
707
|
+
[
|
|
708
|
+
`event.repository?.default_branch !== ${JSON.stringify(branch)}`,
|
|
709
|
+
'event.repository?.default_branch !== "__DEFAULT_BRANCH__"',
|
|
710
|
+
],
|
|
711
|
+
[
|
|
712
|
+
`pr.base?.ref === ${JSON.stringify(branch)}`,
|
|
713
|
+
'pr.base?.ref === "__DEFAULT_BRANCH__"',
|
|
714
|
+
],
|
|
715
|
+
[`'refs/heads/${branch}'`, "'refs/heads/__DEFAULT_BRANCH__'"],
|
|
716
|
+
[
|
|
717
|
+
`workflowHeadBranch: '${branch}'`,
|
|
718
|
+
"workflowHeadBranch: '__DEFAULT_BRANCH__'",
|
|
719
|
+
],
|
|
720
|
+
[JSON.stringify(target), '"__TARGET_TENANT_ID__"'],
|
|
721
|
+
[
|
|
722
|
+
`EAI_APP_KEY: ${JSON.stringify(manifest.appKey)}`,
|
|
723
|
+
'EAI_APP_KEY: "__APP_KEY__"',
|
|
724
|
+
],
|
|
725
|
+
[
|
|
726
|
+
JSON.stringify(`src/eai.config/tenants/${manifest.appKey}.config.ts`),
|
|
727
|
+
'"src/eai.config/tenants/__APP_KEY__.config.ts"',
|
|
728
|
+
],
|
|
729
|
+
[`'${ownerMatch[1]}[bot]'`, "'__MANAGED_REVIEW_OWNER__[bot]'"],
|
|
730
|
+
[`'${ownerMatch[1]}'`, "'__MANAGED_REVIEW_OWNER__'"],
|
|
731
|
+
[JSON.stringify(evidenceUrl), '"__EVIDENCE_BASE_URL__"'],
|
|
732
|
+
[JSON.stringify(callbackUrl), '"__MANAGED_REVIEW_CALLBACK_URL__"'],
|
|
733
|
+
];
|
|
734
|
+
for (const [value, placeholder] of replacements)
|
|
735
|
+
workflow = workflow.replaceAll(value, placeholder);
|
|
736
|
+
assert.ok(
|
|
737
|
+
workflow === fixtureBytes.toString('utf8'),
|
|
738
|
+
'Exported workflow changed its reviewed validation, OIDC or handoff contract.',
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** INVARIANT: exported NCB workflows are validated separately; the original collector controls always run against exact canonical workflow bytes. */
|
|
743
|
+
function resolveDeploymentWorkflowContext(
|
|
744
|
+
root,
|
|
745
|
+
{ controlledRoutingInputPath, controlledRoutingConfigPaths } = {},
|
|
746
|
+
) {
|
|
747
|
+
const actual = regularBytes(join(root, canonicalWorkflowRelativePath));
|
|
748
|
+
const manifestPath = join(root, '.eai-manifest.json');
|
|
749
|
+
if (!existsSync(manifestPath)) {
|
|
750
|
+
assert.equal(
|
|
751
|
+
digest(actual),
|
|
752
|
+
canonicalDigest,
|
|
753
|
+
'Template workflow must retain the pinned canonical digest.',
|
|
754
|
+
);
|
|
755
|
+
return {
|
|
756
|
+
context: 'template',
|
|
757
|
+
workflowPath: join(root, canonicalWorkflowRelativePath),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
const manifest = JSON.parse(regularBytes(manifestPath));
|
|
761
|
+
if (manifest.schemaVersion === 1) {
|
|
762
|
+
validateCliManifest(manifest);
|
|
763
|
+
assert.equal(
|
|
764
|
+
digest(actual),
|
|
765
|
+
canonicalDigest,
|
|
766
|
+
'CLI workflow must retain the pinned canonical digest.',
|
|
767
|
+
);
|
|
768
|
+
return {
|
|
769
|
+
context: 'cli',
|
|
770
|
+
workflowPath: join(root, canonicalWorkflowRelativePath),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
assert.equal(
|
|
774
|
+
manifest.schemaVersion,
|
|
775
|
+
'eai.generated_app_manifest.v1',
|
|
776
|
+
'Unsupported deployment workflow context.',
|
|
777
|
+
);
|
|
778
|
+
const canonicalPath = join(root, fixtureDirectory, 'eai-app.yml');
|
|
779
|
+
assert.equal(
|
|
780
|
+
digest(regularBytes(canonicalPath)),
|
|
781
|
+
canonicalDigest,
|
|
782
|
+
'Canonical source-unknown workflow fixture drifted.',
|
|
783
|
+
);
|
|
784
|
+
const operationBytes = regularBytes(
|
|
785
|
+
join(root, '.eai/generated-source-operation.json'),
|
|
786
|
+
);
|
|
787
|
+
const operation = JSON.parse(operationBytes);
|
|
788
|
+
const controlledProof =
|
|
789
|
+
controlledRoutingInputPath === undefined
|
|
790
|
+
? undefined
|
|
791
|
+
: readControlledRoutingProof(
|
|
792
|
+
controlledRoutingInputPath,
|
|
793
|
+
root,
|
|
794
|
+
controlledRoutingConfigPaths,
|
|
795
|
+
);
|
|
796
|
+
validateGeneratedWorkflow(
|
|
797
|
+
manifest,
|
|
798
|
+
actual,
|
|
799
|
+
regularBytes(join(root, fixtureDirectory, 'generated-eai-app.yml')),
|
|
800
|
+
operation,
|
|
801
|
+
controlledProof,
|
|
802
|
+
operationBytes,
|
|
803
|
+
);
|
|
804
|
+
return { context: 'admin-portal-generated', workflowPath: canonicalPath };
|
|
805
|
+
}
|
|
23
806
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
807
|
const evidenceScript = join(
|
|
25
808
|
repoRoot,
|
|
26
809
|
'scripts/source-unknown-deployment-evidence.mjs',
|
|
27
810
|
);
|
|
28
|
-
const
|
|
811
|
+
const selectedControlledRoutingConfigPaths = {
|
|
812
|
+
publicApi: process.env.EAI_TEMPLATE_CONTROLLED_PUBLICAPI_CONFIG,
|
|
813
|
+
portal: process.env.EAI_TEMPLATE_CONTROLLED_PORTAL_CONFIG,
|
|
814
|
+
};
|
|
815
|
+
const { workflowPath } = resolveDeploymentWorkflowContext(repoRoot, {
|
|
816
|
+
controlledRoutingInputPath: process.env.EAI_TEMPLATE_CONTROLLED_ROUTING_INPUT,
|
|
817
|
+
controlledRoutingConfigPaths: selectedControlledRoutingConfigPaths,
|
|
818
|
+
});
|
|
29
819
|
const readmePath = join(repoRoot, 'README.md');
|
|
30
820
|
const digestPattern = /^sha256:[a-f0-9]{64}$/;
|
|
31
821
|
|
|
@@ -958,6 +1748,103 @@ test('local CLI dispatch binds direct DEV tunnel, original template and numeric
|
|
|
958
1748
|
}
|
|
959
1749
|
});
|
|
960
1750
|
|
|
1751
|
+
test('local customer-owned source uses its own exact direct-operation audience', () => {
|
|
1752
|
+
const root = mkdtempSync(join(tmpdir(), 'eai-source-unknown-local-dispatch-'));
|
|
1753
|
+
try {
|
|
1754
|
+
writeFixtureApp(root);
|
|
1755
|
+
execFileSync('git', ['init', '-q'], { cwd: root });
|
|
1756
|
+
execFileSync('git', ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.com',
|
|
1757
|
+
'commit', '--allow-empty', '-m', 'fixture'], { cwd: root });
|
|
1758
|
+
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim();
|
|
1759
|
+
const expiry = new Date(Date.now() + 30 * 60 * 1000).toISOString().replace('Z', '+00:00');
|
|
1760
|
+
const args = ['validate-dispatch', '--root', root, '--source-mode', 'source-unknown',
|
|
1761
|
+
'--app-key', 'rates-review', '--tenant-id', 'tenant-parent',
|
|
1762
|
+
'--target-tenant-id', 'hosting-tenant', '--operation-id', `source-unknown-${'a'.repeat(16)}`,
|
|
1763
|
+
'--nonce', 'one-time-source-nonce', '--ref', 'refs/heads/main',
|
|
1764
|
+
'--environment', 'dev', '--expected-config-hash', configHash(root),
|
|
1765
|
+
'--commit', commit, '--workflow-sha', commit,
|
|
1766
|
+
'--public-api-url', 'https://careful-8000.eai.devtunnels.ms',
|
|
1767
|
+
'--local-e2e-tunnel', 'true', '--local-e2e-expires-at', expiry,
|
|
1768
|
+
'--github-event-name', 'workflow_dispatch', '--reusable-call', 'false',
|
|
1769
|
+
'--repository-id', '12345'];
|
|
1770
|
+
runEvidenceScript(args);
|
|
1771
|
+
const withoutTarget = [...args];
|
|
1772
|
+
withoutTarget.splice(withoutTarget.indexOf('--target-tenant-id'), 2);
|
|
1773
|
+
runEvidenceScript(withoutTarget);
|
|
1774
|
+
for (const [key, value] of [
|
|
1775
|
+
['--public-api-url', 'https://attacker.example'],
|
|
1776
|
+
['--operation-id', `cli-managed-${'a'.repeat(32)}`],
|
|
1777
|
+
['--ref', 'refs/pull/1/merge'],
|
|
1778
|
+
['--environment', 'prod'],
|
|
1779
|
+
['--local-e2e-expires-at', '2000-01-01T00:00:00+00:00'],
|
|
1780
|
+
['--repository-id', '0'],
|
|
1781
|
+
['--reusable-call', 'true'],
|
|
1782
|
+
]) {
|
|
1783
|
+
const mutated = [...args];
|
|
1784
|
+
mutated[mutated.indexOf(key) + 1] = value;
|
|
1785
|
+
const result = spawnSync(process.execPath, [evidenceScript, ...mutated], { encoding: 'utf8' });
|
|
1786
|
+
assert.equal(result.status, 1, `${key}=${value} must fail`);
|
|
1787
|
+
}
|
|
1788
|
+
} finally {
|
|
1789
|
+
rmSync(root, { recursive: true, force: true });
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
test('direct local workflow checks the same audience with explicit and omitted target tenants', () => {
|
|
1794
|
+
const workflow = readFileSync(workflowPath, 'utf8');
|
|
1795
|
+
const marker = 'const tuple = sourceUnknownLocal';
|
|
1796
|
+
const first = workflow.indexOf(marker);
|
|
1797
|
+
const second = workflow.indexOf(marker, first + marker.length);
|
|
1798
|
+
assert.ok(first >= 0 && second > first);
|
|
1799
|
+
const snippets = [first, second].map((start) => {
|
|
1800
|
+
const end = workflow.indexOf('if (local.audience !== audience)', start);
|
|
1801
|
+
assert.ok(end > start);
|
|
1802
|
+
return `${workflow.slice(start, end)}\naudience;`;
|
|
1803
|
+
});
|
|
1804
|
+
const origin = 'https://careful-8000.eai.devtunnels.ms';
|
|
1805
|
+
const tenantId = 'tenant-parent';
|
|
1806
|
+
const nonce = 'one-time-source-nonce';
|
|
1807
|
+
const nonceDigest = `sha256:${digest(nonce)}`;
|
|
1808
|
+
const expiry = '2026-10-11T02:00:00+00:00';
|
|
1809
|
+
const evidence = {
|
|
1810
|
+
nonce,
|
|
1811
|
+
operationId: `source-unknown-${'a'.repeat(16)}`,
|
|
1812
|
+
workflowPath: canonicalWorkflowRelativePath,
|
|
1813
|
+
ref: 'refs/heads/main',
|
|
1814
|
+
commitSha: 'b'.repeat(40),
|
|
1815
|
+
configHash: `sha256:${'c'.repeat(64)}`,
|
|
1816
|
+
};
|
|
1817
|
+
const audienceFor = (snippet, targetInput, evidenceTarget) =>
|
|
1818
|
+
runInNewContext(snippet, {
|
|
1819
|
+
createHash,
|
|
1820
|
+
process: { env: {
|
|
1821
|
+
TENANT_ID: tenantId,
|
|
1822
|
+
TARGET_TENANT_ID: targetInput,
|
|
1823
|
+
APP_KEY: 'rates-review',
|
|
1824
|
+
}, exit: () => { throw new Error('Unexpected workflow rejection.'); } },
|
|
1825
|
+
sourceUnknownLocal: true,
|
|
1826
|
+
local: { origin, nonceDigest },
|
|
1827
|
+
evidence: { ...evidence, ...(evidenceTarget ? { targetTenantId: evidenceTarget } : {}) },
|
|
1828
|
+
repositoryId: '12345',
|
|
1829
|
+
repoId: '12345',
|
|
1830
|
+
expiresAt: expiry,
|
|
1831
|
+
expiry,
|
|
1832
|
+
});
|
|
1833
|
+
for (const target of ['', 'hosting-tenant']) {
|
|
1834
|
+
const expectedTarget = target || tenantId;
|
|
1835
|
+
const tuple = ['eai.source-unknown-local-e2e-aud.v1', origin, tenantId,
|
|
1836
|
+
expectedTarget, 'rates-review', evidence.operationId, '12345',
|
|
1837
|
+
evidence.workflowPath, evidence.ref, evidence.commitSha, evidence.configHash,
|
|
1838
|
+
nonceDigest, expiry];
|
|
1839
|
+
const expected = `api://enterprise-ai-publicapi/source-unknown/local-v1/${digest(JSON.stringify(tuple))}`;
|
|
1840
|
+
for (const snippet of snippets) {
|
|
1841
|
+
assert.equal(audienceFor(snippet, target, target), expected);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
assert.notEqual(audienceFor(snippets[1], 'hosting-tenant', 'other-tenant'),
|
|
1845
|
+
audienceFor(snippets[0], 'hosting-tenant', 'other-tenant'));
|
|
1846
|
+
});
|
|
1847
|
+
|
|
961
1848
|
test('source commit is resolved before checkout for direct and reusable calls', () => {
|
|
962
1849
|
const workflow = readFileSync(workflowPath, 'utf8');
|
|
963
1850
|
const stepStart = workflow.indexOf(' - name: Validate workflow invocation');
|
|
@@ -4692,3 +5579,1354 @@ syncBuiltinESMExports();
|
|
|
4692
5579
|
rmSync(workDir, { recursive: true, force: true });
|
|
4693
5580
|
}
|
|
4694
5581
|
});
|
|
5582
|
+
|
|
5583
|
+
const root = repoRoot;
|
|
5584
|
+
const fixtures = join(root, 'tests/fixtures/source-unknown');
|
|
5585
|
+
const canonical = regularBytes(workflowPath);
|
|
5586
|
+
// INVARIANT: synthetic controlled input stays inline so legacy four-file repairs execute every control without new fixture dependencies.
|
|
5587
|
+
const generated = gunzipSync(
|
|
5588
|
+
Buffer.from(
|
|
5589
|
+
'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',
|
|
5590
|
+
'base64',
|
|
5591
|
+
),
|
|
5592
|
+
);
|
|
5593
|
+
assert.equal(digest(generated), generatedDigest);
|
|
5594
|
+
|
|
5595
|
+
const checksum = (bytes) =>
|
|
5596
|
+
`sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
5597
|
+
|
|
5598
|
+
function exportedFixture(overrides = {}) {
|
|
5599
|
+
const values = {
|
|
5600
|
+
DEFAULT_BRANCH: 'main',
|
|
5601
|
+
TARGET_TENANT_ID: 'customer-tenant-1',
|
|
5602
|
+
APP_KEY: 'incident-intake',
|
|
5603
|
+
MANAGED_REVIEW_OWNER: 'eai-generated-apps',
|
|
5604
|
+
EVIDENCE_BASE_URL: 'https://dev-api.au.myenterprise.ai/public',
|
|
5605
|
+
MANAGED_REVIEW_CALLBACK_URL:
|
|
5606
|
+
'https://dev-admin-portal.myenterprise.ai/api/platform/generated-apps/managed-review/workflow',
|
|
5607
|
+
...overrides,
|
|
5608
|
+
};
|
|
5609
|
+
let workflow = generated.toString('utf8');
|
|
5610
|
+
for (const [name, value] of Object.entries(values))
|
|
5611
|
+
workflow = workflow.replaceAll(`__${name}__`, value);
|
|
5612
|
+
const operation = {
|
|
5613
|
+
schemaVersion: 'eai.generated_source_operation.v1',
|
|
5614
|
+
appKey: values.APP_KEY,
|
|
5615
|
+
tenantId: values.TARGET_TENANT_ID,
|
|
5616
|
+
operationId: 'source-123',
|
|
5617
|
+
configHash: `sha256:${'a'.repeat(64)}`,
|
|
5618
|
+
githubInstallationId: '123',
|
|
5619
|
+
};
|
|
5620
|
+
const manifest = {
|
|
5621
|
+
schemaVersion: 'eai.generated_app_manifest.v1',
|
|
5622
|
+
sourceMode: 'admin-portal-generated',
|
|
5623
|
+
appKey: values.APP_KEY,
|
|
5624
|
+
appName: 'Incident intake',
|
|
5625
|
+
generatedAt: '2026-10-08T05:42:52.888Z',
|
|
5626
|
+
templateRepository: 'eai-tools/eai-app-template',
|
|
5627
|
+
workflowPath: canonicalWorkflowRelativePath,
|
|
5628
|
+
environment: 'eai-generated-preview',
|
|
5629
|
+
codeownersPath: '.github/CODEOWNERS',
|
|
5630
|
+
repositoryGuardrailsPath: '.github/eai-repository-guardrails.json',
|
|
5631
|
+
requiredBeforeTenantAccess: [
|
|
5632
|
+
'branch_ruleset',
|
|
5633
|
+
'workflow_path_protection',
|
|
5634
|
+
'environment_protection',
|
|
5635
|
+
'codeowners',
|
|
5636
|
+
'publicapi_github_oidc_allowlist',
|
|
5637
|
+
],
|
|
5638
|
+
managedFiles: [
|
|
5639
|
+
{
|
|
5640
|
+
path: canonicalWorkflowRelativePath,
|
|
5641
|
+
checksum: checksum(workflow),
|
|
5642
|
+
encoding: 'utf8',
|
|
5643
|
+
owner: 'admin-portal-generated',
|
|
5644
|
+
},
|
|
5645
|
+
],
|
|
5646
|
+
generatedFileScope: [canonicalWorkflowRelativePath],
|
|
5647
|
+
};
|
|
5648
|
+
return { workflow: Buffer.from(workflow), manifest, operation };
|
|
5649
|
+
}
|
|
5650
|
+
|
|
5651
|
+
function contextFixture(t, manifest, workflow = canonical, operation) {
|
|
5652
|
+
const directory = mkdtempSync(join(tmpdir(), 'eai-workflow-context-'));
|
|
5653
|
+
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
|
5654
|
+
mkdirSync(join(directory, '.github/workflows'), { recursive: true });
|
|
5655
|
+
mkdirSync(join(directory, 'tests/fixtures'), { recursive: true });
|
|
5656
|
+
mkdirSync(join(directory, fixtureDirectory), { recursive: true });
|
|
5657
|
+
writeFileSync(join(directory, fixtureDirectory, 'eai-app.yml'), canonical);
|
|
5658
|
+
writeFileSync(
|
|
5659
|
+
join(directory, fixtureDirectory, 'generated-eai-app.yml'),
|
|
5660
|
+
generated,
|
|
5661
|
+
);
|
|
5662
|
+
writeFileSync(join(directory, canonicalWorkflowRelativePath), workflow);
|
|
5663
|
+
if (manifest !== undefined)
|
|
5664
|
+
writeFileSync(
|
|
5665
|
+
join(directory, '.eai-manifest.json'),
|
|
5666
|
+
JSON.stringify(manifest),
|
|
5667
|
+
);
|
|
5668
|
+
if (operation !== undefined) {
|
|
5669
|
+
mkdirSync(join(directory, '.eai'));
|
|
5670
|
+
writeFileSync(
|
|
5671
|
+
join(directory, '.eai/generated-source-operation.json'),
|
|
5672
|
+
JSON.stringify(operation),
|
|
5673
|
+
);
|
|
5674
|
+
}
|
|
5675
|
+
return directory;
|
|
5676
|
+
}
|
|
5677
|
+
|
|
5678
|
+
test('template context requires byte-identical canonical workflow', (t) => {
|
|
5679
|
+
const context = resolveDeploymentWorkflowContext(contextFixture(t));
|
|
5680
|
+
assert.equal(context.context, 'template');
|
|
5681
|
+
});
|
|
5682
|
+
|
|
5683
|
+
test('generated operation and tenant ID grammar matches the collector admission boundary', () => {
|
|
5684
|
+
assert.ok(
|
|
5685
|
+
regularBytes(evidenceScript).toString('utf8').includes(
|
|
5686
|
+
`const SAFE_PATH_SEGMENT = ${runtimeSafePathSegmentPattern.toString()};`,
|
|
5687
|
+
),
|
|
5688
|
+
);
|
|
5689
|
+
});
|
|
5690
|
+
|
|
5691
|
+
for (const field of ['operationId', 'tenantId']) {
|
|
5692
|
+
for (const value of ['a', 'source-123', 'source:operation_1.2', 'f6b8f71d-ef47-afc1-58e0-504f1ae99950', 'a'.repeat(256)])
|
|
5693
|
+
test(`generated ${field} accepts safe collector ID ${JSON.stringify(value)}`, (t) => {
|
|
5694
|
+
const fixture = exportedFixture(
|
|
5695
|
+
field === 'tenantId' ? { TARGET_TENANT_ID: value } : {},
|
|
5696
|
+
);
|
|
5697
|
+
fixture.operation[field] = value;
|
|
5698
|
+
assert.equal(
|
|
5699
|
+
resolveDeploymentWorkflowContext(
|
|
5700
|
+
contextFixture(t, fixture.manifest, fixture.workflow, fixture.operation),
|
|
5701
|
+
).context,
|
|
5702
|
+
'admin-portal-generated',
|
|
5703
|
+
);
|
|
5704
|
+
});
|
|
5705
|
+
|
|
5706
|
+
for (const value of [
|
|
5707
|
+
'',
|
|
5708
|
+
'../other',
|
|
5709
|
+
'source/other',
|
|
5710
|
+
'source?other',
|
|
5711
|
+
'source#other',
|
|
5712
|
+
'source\\other',
|
|
5713
|
+
'source other',
|
|
5714
|
+
'source\nother',
|
|
5715
|
+
'source\n',
|
|
5716
|
+
'source\r\n',
|
|
5717
|
+
'source\0',
|
|
5718
|
+
'-source',
|
|
5719
|
+
'source-',
|
|
5720
|
+
'source.',
|
|
5721
|
+
'source:',
|
|
5722
|
+
'source_',
|
|
5723
|
+
'a'.repeat(257),
|
|
5724
|
+
123,
|
|
5725
|
+
])
|
|
5726
|
+
test(`generated ${field} rejects unsafe collector ID ${JSON.stringify(value)}`, (t) => {
|
|
5727
|
+
const fixture = exportedFixture(
|
|
5728
|
+
field === 'tenantId' ? { TARGET_TENANT_ID: value } : {},
|
|
5729
|
+
);
|
|
5730
|
+
fixture.operation[field] = value;
|
|
5731
|
+
assert.throws(
|
|
5732
|
+
() =>
|
|
5733
|
+
resolveDeploymentWorkflowContext(
|
|
5734
|
+
contextFixture(t, fixture.manifest, fixture.workflow, fixture.operation),
|
|
5735
|
+
),
|
|
5736
|
+
/must match collector safe path grammar/,
|
|
5737
|
+
);
|
|
5738
|
+
});
|
|
5739
|
+
}
|
|
5740
|
+
|
|
5741
|
+
test('repository workflow is validated against its actual declared context', () => {
|
|
5742
|
+
assert.ok(
|
|
5743
|
+
['template', 'cli', 'admin-portal-generated'].includes(
|
|
5744
|
+
resolveDeploymentWorkflowContext(root, {
|
|
5745
|
+
controlledRoutingInputPath:
|
|
5746
|
+
process.env.EAI_TEMPLATE_CONTROLLED_ROUTING_INPUT,
|
|
5747
|
+
controlledRoutingConfigPaths: selectedControlledRoutingConfigPaths,
|
|
5748
|
+
}).context,
|
|
5749
|
+
),
|
|
5750
|
+
);
|
|
5751
|
+
});
|
|
5752
|
+
|
|
5753
|
+
test('known CLI project manifest retains actual canonical workflow authority', (t) => {
|
|
5754
|
+
const directory = contextFixture(t, {
|
|
5755
|
+
schemaVersion: 1,
|
|
5756
|
+
cli: { version: '3.19.1' },
|
|
5757
|
+
template: {
|
|
5758
|
+
commit: 'a'.repeat(40),
|
|
5759
|
+
repo: 'https://github.com/eai-support/eai-app-template.git',
|
|
5760
|
+
},
|
|
5761
|
+
gofer: {
|
|
5762
|
+
bundle: { commit: 'b'.repeat(40) },
|
|
5763
|
+
managedFiles: {
|
|
5764
|
+
'.specify/script.mjs': { source: 'bundled', sha256: 'c'.repeat(64) },
|
|
5765
|
+
},
|
|
5766
|
+
},
|
|
5767
|
+
});
|
|
5768
|
+
assert.deepEqual(resolveDeploymentWorkflowContext(directory), {
|
|
5769
|
+
context: 'cli',
|
|
5770
|
+
workflowPath: join(directory, canonicalWorkflowRelativePath),
|
|
5771
|
+
});
|
|
5772
|
+
});
|
|
5773
|
+
|
|
5774
|
+
test('NCB validates its actual generated workflow and keeps all canonical collector controls', (t) => {
|
|
5775
|
+
const fixture = exportedFixture();
|
|
5776
|
+
const directory = contextFixture(
|
|
5777
|
+
t,
|
|
5778
|
+
fixture.manifest,
|
|
5779
|
+
fixture.workflow,
|
|
5780
|
+
fixture.operation,
|
|
5781
|
+
);
|
|
5782
|
+
assert.deepEqual(resolveDeploymentWorkflowContext(directory), {
|
|
5783
|
+
context: 'admin-portal-generated',
|
|
5784
|
+
workflowPath: join(directory, 'tests/fixtures/source-unknown/eai-app.yml'),
|
|
5785
|
+
});
|
|
5786
|
+
});
|
|
5787
|
+
|
|
5788
|
+
test('declared generator inputs vary without changing protected workflow code', () => {
|
|
5789
|
+
const fixture = exportedFixture({
|
|
5790
|
+
DEFAULT_BRANCH: 'release/main',
|
|
5791
|
+
APP_KEY: 'main',
|
|
5792
|
+
TARGET_TENANT_ID: 'other-customer',
|
|
5793
|
+
MANAGED_REVIEW_OWNER: 'customer-org',
|
|
5794
|
+
});
|
|
5795
|
+
validateGeneratedWorkflow(
|
|
5796
|
+
fixture.manifest,
|
|
5797
|
+
fixture.workflow,
|
|
5798
|
+
generated,
|
|
5799
|
+
fixture.operation,
|
|
5800
|
+
);
|
|
5801
|
+
});
|
|
5802
|
+
|
|
5803
|
+
for (const [name, mutate] of [
|
|
5804
|
+
[
|
|
5805
|
+
'unsupported schema',
|
|
5806
|
+
(f) => {
|
|
5807
|
+
f.manifest.schemaVersion = 'unknown';
|
|
5808
|
+
},
|
|
5809
|
+
],
|
|
5810
|
+
[
|
|
5811
|
+
'wrong source mode',
|
|
5812
|
+
(f) => {
|
|
5813
|
+
f.manifest.sourceMode = 'eai-cli-generated';
|
|
5814
|
+
},
|
|
5815
|
+
],
|
|
5816
|
+
[
|
|
5817
|
+
'unsafe workflow path',
|
|
5818
|
+
(f) => {
|
|
5819
|
+
f.manifest.workflowPath = '../eai-app.yml';
|
|
5820
|
+
},
|
|
5821
|
+
],
|
|
5822
|
+
[
|
|
5823
|
+
'missing workflow',
|
|
5824
|
+
(f) => {
|
|
5825
|
+
f.manifest.managedFiles = [];
|
|
5826
|
+
f.manifest.generatedFileScope = [];
|
|
5827
|
+
},
|
|
5828
|
+
],
|
|
5829
|
+
[
|
|
5830
|
+
'duplicate scope',
|
|
5831
|
+
(f) => {
|
|
5832
|
+
f.manifest.managedFiles.push({ ...f.manifest.managedFiles[0] });
|
|
5833
|
+
f.manifest.generatedFileScope.push(canonicalWorkflowRelativePath);
|
|
5834
|
+
},
|
|
5835
|
+
],
|
|
5836
|
+
[
|
|
5837
|
+
'checksum mismatch',
|
|
5838
|
+
(f) => {
|
|
5839
|
+
f.manifest.managedFiles[0].checksum = `sha256:${'0'.repeat(64)}`;
|
|
5840
|
+
},
|
|
5841
|
+
],
|
|
5842
|
+
[
|
|
5843
|
+
'scope mismatch',
|
|
5844
|
+
(f) => {
|
|
5845
|
+
f.manifest.generatedFileScope = [];
|
|
5846
|
+
},
|
|
5847
|
+
],
|
|
5848
|
+
[
|
|
5849
|
+
'wrong tenant',
|
|
5850
|
+
(f) => {
|
|
5851
|
+
f.operation.tenantId = 'wrong-customer';
|
|
5852
|
+
},
|
|
5853
|
+
],
|
|
5854
|
+
[
|
|
5855
|
+
'wrong app',
|
|
5856
|
+
(f) => {
|
|
5857
|
+
f.operation.appKey = 'wrong-app';
|
|
5858
|
+
},
|
|
5859
|
+
],
|
|
5860
|
+
[
|
|
5861
|
+
'unknown operation field',
|
|
5862
|
+
(f) => {
|
|
5863
|
+
f.operation.callbackUrl = 'https://attacker.example';
|
|
5864
|
+
},
|
|
5865
|
+
],
|
|
5866
|
+
])
|
|
5867
|
+
test(`rejects ${name} instead of falling back to fixture`, (t) => {
|
|
5868
|
+
const fixture = exportedFixture();
|
|
5869
|
+
mutate(fixture);
|
|
5870
|
+
assert.throws(() =>
|
|
5871
|
+
resolveDeploymentWorkflowContext(
|
|
5872
|
+
contextFixture(
|
|
5873
|
+
t,
|
|
5874
|
+
fixture.manifest,
|
|
5875
|
+
fixture.workflow,
|
|
5876
|
+
fixture.operation,
|
|
5877
|
+
),
|
|
5878
|
+
),
|
|
5879
|
+
);
|
|
5880
|
+
});
|
|
5881
|
+
|
|
5882
|
+
for (const [name, before, after] of [
|
|
5883
|
+
[
|
|
5884
|
+
'OIDC permission widening',
|
|
5885
|
+
' contents: read\n steps:\n - name: Checkout unprivileged',
|
|
5886
|
+
' contents: read\n id-token: write\n steps:\n - name: Checkout unprivileged',
|
|
5887
|
+
],
|
|
5888
|
+
[
|
|
5889
|
+
'validation before image publication',
|
|
5890
|
+
' - name: Typecheck\n id: typecheck',
|
|
5891
|
+
' - name: Typecheck\n if: false\n id: typecheck',
|
|
5892
|
+
],
|
|
5893
|
+
[
|
|
5894
|
+
'same-repository review binding',
|
|
5895
|
+
'github.event.pull_request.head.repo.id == github.event.repository.id',
|
|
5896
|
+
'true',
|
|
5897
|
+
],
|
|
5898
|
+
[
|
|
5899
|
+
'merged source authority',
|
|
5900
|
+
'pr.merge_commit_sha === process.env.GITHUB_SHA',
|
|
5901
|
+
'true',
|
|
5902
|
+
],
|
|
5903
|
+
['nonce required', '!value || /[\\r\\n]/.test(value)', 'false'],
|
|
5904
|
+
[
|
|
5905
|
+
'canonical operation route',
|
|
5906
|
+
'/source-preparations/${encodeURIComponent(binding.operationId)}/workflow-evidence',
|
|
5907
|
+
'/workflow-evidence',
|
|
5908
|
+
],
|
|
5909
|
+
[
|
|
5910
|
+
'current artifact digest',
|
|
5911
|
+
'${{ steps.image-artifact.outputs.artifact-digest }}',
|
|
5912
|
+
'${{ secrets.ARBITRARY_DIGEST }}',
|
|
5913
|
+
],
|
|
5914
|
+
[
|
|
5915
|
+
'validation outcomes checked',
|
|
5916
|
+
"outcomes.some(value => value !== 'success')",
|
|
5917
|
+
'false',
|
|
5918
|
+
],
|
|
5919
|
+
[
|
|
5920
|
+
'no-follow producer read',
|
|
5921
|
+
'fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK',
|
|
5922
|
+
'fs.constants.O_RDONLY',
|
|
5923
|
+
],
|
|
5924
|
+
[
|
|
5925
|
+
'conditional OIDC issuance',
|
|
5926
|
+
" id: github-oidc\n if: (github.event_name == 'push') || (github.event_name == 'workflow_dispatch' && inputs.handover_to_cli)",
|
|
5927
|
+
' id: github-oidc',
|
|
5928
|
+
],
|
|
5929
|
+
[
|
|
5930
|
+
'trusted callback',
|
|
5931
|
+
'https://dev-admin-portal.myenterprise.ai/api/platform/generated-apps/managed-review/workflow',
|
|
5932
|
+
'http://attacker.example/callback',
|
|
5933
|
+
],
|
|
5934
|
+
])
|
|
5935
|
+
test(`rejects workflow tamper: ${name}, even with recomputed managed checksum`, () => {
|
|
5936
|
+
const fixture = exportedFixture();
|
|
5937
|
+
assert.ok(
|
|
5938
|
+
fixture.workflow.toString().includes(before),
|
|
5939
|
+
'Negative must alter actual contract bytes.',
|
|
5940
|
+
);
|
|
5941
|
+
fixture.workflow = Buffer.from(
|
|
5942
|
+
fixture.workflow.toString().replace(before, after),
|
|
5943
|
+
);
|
|
5944
|
+
fixture.manifest.managedFiles[0].checksum = checksum(fixture.workflow);
|
|
5945
|
+
assert.throws(() =>
|
|
5946
|
+
validateGeneratedWorkflow(
|
|
5947
|
+
fixture.manifest,
|
|
5948
|
+
fixture.workflow,
|
|
5949
|
+
generated,
|
|
5950
|
+
fixture.operation,
|
|
5951
|
+
),
|
|
5952
|
+
);
|
|
5953
|
+
});
|
|
5954
|
+
|
|
5955
|
+
test('unknown or malformed CLI context cannot suppress actual workflow checks', (t) => {
|
|
5956
|
+
for (const manifest of [
|
|
5957
|
+
{ schemaVersion: 2 },
|
|
5958
|
+
{ schemaVersion: 1, sourceMode: 'admin-portal-generated' },
|
|
5959
|
+
{
|
|
5960
|
+
schemaVersion: 1,
|
|
5961
|
+
gofer: {
|
|
5962
|
+
managedFiles: {
|
|
5963
|
+
'../escape': { sha256: 'a'.repeat(64), source: 'bundled' },
|
|
5964
|
+
},
|
|
5965
|
+
},
|
|
5966
|
+
},
|
|
5967
|
+
]) {
|
|
5968
|
+
assert.throws(() =>
|
|
5969
|
+
resolveDeploymentWorkflowContext(contextFixture(t, manifest)),
|
|
5970
|
+
);
|
|
5971
|
+
}
|
|
5972
|
+
});
|
|
5973
|
+
|
|
5974
|
+
test('fixture drift and template workflow drift fail closed', (t) => {
|
|
5975
|
+
const directory = contextFixture(t);
|
|
5976
|
+
writeFileSync(
|
|
5977
|
+
join(directory, canonicalWorkflowRelativePath),
|
|
5978
|
+
'name: changed\n',
|
|
5979
|
+
);
|
|
5980
|
+
assert.throws(() => resolveDeploymentWorkflowContext(directory));
|
|
5981
|
+
const fixture = exportedFixture();
|
|
5982
|
+
const generatedDirectory = contextFixture(
|
|
5983
|
+
t,
|
|
5984
|
+
fixture.manifest,
|
|
5985
|
+
fixture.workflow,
|
|
5986
|
+
fixture.operation,
|
|
5987
|
+
);
|
|
5988
|
+
writeFileSync(
|
|
5989
|
+
join(generatedDirectory, 'tests/fixtures/source-unknown/eai-app.yml'),
|
|
5990
|
+
'name: changed\n',
|
|
5991
|
+
);
|
|
5992
|
+
assert.throws(() => resolveDeploymentWorkflowContext(generatedDirectory));
|
|
5993
|
+
});
|
|
5994
|
+
|
|
5995
|
+
test('generated contract fixture drift fails before comparison', () => {
|
|
5996
|
+
const fixture = exportedFixture();
|
|
5997
|
+
assert.throws(() =>
|
|
5998
|
+
validateGeneratedWorkflow(
|
|
5999
|
+
fixture.manifest,
|
|
6000
|
+
fixture.workflow,
|
|
6001
|
+
Buffer.from('changed'),
|
|
6002
|
+
fixture.operation,
|
|
6003
|
+
),
|
|
6004
|
+
);
|
|
6005
|
+
});
|
|
6006
|
+
|
|
6007
|
+
test('inline synthetic generated input retains repository and actual normalized export parity', () => {
|
|
6008
|
+
assert.equal(digest(generated), generatedDigest);
|
|
6009
|
+
if (existsSync(fixtures)) {
|
|
6010
|
+
assert.ok(regularBytes(join(fixtures, 'eai-app.yml')).equals(canonical));
|
|
6011
|
+
assert.ok(
|
|
6012
|
+
regularBytes(join(fixtures, 'generated-eai-app.yml')).equals(generated),
|
|
6013
|
+
);
|
|
6014
|
+
} else {
|
|
6015
|
+
assert.ok(
|
|
6016
|
+
['template', 'cli'].includes(
|
|
6017
|
+
resolveDeploymentWorkflowContext(root, {
|
|
6018
|
+
controlledRoutingInputPath:
|
|
6019
|
+
process.env.EAI_TEMPLATE_CONTROLLED_ROUTING_INPUT,
|
|
6020
|
+
controlledRoutingConfigPaths: selectedControlledRoutingConfigPaths,
|
|
6021
|
+
}).context,
|
|
6022
|
+
),
|
|
6023
|
+
);
|
|
6024
|
+
assert.equal(
|
|
6025
|
+
digest(regularBytes(join(root, canonicalWorkflowRelativePath))),
|
|
6026
|
+
canonicalDigest,
|
|
6027
|
+
);
|
|
6028
|
+
}
|
|
6029
|
+
});
|
|
6030
|
+
|
|
6031
|
+
test('legacy four-file evidence repair admits no fixtures and rejects canonical tampering', (t) => {
|
|
6032
|
+
const manifest = {
|
|
6033
|
+
schemaVersion: 1,
|
|
6034
|
+
cli: { version: '3.19.1' },
|
|
6035
|
+
template: {
|
|
6036
|
+
commit: 'a'.repeat(40),
|
|
6037
|
+
repo: 'https://github.com/eai-support/eai-app-template.git',
|
|
6038
|
+
},
|
|
6039
|
+
};
|
|
6040
|
+
const directory = contextFixture(t, manifest);
|
|
6041
|
+
rmSync(join(directory, fixtureDirectory), { recursive: true });
|
|
6042
|
+
assert.deepEqual(resolveDeploymentWorkflowContext(directory), {
|
|
6043
|
+
context: 'cli',
|
|
6044
|
+
workflowPath: join(directory, canonicalWorkflowRelativePath),
|
|
6045
|
+
});
|
|
6046
|
+
writeFileSync(
|
|
6047
|
+
join(directory, canonicalWorkflowRelativePath),
|
|
6048
|
+
Buffer.concat([canonical, Buffer.from('\n# changed\n')]),
|
|
6049
|
+
);
|
|
6050
|
+
assert.throws(
|
|
6051
|
+
() => resolveDeploymentWorkflowContext(directory),
|
|
6052
|
+
/canonical digest/,
|
|
6053
|
+
);
|
|
6054
|
+
});
|
|
6055
|
+
|
|
6056
|
+
function controlledTunnelFixture(
|
|
6057
|
+
t,
|
|
6058
|
+
{
|
|
6059
|
+
evidenceBaseUrl = 'https://ownerrun-8000.aue01.devtunnels.ms',
|
|
6060
|
+
callbackBaseUrl = 'https://ownerrun-3010.aue.devtunnels.ms',
|
|
6061
|
+
} = {},
|
|
6062
|
+
) {
|
|
6063
|
+
const fixture = exportedFixture({
|
|
6064
|
+
EVIDENCE_BASE_URL: evidenceBaseUrl,
|
|
6065
|
+
MANAGED_REVIEW_CALLBACK_URL: callbackBaseUrl + managedReviewPath,
|
|
6066
|
+
});
|
|
6067
|
+
const candidateRoot = contextFixture(
|
|
6068
|
+
t,
|
|
6069
|
+
fixture.manifest,
|
|
6070
|
+
fixture.workflow,
|
|
6071
|
+
fixture.operation,
|
|
6072
|
+
);
|
|
6073
|
+
const externalDirectory = mkdtempSync(
|
|
6074
|
+
join(tmpdir(), 'eai-controlled-routing-'),
|
|
6075
|
+
);
|
|
6076
|
+
t.after(() => rmSync(externalDirectory, { recursive: true, force: true }));
|
|
6077
|
+
const inputPath = join(realpathSync(externalDirectory), 'owner-routing.json');
|
|
6078
|
+
const configPaths = {
|
|
6079
|
+
publicApi: join(realpathSync(externalDirectory), 'public-api.json'),
|
|
6080
|
+
portal: join(realpathSync(externalDirectory), 'portal.json'),
|
|
6081
|
+
};
|
|
6082
|
+
const safeConfig = {
|
|
6083
|
+
ADMIN_PORTAL_PUBLIC_API_BASE_URL: evidenceBaseUrl,
|
|
6084
|
+
ADMIN_PORTAL_WORKFLOW_CALLBACK_BASE_URL: callbackBaseUrl,
|
|
6085
|
+
};
|
|
6086
|
+
for (const path of Object.values(configPaths))
|
|
6087
|
+
writeFileSync(path, JSON.stringify(safeConfig, null, 2) + '\n', {
|
|
6088
|
+
mode: 0o600,
|
|
6089
|
+
});
|
|
6090
|
+
const proof = {
|
|
6091
|
+
schemaVersion: 'eai.template_controlled_routing.v1',
|
|
6092
|
+
mode: 'owner-controlled-qualification-only',
|
|
6093
|
+
appKey: fixture.manifest.appKey,
|
|
6094
|
+
tenantId: fixture.operation.tenantId,
|
|
6095
|
+
operationId: fixture.operation.operationId,
|
|
6096
|
+
workflowSha256: digest(fixture.workflow),
|
|
6097
|
+
generatedOperationSha256: digest(
|
|
6098
|
+
regularBytes(join(candidateRoot, '.eai/generated-source-operation.json')),
|
|
6099
|
+
),
|
|
6100
|
+
evidenceBaseUrl,
|
|
6101
|
+
managedReviewCallbackUrl: callbackBaseUrl + managedReviewPath,
|
|
6102
|
+
sourceConfigSha256: {
|
|
6103
|
+
publicApi: digest(regularBytes(configPaths.publicApi)),
|
|
6104
|
+
portal: digest(regularBytes(configPaths.portal)),
|
|
6105
|
+
},
|
|
6106
|
+
};
|
|
6107
|
+
writeFileSync(inputPath, JSON.stringify(proof, null, 2) + '\n', {
|
|
6108
|
+
mode: 0o600,
|
|
6109
|
+
});
|
|
6110
|
+
return {
|
|
6111
|
+
fixture,
|
|
6112
|
+
candidateRoot,
|
|
6113
|
+
externalDirectory,
|
|
6114
|
+
inputPath,
|
|
6115
|
+
proof,
|
|
6116
|
+
configPaths,
|
|
6117
|
+
};
|
|
6118
|
+
}
|
|
6119
|
+
|
|
6120
|
+
function supportsControlledRouting() {
|
|
6121
|
+
return process.platform !== 'win32' && typeof process.getuid === 'function';
|
|
6122
|
+
}
|
|
6123
|
+
|
|
6124
|
+
function assertControlledRoutingUnsupported(inputPath, candidateRoot) {
|
|
6125
|
+
assert.throws(
|
|
6126
|
+
() => readControlledRoutingProof(inputPath, candidateRoot),
|
|
6127
|
+
/POSIX owner protection/,
|
|
6128
|
+
);
|
|
6129
|
+
}
|
|
6130
|
+
|
|
6131
|
+
test('canonical app keys admit the exact generated-runtime boundaries', () => {
|
|
6132
|
+
for (const appKey of ['ab', 'a'.repeat(63), 'a--']) {
|
|
6133
|
+
const fixture = exportedFixture({ APP_KEY: appKey });
|
|
6134
|
+
validateGeneratedWorkflow(
|
|
6135
|
+
fixture.manifest,
|
|
6136
|
+
fixture.workflow,
|
|
6137
|
+
generated,
|
|
6138
|
+
fixture.operation,
|
|
6139
|
+
);
|
|
6140
|
+
}
|
|
6141
|
+
});
|
|
6142
|
+
|
|
6143
|
+
for (const appKey of ['a', '1app', 'a'.repeat(64), 'Aapp', 'bad_key', '']) {
|
|
6144
|
+
test(
|
|
6145
|
+
'rejects generated-runtime-invalid app key ' + JSON.stringify(appKey),
|
|
6146
|
+
() => {
|
|
6147
|
+
const fixture = exportedFixture({ APP_KEY: appKey });
|
|
6148
|
+
assert.throws(() =>
|
|
6149
|
+
validateGeneratedWorkflow(
|
|
6150
|
+
fixture.manifest,
|
|
6151
|
+
fixture.workflow,
|
|
6152
|
+
generated,
|
|
6153
|
+
fixture.operation,
|
|
6154
|
+
),
|
|
6155
|
+
);
|
|
6156
|
+
},
|
|
6157
|
+
);
|
|
6158
|
+
}
|
|
6159
|
+
|
|
6160
|
+
test('source operation independently rejects a noncanonical app key', () => {
|
|
6161
|
+
const fixture = exportedFixture();
|
|
6162
|
+
fixture.operation.appKey = '1app';
|
|
6163
|
+
assert.throws(() =>
|
|
6164
|
+
validateGeneratedWorkflow(
|
|
6165
|
+
fixture.manifest,
|
|
6166
|
+
fixture.workflow,
|
|
6167
|
+
generated,
|
|
6168
|
+
fixture.operation,
|
|
6169
|
+
),
|
|
6170
|
+
);
|
|
6171
|
+
});
|
|
6172
|
+
|
|
6173
|
+
test('every trusted deployed Portal cell pair is explicit and exact', () => {
|
|
6174
|
+
for (const [evidence, callback] of trustedRoutingPairs) {
|
|
6175
|
+
for (const ending of ['', '/']) {
|
|
6176
|
+
const fixture = exportedFixture({
|
|
6177
|
+
EVIDENCE_BASE_URL: evidence + ending,
|
|
6178
|
+
MANAGED_REVIEW_CALLBACK_URL: callback,
|
|
6179
|
+
});
|
|
6180
|
+
validateGeneratedWorkflow(
|
|
6181
|
+
fixture.manifest,
|
|
6182
|
+
fixture.workflow,
|
|
6183
|
+
generated,
|
|
6184
|
+
fixture.operation,
|
|
6185
|
+
);
|
|
6186
|
+
}
|
|
6187
|
+
}
|
|
6188
|
+
});
|
|
6189
|
+
|
|
6190
|
+
for (const [name, inputs] of [
|
|
6191
|
+
[
|
|
6192
|
+
'hostile evidence host',
|
|
6193
|
+
{ EVIDENCE_BASE_URL: 'https://attacker.example/public' },
|
|
6194
|
+
],
|
|
6195
|
+
[
|
|
6196
|
+
'suffix evidence host',
|
|
6197
|
+
{
|
|
6198
|
+
EVIDENCE_BASE_URL:
|
|
6199
|
+
'https://dev-api.au.myenterprise.ai.attacker.example/public',
|
|
6200
|
+
},
|
|
6201
|
+
],
|
|
6202
|
+
[
|
|
6203
|
+
'subdomain evidence host',
|
|
6204
|
+
{ EVIDENCE_BASE_URL: 'https://evil.dev-api.au.myenterprise.ai/public' },
|
|
6205
|
+
],
|
|
6206
|
+
[
|
|
6207
|
+
'evidence credentials',
|
|
6208
|
+
{
|
|
6209
|
+
EVIDENCE_BASE_URL:
|
|
6210
|
+
'https://user:password@dev-api.au.myenterprise.ai/public',
|
|
6211
|
+
},
|
|
6212
|
+
],
|
|
6213
|
+
[
|
|
6214
|
+
'evidence explicit default port',
|
|
6215
|
+
{ EVIDENCE_BASE_URL: 'https://dev-api.au.myenterprise.ai:443/public' },
|
|
6216
|
+
],
|
|
6217
|
+
[
|
|
6218
|
+
'evidence alternate port',
|
|
6219
|
+
{ EVIDENCE_BASE_URL: 'https://dev-api.au.myenterprise.ai:8443/public' },
|
|
6220
|
+
],
|
|
6221
|
+
[
|
|
6222
|
+
'evidence wrong path',
|
|
6223
|
+
{ EVIDENCE_BASE_URL: 'https://dev-api.au.myenterprise.ai/attacker' },
|
|
6224
|
+
],
|
|
6225
|
+
[
|
|
6226
|
+
'evidence query',
|
|
6227
|
+
{
|
|
6228
|
+
EVIDENCE_BASE_URL:
|
|
6229
|
+
'https://dev-api.au.myenterprise.ai/public?redirect=attacker',
|
|
6230
|
+
},
|
|
6231
|
+
],
|
|
6232
|
+
[
|
|
6233
|
+
'evidence fragment',
|
|
6234
|
+
{ EVIDENCE_BASE_URL: 'https://dev-api.au.myenterprise.ai/public#attacker' },
|
|
6235
|
+
],
|
|
6236
|
+
[
|
|
6237
|
+
'hostile callback host',
|
|
6238
|
+
{
|
|
6239
|
+
MANAGED_REVIEW_CALLBACK_URL:
|
|
6240
|
+
'https://attacker.example' + managedReviewPath,
|
|
6241
|
+
},
|
|
6242
|
+
],
|
|
6243
|
+
[
|
|
6244
|
+
'callback path suffix',
|
|
6245
|
+
{
|
|
6246
|
+
MANAGED_REVIEW_CALLBACK_URL:
|
|
6247
|
+
'https://dev-admin-portal.myenterprise.ai/evil' + managedReviewPath,
|
|
6248
|
+
},
|
|
6249
|
+
],
|
|
6250
|
+
[
|
|
6251
|
+
'callback query',
|
|
6252
|
+
{
|
|
6253
|
+
MANAGED_REVIEW_CALLBACK_URL:
|
|
6254
|
+
'https://dev-admin-portal.myenterprise.ai' +
|
|
6255
|
+
managedReviewPath +
|
|
6256
|
+
'?redirect=attacker',
|
|
6257
|
+
},
|
|
6258
|
+
],
|
|
6259
|
+
[
|
|
6260
|
+
'callback explicit port',
|
|
6261
|
+
{
|
|
6262
|
+
MANAGED_REVIEW_CALLBACK_URL:
|
|
6263
|
+
'https://dev-admin-portal.myenterprise.ai:443' + managedReviewPath,
|
|
6264
|
+
},
|
|
6265
|
+
],
|
|
6266
|
+
[
|
|
6267
|
+
'cross-environment evidence',
|
|
6268
|
+
{ EVIDENCE_BASE_URL: 'https://api.au.myenterprise.ai/public' },
|
|
6269
|
+
],
|
|
6270
|
+
[
|
|
6271
|
+
'cross-region callback',
|
|
6272
|
+
{
|
|
6273
|
+
EVIDENCE_BASE_URL: 'https://api.au.myenterprise.ai/public',
|
|
6274
|
+
MANAGED_REVIEW_CALLBACK_URL:
|
|
6275
|
+
'https://admin-portal.eu.myenterprise.ai' + managedReviewPath,
|
|
6276
|
+
},
|
|
6277
|
+
],
|
|
6278
|
+
]) {
|
|
6279
|
+
test('rejects recomputed-manifest workflow with ' + name, () => {
|
|
6280
|
+
const fixture = exportedFixture(inputs);
|
|
6281
|
+
assert.throws(() =>
|
|
6282
|
+
validateGeneratedWorkflow(
|
|
6283
|
+
fixture.manifest,
|
|
6284
|
+
fixture.workflow,
|
|
6285
|
+
generated,
|
|
6286
|
+
fixture.operation,
|
|
6287
|
+
),
|
|
6288
|
+
);
|
|
6289
|
+
});
|
|
6290
|
+
}
|
|
6291
|
+
|
|
6292
|
+
test('local tunnel destinations never fall back to repository-derived trust', (t) => {
|
|
6293
|
+
const f = controlledTunnelFixture(t);
|
|
6294
|
+
assert.throws(
|
|
6295
|
+
() => resolveDeploymentWorkflowContext(f.candidateRoot),
|
|
6296
|
+
/exact trusted Portal cell pair/,
|
|
6297
|
+
);
|
|
6298
|
+
});
|
|
6299
|
+
|
|
6300
|
+
test('explicit owner-controlled tunnel qualification binds raw operation and workflow without claiming live authority', (t) => {
|
|
6301
|
+
const f = controlledTunnelFixture(t);
|
|
6302
|
+
if (!supportsControlledRouting()) {
|
|
6303
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6304
|
+
return;
|
|
6305
|
+
}
|
|
6306
|
+
assert.equal(
|
|
6307
|
+
resolveDeploymentWorkflowContext(f.candidateRoot, {
|
|
6308
|
+
controlledRoutingInputPath: f.inputPath,
|
|
6309
|
+
controlledRoutingConfigPaths: f.configPaths,
|
|
6310
|
+
}).context,
|
|
6311
|
+
'admin-portal-generated',
|
|
6312
|
+
);
|
|
6313
|
+
});
|
|
6314
|
+
|
|
6315
|
+
for (const [name, mutate] of [
|
|
6316
|
+
[
|
|
6317
|
+
'wrong app',
|
|
6318
|
+
(p) => {
|
|
6319
|
+
p.appKey = 'different-app';
|
|
6320
|
+
},
|
|
6321
|
+
],
|
|
6322
|
+
[
|
|
6323
|
+
'wrong tenant',
|
|
6324
|
+
(p) => {
|
|
6325
|
+
p.tenantId = 'different-tenant';
|
|
6326
|
+
},
|
|
6327
|
+
],
|
|
6328
|
+
[
|
|
6329
|
+
'wrong operation',
|
|
6330
|
+
(p) => {
|
|
6331
|
+
p.operationId = 'source-different';
|
|
6332
|
+
},
|
|
6333
|
+
],
|
|
6334
|
+
[
|
|
6335
|
+
'workflow bytes drift',
|
|
6336
|
+
(p) => {
|
|
6337
|
+
p.workflowSha256 = '0'.repeat(64);
|
|
6338
|
+
},
|
|
6339
|
+
],
|
|
6340
|
+
[
|
|
6341
|
+
'operation bytes drift',
|
|
6342
|
+
(p) => {
|
|
6343
|
+
p.generatedOperationSha256 = '0'.repeat(64);
|
|
6344
|
+
},
|
|
6345
|
+
],
|
|
6346
|
+
[
|
|
6347
|
+
'extra input fields',
|
|
6348
|
+
(p) => {
|
|
6349
|
+
p.actorAuthorization = true;
|
|
6350
|
+
},
|
|
6351
|
+
],
|
|
6352
|
+
[
|
|
6353
|
+
'noncontrolled mode',
|
|
6354
|
+
(p) => {
|
|
6355
|
+
p.mode = 'live-authorized';
|
|
6356
|
+
},
|
|
6357
|
+
],
|
|
6358
|
+
[
|
|
6359
|
+
'missing source config',
|
|
6360
|
+
(p) => {
|
|
6361
|
+
delete p.sourceConfigSha256;
|
|
6362
|
+
},
|
|
6363
|
+
],
|
|
6364
|
+
[
|
|
6365
|
+
'malformed source digest',
|
|
6366
|
+
(p) => {
|
|
6367
|
+
p.sourceConfigSha256.portal = '';
|
|
6368
|
+
},
|
|
6369
|
+
],
|
|
6370
|
+
[
|
|
6371
|
+
'hostile local authority',
|
|
6372
|
+
(p) => {
|
|
6373
|
+
p.evidenceBaseUrl = 'https://attacker.example/public';
|
|
6374
|
+
},
|
|
6375
|
+
],
|
|
6376
|
+
[
|
|
6377
|
+
'wrong tunnel port',
|
|
6378
|
+
(p) => {
|
|
6379
|
+
p.evidenceBaseUrl = 'https://ownerrun-8001.aue.devtunnels.ms';
|
|
6380
|
+
},
|
|
6381
|
+
],
|
|
6382
|
+
[
|
|
6383
|
+
'wrong callback tunnel port',
|
|
6384
|
+
(p) => {
|
|
6385
|
+
p.managedReviewCallbackUrl =
|
|
6386
|
+
'https://ownerrun-3000.aue.devtunnels.ms' + managedReviewPath;
|
|
6387
|
+
},
|
|
6388
|
+
],
|
|
6389
|
+
[
|
|
6390
|
+
'different local evidence URL',
|
|
6391
|
+
(p) => {
|
|
6392
|
+
p.evidenceBaseUrl = 'https://otherowner-8000.aue.devtunnels.ms';
|
|
6393
|
+
},
|
|
6394
|
+
],
|
|
6395
|
+
]) {
|
|
6396
|
+
test('controlled tunnel input rejects ' + name, (t) => {
|
|
6397
|
+
const f = controlledTunnelFixture(t);
|
|
6398
|
+
mutate(f.proof);
|
|
6399
|
+
writeFileSync(f.inputPath, JSON.stringify(f.proof, null, 2) + '\n');
|
|
6400
|
+
if (!supportsControlledRouting()) {
|
|
6401
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6402
|
+
return;
|
|
6403
|
+
}
|
|
6404
|
+
assert.throws(() =>
|
|
6405
|
+
resolveDeploymentWorkflowContext(f.candidateRoot, {
|
|
6406
|
+
controlledRoutingInputPath: f.inputPath,
|
|
6407
|
+
controlledRoutingConfigPaths: f.configPaths,
|
|
6408
|
+
}),
|
|
6409
|
+
);
|
|
6410
|
+
});
|
|
6411
|
+
}
|
|
6412
|
+
|
|
6413
|
+
for (const [name, alter] of [
|
|
6414
|
+
[
|
|
6415
|
+
'missing input',
|
|
6416
|
+
(f) => {
|
|
6417
|
+
rmSync(f.inputPath);
|
|
6418
|
+
},
|
|
6419
|
+
],
|
|
6420
|
+
[
|
|
6421
|
+
'candidate-repository input',
|
|
6422
|
+
(f) => {
|
|
6423
|
+
const inside = join(realpathSync(f.candidateRoot), 'owner-routing.json');
|
|
6424
|
+
writeFileSync(inside, JSON.stringify(f.proof, null, 2) + '\n', {
|
|
6425
|
+
mode: 0o600,
|
|
6426
|
+
});
|
|
6427
|
+
f.inputPath = inside;
|
|
6428
|
+
},
|
|
6429
|
+
],
|
|
6430
|
+
[
|
|
6431
|
+
'symlink input',
|
|
6432
|
+
(f) => {
|
|
6433
|
+
const link = join(realpathSync(f.externalDirectory), 'symlink.json');
|
|
6434
|
+
symlinkSync(f.inputPath, link);
|
|
6435
|
+
f.inputPath = link;
|
|
6436
|
+
},
|
|
6437
|
+
],
|
|
6438
|
+
[
|
|
6439
|
+
'hardlink input',
|
|
6440
|
+
(f) => {
|
|
6441
|
+
linkSync(
|
|
6442
|
+
f.inputPath,
|
|
6443
|
+
join(realpathSync(f.externalDirectory), 'hardlink.json'),
|
|
6444
|
+
);
|
|
6445
|
+
},
|
|
6446
|
+
],
|
|
6447
|
+
[
|
|
6448
|
+
'unsafe file permissions',
|
|
6449
|
+
(f) => {
|
|
6450
|
+
chmodSync(f.inputPath, 0o644);
|
|
6451
|
+
},
|
|
6452
|
+
],
|
|
6453
|
+
[
|
|
6454
|
+
'unsafe parent permissions',
|
|
6455
|
+
(f) => {
|
|
6456
|
+
chmodSync(f.externalDirectory, 0o755);
|
|
6457
|
+
},
|
|
6458
|
+
],
|
|
6459
|
+
[
|
|
6460
|
+
'oversized input',
|
|
6461
|
+
(f) => {
|
|
6462
|
+
writeFileSync(f.inputPath, ' '.repeat(8193));
|
|
6463
|
+
},
|
|
6464
|
+
],
|
|
6465
|
+
[
|
|
6466
|
+
'malformed input',
|
|
6467
|
+
(f) => {
|
|
6468
|
+
writeFileSync(f.inputPath, '{');
|
|
6469
|
+
},
|
|
6470
|
+
],
|
|
6471
|
+
[
|
|
6472
|
+
'duplicate JSON fields',
|
|
6473
|
+
(f) => {
|
|
6474
|
+
writeFileSync(
|
|
6475
|
+
f.inputPath,
|
|
6476
|
+
'{"mode":"owner-controlled-qualification-only",' +
|
|
6477
|
+
JSON.stringify(f.proof).slice(1),
|
|
6478
|
+
);
|
|
6479
|
+
},
|
|
6480
|
+
],
|
|
6481
|
+
]) {
|
|
6482
|
+
test('controlled tunnel input rejects ' + name, (t) => {
|
|
6483
|
+
const f = controlledTunnelFixture(t);
|
|
6484
|
+
if (!supportsControlledRouting()) {
|
|
6485
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6486
|
+
return;
|
|
6487
|
+
}
|
|
6488
|
+
alter(f);
|
|
6489
|
+
assert.throws(() =>
|
|
6490
|
+
readControlledRoutingProof(f.inputPath, f.candidateRoot, f.configPaths),
|
|
6491
|
+
);
|
|
6492
|
+
});
|
|
6493
|
+
}
|
|
6494
|
+
|
|
6495
|
+
test('controlled proof rejects changed input bytes after admission', (t) => {
|
|
6496
|
+
const f = controlledTunnelFixture(t);
|
|
6497
|
+
if (!supportsControlledRouting()) {
|
|
6498
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6499
|
+
return;
|
|
6500
|
+
}
|
|
6501
|
+
const handle = readControlledRoutingProof(
|
|
6502
|
+
f.inputPath,
|
|
6503
|
+
f.candidateRoot,
|
|
6504
|
+
f.configPaths,
|
|
6505
|
+
);
|
|
6506
|
+
const operationBytes = regularBytes(
|
|
6507
|
+
join(f.candidateRoot, '.eai/generated-source-operation.json'),
|
|
6508
|
+
);
|
|
6509
|
+
f.proof.operationId = 'source-changed';
|
|
6510
|
+
writeFileSync(f.inputPath, JSON.stringify(f.proof, null, 2) + '\n');
|
|
6511
|
+
assert.throws(
|
|
6512
|
+
() =>
|
|
6513
|
+
validateGeneratedWorkflow(
|
|
6514
|
+
f.fixture.manifest,
|
|
6515
|
+
f.fixture.workflow,
|
|
6516
|
+
generated,
|
|
6517
|
+
f.fixture.operation,
|
|
6518
|
+
handle,
|
|
6519
|
+
operationBytes,
|
|
6520
|
+
),
|
|
6521
|
+
/changed after capture/,
|
|
6522
|
+
);
|
|
6523
|
+
});
|
|
6524
|
+
|
|
6525
|
+
test('caller cannot fabricate an opaque controlled routing admission', () => {
|
|
6526
|
+
const f = exportedFixture();
|
|
6527
|
+
assert.throws(
|
|
6528
|
+
() =>
|
|
6529
|
+
validateGeneratedWorkflow(
|
|
6530
|
+
f.manifest,
|
|
6531
|
+
f.workflow,
|
|
6532
|
+
generated,
|
|
6533
|
+
f.operation,
|
|
6534
|
+
{},
|
|
6535
|
+
),
|
|
6536
|
+
/Unrecognized controlled routing proof/,
|
|
6537
|
+
);
|
|
6538
|
+
});
|
|
6539
|
+
|
|
6540
|
+
for (const permissions of ['input', 'parent']) {
|
|
6541
|
+
test(
|
|
6542
|
+
'controlled proof rejects changed ' +
|
|
6543
|
+
permissions +
|
|
6544
|
+
' permissions after admission',
|
|
6545
|
+
(t) => {
|
|
6546
|
+
const f = controlledTunnelFixture(t);
|
|
6547
|
+
if (!supportsControlledRouting()) {
|
|
6548
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6549
|
+
return;
|
|
6550
|
+
}
|
|
6551
|
+
const handle = readControlledRoutingProof(
|
|
6552
|
+
f.inputPath,
|
|
6553
|
+
f.candidateRoot,
|
|
6554
|
+
f.configPaths,
|
|
6555
|
+
);
|
|
6556
|
+
const operationBytes = regularBytes(
|
|
6557
|
+
join(f.candidateRoot, '.eai/generated-source-operation.json'),
|
|
6558
|
+
);
|
|
6559
|
+
chmodSync(
|
|
6560
|
+
permissions === 'input' ? f.inputPath : f.externalDirectory,
|
|
6561
|
+
permissions === 'input' ? 0o644 : 0o755,
|
|
6562
|
+
);
|
|
6563
|
+
assert.throws(
|
|
6564
|
+
() =>
|
|
6565
|
+
validateGeneratedWorkflow(
|
|
6566
|
+
f.fixture.manifest,
|
|
6567
|
+
f.fixture.workflow,
|
|
6568
|
+
generated,
|
|
6569
|
+
f.fixture.operation,
|
|
6570
|
+
handle,
|
|
6571
|
+
operationBytes,
|
|
6572
|
+
),
|
|
6573
|
+
/owner/,
|
|
6574
|
+
);
|
|
6575
|
+
},
|
|
6576
|
+
);
|
|
6577
|
+
}
|
|
6578
|
+
|
|
6579
|
+
test('controlled input rejects a different effective filesystem owner', (t) => {
|
|
6580
|
+
const f = controlledTunnelFixture(t);
|
|
6581
|
+
if (!supportsControlledRouting()) {
|
|
6582
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6583
|
+
return;
|
|
6584
|
+
}
|
|
6585
|
+
const uid = process.getuid();
|
|
6586
|
+
t.mock.method(process, 'getuid', () => uid + 1);
|
|
6587
|
+
assert.throws(
|
|
6588
|
+
() =>
|
|
6589
|
+
readControlledRoutingProof(f.inputPath, f.candidateRoot, f.configPaths),
|
|
6590
|
+
/owner-protected/,
|
|
6591
|
+
);
|
|
6592
|
+
});
|
|
6593
|
+
|
|
6594
|
+
test('non-regular owner input fails without blocking on a FIFO', (t) => {
|
|
6595
|
+
const f = controlledTunnelFixture(t);
|
|
6596
|
+
if (!supportsControlledRouting()) {
|
|
6597
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6598
|
+
return;
|
|
6599
|
+
}
|
|
6600
|
+
rmSync(f.inputPath);
|
|
6601
|
+
execFileSync('mkfifo', [f.inputPath]);
|
|
6602
|
+
chmodSync(f.inputPath, 0o600);
|
|
6603
|
+
const program = `import assert from 'node:assert/strict';
|
|
6604
|
+
import {closeSync,constants as fsConstants,fstatSync,lstatSync,openSync,readSync,realpathSync} from 'node:fs';
|
|
6605
|
+
import {dirname,isAbsolute,relative,resolve} from 'node:path';
|
|
6606
|
+
${controlledReadFlags.toString()}
|
|
6607
|
+
${snapshotControlledDirectories.toString()}
|
|
6608
|
+
${assertControlledDirectories.toString()}
|
|
6609
|
+
${controlledFileMatches.toString()}
|
|
6610
|
+
${readProtectedRoutingBytes.toString()}
|
|
6611
|
+
assert.throws(() => readProtectedRoutingBytes(${JSON.stringify(f.inputPath)}, ${JSON.stringify(f.candidateRoot)}), /bounded owner-only single-link file/);`;
|
|
6612
|
+
const result = spawnSync(
|
|
6613
|
+
process.execPath,
|
|
6614
|
+
['--input-type=module', '-e', program],
|
|
6615
|
+
{ timeout: 2000, encoding: 'utf8' },
|
|
6616
|
+
);
|
|
6617
|
+
assert.equal(
|
|
6618
|
+
result.error,
|
|
6619
|
+
undefined,
|
|
6620
|
+
'FIFO rejection must complete within its bounded child deadline.',
|
|
6621
|
+
);
|
|
6622
|
+
assert.equal(result.signal, null);
|
|
6623
|
+
assert.equal(result.status, 0, result.stderr);
|
|
6624
|
+
});
|
|
6625
|
+
|
|
6626
|
+
for (const flag of ['O_NOFOLLOW', 'O_NONBLOCK']) {
|
|
6627
|
+
test('protected reads reject missing or invalid ' + flag + ' support', () => {
|
|
6628
|
+
for (const value of [undefined, 0, -1, 1.5])
|
|
6629
|
+
assert.throws(
|
|
6630
|
+
() => controlledReadFlags({ ...fsConstants, [flag]: value }),
|
|
6631
|
+
/require.*support/,
|
|
6632
|
+
);
|
|
6633
|
+
});
|
|
6634
|
+
}
|
|
6635
|
+
|
|
6636
|
+
for (const name of ['publicApi', 'portal']) {
|
|
6637
|
+
test(
|
|
6638
|
+
'independent ' + name + ' config rejects invented receipt digests',
|
|
6639
|
+
(t) => {
|
|
6640
|
+
const f = controlledTunnelFixture(t);
|
|
6641
|
+
if (!supportsControlledRouting()) {
|
|
6642
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6643
|
+
return;
|
|
6644
|
+
}
|
|
6645
|
+
f.proof.sourceConfigSha256[name] = '0'.repeat(64);
|
|
6646
|
+
writeFileSync(f.inputPath, JSON.stringify(f.proof, null, 2) + '\n');
|
|
6647
|
+
assert.throws(
|
|
6648
|
+
() =>
|
|
6649
|
+
readControlledRoutingProof(
|
|
6650
|
+
f.inputPath,
|
|
6651
|
+
f.candidateRoot,
|
|
6652
|
+
f.configPaths,
|
|
6653
|
+
),
|
|
6654
|
+
/receipt digest/,
|
|
6655
|
+
);
|
|
6656
|
+
},
|
|
6657
|
+
);
|
|
6658
|
+
|
|
6659
|
+
test(
|
|
6660
|
+
'independent ' +
|
|
6661
|
+
name +
|
|
6662
|
+
' config rejects stale bytes after opaque admission',
|
|
6663
|
+
(t) => {
|
|
6664
|
+
const f = controlledTunnelFixture(t);
|
|
6665
|
+
if (!supportsControlledRouting()) {
|
|
6666
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6667
|
+
return;
|
|
6668
|
+
}
|
|
6669
|
+
const handle = readControlledRoutingProof(
|
|
6670
|
+
f.inputPath,
|
|
6671
|
+
f.candidateRoot,
|
|
6672
|
+
f.configPaths,
|
|
6673
|
+
);
|
|
6674
|
+
writeFileSync(
|
|
6675
|
+
f.configPaths[name],
|
|
6676
|
+
JSON.stringify({
|
|
6677
|
+
ADMIN_PORTAL_PUBLIC_API_BASE_URL:
|
|
6678
|
+
'https://changed-8000.aue.devtunnels.ms',
|
|
6679
|
+
}),
|
|
6680
|
+
);
|
|
6681
|
+
assert.throws(
|
|
6682
|
+
() =>
|
|
6683
|
+
validateGeneratedWorkflow(
|
|
6684
|
+
f.fixture.manifest,
|
|
6685
|
+
f.fixture.workflow,
|
|
6686
|
+
generated,
|
|
6687
|
+
f.fixture.operation,
|
|
6688
|
+
handle,
|
|
6689
|
+
regularBytes(
|
|
6690
|
+
join(f.candidateRoot, '.eai/generated-source-operation.json'),
|
|
6691
|
+
),
|
|
6692
|
+
),
|
|
6693
|
+
/receipt digest/,
|
|
6694
|
+
);
|
|
6695
|
+
},
|
|
6696
|
+
);
|
|
6697
|
+
|
|
6698
|
+
for (const key of [
|
|
6699
|
+
'ADMIN_PORTAL_PUBLIC_API_BASE_URL',
|
|
6700
|
+
'ADMIN_PORTAL_WORKFLOW_CALLBACK_BASE_URL',
|
|
6701
|
+
]) {
|
|
6702
|
+
test(
|
|
6703
|
+
'independent ' +
|
|
6704
|
+
name +
|
|
6705
|
+
' config rejects wrong preferred ' +
|
|
6706
|
+
key +
|
|
6707
|
+
' despite recomputed digest',
|
|
6708
|
+
(t) => {
|
|
6709
|
+
const f = controlledTunnelFixture(t);
|
|
6710
|
+
if (!supportsControlledRouting()) {
|
|
6711
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6712
|
+
return;
|
|
6713
|
+
}
|
|
6714
|
+
const config = JSON.parse(regularBytes(f.configPaths[name]));
|
|
6715
|
+
config[key] = key.endsWith('API_BASE_URL')
|
|
6716
|
+
? 'https://other-8000.aue.devtunnels.ms'
|
|
6717
|
+
: 'https://other-3010.aue.devtunnels.ms';
|
|
6718
|
+
writeFileSync(f.configPaths[name], JSON.stringify(config));
|
|
6719
|
+
f.proof.sourceConfigSha256[name] = digest(
|
|
6720
|
+
regularBytes(f.configPaths[name]),
|
|
6721
|
+
);
|
|
6722
|
+
writeFileSync(f.inputPath, JSON.stringify(f.proof, null, 2) + '\n');
|
|
6723
|
+
assert.throws(
|
|
6724
|
+
() =>
|
|
6725
|
+
readControlledRoutingProof(
|
|
6726
|
+
f.inputPath,
|
|
6727
|
+
f.candidateRoot,
|
|
6728
|
+
f.configPaths,
|
|
6729
|
+
),
|
|
6730
|
+
/Independent.*destination/,
|
|
6731
|
+
);
|
|
6732
|
+
},
|
|
6733
|
+
);
|
|
6734
|
+
}
|
|
6735
|
+
}
|
|
6736
|
+
|
|
6737
|
+
test('controlled qualification cannot infer independent config paths from the proof', (t) => {
|
|
6738
|
+
const f = controlledTunnelFixture(t);
|
|
6739
|
+
if (!supportsControlledRouting()) {
|
|
6740
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6741
|
+
return;
|
|
6742
|
+
}
|
|
6743
|
+
assert.throws(
|
|
6744
|
+
() => readControlledRoutingProof(f.inputPath, f.candidateRoot),
|
|
6745
|
+
/explicit independent source-config paths/,
|
|
6746
|
+
);
|
|
6747
|
+
});
|
|
6748
|
+
|
|
6749
|
+
test('malformed protected config never exposes its private content in parser errors', (t) => {
|
|
6750
|
+
const f = controlledTunnelFixture(t);
|
|
6751
|
+
if (!supportsControlledRouting()) {
|
|
6752
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6753
|
+
return;
|
|
6754
|
+
}
|
|
6755
|
+
const bytes = Buffer.from('not-json-private-sentinel-do-not-emit');
|
|
6756
|
+
writeFileSync(f.configPaths.portal, bytes);
|
|
6757
|
+
f.proof.sourceConfigSha256.portal = digest(bytes);
|
|
6758
|
+
writeFileSync(f.inputPath, JSON.stringify(f.proof, null, 2) + '\n');
|
|
6759
|
+
assert.throws(
|
|
6760
|
+
() =>
|
|
6761
|
+
readControlledRoutingProof(f.inputPath, f.candidateRoot, f.configPaths),
|
|
6762
|
+
(error) =>
|
|
6763
|
+
error.message ===
|
|
6764
|
+
'Independent source-config receipt has invalid preferred routing fields.' &&
|
|
6765
|
+
!String(error).includes('private-sentinel'),
|
|
6766
|
+
);
|
|
6767
|
+
});
|
|
6768
|
+
|
|
6769
|
+
test('independent configs have a separate bounded limit without widening the proof cap', (t) => {
|
|
6770
|
+
const f = controlledTunnelFixture(t);
|
|
6771
|
+
if (!supportsControlledRouting()) {
|
|
6772
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6773
|
+
return;
|
|
6774
|
+
}
|
|
6775
|
+
const config = JSON.parse(regularBytes(f.configPaths.portal));
|
|
6776
|
+
config.controlledPadding = 'x'.repeat(9000);
|
|
6777
|
+
writeFileSync(f.configPaths.portal, JSON.stringify(config));
|
|
6778
|
+
f.proof.sourceConfigSha256.portal = digest(
|
|
6779
|
+
regularBytes(f.configPaths.portal),
|
|
6780
|
+
);
|
|
6781
|
+
writeFileSync(f.inputPath, JSON.stringify(f.proof, null, 2) + '\n');
|
|
6782
|
+
readControlledRoutingProof(f.inputPath, f.candidateRoot, f.configPaths);
|
|
6783
|
+
writeFileSync(f.configPaths.portal, 'x'.repeat(512 * 1024 + 1));
|
|
6784
|
+
assert.throws(
|
|
6785
|
+
() =>
|
|
6786
|
+
readControlledRoutingProof(f.inputPath, f.candidateRoot, f.configPaths),
|
|
6787
|
+
/bounded owner-only/,
|
|
6788
|
+
);
|
|
6789
|
+
});
|
|
6790
|
+
|
|
6791
|
+
test('opened controlled input rejects a swapped parent directory before accepting bytes', (t) => {
|
|
6792
|
+
const f = controlledTunnelFixture(t);
|
|
6793
|
+
if (!supportsControlledRouting()) {
|
|
6794
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6795
|
+
return;
|
|
6796
|
+
}
|
|
6797
|
+
const originalParent = dirname(f.inputPath);
|
|
6798
|
+
const movedParent = originalParent + '-moved';
|
|
6799
|
+
const hostileParent = realpathSync(
|
|
6800
|
+
mkdtempSync(join(tmpdir(), 'eai-controlled-parent-swap-')),
|
|
6801
|
+
);
|
|
6802
|
+
t.after(() => {
|
|
6803
|
+
rmSync(movedParent, { recursive: true, force: true });
|
|
6804
|
+
rmSync(hostileParent, { recursive: true, force: true });
|
|
6805
|
+
});
|
|
6806
|
+
writeFileSync(
|
|
6807
|
+
join(hostileParent, 'owner-routing.json'),
|
|
6808
|
+
regularBytes(f.inputPath),
|
|
6809
|
+
{ mode: 0o600 },
|
|
6810
|
+
);
|
|
6811
|
+
const program = `import assert from 'node:assert/strict';
|
|
6812
|
+
import {closeSync,constants as fsConstants,fstatSync,lstatSync,openSync as nativeOpen,readSync,realpathSync,renameSync,symlinkSync} from 'node:fs';
|
|
6813
|
+
import {dirname,isAbsolute,relative,resolve} from 'node:path';
|
|
6814
|
+
const openSync = (path, flags) => { renameSync(${JSON.stringify(originalParent)}, ${JSON.stringify(movedParent)}); symlinkSync(${JSON.stringify(hostileParent)}, ${JSON.stringify(originalParent)}); return nativeOpen(path, flags); };
|
|
6815
|
+
${controlledReadFlags.toString()}
|
|
6816
|
+
${snapshotControlledDirectories.toString()}
|
|
6817
|
+
${assertControlledDirectories.toString()}
|
|
6818
|
+
${controlledFileMatches.toString()}
|
|
6819
|
+
${readProtectedRoutingBytes.toString()}
|
|
6820
|
+
assert.throws(() => readProtectedRoutingBytes(${JSON.stringify(f.inputPath)}, ${JSON.stringify(f.candidateRoot)}), /parent chain changed/);`;
|
|
6821
|
+
const result = spawnSync(
|
|
6822
|
+
process.execPath,
|
|
6823
|
+
['--input-type=module', '-e', program],
|
|
6824
|
+
{ timeout: 2000, encoding: 'utf8' },
|
|
6825
|
+
);
|
|
6826
|
+
assert.equal(result.error, undefined);
|
|
6827
|
+
assert.equal(result.signal, null);
|
|
6828
|
+
assert.equal(result.status, 0, result.stderr);
|
|
6829
|
+
});
|
|
6830
|
+
|
|
6831
|
+
for (const alias of [false, true]) {
|
|
6832
|
+
test(
|
|
6833
|
+
'independent service config paths reject ' +
|
|
6834
|
+
(alias ? 'normalized aliases' : 'the same file'),
|
|
6835
|
+
(t) => {
|
|
6836
|
+
const f = controlledTunnelFixture(t);
|
|
6837
|
+
if (!supportsControlledRouting()) {
|
|
6838
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6839
|
+
return;
|
|
6840
|
+
}
|
|
6841
|
+
f.configPaths.portal = alias
|
|
6842
|
+
? dirname(f.configPaths.publicApi) + '/./public-api.json'
|
|
6843
|
+
: f.configPaths.publicApi;
|
|
6844
|
+
assert.throws(
|
|
6845
|
+
() =>
|
|
6846
|
+
readControlledRoutingProof(
|
|
6847
|
+
f.inputPath,
|
|
6848
|
+
f.candidateRoot,
|
|
6849
|
+
f.configPaths,
|
|
6850
|
+
),
|
|
6851
|
+
/distinct resolved paths/,
|
|
6852
|
+
);
|
|
6853
|
+
},
|
|
6854
|
+
);
|
|
6855
|
+
}
|
|
6856
|
+
|
|
6857
|
+
for (const [name, routing] of [
|
|
6858
|
+
[
|
|
6859
|
+
'hyphenated tunnel names',
|
|
6860
|
+
{
|
|
6861
|
+
evidenceBaseUrl: 'https://owner-run-name-8000.aue01.devtunnels.ms',
|
|
6862
|
+
callbackBaseUrl: 'https://owner-run-name-3010.aue.devtunnels.ms',
|
|
6863
|
+
},
|
|
6864
|
+
],
|
|
6865
|
+
[
|
|
6866
|
+
'a valid non-AUE tunnel region',
|
|
6867
|
+
{
|
|
6868
|
+
evidenceBaseUrl: 'https://owner-run-8000.use1.devtunnels.ms',
|
|
6869
|
+
callbackBaseUrl: 'https://owner-run-3010.use1.devtunnels.ms',
|
|
6870
|
+
},
|
|
6871
|
+
],
|
|
6872
|
+
]) {
|
|
6873
|
+
test('independently bound controlled inputs admit ' + name, (t) => {
|
|
6874
|
+
const f = controlledTunnelFixture(t, routing);
|
|
6875
|
+
if (!supportsControlledRouting()) {
|
|
6876
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6877
|
+
return;
|
|
6878
|
+
}
|
|
6879
|
+
assert.equal(
|
|
6880
|
+
resolveDeploymentWorkflowContext(f.candidateRoot, {
|
|
6881
|
+
controlledRoutingInputPath: f.inputPath,
|
|
6882
|
+
controlledRoutingConfigPaths: f.configPaths,
|
|
6883
|
+
}).context,
|
|
6884
|
+
'admin-portal-generated',
|
|
6885
|
+
);
|
|
6886
|
+
});
|
|
6887
|
+
}
|
|
6888
|
+
|
|
6889
|
+
for (const [name, routing] of [
|
|
6890
|
+
[
|
|
6891
|
+
'a hostile tunnel suffix',
|
|
6892
|
+
{
|
|
6893
|
+
evidenceBaseUrl:
|
|
6894
|
+
'https://owner-run-8000.use1.devtunnels.ms.attacker.example',
|
|
6895
|
+
},
|
|
6896
|
+
],
|
|
6897
|
+
[
|
|
6898
|
+
'an invalid tunnel region label',
|
|
6899
|
+
{ evidenceBaseUrl: 'https://owner-run-8000.bad_region.devtunnels.ms' },
|
|
6900
|
+
],
|
|
6901
|
+
[
|
|
6902
|
+
'a wrong evidence tunnel port',
|
|
6903
|
+
{ evidenceBaseUrl: 'https://owner-run-8001.use1.devtunnels.ms' },
|
|
6904
|
+
],
|
|
6905
|
+
[
|
|
6906
|
+
'a wrong callback tunnel port',
|
|
6907
|
+
{ callbackBaseUrl: 'https://owner-run-3000.use1.devtunnels.ms' },
|
|
6908
|
+
],
|
|
6909
|
+
[
|
|
6910
|
+
'a callback tunnel suffix',
|
|
6911
|
+
{
|
|
6912
|
+
callbackBaseUrl:
|
|
6913
|
+
'https://owner-run-3010.use1.devtunnels.ms.attacker.example',
|
|
6914
|
+
},
|
|
6915
|
+
],
|
|
6916
|
+
]) {
|
|
6917
|
+
test('even independently matching controlled configs reject ' + name, (t) => {
|
|
6918
|
+
const f = controlledTunnelFixture(t, routing);
|
|
6919
|
+
if (!supportsControlledRouting()) {
|
|
6920
|
+
assertControlledRoutingUnsupported(f.inputPath, f.candidateRoot);
|
|
6921
|
+
return;
|
|
6922
|
+
}
|
|
6923
|
+
assert.throws(
|
|
6924
|
+
() =>
|
|
6925
|
+
resolveDeploymentWorkflowContext(f.candidateRoot, {
|
|
6926
|
+
controlledRoutingInputPath: f.inputPath,
|
|
6927
|
+
controlledRoutingConfigPaths: f.configPaths,
|
|
6928
|
+
}),
|
|
6929
|
+
/port-(?:8000|3010) HTTPS tunnel/,
|
|
6930
|
+
);
|
|
6931
|
+
});
|
|
6932
|
+
}
|