@runeditrun/rer 0.2.0 → 0.2.2
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 +15 -4
- package/cli/README.md +4 -2
- package/cli/auth-copy.mjs +25 -18
- package/cli/bounded.mjs +13 -4
- package/cli/contract.mjs +3 -3
- package/cli/deploy-inputs.mjs +33 -0
- package/cli/deploy-owner.mjs +61 -0
- package/cli/deploy.mjs +95 -9
- package/cli/detached-server.mjs +51 -0
- package/cli/door.mjs +1 -1
- package/cli/hosted-output.mjs +2 -2
- package/cli/hydration/check.mjs +2 -2
- package/cli/inspect.mjs +24 -32
- package/cli/lifecycle.mjs +14 -4
- package/cli/main.mjs +1 -0
- package/cli/managed-server.mjs +12 -5
- package/cli/parse.mjs +5 -2
- package/cli/pnpm-probe.mjs +14 -0
- package/cli/pnpm-runtime.mjs +22 -22
- package/cli/portability-policy.mjs +55 -0
- package/cli/portability-worker.mjs +8 -4
- package/cli/portability.mjs +20 -4
- package/cli/project-pnpm.mjs +67 -0
- package/cli/release.mjs +36 -0
- package/cli/server-registry.mjs +2 -2
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +55 -31
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +55 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +243 -0
- package/cli/site-render.mjs +7 -5
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +32 -11
- package/global/CHANGELOG.md +39 -0
- package/global/README.md +15 -4
- package/global/cli/install.mjs +26 -9
- package/global/cli/path-link.mjs +30 -0
- package/global/cli/status.mjs +1 -1
- package/global/cli/uninstall.mjs +5 -1
- package/global/launch/install-pinned.mjs +4 -0
- package/global/main.mjs +30 -3
- package/global/output.mjs +12 -0
- package/global/registry.mjs +2 -2
- package/global/rer.mjs +3 -0
- package/modules/container/deploy-input.mjs +5 -9
- package/modules/dependencies/auth.mjs +2 -1
- package/modules/deploy-kit/README.md +25 -7
- package/modules/deploy-kit/d1-import.mjs +35 -0
- package/modules/deploy-kit/deploy.mjs +9 -5
- package/modules/deploy-kit/diagnostics.mjs +65 -0
- package/modules/deploy-kit/env-file.mjs +26 -0
- package/modules/deploy-kit/health-request.mjs +6 -1
- package/modules/deploy-kit/health.mjs +35 -15
- package/modules/deploy-kit/migration-ledger.mjs +48 -0
- package/modules/deploy-kit/migrations.mjs +11 -15
- package/modules/deploy-kit/owner-cli.mjs +29 -10
- package/modules/deploy-kit/owner-remove.mjs +105 -0
- package/modules/deploy-kit/owner-secret.mjs +49 -0
- package/modules/deploy-kit/owner.mjs +71 -10
- package/modules/deploy-kit/provider.mjs +59 -7
- package/modules/job-queue/config.mjs +4 -2
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/identity/oidc/README.md +18 -0
- package/modules/operator-auth/identity/oidc/config.mjs +4 -2
- package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
- package/modules/operator-auth/identity/oidc/index.mjs +28 -9
- package/modules/operator-auth/identity/oidc/network.mjs +9 -4
- package/modules/operator-auth/identity/oidc/store.mjs +19 -3
- package/modules/operator-auth/index.d.mts +6 -9
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- package/modules/portability/README.md +28 -0
- package/modules/portability/bridge.mjs +10 -3
- package/modules/portability/import.mjs +4 -2
- package/modules/release/README.md +63 -0
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +9 -0
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/commands.mjs +3 -1
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/package.json +2 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +29 -5
- package/scripts/publish-check/publication-config.json +9 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
package/cli/server-registry.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createConnection, createServer } from 'node:net';
|
|
2
2
|
import { randomUUID, createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { chmod, realpath, unlink, lstat, mkdir } from 'node:fs/promises';
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
6
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -80,7 +80,7 @@ function limitMessage(counted,cap,explicit,mode) {
|
|
|
80
80
|
// the old default of 2 in records written before this rule) never lowers the cap
|
|
81
81
|
// below the current default.
|
|
82
82
|
export const defaultServerLimit=serverDefaults.serverLimit;
|
|
83
|
-
export const serversDirectory=(env=process.env)=>resolve(env.RER_SERVERS_DIR??join(
|
|
83
|
+
export const serversDirectory=(env=process.env)=>resolve(env.RER_SERVERS_DIR??join((env.HOME || homedir()),'.rer/servers'));
|
|
84
84
|
export async function serverRegistry(root,{env=process.env,memory=availableMemory}={}) {
|
|
85
85
|
const floor=memoryFloor(env),explicit=env.RER_SERVER_LIMIT!==undefined,limit=positive(env.RER_SERVER_LIMIT,'RER_SERVER_LIMIT',defaultServerLimit),journey=declaresJourney(env);
|
|
86
86
|
const checkout=await realpath(root), lifetime=await identity();
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { walkSyntax, stringValue, memberName } from './site-syntax.mjs';
|
|
2
|
+
import { refuse } from './errors.mjs';
|
|
3
|
+
const finite = (values, key = value => value) => {
|
|
4
|
+
const unique = [...new Map(values.map(value => [key(value), value])).values()];
|
|
5
|
+
if (unique.length > 1024) throw refuse('The run site has too many possible values for a browser resource reference to check.');
|
|
6
|
+
return unique;
|
|
7
|
+
};
|
|
8
|
+
const propertyNames = (node, strings) => {
|
|
9
|
+
const key = node?.type === 'MemberExpression' ? node.property : node?.key;
|
|
10
|
+
if (!key) return [];
|
|
11
|
+
if (!node.computed) return [key.name ?? String(key.value)];
|
|
12
|
+
return key.type === 'Literal' && typeof key.value === 'number' ? [String(key.value)] : [...strings(key)];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Keep bindings lexical: a library's parameter or local variable must not take
|
|
16
|
+
// an unrelated same-named value from the island. Resolve finite constant values
|
|
17
|
+
// at a resource sink, rather than treating every URL-shaped string as a fetch.
|
|
18
|
+
export function localValues(tree) {
|
|
19
|
+
const scopes = new WeakMap(), writesByName = new Map(), computedWrites = [], assigned = new WeakMap(), root = {bindings:new Map(), parent:null, function:true};
|
|
20
|
+
const writeIndex = name => {
|
|
21
|
+
if (!writesByName.has(name)) writesByName.set(name, {writes:new Set(), users:new Set()});
|
|
22
|
+
return writesByName.get(name);
|
|
23
|
+
};
|
|
24
|
+
const bind = (scope, name, value) => {
|
|
25
|
+
if (!scope.bindings.has(name)) scope.bindings.set(name, []);
|
|
26
|
+
if (value) scope.bindings.get(name).push(value);
|
|
27
|
+
};
|
|
28
|
+
const lookup = (node, name) => {
|
|
29
|
+
let scope = scopes.get(node);
|
|
30
|
+
while (scope && !scope.bindings.has(name)) scope = scope.parent;
|
|
31
|
+
return scope?.bindings.get(name);
|
|
32
|
+
};
|
|
33
|
+
const bindPattern = (scope, pattern, value) => {
|
|
34
|
+
if (pattern.type === 'Identifier') { bind(scope, pattern.name, value); return; }
|
|
35
|
+
if (pattern.type === 'RestElement') { bindPattern(scope, pattern.argument); return; }
|
|
36
|
+
if (pattern.type === 'AssignmentPattern') { bindPattern(scope, pattern.left, value); bindPattern(scope, pattern.left, pattern.right); return; }
|
|
37
|
+
for (const [index, item] of (pattern.type === 'ObjectPattern' ? pattern.properties : pattern.elements ?? []).entries()) {
|
|
38
|
+
if (!item) continue;
|
|
39
|
+
const target = pattern.type === 'ObjectPattern' ? item.value ?? item.argument : item;
|
|
40
|
+
const key = pattern.type === 'ObjectPattern' ? item.key : {type:'Literal', value:index};
|
|
41
|
+
const member = value && key ? {type:'MemberExpression', object:value, property:key, computed:pattern.type !== 'ObjectPattern' || item.computed || key.type === 'Literal'} : null;
|
|
42
|
+
if (member) scopes.set(member, scope);
|
|
43
|
+
bindPattern(scope, target, member);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walkSyntax(tree, (node, path) => {
|
|
47
|
+
const parent = path.at(-1);
|
|
48
|
+
let scope = scopes.get(parent) ?? root;
|
|
49
|
+
if (['FunctionDeclaration','ClassDeclaration'].includes(node.type) && node.id) bind(scope, node.id.name, node);
|
|
50
|
+
if (node.type === 'ImportDeclaration') for (const specifier of node.specifiers) bind(scope, specifier.local.name);
|
|
51
|
+
if (/^(?:FunctionExpression|FunctionDeclaration|ArrowFunctionExpression)$/.test(node.type) || node.type === 'BlockStatement' || node.type === 'CatchClause') {
|
|
52
|
+
scope = {bindings:new Map(), parent:scope, function:node.type !== 'BlockStatement' && node.type !== 'CatchClause'};
|
|
53
|
+
if (node.type === 'FunctionExpression' && node.id) bind(scope, node.id.name, node);
|
|
54
|
+
for (const param of node.params ?? (node.param ? [node.param] : [])) bindPattern(scope, param);
|
|
55
|
+
}
|
|
56
|
+
scopes.set(node, scope);
|
|
57
|
+
if (node.type === 'VariableDeclarator') {
|
|
58
|
+
if (parent.kind === 'var') while (!scope.function && scope.parent) scope = scope.parent;
|
|
59
|
+
bindPattern(scope, node.id, node.init);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
walkSyntax(tree, node => {
|
|
63
|
+
if (node.type !== 'AssignmentExpression') return;
|
|
64
|
+
const operator = node.operator.slice(0, -1);
|
|
65
|
+
const value = node.operator === '=' ? node.right : {type:['&&','||','??'].includes(operator) ? 'LogicalExpression' : 'BinaryExpression', operator, left:node.left, right:node.right};
|
|
66
|
+
assigned.set(node, value);
|
|
67
|
+
if (node.left.type === 'MemberExpression') {
|
|
68
|
+
const name = memberName(node.left), write = {left:node.left, right:value};
|
|
69
|
+
if (name === undefined) computedWrites.push(write);
|
|
70
|
+
else writeIndex(name).writes.add(write);
|
|
71
|
+
}
|
|
72
|
+
if (node.left.type !== 'Identifier') return;
|
|
73
|
+
lookup(node, node.left.name)?.push(value);
|
|
74
|
+
});
|
|
75
|
+
// Resolve each expression once into a shared dataflow graph. Dependencies
|
|
76
|
+
// are re-evaluated only when they gain a value, so cyclic aliases converge
|
|
77
|
+
// instead of exploring every possible path through member assignments.
|
|
78
|
+
const expressionResults = new WeakMap(), expressionStates = new WeakMap(), stringStates = new WeakMap(), writeStates = new WeakMap(), pending = new Set();
|
|
79
|
+
const states = kind => kind === 'strings' ? stringStates : expressionStates;
|
|
80
|
+
let evaluating;
|
|
81
|
+
const state = (map, node, kind) => {
|
|
82
|
+
let result = map.get(node);
|
|
83
|
+
if (!result) {
|
|
84
|
+
result = {node, kind, values:new Set(), users:new Set()};
|
|
85
|
+
map.set(node, result); pending.add(result);
|
|
86
|
+
}
|
|
87
|
+
if (evaluating) result.users.add(evaluating);
|
|
88
|
+
return result;
|
|
89
|
+
};
|
|
90
|
+
const read = (node, kind = 'expressions') => node ? state(states(kind), node, kind).values : new Set();
|
|
91
|
+
const keys = node => propertyNames(node, key => read(key, 'strings'));
|
|
92
|
+
const memberWrites = name => {
|
|
93
|
+
const index = writeIndex(name);
|
|
94
|
+
if (evaluating) index.users.add(evaluating);
|
|
95
|
+
return index.writes;
|
|
96
|
+
};
|
|
97
|
+
// A computed write joins the same fixed point as its key's bindings. Index
|
|
98
|
+
// each discovered key once and wake only readers of that member name.
|
|
99
|
+
for (const write of computedWrites) state(writeStates, write, 'writes');
|
|
100
|
+
function indexWrite(write) {
|
|
101
|
+
const names = keys(write.left);
|
|
102
|
+
for (const name of names) {
|
|
103
|
+
const index = writeIndex(name);
|
|
104
|
+
if (index.writes.has(write)) continue;
|
|
105
|
+
index.writes.add(write);
|
|
106
|
+
for (const user of index.users) pending.add(user);
|
|
107
|
+
}
|
|
108
|
+
return names;
|
|
109
|
+
}
|
|
110
|
+
const isClass = node => ['ClassDeclaration','ClassExpression'].includes(node.type);
|
|
111
|
+
function classProperties(constructor, wanted, isStatic, fields, seen = new Set()) {
|
|
112
|
+
const found = new Map();
|
|
113
|
+
if (!isClass(constructor) || seen.has(constructor)) return found;
|
|
114
|
+
seen = new Set(seen).add(constructor);
|
|
115
|
+
let unknownKey = false;
|
|
116
|
+
for (const property of constructor.body.body) {
|
|
117
|
+
if (Boolean(property.static) !== isStatic || (fields !== undefined && (property.type === 'PropertyDefinition') !== fields)) continue;
|
|
118
|
+
const names = keys(property);
|
|
119
|
+
if (property.computed && !names.length) unknownKey = true;
|
|
120
|
+
for (const name of wanted) if (names.includes(name)) {
|
|
121
|
+
// Static fields initialize after methods even when written before them.
|
|
122
|
+
const previous = found.get(name)?.[0];
|
|
123
|
+
if (property.type === 'PropertyDefinition' || previous?.type !== 'PropertyDefinition') found.set(name, [property]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const inherited = wanted.filter(name => !found.has(name));
|
|
127
|
+
// Wait for a computed override's key instead of prematurely retaining the
|
|
128
|
+
// inherited definition in the monotone graph. Unknown keys stay unknown.
|
|
129
|
+
if (inherited.length && !unknownKey) for (const parent of read(constructor.superClass)) {
|
|
130
|
+
for (const [name, properties] of classProperties(parent, inherited, isStatic, fields, seen)) found.set(name, [...(found.get(name) ?? []), ...properties]);
|
|
131
|
+
}
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
134
|
+
function classMembers(constructor, names, isStatic) {
|
|
135
|
+
if (isStatic) return [...classProperties(constructor, names, true).values()].flat();
|
|
136
|
+
// Instance fields shadow prototype methods, including a method declared by
|
|
137
|
+
// a subclass. Within each category the nearest declaration wins.
|
|
138
|
+
const fields = classProperties(constructor, names, false, true);
|
|
139
|
+
const methods = classProperties(constructor, names.filter(name => !fields.has(name)), false, false);
|
|
140
|
+
return [...fields.values(), ...methods.values()].flat();
|
|
141
|
+
}
|
|
142
|
+
function expressionValues(node) {
|
|
143
|
+
if (node.type === 'Identifier') {
|
|
144
|
+
const values = lookup(node, node.name);
|
|
145
|
+
return values?.length ? values.flatMap(value => [...read(value)]) : [node];
|
|
146
|
+
}
|
|
147
|
+
if (node.type === 'MemberExpression') {
|
|
148
|
+
const names = keys(node);
|
|
149
|
+
const objects = read(node.object), members = [node];
|
|
150
|
+
const properties = items => {
|
|
151
|
+
for (const property of items) if (keys(property).some(key => names.includes(key))) members.push(...read(property.value));
|
|
152
|
+
};
|
|
153
|
+
for (const object of objects) {
|
|
154
|
+
if (object.type === 'ArrayExpression') for (const key of names) members.push(...read(object.elements[Number(key)]));
|
|
155
|
+
if (object.type === 'ObjectExpression') properties(object.properties);
|
|
156
|
+
if (isClass(object)) properties(classMembers(object, names, true));
|
|
157
|
+
if (object.type === 'NewExpression') for (const constructor of read(object.callee)) {
|
|
158
|
+
if (isClass(constructor)) properties(classMembers(constructor, names, false));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const key of names) for (const write of memberWrites(key)) {
|
|
162
|
+
if (write.left !== node && [...read(write.left.object)].some(object => objects.has(object))) members.push(...read(write.right));
|
|
163
|
+
}
|
|
164
|
+
return members;
|
|
165
|
+
}
|
|
166
|
+
if (node.type === 'ConditionalExpression') return [...read(node.consequent), ...read(node.alternate)];
|
|
167
|
+
if (node.type === 'LogicalExpression') return [...read(node.left), ...read(node.right)];
|
|
168
|
+
if (node.type === 'SequenceExpression') return read(node.expressions.at(-1));
|
|
169
|
+
if (node.type === 'AssignmentExpression') return read(assigned.get(node));
|
|
170
|
+
return [node];
|
|
171
|
+
}
|
|
172
|
+
function stringValues(node, seen = new Set()) {
|
|
173
|
+
if (!node || seen.has(node)) return [];
|
|
174
|
+
seen = new Set(seen).add(node);
|
|
175
|
+
const value = stringValue(node);
|
|
176
|
+
if (value !== undefined) return [value];
|
|
177
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') return finite(stringValues(node.left, seen).flatMap(left => stringValues(node.right, seen).map(right => left + right)));
|
|
178
|
+
if (node.type === 'TemplateLiteral') {
|
|
179
|
+
let values = [node.quasis[0].value.cooked ?? ''];
|
|
180
|
+
for (let i = 0; i < node.expressions.length; i++) values = finite(values.flatMap(left => stringValues(node.expressions[i], seen).map(right => left + right + (node.quasis[i + 1].value.cooked ?? ''))));
|
|
181
|
+
return values;
|
|
182
|
+
}
|
|
183
|
+
if (node.type === 'CallExpression' && ['decodeURI','decodeURIComponent','atob'].includes(node.callee.name)) return stringValues(node.arguments[0], seen).flatMap(value => {
|
|
184
|
+
try {return [node.callee.name === 'atob' ? Buffer.from(value,'base64').toString('utf8') : decodeURIComponent(value)];} catch {return [];}
|
|
185
|
+
});
|
|
186
|
+
// Expand only the finite syntax graph. Recursive string construction has
|
|
187
|
+
// an unknown tail, not an ever-growing set of invented complete strings.
|
|
188
|
+
// Alias states already contain their transitive values; unresolved member
|
|
189
|
+
// identities contain no additional strings and must not be expanded again.
|
|
190
|
+
return finite([...read(node)].filter(value => value !== node && !['Identifier','MemberExpression'].includes(value.type)).flatMap(value => stringValues(value, seen)));
|
|
191
|
+
}
|
|
192
|
+
function resolve(node, kind) {
|
|
193
|
+
if (!node) return [];
|
|
194
|
+
const result = state(states(kind), node, kind);
|
|
195
|
+
for (const next of pending) {
|
|
196
|
+
pending.delete(next); evaluating = next;
|
|
197
|
+
const values = next.kind === 'writes' ? indexWrite(next.node) : next.kind === 'strings' ? stringValues(next.node) : expressionValues(next.node);
|
|
198
|
+
let changed = false;
|
|
199
|
+
for (const value of values) if (!next.values.has(value)) { next.values.add(value); changed = true; }
|
|
200
|
+
if (next.kind === 'strings') finite([...next.values]);
|
|
201
|
+
if (changed) for (const user of next.users) pending.add(user);
|
|
202
|
+
}
|
|
203
|
+
evaluating = undefined;
|
|
204
|
+
return [...result.values];
|
|
205
|
+
}
|
|
206
|
+
function expressions(node) {
|
|
207
|
+
if (!node) return [];
|
|
208
|
+
if (expressionResults.has(node)) return expressionResults.get(node);
|
|
209
|
+
const values = resolve(node, 'expressions').filter(value => {
|
|
210
|
+
if (value.type !== 'MemberExpression') return true;
|
|
211
|
+
// A member is its own unresolved identity until a value reaches it. Keep
|
|
212
|
+
// unresolved cycles, but remove that identity after resolving an alias.
|
|
213
|
+
return ![...expressionStates.get(value).values].some(other => other !== value && (other.type !== 'MemberExpression' || !expressionStates.get(other)?.values.has(value)));
|
|
214
|
+
});
|
|
215
|
+
// Indexing is complete and every dependency has settled before caching.
|
|
216
|
+
expressionResults.set(node, values);
|
|
217
|
+
return values;
|
|
218
|
+
}
|
|
219
|
+
const strings = node => resolve(node, 'strings');
|
|
220
|
+
const names = node => propertyNames(node, strings);
|
|
221
|
+
const origins = expressions;
|
|
222
|
+
const localClass = node => {
|
|
223
|
+
const values = expressions(node);
|
|
224
|
+
return values.length > 0 && values.every(value => {
|
|
225
|
+
const constructors = value.type === 'NewExpression' ? expressions(value.callee) : [value];
|
|
226
|
+
return constructors.length > 0 && constructors.every(isClass);
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
// Recognize browser globals through aliases without mistaking library methods
|
|
230
|
+
// or shadowed parameters for those APIs.
|
|
231
|
+
function global(node, name, seen = new Set()) {
|
|
232
|
+
if (!node || seen.has(node)) return false;
|
|
233
|
+
seen = new Set(seen).add(node);
|
|
234
|
+
return origins(node).some(value => {
|
|
235
|
+
if (value.type === 'Identifier') return value.name === name && !lookup(value, value.name);
|
|
236
|
+
return value.type === 'MemberExpression' && names(value).includes(name) && ['window','globalThis','self'].some(root => global(value.object, root, seen));
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// A runtime suffix does not erase a known origin or route at a resource sink.
|
|
240
|
+
// Retain only the prefix before the first unknown value; later fragments are
|
|
241
|
+
// not a trustworthy prefix. Complete values still use ordinary file checks.
|
|
242
|
+
const alternatives = values => finite(values, value => JSON.stringify(value));
|
|
243
|
+
const combine = (left, right) => alternatives(left.flatMap(start => start.complete ? right.map(end => ({value:start.value + end.value, complete:end.complete})) : [start]));
|
|
244
|
+
function resources(node, seen = new Set()) {
|
|
245
|
+
const unknown = [{value:'', complete:false}];
|
|
246
|
+
if (!node || seen.has(node)) return unknown;
|
|
247
|
+
seen = new Set(seen).add(node);
|
|
248
|
+
const value = stringValue(node);
|
|
249
|
+
if (value !== undefined) return [{value, complete:true}];
|
|
250
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') return combine(resources(node.left, seen), resources(node.right, seen));
|
|
251
|
+
if (node.type === 'TemplateLiteral') {
|
|
252
|
+
let values = [{value:node.quasis[0].value.cooked ?? '', complete:true}];
|
|
253
|
+
for (let i = 0; i < node.expressions.length; i++) values = combine(combine(values, resources(node.expressions[i], seen)), [{value:node.quasis[i + 1].value.cooked ?? '', complete:true}]);
|
|
254
|
+
return values;
|
|
255
|
+
}
|
|
256
|
+
if (node.type === 'NewExpression' && global(node.callee, 'Request')) return resources(node.arguments[0], seen);
|
|
257
|
+
if (node.type === 'NewExpression' && global(node.callee, 'URL')) {
|
|
258
|
+
const targets = resources(node.arguments[0], seen), bases = resources(node.arguments[1], seen);
|
|
259
|
+
const written = [...memberWrites('href')].filter(write => expressions(write.left.object).includes(node)).flatMap(write => resources(write.right, seen));
|
|
260
|
+
return alternatives([...written, ...targets.flatMap(target => {
|
|
261
|
+
// Absolute targets do not depend on the base. Incomplete targets retain
|
|
262
|
+
// their known prefix, including a foreign origin or an instance subtree.
|
|
263
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target.value)) return [target];
|
|
264
|
+
if (!node.arguments[1]) return unknown;
|
|
265
|
+
return bases.flatMap(base => {
|
|
266
|
+
if (!base.complete) {
|
|
267
|
+
if (target.value && /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(base.value)) return [base];
|
|
268
|
+
return target.value.startsWith('/') ? [target] : unknown;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
if (!target.value && !target.complete) return unknown;
|
|
272
|
+
return [{value:new URL(target.value, base.value).href, complete:target.complete}];
|
|
273
|
+
} catch { return unknown; }
|
|
274
|
+
});
|
|
275
|
+
})]);
|
|
276
|
+
}
|
|
277
|
+
if (node.type === 'MemberExpression' && names(node).some(name => ['href','url'].includes(name))) {
|
|
278
|
+
const objects = expressions(node.object), values = objects.flatMap(object => object.type === 'NewExpression' && (global(object.callee, 'URL') || global(object.callee, 'Request')) ? resources(object, seen) : []);
|
|
279
|
+
if (values.length) return alternatives(values);
|
|
280
|
+
}
|
|
281
|
+
if (node.type === 'CallExpression' && names(node.callee).some(name => ['toString','toJSON'].includes(name))) {
|
|
282
|
+
const values = expressions(node.callee.object).flatMap(object => object.type === 'NewExpression' && global(object.callee, 'URL') ? resources(object, seen) : []);
|
|
283
|
+
if (values.length) return alternatives(values);
|
|
284
|
+
}
|
|
285
|
+
const resolved = expressions(node).filter(value => value !== node);
|
|
286
|
+
if (resolved.length) return alternatives(resolved.flatMap(value => resources(value, seen)));
|
|
287
|
+
const complete = strings(node);
|
|
288
|
+
return complete.length ? complete.map(value => ({value, complete:true})) : unknown;
|
|
289
|
+
}
|
|
290
|
+
return {strings, names, resources, expressions, origins, global, localClass};
|
|
291
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { memberName } from './site-syntax.mjs';
|
|
2
|
+
|
|
3
|
+
// Follow actual browser object provenance. An object's field named `href`,
|
|
4
|
+
// or a URL parser's `href` setter, does not make that object a DOM element.
|
|
5
|
+
export function browserObjects({expressions, origins, strings, global}) {
|
|
6
|
+
const window = node => ['window','globalThis','self'].some(name => global(node, name));
|
|
7
|
+
const document = node => global(node, 'document');
|
|
8
|
+
const location = node => global(node, 'location') || origins(node).some(value => memberName(value) === 'location' && (window(value.object) || document(value.object)));
|
|
9
|
+
const constructed = (node, name) => expressions(node).some(value => value.type === 'NewExpression' && global(value.callee, name));
|
|
10
|
+
const queries = new Set(['querySelector','querySelectorAll','getElementById','getElementsByTagName','getElementsByClassName','getElementsByName','createElement','createElementNS']);
|
|
11
|
+
const children = new Set(['body','head','documentElement','activeElement','firstChild','lastChild','firstElementChild','lastElementChild','nextSibling','previousSibling','nextElementSibling','previousElementSibling','parentNode','parentElement','children','childNodes','forms','images','links','content','contentDocument']);
|
|
12
|
+
function dom(node, seen = new Set()) {
|
|
13
|
+
if (!node || seen.has(node)) return false;
|
|
14
|
+
seen = new Set(seen).add(node);
|
|
15
|
+
return expressions(node).some(value => {
|
|
16
|
+
if (document(value)) return true;
|
|
17
|
+
if (value.type === 'NewExpression') return ['Image','Audio','Option'].some(name => global(value.callee, name));
|
|
18
|
+
if (value.type === 'CallExpression') {
|
|
19
|
+
const method = memberName(value.callee), object = value.callee.object;
|
|
20
|
+
return (queries.has(method) || ['closest','cloneNode','item','namedItem'].includes(method)) && dom(object, seen);
|
|
21
|
+
}
|
|
22
|
+
if (value.type !== 'MemberExpression') return false;
|
|
23
|
+
return (children.has(memberName(value)) || value.computed) && dom(value.object, seen);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function anchor(node) {
|
|
27
|
+
return expressions(node).some(value => {
|
|
28
|
+
if (value.type !== 'CallExpression' || !dom(value.callee.object)) return false;
|
|
29
|
+
const method = memberName(value.callee);
|
|
30
|
+
if (['createElement','createElementNS','getElementsByTagName'].includes(method)) return strings(value.arguments[method === 'createElementNS' ? 1 : 0]).some(tag => /^(?:a|area)$/i.test(tag));
|
|
31
|
+
return ['querySelector','closest'].includes(method) && strings(value.arguments[0]).some(selector => /^(?:a|area)(?:[.#[:]|$)/i.test(selector));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const style = node => expressions(node).some(value => memberName(value) === 'style' && dom(value.object));
|
|
35
|
+
return {window, document, location, constructed, dom, anchor, style};
|
|
36
|
+
}
|
package/cli/site-check.mjs
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { lstat, readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { join, posix } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { privateDataScanner, decodedText } from './site-data.mjs';
|
|
6
|
+
import { javascriptContent, javascriptContext } from './site-references.mjs';
|
|
7
|
+
import { javascriptStrings } from './site-syntax.mjs';
|
|
8
|
+
import { markupReferences, markupStrings, htmlElements, cssStrings } from './site-markup.mjs';
|
|
9
|
+
import { refuse, RerError } from './errors.mjs';
|
|
4
10
|
import { DEV_AI_MARKER, DEV_LAYER_MARKER } from './dev-identity.mjs';
|
|
5
11
|
|
|
6
12
|
// What a run site may hold: static pages and the files they use, served as
|
|
@@ -32,6 +38,17 @@ async function files(folder) {
|
|
|
32
38
|
|
|
33
39
|
const safeDecode = path => { try { return decodeURI(path); } catch { return path; } };
|
|
34
40
|
|
|
41
|
+
export function identityPattern(value) {
|
|
42
|
+
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
43
|
+
const word = '[\\p{L}\\p{M}\\p{N}_]', mailbox = value.includes('@');
|
|
44
|
+
const left = mailbox ? "[\\p{L}\\p{M}\\p{N}_.!#$%&'*+/=?^`{|}~@-]" : word;
|
|
45
|
+
const right = mailbox ? '[\\p{L}\\p{M}\\p{N}_@-]|\\.[\\p{L}\\p{M}\\p{N}_-]' : word;
|
|
46
|
+
// Paired prose quotes delimit the whole mailbox. An apostrophe inside a
|
|
47
|
+
// longer local part (prefix'owner@example.test) must keep its old boundary.
|
|
48
|
+
const quoted = mailbox ? `|(?<!\\\\)'${escaped}'` : '';
|
|
49
|
+
return {mailbox, pattern:new RegExp(`(?<!${left})(?:${escaped}(?!${right})${quoted})`,mailbox ? 'iu' : 'u')};
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
/** How Cloudflare's static assets (auto-trailing-slash) answer a path, or null. */
|
|
36
53
|
export function servedFile(names, pathname) {
|
|
37
54
|
const path = pathname.replace(/^\/+/, '');
|
|
@@ -43,54 +60,61 @@ export function servedFile(names, pathname) {
|
|
|
43
60
|
return null;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
|
-
// href/src attributes, srcset candidates and CSS url() references.
|
|
47
|
-
function references(text, html) {
|
|
48
|
-
const found = [];
|
|
49
|
-
if (html) {
|
|
50
|
-
for (const match of text.matchAll(/<([a-z][a-z0-9-]*)\b([^>]*)>/gi)) {
|
|
51
|
-
const [, tag, attributes] = match, rel = /\brel\s*=\s*["']?([^"'>]*)/i.exec(attributes)?.[1].toLowerCase() ?? '';
|
|
52
|
-
for (const attribute of attributes.matchAll(/\b(href|src|srcset|action|poster|data)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
53
|
-
const name = attribute[1].toLowerCase(), value = (attribute[2] ?? attribute[3]).replace(/&/g, '&');
|
|
54
|
-
const values = name === 'srcset' ? value.split(',').map(part => part.trim().split(/\s+/)[0]).filter(Boolean) : [value];
|
|
55
|
-
// Only a plain link or a page anchor navigates; everything else is fetched by the page.
|
|
56
|
-
const navigates = name === 'href' && (tag.toLowerCase() === 'a' || tag.toLowerCase() === 'area' || (tag.toLowerCase() === 'link' && /\b(?:canonical|alternate|author|license|help|me)\b/.test(rel)));
|
|
57
|
-
for (const url of values) found.push({ url, navigates });
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
for (const match of text.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/gi)) found.push({ url: match[1] ?? match[2] ?? match[3], navigates: false });
|
|
62
|
-
return found;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
63
|
/**
|
|
66
64
|
* Refuse a run site folder that is not exactly static pages and the files they
|
|
67
65
|
* use: an index and a Run page, every same-origin link resolving, no link to an
|
|
68
66
|
* instance-only route, nothing fetched from another origin, no dev layer, and
|
|
69
67
|
* none of the given private values (owner identity, secrets, the dev key).
|
|
70
68
|
*/
|
|
71
|
-
export async function checkSite(folder, { privateValues = [] } = {}) {
|
|
69
|
+
export async function checkSite(folder, { privateValues = [], privateIdentities = [], root } = {}) {
|
|
72
70
|
const all = await files(folder), names = new Set(all.keys());
|
|
73
71
|
if (!names.has('index.html')) throw refuse('The run site has no index.html (its homepage).');
|
|
74
72
|
if (!servedFile(names, '/run')) throw refuse('The run site has no /run page.');
|
|
75
73
|
const markers = [DEV_LAYER_MARKER, DEV_AI_MARKER].map(value => Buffer.from(value));
|
|
76
|
-
const
|
|
77
|
-
|
|
74
|
+
const containsPrivateData = privateDataScanner(privateValues, privateIdentities);
|
|
75
|
+
let parse;
|
|
76
|
+
if (root) {
|
|
77
|
+
const require = createRequire(join(root, 'package.json'));
|
|
78
|
+
({parseAst:parse} = await import(pathToFileURL(require.resolve('vite')).href));
|
|
79
|
+
}
|
|
80
|
+
const documents = [], treesByFile = new Map();
|
|
81
|
+
for (const [site, bytes] of all) {
|
|
82
|
+
const text = bytes.toString('utf8'), trees = /\.[cm]?js$/.test(site) && parse ? [parse(text)] : [];
|
|
83
|
+
if (/\.(?:html|svg)$/.test(site)) {
|
|
84
|
+
documents.push(text);
|
|
85
|
+
for (const {tag, attributes, content} of htmlElements(text)) {
|
|
86
|
+
if (tag === 'script' && !attributes.has('src') && parse && /^(?:|module|(?:text|application)\/(?:java|ecma)script)$/i.test(attributes.get('type') ?? '')) trees.push(parse(content));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
treesByFile.set(site, trees);
|
|
90
|
+
}
|
|
91
|
+
const context = javascriptContext(documents, [...treesByFile.values()].flat());
|
|
92
|
+
const problems = [], unresolved = [];
|
|
78
93
|
for (const [site, bytes] of all) {
|
|
79
94
|
if (markers.some(marker => bytes.includes(marker))) throw refuse(`The run site file ${site} contains the local-only dev layer.`);
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
const html = /\.(?:html|svg)$/.test(site), css = site.endsWith('.css'), js = /\.[cm]?js$/.test(site);
|
|
96
|
+
const text = bytes.toString('utf8'), trees = treesByFile.get(site), literals = css ? cssStrings(text) : html ? markupStrings(text) : [];
|
|
97
|
+
const scripts = trees.map(tree => javascriptContent(tree, context)), strings = trees.flatMap(javascriptStrings);
|
|
98
|
+
if (containsPrivateData(bytes, [...literals, ...strings, ...strings.flatMap(markupStrings), ...scripts.flatMap(script => script.literals)])) throw new RerError('site_private_data', `The run site file ${site} contains this installation's private data (owner identity or a secret). The run site renders only edition identity.`, 4);
|
|
99
|
+
if (!html && !css && !js) continue;
|
|
100
|
+
const refs = [...(js ? [] : markupReferences(text, html)), ...scripts.flatMap(script => script.references)];
|
|
101
|
+
for (const script of scripts) for (const {kind} of script.unresolved) unresolved.push({asset:site, kind});
|
|
102
|
+
for (const reference of refs) {
|
|
103
|
+
const url = decodedText(reference.url), {navigates} = reference;
|
|
84
104
|
if (!url || /^(?:#|mailto:|tel:|data:|javascript:void)/i.test(url)) continue;
|
|
85
105
|
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url)) {
|
|
86
106
|
if (!navigates) problems.push(`${site} fetches ${url} from another origin`);
|
|
87
107
|
continue;
|
|
88
108
|
}
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
const pathPrefix = safeDecode(url.replace(/[?#].*$/, ''));
|
|
110
|
+
const pathname = posix.resolve(reference.documentRelative ? '/' : '/' + posix.dirname(site), pathPrefix);
|
|
111
|
+
// An unfinished `/api` could become `/apiculture`; a slash, query or
|
|
112
|
+
// fragment already fixes the instance path even when its tail is runtime.
|
|
113
|
+
const boundary = !reference.dynamic || /[?#]/.test(url) || pathPrefix.endsWith('/');
|
|
114
|
+
if (INSTANCE_ROUTES.some(route => (pathname === route && boundary) || pathname.startsWith(route + '/'))) problems.push(`${site} links to ${pathname}, which exists only on an installed instance`);
|
|
115
|
+
else if (!reference.dynamic && !servedFile(names, safeDecode(pathname))) problems.push(`${site} links to ${pathname}, which the site does not have`);
|
|
92
116
|
}
|
|
93
117
|
}
|
|
94
118
|
if (problems.length) throw refuse(`The run site has broken or out-of-site references: ${problems.slice(0, 8).join('; ')}${problems.length > 8 ? `; and ${problems.length - 8} more` : ''}.`);
|
|
95
|
-
return { files: names.size, pages: [...names].filter(name => name.endsWith('.html')).length };
|
|
119
|
+
return { files: names.size, pages: [...names].filter(name => name.endsWith('.html')).length, references:{unresolved} };
|
|
96
120
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { clientInputGuard } from './site-inputs.mjs';
|
|
4
|
+
|
|
5
|
+
// A separate browser build, using the seed's plugins. Only the graph reachable
|
|
6
|
+
// from its named entries ships; an external server import in that graph fails.
|
|
7
|
+
export async function writeSiteAsset(folder, name, data) {
|
|
8
|
+
if (!name.startsWith('site-assets/') || name.split('/').includes('..')) throw Error('Compiled output must stay under site-assets/.');
|
|
9
|
+
const file = join(folder,name), bytes = Buffer.from(data);
|
|
10
|
+
await mkdir(dirname(file),{recursive:true});
|
|
11
|
+
try { await writeFile(file,bytes,{flag:'wx'}); }
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code !== 'EEXIST') throw error;
|
|
14
|
+
if (!bytes.equals(await readFile(file))) throw Error(`Conflicting compiled site asset ${name}.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function buildClients(vite, root, folder, { entries = {}, config, privatePaths = [] } = {}) {
|
|
19
|
+
if (!Object.keys(entries).length) return {clients:{},styles:[]};
|
|
20
|
+
const guard = await clientInputGuard(root, privatePaths, async code => {
|
|
21
|
+
await writeFile(`${folder}.refusal`, code);
|
|
22
|
+
throw Error(code === 'site_private_input' ? 'Run site client imports a private or outside-seed file.' : 'Run site client imports a server-only module.');
|
|
23
|
+
}, vite);
|
|
24
|
+
const scratch = `${folder}.client`;
|
|
25
|
+
try {
|
|
26
|
+
const built = await vite.build({root, plugins:[guard], configFile:config ? resolve(root,config) : false, envFile:false, logLevel:'error', mode:'production',
|
|
27
|
+
build:{ssr:false, lib:false, outDir:scratch, write:false, copyPublicDir:false, emptyOutDir:true, assetsDir:'site-assets', sourcemap:false, reportCompressedSize:false, watch:null,
|
|
28
|
+
rollupOptions:{input:Object.fromEntries(Object.entries(entries).map(([name,path]) => [name,resolve(root,path)])), output:{entryFileNames:'site-assets/[name]-[hash].js',chunkFileNames:'site-assets/[name]-[hash].js'}}}});
|
|
29
|
+
const output = (Array.isArray(built) ? built : [built]).flatMap(result => result.output ?? []);
|
|
30
|
+
const chunks = new Map(output.filter(item => item.type === 'chunk').map(item => [item.fileName,item]));
|
|
31
|
+
const clients = {}, shipped = new Set();
|
|
32
|
+
const visit = file => {
|
|
33
|
+
if (shipped.has(file)) return;
|
|
34
|
+
const chunk = chunks.get(file);
|
|
35
|
+
if (!chunk) throw Error(`Run site client imports ${file}, which is not bundled for the browser. Remove its server dependency or bundle it in the client config.`);
|
|
36
|
+
shipped.add(file);
|
|
37
|
+
for (const dependency of [...chunk.imports,...chunk.dynamicImports]) visit(dependency);
|
|
38
|
+
};
|
|
39
|
+
for (const name of Object.keys(entries)) {
|
|
40
|
+
const entry = output.find(item => item.type === 'chunk' && item.isEntry && item.name === name);
|
|
41
|
+
if (!entry) throw Error(`Run site client ${name} produced no entry chunk.`);
|
|
42
|
+
clients[name] = '/' + entry.fileName; visit(entry.fileName);
|
|
43
|
+
}
|
|
44
|
+
for (const item of output) {
|
|
45
|
+
if (item.type === 'chunk' && !shipped.has(item.fileName)) continue;
|
|
46
|
+
await writeSiteAsset(folder,item.fileName,item.type === 'chunk' ? item.code : item.source);
|
|
47
|
+
}
|
|
48
|
+
return {clients,styles:output.filter(item=>item.type==='asset'&&item.fileName.endsWith('.css')).map(item=>'/' + item.fileName)};
|
|
49
|
+
} finally { await rm(scratch,{recursive:true,force:true}); }
|
|
50
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { identityPattern } from './site-check.mjs';
|
|
2
|
+
|
|
3
|
+
// HTML Living Standard named character references (Python html.entities.html5).
|
|
4
|
+
import entities from './site-entities.json' with {type:'json'};
|
|
5
|
+
const numericReplacements = {128:8364,130:8218,131:402,132:8222,133:8230,134:8224,135:8225,136:710,137:8240,138:352,139:8249,140:338,142:381,145:8216,146:8217,147:8220,148:8221,149:8226,150:8211,151:8212,152:732,153:8482,154:353,155:8250,156:339,158:382,159:376};
|
|
6
|
+
export function decodedText(text) {
|
|
7
|
+
return text.replace(/&#(?:x([\da-f]+)|(\d+));?|&([a-z][a-z0-9]*;?)/gi, (match, hex, decimal, name) => {
|
|
8
|
+
if (name) {
|
|
9
|
+
for (let length = name.length; length; length--) if (entities[name.slice(0,length)] !== undefined) return entities[name.slice(0,length)] + name.slice(length);
|
|
10
|
+
return match;
|
|
11
|
+
}
|
|
12
|
+
const code = parseInt(hex ?? decimal, hex ? 16 : 10);
|
|
13
|
+
return String.fromCodePoint(numericReplacements[code] ?? (!code || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff) ? 0xfffd : code));
|
|
14
|
+
}).replace(/(?:%[\da-f]{2})+/gi, value => {try {return decodeURIComponent(value);} catch {return value;}})
|
|
15
|
+
.replace(/\\(?:x([\da-f]{2})|u([\da-f]{4})|u\{([\da-f]+)\})/gi, (match, hex, unicode, point) => {
|
|
16
|
+
const code = parseInt(hex ?? unicode ?? point,16); return code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Scan every emitted file, including binary assets. Decode textual forms as well
|
|
21
|
+
// as matching base64 directly, so encoding a registered value cannot publish it.
|
|
22
|
+
export function privateDataScanner(values, identities) {
|
|
23
|
+
const secrets = values.filter(value => typeof value === 'string' && value.length);
|
|
24
|
+
const owners = identities.filter(value => typeof value === 'string' && value.length).map(value => ({value, ...identityPattern(value)}));
|
|
25
|
+
const encodings = value => [Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64').replace(/=+$/, ''), Buffer.from(value).toString('base64url')];
|
|
26
|
+
const encodedSecrets = secrets.flatMap(encodings);
|
|
27
|
+
const encodedOwners = owners.flatMap(({value}) => encodings(value)).map(value => new RegExp(`(?<![A-Za-z0-9+/_-])${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9+/_=-])`));
|
|
28
|
+
return (bytes, literalValues = []) => {
|
|
29
|
+
if (secrets.some(value => bytes.includes(Buffer.from(value)))) return true;
|
|
30
|
+
const raw = bytes.toString('utf8');
|
|
31
|
+
const forms = new Set([raw, ...literalValues]);
|
|
32
|
+
for (const value of [raw, ...literalValues]) {
|
|
33
|
+
let decoded = value;
|
|
34
|
+
for (let depth = 0; depth < 4; depth++) { decoded = decodedText(decoded); forms.add(decoded); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const form of forms) {
|
|
38
|
+
// Complete string values preserve the whitespace that atob accepts, even
|
|
39
|
+
// when the payload spans lines or carries encoded whitespace.
|
|
40
|
+
const compact = form.replace(/[\t\n\f\r ]/g, '');
|
|
41
|
+
if (/^[A-Za-z0-9+/_-]{8,}={0,2}$/.test(compact) && compact.length % 4 !== 1) {
|
|
42
|
+
const plain = Buffer.from(compact, 'base64').toString('utf8');
|
|
43
|
+
forms.add(plain); forms.add(decodedText(plain));
|
|
44
|
+
}
|
|
45
|
+
if (secrets.some(value => form.includes(value)) || owners.some(({pattern}) => pattern.test(form))) return true;
|
|
46
|
+
if (encodedSecrets.some(value => form.includes(value)) || encodedOwners.some(pattern => pattern.test(form))) return true;
|
|
47
|
+
// A base64 payload may include surrounding text, changing its alignment.
|
|
48
|
+
for (const match of form.matchAll(/[A-Za-z0-9+/_-]{8,}={0,2}/g)) for (let offset = 0; offset < 4; offset++) {
|
|
49
|
+
const plain = Buffer.from(match[0].slice(offset), 'base64').toString('utf8');
|
|
50
|
+
if (secrets.some(value => plain.includes(value)) || owners.some(({pattern}) => pattern.test(plain))) return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
};
|
|
55
|
+
}
|