hammerkit 1.23.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.schema.json +10 -0
- package/dist/cli.js +6 -0
- package/dist/executer/execute-command.js +7 -1
- package/dist/optimizer/get-cache-directory.js +5 -0
- package/dist/planner/utils/append-work-task.js +1 -0
- package/dist/planner/work-secret.js +6 -2
- package/dist/program.js +6 -0
- package/dist/remote/bundle.js +59 -0
- package/dist/remote/collect-bundle.js +41 -0
- package/dist/remote/credentials.js +93 -0
- package/dist/remote/device-login.js +177 -0
- package/dist/remote/host-config.js +170 -0
- package/dist/remote/location.js +46 -0
- package/dist/remote/remote-commands.js +158 -0
- package/dist/remote/remote-work-commands.js +224 -0
- package/dist/remote/server-client.js +88 -0
- package/dist/remote/source-manifest.js +79 -0
- package/dist/remote/text-diff.js +89 -0
- package/dist/schema/build-file-base-task-schema.js +4 -0
- package/dist/schema/build-file-schema.js +3 -0
- package/dist/schema/reference-parser.js +9 -2
- package/dist/schema/runner-schema.js +13 -0
- package/dist/secrets/provider-values.js +4 -0
- package/dist/server/accounts-api.js +42 -0
- package/dist/server/approvals-api.js +104 -0
- package/dist/server/definitions-api.js +163 -0
- package/dist/server/definitions.js +160 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +237 -0
- package/dist/server/git-checkout.js +115 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/plan.js +296 -0
- package/dist/server/policy.js +138 -0
- package/dist/server/proposals-api.js +145 -0
- package/dist/server/proposals.js +173 -0
- package/dist/server/repo-access.js +127 -0
- package/dist/server/repo.js +94 -0
- package/dist/server/run-environment.js +20 -0
- package/dist/server/run-plan.js +44 -0
- package/dist/server/run-request.js +85 -0
- package/dist/server/run-store.js +61 -0
- package/dist/server/run-worker-command.js +17 -0
- package/dist/server/run-worker-main.js +7 -0
- package/dist/server/run-worker.js +179 -0
- package/dist/server/runs.js +432 -0
- package/dist/server/server-command.js +61 -0
- package/dist/server/server-config.js +394 -0
- package/dist/server/server.js +434 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/package.json +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TokenVerifier = exports.quote = exports.TokenError = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
// The reason is for the operator's log, never for the caller.
|
|
6
|
+
class TokenError extends Error {
|
|
7
|
+
}
|
|
8
|
+
exports.TokenError = TokenError;
|
|
9
|
+
// Values from a token are written to the operator's log before the signature
|
|
10
|
+
// is checked, so they are quoted and cut: they can't start a log line of their own.
|
|
11
|
+
function quote(value) {
|
|
12
|
+
return JSON.stringify(String(value).slice(0, 80));
|
|
13
|
+
}
|
|
14
|
+
exports.quote = quote;
|
|
15
|
+
// Asymmetric algorithms only: there is no shared secret, and `none` and HS*
|
|
16
|
+
// are never accepted.
|
|
17
|
+
const ALGORITHMS = {
|
|
18
|
+
RS256: { hash: 'sha256', kty: 'RSA' },
|
|
19
|
+
RS384: { hash: 'sha384', kty: 'RSA' },
|
|
20
|
+
RS512: { hash: 'sha512', kty: 'RSA' },
|
|
21
|
+
PS256: { hash: 'sha256', kty: 'RSA', pss: true },
|
|
22
|
+
ES256: { hash: 'sha256', kty: 'EC', crv: 'P-256' },
|
|
23
|
+
ES384: { hash: 'sha384', kty: 'EC', crv: 'P-384' },
|
|
24
|
+
ES512: { hash: 'sha512', kty: 'EC', crv: 'P-521' },
|
|
25
|
+
};
|
|
26
|
+
const CLOCK_SKEW_SECONDS = 60;
|
|
27
|
+
const KEYS_TTL_MS = 10 * 60 * 1000;
|
|
28
|
+
// an unknown kid refreshes the keys, but not more often than this
|
|
29
|
+
const REFRESH_COOLDOWN_MS = 60 * 1000;
|
|
30
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
31
|
+
// after a failed load the issuer is left alone for this long, instead of being hit by every request
|
|
32
|
+
const FAILURE_BACKOFF_MS = 10 * 1000;
|
|
33
|
+
// keys that could not be refreshed are still used for this long past their TTL, so a short IdP outage does not lock everyone out
|
|
34
|
+
const STALE_KEYS_MS = 60 * 60 * 1000;
|
|
35
|
+
class TokenVerifier {
|
|
36
|
+
constructor(issuers, options = {}) {
|
|
37
|
+
this.issuers = issuers;
|
|
38
|
+
this.keys = new Map();
|
|
39
|
+
this.loading = new Map();
|
|
40
|
+
this.failures = new Map();
|
|
41
|
+
const fetcher = options.fetch ?? fetch;
|
|
42
|
+
this.now = options.now ?? Date.now;
|
|
43
|
+
this.fetchJson = async (url) => {
|
|
44
|
+
const response = await fetcher(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new TokenError(`${url} answered ${response.status}`);
|
|
47
|
+
}
|
|
48
|
+
return response.json();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async verify(token) {
|
|
52
|
+
const parts = token.split('.');
|
|
53
|
+
if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
|
|
54
|
+
throw new TokenError('not a signed JWT');
|
|
55
|
+
}
|
|
56
|
+
const header = decodeObject(parts[0], 'header');
|
|
57
|
+
const payload = decodeObject(parts[1], 'payload');
|
|
58
|
+
const algorithm = typeof header.alg === 'string' ? ALGORITHMS[header.alg] : undefined;
|
|
59
|
+
if (!algorithm) {
|
|
60
|
+
throw new TokenError(`algorithm ${quote(header.alg)} is not accepted`);
|
|
61
|
+
}
|
|
62
|
+
const issuer = this.issuers.find((i) => i.issuer === payload.iss);
|
|
63
|
+
if (!issuer) {
|
|
64
|
+
throw new TokenError(`issuer ${quote(payload.iss)} is not accepted`);
|
|
65
|
+
}
|
|
66
|
+
const jwk = await this.findKey(issuer, algorithm, typeof header.kid === 'string' ? header.kid : undefined);
|
|
67
|
+
const signature = Buffer.from(parts[2], 'base64url');
|
|
68
|
+
const data = Buffer.from(`${parts[0]}.${parts[1]}`);
|
|
69
|
+
const key = (0, crypto_1.createPublicKey)({ key: jwk, format: 'jwk' });
|
|
70
|
+
if (!verifySignature(algorithm, data, key, signature)) {
|
|
71
|
+
throw new TokenError('signature does not match');
|
|
72
|
+
}
|
|
73
|
+
return this.readClaims(issuer, payload);
|
|
74
|
+
}
|
|
75
|
+
readClaims(issuer, payload) {
|
|
76
|
+
const now = this.now() / 1000;
|
|
77
|
+
if (typeof payload.exp !== 'number') {
|
|
78
|
+
throw new TokenError('token has no expiry');
|
|
79
|
+
}
|
|
80
|
+
if (payload.exp + CLOCK_SKEW_SECONDS < now) {
|
|
81
|
+
throw new TokenError('token expired');
|
|
82
|
+
}
|
|
83
|
+
if (typeof payload.nbf === 'number' && payload.nbf - CLOCK_SKEW_SECONDS > now) {
|
|
84
|
+
throw new TokenError('token not valid yet');
|
|
85
|
+
}
|
|
86
|
+
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
87
|
+
if (!audiences.includes(issuer.audience)) {
|
|
88
|
+
throw new TokenError(`token is not meant for ${issuer.audience}`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
|
|
91
|
+
throw new TokenError('token has no subject');
|
|
92
|
+
}
|
|
93
|
+
const groups = payload[issuer.groupsClaim];
|
|
94
|
+
const claims = {};
|
|
95
|
+
for (const [name, value] of Object.entries(payload)) {
|
|
96
|
+
if (typeof value === 'string') {
|
|
97
|
+
claims[name] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
iss: issuer.issuer,
|
|
102
|
+
sub: payload.sub,
|
|
103
|
+
groups: Array.isArray(groups)
|
|
104
|
+
? groups.filter((g) => typeof g === 'string')
|
|
105
|
+
: typeof groups === 'string'
|
|
106
|
+
? [groups]
|
|
107
|
+
: [],
|
|
108
|
+
claims,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async findKey(issuer, algorithm, kid) {
|
|
112
|
+
const known = this.keys.get(issuer.issuer);
|
|
113
|
+
const stale = !known || this.now() - known.loadedAt > KEYS_TTL_MS;
|
|
114
|
+
let loaded;
|
|
115
|
+
if (stale) {
|
|
116
|
+
try {
|
|
117
|
+
loaded = await this.loadKeys(issuer);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
if (!known || this.now() - known.loadedAt > KEYS_TTL_MS + STALE_KEYS_MS) {
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
loaded = known;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
loaded = known;
|
|
128
|
+
}
|
|
129
|
+
let jwk = pickKey(loaded.keys, algorithm, kid);
|
|
130
|
+
if (!jwk && !stale && this.now() - loaded.loadedAt > REFRESH_COOLDOWN_MS) {
|
|
131
|
+
// the issuer may have rotated its keys since they were cached
|
|
132
|
+
try {
|
|
133
|
+
loaded = await this.loadKeys(issuer);
|
|
134
|
+
jwk = pickKey(loaded.keys, algorithm, kid);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// keep the cached keys: the token simply has no key we know
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!jwk) {
|
|
141
|
+
throw new TokenError(`issuer has no ${algorithm.kty} key${kid ? ` ${quote(kid)}` : ''}`);
|
|
142
|
+
}
|
|
143
|
+
return jwk;
|
|
144
|
+
}
|
|
145
|
+
// One load per issuer at a time, and none right after a failed one.
|
|
146
|
+
loadKeys(issuer) {
|
|
147
|
+
const running = this.loading.get(issuer.issuer);
|
|
148
|
+
if (running) {
|
|
149
|
+
return running;
|
|
150
|
+
}
|
|
151
|
+
const failure = this.failures.get(issuer.issuer);
|
|
152
|
+
if (failure && this.now() - failure.at < FAILURE_BACKOFF_MS) {
|
|
153
|
+
return Promise.reject(failure.error);
|
|
154
|
+
}
|
|
155
|
+
const load = this.fetchKeys(issuer)
|
|
156
|
+
.then((loaded) => {
|
|
157
|
+
this.failures.delete(issuer.issuer);
|
|
158
|
+
return loaded;
|
|
159
|
+
})
|
|
160
|
+
.catch((error) => {
|
|
161
|
+
this.failures.set(issuer.issuer, { at: this.now(), error });
|
|
162
|
+
throw error;
|
|
163
|
+
})
|
|
164
|
+
.finally(() => this.loading.delete(issuer.issuer));
|
|
165
|
+
this.loading.set(issuer.issuer, load);
|
|
166
|
+
return load;
|
|
167
|
+
}
|
|
168
|
+
async fetchKeys(issuer) {
|
|
169
|
+
const base = issuer.issuer.replace(/\/+$/, '');
|
|
170
|
+
const metadata = (await this.fetchJson(`${base}/.well-known/openid-configuration`));
|
|
171
|
+
if (metadata.issuer !== issuer.issuer) {
|
|
172
|
+
throw new TokenError(`issuer metadata names ${quote(metadata.issuer)}`);
|
|
173
|
+
}
|
|
174
|
+
const jwksUri = typeof metadata.jwks_uri === 'string' ? metadata.jwks_uri : '';
|
|
175
|
+
if (!isSecureUrl(jwksUri)) {
|
|
176
|
+
throw new TokenError(`jwks_uri ${quote(jwksUri)} is not https`);
|
|
177
|
+
}
|
|
178
|
+
const jwks = (await this.fetchJson(jwksUri));
|
|
179
|
+
const loaded = { keys: Array.isArray(jwks.keys) ? jwks.keys : [], loadedAt: this.now() };
|
|
180
|
+
this.keys.set(issuer.issuer, loaded);
|
|
181
|
+
return loaded;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
exports.TokenVerifier = TokenVerifier;
|
|
185
|
+
function pickKey(keys, algorithm, kid) {
|
|
186
|
+
const candidates = keys.filter((jwk) => jwk.kty === algorithm.kty &&
|
|
187
|
+
jwk.use !== 'enc' &&
|
|
188
|
+
(algorithm.crv === undefined || jwk.crv === algorithm.crv));
|
|
189
|
+
if (kid !== undefined) {
|
|
190
|
+
return candidates.find((jwk) => jwk.kid === kid);
|
|
191
|
+
}
|
|
192
|
+
// a token without kid is only unambiguous when the issuer has one such key
|
|
193
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
194
|
+
}
|
|
195
|
+
function verifySignature(algorithm, data, key, signature) {
|
|
196
|
+
if (algorithm.kty === 'EC') {
|
|
197
|
+
return (0, crypto_1.verify)(algorithm.hash, data, { key, dsaEncoding: 'ieee-p1363' }, signature);
|
|
198
|
+
}
|
|
199
|
+
if (algorithm.pss) {
|
|
200
|
+
return (0, crypto_1.verify)(algorithm.hash, data, { key, padding: crypto_1.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto_1.constants.RSA_PSS_SALTLEN_DIGEST }, signature);
|
|
201
|
+
}
|
|
202
|
+
return (0, crypto_1.verify)(algorithm.hash, data, key, signature);
|
|
203
|
+
}
|
|
204
|
+
function decodeObject(part, what) {
|
|
205
|
+
try {
|
|
206
|
+
const value = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
|
207
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// reported below
|
|
213
|
+
}
|
|
214
|
+
throw new TokenError(`token ${what} is not a JSON object`);
|
|
215
|
+
}
|
|
216
|
+
function isSecureUrl(value) {
|
|
217
|
+
try {
|
|
218
|
+
const url = new URL(value);
|
|
219
|
+
return (url.protocol === 'https:' ||
|
|
220
|
+
(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)));
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.inspectBundle = exports.planBundle = exports.DefinitionError = void 0;
|
|
4
|
+
const promises_1 = require("fs/promises");
|
|
5
|
+
const os_1 = require("os");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const yaml_1 = require("yaml");
|
|
8
|
+
const get_file_context_1 = require("../file/get-file-context");
|
|
9
|
+
const resolve_git_source_1 = require("../includes/resolve-git-source");
|
|
10
|
+
const schema_parser_1 = require("../schema/schema-parser");
|
|
11
|
+
const reference_parser_1 = require("../schema/reference-parser");
|
|
12
|
+
const work_scope_parser_1 = require("../schema/work-scope-parser");
|
|
13
|
+
const work_task_1 = require("../planner/work-task");
|
|
14
|
+
const work_service_1 = require("../planner/work-service");
|
|
15
|
+
const log_1 = require("../log");
|
|
16
|
+
const parse_error_1 = require("../schema/parse-error");
|
|
17
|
+
class DefinitionError extends Error {
|
|
18
|
+
}
|
|
19
|
+
exports.DefinitionError = DefinitionError;
|
|
20
|
+
// 40 (sha-1) or 64 (sha-256) hex digits
|
|
21
|
+
const COMMIT_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
22
|
+
// A hash alone can only name what is inside the bundle, so everything a build
|
|
23
|
+
// file pulls in from elsewhere has to be pinned: a git include needs a commit
|
|
24
|
+
// and a remote that is not a path on the server.
|
|
25
|
+
function findLooseIncludes(bundle, noRemote = false) {
|
|
26
|
+
const problems = [];
|
|
27
|
+
for (const [path, content] of Object.entries(bundle.files)) {
|
|
28
|
+
if (!/\.ya?ml$/i.test(path)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let document;
|
|
32
|
+
try {
|
|
33
|
+
document = (0, yaml_1.parse)(content);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue; // a file that isn't valid yaml is reported when the bundle is parsed
|
|
37
|
+
}
|
|
38
|
+
for (const block of ['includes', 'references']) {
|
|
39
|
+
for (const [name, include] of Object.entries(document?.[block] ?? {})) {
|
|
40
|
+
const source = include;
|
|
41
|
+
if (!source || typeof source !== 'object' || source.git === undefined) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (noRemote) {
|
|
45
|
+
problems.push(`${path}: ${block}.${name}: a proposal may not include from git, the server would fetch it before anybody approved it`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (typeof source.git !== 'string' || !/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
|
|
49
|
+
problems.push(`${path}: ${block}.${name}: git has to be an https:// or ssh:// address`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
|
|
52
|
+
problems.push(`${path}: ${block}.${name}: pin ref to a commit, a branch or tag can move`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return problems;
|
|
58
|
+
}
|
|
59
|
+
// What a build file may not name on a server: the places it would run or store things
|
|
60
|
+
// are the operator's. The docker host, a Kubernetes cluster and a remote cache are
|
|
61
|
+
// credentials of the machine, not of the build file, and a project file that could carry
|
|
62
|
+
// them could send runs, or the cache, anywhere.
|
|
63
|
+
function findHostProblems(bundle) {
|
|
64
|
+
const problems = [];
|
|
65
|
+
for (const [path, content] of Object.entries(bundle.files)) {
|
|
66
|
+
if (!/\.ya?ml$/i.test(path)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
let document;
|
|
70
|
+
try {
|
|
71
|
+
document = (0, yaml_1.parse)(content);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
continue; // reported when the bundle is parsed
|
|
75
|
+
}
|
|
76
|
+
if (!document || typeof document !== 'object') {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (document.environments && Object.keys(document.environments).length > 0) {
|
|
80
|
+
problems.push(`${path}: environments pick a docker host or a Kubernetes cluster, which are the server's to choose, remove them`);
|
|
81
|
+
}
|
|
82
|
+
for (const [name, cache] of Object.entries(document.caches ?? {})) {
|
|
83
|
+
if (cache?.backend?.type !== 'local' || cache.backend.path !== undefined) {
|
|
84
|
+
problems.push(`${path}: caches.${name}: a server keeps its own cache, a build file names no backend or path`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const [name, service] of Object.entries(document.services ?? {})) {
|
|
88
|
+
if (service &&
|
|
89
|
+
typeof service === 'object' &&
|
|
90
|
+
('selector' in service || 'kubeconfig' in service || 'context' in service)) {
|
|
91
|
+
problems.push(`${path}: services.${name}: Kubernetes services are not supported on a server`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return problems;
|
|
96
|
+
}
|
|
97
|
+
// What a build file may read while it is inspected: its own files. The rest of
|
|
98
|
+
// the server is not its business (a missing file is "not there", not an error).
|
|
99
|
+
function confined(files, root) {
|
|
100
|
+
const check = (path) => {
|
|
101
|
+
if (!(0, resolve_git_source_1.isWithin)(root, (0, path_1.resolve)(root, path))) {
|
|
102
|
+
throw new DefinitionError(`${path} is outside of the bundle`);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const guarded = (fn) => (path) => {
|
|
106
|
+
check(path);
|
|
107
|
+
return fn.call(files, path);
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
...files,
|
|
111
|
+
read: guarded(files.read),
|
|
112
|
+
readStream: guarded(files.readStream),
|
|
113
|
+
listFiles: guarded(files.listFiles),
|
|
114
|
+
stats: guarded(files.stats),
|
|
115
|
+
exists: async (path) => (0, resolve_git_source_1.isWithin)(root, (0, path_1.resolve)(root, path)) && files.exists(path),
|
|
116
|
+
watch: files.watch,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// A path of a task, written relative to the root of the bundle with forward
|
|
120
|
+
// slashes. A leading `!` (an exclusion) is kept. Nothing is returned for a path
|
|
121
|
+
// outside of the bundle.
|
|
122
|
+
function insideBundle(root, cwd, path) {
|
|
123
|
+
const negated = path.startsWith('!');
|
|
124
|
+
const absolute = (0, path_1.resolve)(cwd, negated ? path.slice(1) : path);
|
|
125
|
+
const inside = (0, path_1.relative)(root, absolute);
|
|
126
|
+
if (inside === '..' || inside.startsWith(`..${path_1.sep}`) || (0, path_1.isAbsolute)(inside)) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const portable = inside.split(path_1.sep).join(path_1.posix.sep);
|
|
130
|
+
// a pattern keeps its glob characters, `resolve` only normalizes separators
|
|
131
|
+
return [`${negated ? '!' : ''}${portable === '' ? '.' : portable}`];
|
|
132
|
+
}
|
|
133
|
+
function itemsOf(workTree) {
|
|
134
|
+
return [...Object.values(workTree.tasks), ...Object.values(workTree.services)];
|
|
135
|
+
}
|
|
136
|
+
function secretsOf(item) {
|
|
137
|
+
return 'secrets' in item.data ? item.data.secrets : [];
|
|
138
|
+
}
|
|
139
|
+
function describeSecret(secret) {
|
|
140
|
+
const target = secret.target.type === 'env' ? secret.target.name : secret.target.path;
|
|
141
|
+
const source = secret.source;
|
|
142
|
+
if (source.type === 'provider') {
|
|
143
|
+
return { from: `${source.provider}:${source.ref}`, account: source.account, target };
|
|
144
|
+
}
|
|
145
|
+
return { from: source.type === 'env' ? `env:${source.name}` : `file:${source.path}`, account: null, target };
|
|
146
|
+
}
|
|
147
|
+
// What a server refuses to publish. A secret that reads a file or a variable of the
|
|
148
|
+
// server has no account, so nothing would stand between the build file and the
|
|
149
|
+
// operator's own credentials.
|
|
150
|
+
function findPolicyProblems(items, policy, root) {
|
|
151
|
+
const problems = [];
|
|
152
|
+
for (const item of items) {
|
|
153
|
+
const runtime = item.data.type === 'local-task' ? 'local' : 'docker';
|
|
154
|
+
if (policy.runtimes && !policy.runtimes.includes(runtime)) {
|
|
155
|
+
problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
|
|
156
|
+
}
|
|
157
|
+
const data = item.data;
|
|
158
|
+
if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
|
|
159
|
+
for (const mount of data.mounts) {
|
|
160
|
+
// a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
|
|
161
|
+
if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
|
|
162
|
+
problems.push(`${item.name}: mounts ${mount.localPath}, which is outside of the bundle`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const secret of secretsOf(item)) {
|
|
167
|
+
const source = secret.source;
|
|
168
|
+
if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
|
|
169
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
|
|
170
|
+
}
|
|
171
|
+
else if (source.type === 'file') {
|
|
172
|
+
problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
|
|
173
|
+
}
|
|
174
|
+
else if (source.type === 'env' && !policy.envs.includes(source.name)) {
|
|
175
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in runs.env.allow`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return problems;
|
|
180
|
+
}
|
|
181
|
+
function toPlan(root, item) {
|
|
182
|
+
const data = item.data;
|
|
183
|
+
const secrets = secretsOf(item).map(describeSecret);
|
|
184
|
+
const accounts = [...new Set(secrets.flatMap((secret) => (secret.account ? [secret.account] : [])))].sort();
|
|
185
|
+
const mounts = (0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)
|
|
186
|
+
? data.mounts.map((mount) => {
|
|
187
|
+
const inside = insideBundle(root, data.cwd, mount.localPath);
|
|
188
|
+
return `${inside.length > 0 ? inside[0] : mount.localPath}:${mount.containerPath}${mount.readOnly ? ':ro' : ''}`;
|
|
189
|
+
})
|
|
190
|
+
: [];
|
|
191
|
+
const ports = 'ports' in data ? data.ports.map((port) => `${port.hostPort ?? ''}:${port.containerPort}`) : [];
|
|
192
|
+
const flags = [];
|
|
193
|
+
if (data.type === 'local-task') {
|
|
194
|
+
flags.push('local-task');
|
|
195
|
+
}
|
|
196
|
+
if ('ports' in data && data.ports.some((port) => port.hostPort !== null)) {
|
|
197
|
+
flags.push('host-port');
|
|
198
|
+
}
|
|
199
|
+
if (data.scope.remote) {
|
|
200
|
+
flags.push('remote-include');
|
|
201
|
+
}
|
|
202
|
+
const isTask = data.type === 'local-task' || data.type === 'container-task';
|
|
203
|
+
return {
|
|
204
|
+
kind: isTask ? 'task' : 'service',
|
|
205
|
+
runtime: data.type === 'local-task' ? 'local' : 'docker',
|
|
206
|
+
...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
|
|
207
|
+
cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
|
|
208
|
+
deps: item.deps.map((dep) => dep.name).sort(),
|
|
209
|
+
needs: item.needs.map((need) => need.name).sort(),
|
|
210
|
+
accounts,
|
|
211
|
+
secrets,
|
|
212
|
+
mounts,
|
|
213
|
+
ports,
|
|
214
|
+
generates: 'generates' in data ? data.generates.map((generate) => generate.path) : [],
|
|
215
|
+
flags,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
// A file that does not match the schema says where, not only that it does not.
|
|
219
|
+
function describeFailure(e) {
|
|
220
|
+
if (e instanceof parse_error_1.ParseError) {
|
|
221
|
+
const issue = e.zod.issues[0];
|
|
222
|
+
return `${e.buildFilePath}: ${issue.path.length > 0 ? `${issue.path.join('.')}: ` : ''}${issue.message}`;
|
|
223
|
+
}
|
|
224
|
+
return (0, log_1.getErrorMessage)(e);
|
|
225
|
+
}
|
|
226
|
+
// Writes the bundle into a directory of its own and reads it the way a run
|
|
227
|
+
// would, so a build file that doesn't parse is refused when it is published, not
|
|
228
|
+
// when somebody wants to run it. The same function answers what a run will do,
|
|
229
|
+
// with the scope of the run: which tasks, services and accounts it involves.
|
|
230
|
+
async function planBundle(bundle, environment, options = {}) {
|
|
231
|
+
const problems = [...findLooseIncludes(bundle, options.noRemote), ...(options.policy ? findHostProblems(bundle) : [])];
|
|
232
|
+
if (problems.length > 0) {
|
|
233
|
+
throw new DefinitionError(problems.join('\n'));
|
|
234
|
+
}
|
|
235
|
+
const directory = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'hammerkit-definition-'));
|
|
236
|
+
try {
|
|
237
|
+
for (const [path, content] of Object.entries(bundle.files)) {
|
|
238
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)((0, path_1.join)(directory, path)), { recursive: true });
|
|
239
|
+
await (0, promises_1.writeFile)((0, path_1.join)(directory, path), content);
|
|
240
|
+
}
|
|
241
|
+
const inspected = {
|
|
242
|
+
...environment,
|
|
243
|
+
cwd: directory,
|
|
244
|
+
file: confined((0, get_file_context_1.getFileContext)(directory), directory),
|
|
245
|
+
};
|
|
246
|
+
try {
|
|
247
|
+
const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected);
|
|
248
|
+
const context = await (0, reference_parser_1.parseReferences)(ctx, scope, inspected);
|
|
249
|
+
const workTree = (0, work_scope_parser_1.getWorkContext)(context, options.scope ?? { environmentName: null }, inspected);
|
|
250
|
+
const items = itemsOf(workTree);
|
|
251
|
+
if (options.policy) {
|
|
252
|
+
const policyProblems = findPolicyProblems(items, options.policy, directory);
|
|
253
|
+
if (policyProblems.length > 0) {
|
|
254
|
+
throw new DefinitionError(policyProblems.join('\n'));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const inputs = {};
|
|
258
|
+
for (const item of Object.values(workTree.tasks)) {
|
|
259
|
+
inputs[item.name] = {
|
|
260
|
+
// the sources a task inherits from its dependencies belong to those
|
|
261
|
+
src: item.data.src
|
|
262
|
+
.filter((source) => source.inherited === null)
|
|
263
|
+
.flatMap((source) => insideBundle(directory, item.data.cwd, source.source)),
|
|
264
|
+
mounts: (0, work_task_1.isContainerWorkTask)(item.data)
|
|
265
|
+
? item.data.mounts.flatMap((mount) => insideBundle(directory, item.data.cwd, mount.localPath))
|
|
266
|
+
: [],
|
|
267
|
+
deps: item.deps.map((dep) => dep.name).sort(),
|
|
268
|
+
needs: item.needs.map((need) => need.name).sort(),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const plan = {};
|
|
272
|
+
for (const item of items) {
|
|
273
|
+
plan[item.name] = toPlan(directory, item);
|
|
274
|
+
}
|
|
275
|
+
const sorted = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
276
|
+
return {
|
|
277
|
+
tasks: Object.keys(context.tasks).sort(),
|
|
278
|
+
services: Object.keys(context.services).sort(),
|
|
279
|
+
inputs: sorted(inputs),
|
|
280
|
+
plan: sorted(plan),
|
|
281
|
+
accounts: [...new Set(Object.values(plan).flatMap((entry) => entry.accounts))].sort(),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
throw new DefinitionError(describeFailure(e).split(directory).join('.'));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
await (0, promises_1.rm)(directory, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
exports.planBundle = planBundle;
|
|
293
|
+
// Reads a bundle the way a run would; see planBundle.
|
|
294
|
+
const inspectBundle = (bundle, environment) => planBundle(bundle, environment);
|
|
295
|
+
exports.inspectBundle = inspectBundle;
|
|
296
|
+
//# sourceMappingURL=plan.js.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decideRun = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
|
|
4
|
+
const repo_1 = require("./repo");
|
|
5
|
+
const BUILDS_ORDER = ['run', 'propose', 'publish'];
|
|
6
|
+
function matches(entitlement, principal) {
|
|
7
|
+
if (entitlement.subject) {
|
|
8
|
+
return entitlement.subject.iss === principal.iss && entitlement.subject.sub === principal.sub;
|
|
9
|
+
}
|
|
10
|
+
if (entitlement.group) {
|
|
11
|
+
return entitlement.group.iss === principal.iss && principal.groups.includes(entitlement.group.name);
|
|
12
|
+
}
|
|
13
|
+
if (entitlement.claims) {
|
|
14
|
+
return (entitlement.claims.iss === principal.iss &&
|
|
15
|
+
Object.entries(entitlement.claims.match).every(([name, value]) => principal.claims[name] === value));
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
exports.matches = matches;
|
|
20
|
+
function atLeast(grant, level) {
|
|
21
|
+
return grant.builds !== null && BUILDS_ORDER.indexOf(grant.builds) >= BUILDS_ORDER.indexOf(level);
|
|
22
|
+
}
|
|
23
|
+
const mayRun = (grant) => atLeast(grant, 'run');
|
|
24
|
+
exports.mayRun = mayRun;
|
|
25
|
+
const mayPropose = (grant) => atLeast(grant, 'propose');
|
|
26
|
+
exports.mayPropose = mayPropose;
|
|
27
|
+
const mayPublish = (grant) => atLeast(grant, 'publish');
|
|
28
|
+
exports.mayPublish = mayPublish;
|
|
29
|
+
// A caller who can neither run nor publish anything sees nothing of the server's
|
|
30
|
+
// build files either. Someone who uses an account sees them: they have to know what
|
|
31
|
+
// they approve.
|
|
32
|
+
function maySeeDefinitions(grant) {
|
|
33
|
+
return grant.builds !== null || Object.keys(grant.accounts).length > 0;
|
|
34
|
+
}
|
|
35
|
+
exports.maySeeDefinitions = maySeeDefinitions;
|
|
36
|
+
// `use` includes deciding about the runs others request on the account.
|
|
37
|
+
function canUse(grant, account) {
|
|
38
|
+
return grant.accounts[account] === 'use';
|
|
39
|
+
}
|
|
40
|
+
exports.canUse = canUse;
|
|
41
|
+
// Pulling a version back is a safe thing to do, so it is open to who publishes the build
|
|
42
|
+
// file, and to who uses an account the version works with: they are the ones who
|
|
43
|
+
// answer for what runs as it.
|
|
44
|
+
function mayYank(grant, version) {
|
|
45
|
+
return (0, exports.mayPublish)(grant) || (version.accounts ?? []).some((account) => canUse(grant, account));
|
|
46
|
+
}
|
|
47
|
+
exports.mayYank = mayYank;
|
|
48
|
+
function usedAccounts(grant) {
|
|
49
|
+
return Object.keys(grant.accounts).filter((account) => grant.accounts[account] === 'use');
|
|
50
|
+
}
|
|
51
|
+
exports.usedAccounts = usedAccounts;
|
|
52
|
+
// Default deny: a caller without a matching entitlement gets nothing.
|
|
53
|
+
function getGrant(entitlements, principal) {
|
|
54
|
+
const matched = [];
|
|
55
|
+
const accounts = {};
|
|
56
|
+
const repos = [];
|
|
57
|
+
let builds = null;
|
|
58
|
+
entitlements.forEach((entitlement, index) => {
|
|
59
|
+
if (!matches(entitlement, principal)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
matched.push(index);
|
|
63
|
+
for (const [account, level] of Object.entries(entitlement.accounts)) {
|
|
64
|
+
if (accounts[account] !== 'use') {
|
|
65
|
+
accounts[account] = level;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const repo of entitlement.repos) {
|
|
69
|
+
if (!repos.includes(repo)) {
|
|
70
|
+
repos.push(repo);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (entitlement.builds &&
|
|
74
|
+
(builds === null || BUILDS_ORDER.indexOf(entitlement.builds) > BUILDS_ORDER.indexOf(builds))) {
|
|
75
|
+
builds = entitlement.builds;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return { accounts, repos, builds, matched };
|
|
79
|
+
}
|
|
80
|
+
exports.getGrant = getGrant;
|
|
81
|
+
const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
82
|
+
// The single place that decides what happens to a run that was submitted: it
|
|
83
|
+
// starts, waits for an approval, or is refused with the reason.
|
|
84
|
+
function decideRun(input) {
|
|
85
|
+
const { grant, accounts, catalog, source, approvable, readable } = input;
|
|
86
|
+
if (!(0, exports.mayRun)(grant)) {
|
|
87
|
+
return { type: 'refuse', status: 403, error: 'you are not entitled to run anything on this server' };
|
|
88
|
+
}
|
|
89
|
+
if (source.type === 'upload') {
|
|
90
|
+
return {
|
|
91
|
+
type: 'refuse',
|
|
92
|
+
status: 501,
|
|
93
|
+
error: 'runs from an upload are not supported by this server yet, use a git ref',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// the caller's own list first: what they may not name is not told whether the server can read it
|
|
97
|
+
if (!(0, repo_1.matchesAnyRepo)(grant.repos, source.repo)) {
|
|
98
|
+
return {
|
|
99
|
+
type: 'refuse',
|
|
100
|
+
status: 403,
|
|
101
|
+
error: grant.repos.length > 0
|
|
102
|
+
? `you may not run from ${source.repo}, your repositories are ${grant.repos.join(', ')}`
|
|
103
|
+
: `you may not run from ${source.repo}, no entitlement of yours lists a repository`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (!readable(source.repo)) {
|
|
107
|
+
return { type: 'refuse', status: 400, error: `this server can not read ${source.repo}` };
|
|
108
|
+
}
|
|
109
|
+
const request = [];
|
|
110
|
+
for (const account of accounts) {
|
|
111
|
+
const level = grant.accounts[account];
|
|
112
|
+
if (level === undefined) {
|
|
113
|
+
return { type: 'refuse', status: 403, error: `you may not use the account ${account}, it needs use or request` };
|
|
114
|
+
}
|
|
115
|
+
const rules = catalog[account];
|
|
116
|
+
if (rules?.source && !rules.source.includes(source.type)) {
|
|
117
|
+
return { type: 'refuse', status: 403, error: `the account ${account} is not used by runs from ${source.type}` };
|
|
118
|
+
}
|
|
119
|
+
if (rules?.refs) {
|
|
120
|
+
if (COMMIT.test(source.ref) || !(0, repo_1.matchesRef)(rules.refs, source.ref)) {
|
|
121
|
+
return {
|
|
122
|
+
type: 'refuse',
|
|
123
|
+
status: 403,
|
|
124
|
+
error: `the account ${account} is only used by runs from ${rules.refs.join(', ')}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (level === 'request') {
|
|
129
|
+
if (!approvable(account)) {
|
|
130
|
+
return { type: 'refuse', status: 409, error: `nobody can approve ${account}, no entitlement uses it` };
|
|
131
|
+
}
|
|
132
|
+
request.push(account);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return request.length > 0 ? { type: 'approval', accounts: request } : { type: 'run' };
|
|
136
|
+
}
|
|
137
|
+
exports.decideRun = decideRun;
|
|
138
|
+
//# sourceMappingURL=policy.js.map
|