laneyard 0.2.0 → 0.4.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/README.md +295 -20
- package/dist/src/cli/detect.js +69 -16
- package/dist/src/cli/prompt.js +61 -0
- package/dist/src/cli/secret-import.js +162 -0
- package/dist/src/cli/secret.js +162 -1
- package/dist/src/cli/setup.js +319 -0
- package/dist/src/cli/style.js +31 -0
- package/dist/src/cli/user.js +127 -0
- package/dist/src/config/accounts.js +163 -0
- package/dist/src/config/load.js +61 -1
- package/dist/src/config/schema.js +26 -1
- package/dist/src/config/store.js +10 -0
- package/dist/src/config/yaml.js +14 -0
- package/dist/src/credentials/kinds.js +120 -0
- package/dist/src/db/credentials.js +75 -0
- package/dist/src/db/schema.sql +39 -0
- package/dist/src/db/secrets.js +28 -0
- package/dist/src/db/sessions.js +61 -0
- package/dist/src/git/workspace.js +32 -6
- package/dist/src/heuristics/android-root.js +49 -0
- package/dist/src/heuristics/android-signing.js +98 -0
- package/dist/src/heuristics/appfile.js +60 -0
- package/dist/src/heuristics/blocking-actions.js +22 -0
- package/dist/src/heuristics/env-example.js +36 -0
- package/dist/src/heuristics/platforms.js +136 -0
- package/dist/src/heuristics/readiness.js +697 -25
- package/dist/src/main.js +58 -8
- package/dist/src/runner/gradle-properties.js +187 -0
- package/dist/src/runner/materialise.js +92 -0
- package/dist/src/runner/orchestrate.js +91 -2
- package/dist/src/secrets/vault.js +106 -5
- package/dist/src/server/app.js +94 -15
- package/dist/src/server/auth.js +127 -19
- package/dist/src/server/permissions.js +75 -0
- package/dist/src/server/required-secrets.js +30 -0
- package/dist/src/server/routes/account.js +57 -0
- package/dist/src/server/routes/credentials.js +108 -0
- package/dist/src/server/routes/projects.js +42 -0
- package/dist/src/server/routes/readiness.js +175 -6
- package/dist/src/server/routes/secrets.js +101 -0
- package/dist/src/server/routes/users.js +64 -0
- package/dist/src/sidecar/bridge.js +37 -1
- package/dist/src/sidecar/fastlane-dir.js +23 -0
- package/dist/src/sidecar/lanes.js +6 -0
- package/dist/src/sidecar/uses.js +21 -5
- package/dist/web/assets/{Editor-D5Q4uj4n.js → Editor-DynuBC2l.js} +1 -1
- package/dist/web/assets/index-CpwrNE-K.css +1 -0
- package/dist/web/assets/index-lyZs-Y7J.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/ruby/introspect.rb +122 -9
- package/dist/web/assets/index-D9_EL8LA.js +0 -62
- package/dist/web/assets/index-ZUqTX6d1.css +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fieldsOf } from "../credentials/kinds.js";
|
|
1
2
|
import { scrub } from "../logs/redact.js";
|
|
2
3
|
import { decrypt, encrypt } from "./cipher.js";
|
|
3
4
|
import { loadOrCreateKey } from "./key.js";
|
|
@@ -11,12 +12,14 @@ import { loadOrCreateKey } from "./key.js";
|
|
|
11
12
|
export class Vault {
|
|
12
13
|
key;
|
|
13
14
|
store;
|
|
14
|
-
|
|
15
|
+
credentials;
|
|
16
|
+
constructor(key, store, credentials) {
|
|
15
17
|
this.key = key;
|
|
16
18
|
this.store = store;
|
|
19
|
+
this.credentials = credentials;
|
|
17
20
|
}
|
|
18
|
-
static async open(home, store) {
|
|
19
|
-
return new Vault(await loadOrCreateKey(home), store);
|
|
21
|
+
static async open(home, store, credentials) {
|
|
22
|
+
return new Vault(await loadOrCreateKey(home), store, credentials);
|
|
20
23
|
}
|
|
21
24
|
async set(projectSlug, key, value, masked) {
|
|
22
25
|
this.store.set(projectSlug, key, encrypt(value, this.key), masked);
|
|
@@ -58,11 +61,109 @@ export class Vault {
|
|
|
58
61
|
}
|
|
59
62
|
return out;
|
|
60
63
|
}
|
|
61
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* One value, in the clear — and only when it was never declared secret.
|
|
66
|
+
*
|
|
67
|
+
* The refusal is the point. This vault has been write-only since it was
|
|
68
|
+
* written: the server never sent a value back, so the interface had nothing to
|
|
69
|
+
* uncover and no browser ever held one. That is worth keeping for anything
|
|
70
|
+
* anyone called a secret.
|
|
71
|
+
*
|
|
72
|
+
* But not everything stored here is one. `APP_VERSION`, `SENTRY_ORG`, an
|
|
73
|
+
* issuer id — those are identifiers, and being unable to check what was stored
|
|
74
|
+
* makes an import something you have to take on faith. The line between the
|
|
75
|
+
* two already existed and is the user's own: `masked` is "keep this out of the
|
|
76
|
+
* logs". A value that carries it is never returned, whoever asks.
|
|
77
|
+
*
|
|
78
|
+
* Returns null for an unknown key, and throws for a masked one — a caller
|
|
79
|
+
* that forgot to check must fail loudly rather than leak.
|
|
80
|
+
*/
|
|
81
|
+
reveal(projectSlug, key) {
|
|
82
|
+
const row = this.store.find(projectSlug, key);
|
|
83
|
+
if (!row)
|
|
84
|
+
return null;
|
|
85
|
+
if (row.masked) {
|
|
86
|
+
throw new Error(`${key} is kept out of the logs, so its value is never sent back.`);
|
|
87
|
+
}
|
|
88
|
+
return decrypt(row.valueEnc, this.key);
|
|
89
|
+
}
|
|
90
|
+
/** Flips whether a value is kept out of the logs, leaving the value alone. */
|
|
91
|
+
setMasked(projectSlug, key, masked) {
|
|
92
|
+
return this.store.setMasked(projectSlug, key, masked);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Stores a signing block: the file, and the fields that make it usable.
|
|
96
|
+
*
|
|
97
|
+
* `cipher.ts` speaks strings and a `.jks` is bytes, so the file makes the trip
|
|
98
|
+
* as base64. The fields travel as one JSON object rather than one row each,
|
|
99
|
+
* because a keystore missing its alias is not a block with a gap in it — it is
|
|
100
|
+
* not a block.
|
|
101
|
+
*/
|
|
102
|
+
async setCredential(projectSlug, kind, block) {
|
|
103
|
+
this.credentials.set(projectSlug, kind, {
|
|
104
|
+
fileName: block.fileName,
|
|
105
|
+
fileEnc: encrypt(block.fileBytes.toString("base64"), this.key),
|
|
106
|
+
fieldsEnc: encrypt(JSON.stringify(block.fields), this.key),
|
|
107
|
+
varNames: block.varNames,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* One block that applies to a project, in the clear, or undefined if there is none.
|
|
112
|
+
*
|
|
113
|
+
* Unlike `resolve`, an unreadable row throws. The leniency there is earned: a
|
|
114
|
+
* missing variable makes fastlane stop and say which one. A block that quietly
|
|
115
|
+
* fails to decrypt costs a debug-signed artifact that builds, uploads, and is
|
|
116
|
+
* rejected by the store days later — by which point nobody is looking at this
|
|
117
|
+
* run's log. The kind is in the message because that is the part you need to
|
|
118
|
+
* know before you can act.
|
|
119
|
+
*/
|
|
120
|
+
resolveCredential(projectSlug, kind) {
|
|
121
|
+
const row = this.credentials.find(projectSlug, kind);
|
|
122
|
+
if (!row)
|
|
123
|
+
return undefined;
|
|
124
|
+
try {
|
|
125
|
+
return {
|
|
126
|
+
fileName: row.fileName,
|
|
127
|
+
fileBytes: Buffer.from(decrypt(row.fileEnc, this.key), "base64"),
|
|
128
|
+
fields: JSON.parse(decrypt(row.fieldsEnc, this.key)),
|
|
129
|
+
varNames: row.varNames,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new Error(`The stored ${kind} block cannot be decrypted. Its encryption key changed or the row is damaged; upload the credential again.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
listCredentials(projectSlug) {
|
|
137
|
+
return this.credentials.list(projectSlug);
|
|
138
|
+
}
|
|
139
|
+
listGlobalCredentials() {
|
|
140
|
+
return this.credentials.listGlobal();
|
|
141
|
+
}
|
|
142
|
+
removeCredential(projectSlug, kind) {
|
|
143
|
+
return this.credentials.remove(projectSlug, kind);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The values a run's output must not contain.
|
|
147
|
+
*
|
|
148
|
+
* A block's secret fields belong here as much as a masked secret does: a
|
|
149
|
+
* keystore password reaches the build as an environment variable, and gradle
|
|
150
|
+
* is perfectly willing to echo one back on failure.
|
|
151
|
+
*/
|
|
62
152
|
maskedValues(projectSlug) {
|
|
63
153
|
const masked = this.store.maskedKeys(projectSlug);
|
|
64
|
-
|
|
154
|
+
const values = Object.entries(this.resolve(projectSlug))
|
|
65
155
|
.filter(([key]) => masked.has(key))
|
|
66
156
|
.map(([, value]) => value);
|
|
157
|
+
for (const summary of this.credentials.list(projectSlug)) {
|
|
158
|
+
const block = this.resolveCredential(projectSlug, summary.kind);
|
|
159
|
+
if (!block)
|
|
160
|
+
continue;
|
|
161
|
+
for (const field of fieldsOf(summary.kind)) {
|
|
162
|
+
const value = block.fields[field.name];
|
|
163
|
+
if (field.secret && value)
|
|
164
|
+
values.push(value);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return values;
|
|
67
168
|
}
|
|
68
169
|
}
|
package/dist/src/server/app.js
CHANGED
|
@@ -4,29 +4,40 @@ import Fastify from "fastify";
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { LEGACY_ADMIN_NAME } from "../config/load.js";
|
|
7
8
|
import { RunStore } from "../db/runs.js";
|
|
9
|
+
import { SessionRecords } from "../db/sessions.js";
|
|
8
10
|
import { Workspace } from "../git/workspace.js";
|
|
9
11
|
import { LogStore } from "../logs/store.js";
|
|
12
|
+
import { materialiseCredentials } from "../runner/materialise.js";
|
|
10
13
|
import { executeRun } from "../runner/orchestrate.js";
|
|
11
14
|
import { RunQueue } from "../runner/queue.js";
|
|
12
|
-
import { LoginThrottle, SESSION_COOKIE, SessionStore
|
|
15
|
+
import { authenticate, LoginThrottle, COOKIE_OPTIONS, SESSION_COOKIE, SessionStore } from "./auth.js";
|
|
16
|
+
import { requiresAdmin } from "./permissions.js";
|
|
17
|
+
import { registerCredentialRoutes } from "./routes/credentials.js";
|
|
13
18
|
import { registerFastfileRoutes } from "./routes/fastfile.js";
|
|
14
19
|
import { registerProjectRoutes } from "./routes/projects.js";
|
|
20
|
+
import { registerAccountRoutes } from "./routes/account.js";
|
|
15
21
|
import { registerReadinessRoutes } from "./routes/readiness.js";
|
|
16
22
|
import { registerRunRoutes } from "./routes/runs.js";
|
|
17
23
|
import { registerSecretRoutes } from "./routes/secrets.js";
|
|
24
|
+
import { registerUserRoutes } from "./routes/users.js";
|
|
18
25
|
import { registerWebSocket } from "./ws.js";
|
|
19
26
|
export async function buildApp(deps) {
|
|
20
27
|
const app = Fastify({ logger: false });
|
|
21
28
|
await app.register(cookie);
|
|
29
|
+
// Declared up front so every request carries the field on the same shape,
|
|
30
|
+
// rather than each one growing a property the first time a hook writes it.
|
|
31
|
+
app.decorateRequest("identity", undefined);
|
|
22
32
|
const workspacePath = (slug) => join(deps.root, "workspaces", slug);
|
|
23
33
|
const ctx = {
|
|
24
34
|
...deps,
|
|
25
35
|
runs: new RunStore(deps.db),
|
|
26
36
|
logs: new LogStore(join(deps.root, "logs")),
|
|
27
|
-
sessions: new SessionStore(),
|
|
37
|
+
sessions: new SessionStore(new SessionRecords(deps.db)),
|
|
28
38
|
workspacePath,
|
|
29
39
|
artifactsDir: (runId) => join(deps.root, "artifacts", String(runId)),
|
|
40
|
+
runSecretsDir: (runId) => join(deps.root, "runs", String(runId), "secrets"),
|
|
30
41
|
ensureWorkspace: async (slug) => {
|
|
31
42
|
const entry = deps.config.project(slug);
|
|
32
43
|
if (!entry)
|
|
@@ -55,6 +66,23 @@ export async function buildApp(deps) {
|
|
|
55
66
|
});
|
|
56
67
|
return;
|
|
57
68
|
}
|
|
69
|
+
// Before the run, and outside it: `executeRun` is handed plaintext and
|
|
70
|
+
// never the vault, a boundary worth more than the convenience of moving
|
|
71
|
+
// this one call inside. A block that will not decrypt stops the run here,
|
|
72
|
+
// with the reason, rather than producing an artifact signed by nothing.
|
|
73
|
+
let credentials;
|
|
74
|
+
try {
|
|
75
|
+
credentials = await materialiseCredentials(ctx.vault, slug, ctx.runSecretsDir(runId));
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
ctx.runs.finish(runId, {
|
|
79
|
+
status: "failed",
|
|
80
|
+
exitCode: null,
|
|
81
|
+
errorSummary: ctx.vault.scrub(slug, cause.message),
|
|
82
|
+
});
|
|
83
|
+
ctx.sockets?.finish(runId, "failed");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
58
86
|
await executeRun({
|
|
59
87
|
runId,
|
|
60
88
|
runs: ctx.runs,
|
|
@@ -71,6 +99,9 @@ export async function buildApp(deps) {
|
|
|
71
99
|
},
|
|
72
100
|
env: process.env,
|
|
73
101
|
secrets: ctx.vault.resolve(slug),
|
|
102
|
+
credentialEnv: credentials.env,
|
|
103
|
+
androidKeystore: credentials.keystore,
|
|
104
|
+
cleanup: credentials.cleanup,
|
|
74
105
|
maskedValues: ctx.vault.maskedValues(slug),
|
|
75
106
|
signal,
|
|
76
107
|
onChunk: (chunk, offset) => app.broadcastRunChunk?.(runId, chunk, offset),
|
|
@@ -98,39 +129,87 @@ export async function buildApp(deps) {
|
|
|
98
129
|
app.addHook("onClose", async () => queue.close());
|
|
99
130
|
const throttle = new LoginThrottle();
|
|
100
131
|
app.post("/api/login", async (req, reply) => {
|
|
101
|
-
const { password } = req.body;
|
|
102
|
-
|
|
103
|
-
|
|
132
|
+
const { name, password } = (req.body ?? {});
|
|
133
|
+
// A body with no name is the 0.2 login form, which knew only a password.
|
|
134
|
+
// It authenticates as `admin`, which is precisely the account a lone
|
|
135
|
+
// `server.password_hash` is loaded as — so an upgraded install keeps
|
|
136
|
+
// working, and the loader still owes nobody an answer about which form
|
|
137
|
+
// the file used.
|
|
138
|
+
const account = name ?? LEGACY_ADMIN_NAME;
|
|
139
|
+
const waitMs = throttle.retryAfterMs(account);
|
|
104
140
|
if (waitMs > 0) {
|
|
105
141
|
return reply
|
|
106
142
|
.code(429)
|
|
107
143
|
.header("retry-after", Math.ceil(waitMs / 1000))
|
|
108
144
|
.send({ error: `Too many attempts. Try again in ${Math.ceil(waitMs / 1000)}s.` });
|
|
109
145
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
146
|
+
const users = deps.config.server()?.users ?? [];
|
|
147
|
+
const identity = password ? await authenticate(users, account, password) : null;
|
|
148
|
+
if (!identity) {
|
|
149
|
+
throttle.recordFailure(account);
|
|
150
|
+
// One message for a wrong password and for a name that does not exist:
|
|
151
|
+
// telling them apart is telling a stranger which accounts are worth
|
|
152
|
+
// attacking.
|
|
153
|
+
return reply.code(401).send({ error: "Incorrect name or password" });
|
|
113
154
|
}
|
|
114
|
-
throttle.recordSuccess();
|
|
115
|
-
const token = ctx.sessions.issue();
|
|
155
|
+
throttle.recordSuccess(account);
|
|
156
|
+
const token = ctx.sessions.issue(identity);
|
|
116
157
|
return reply
|
|
117
|
-
.setCookie(SESSION_COOKIE, token,
|
|
118
|
-
.send({ ok: true });
|
|
158
|
+
.setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
|
|
159
|
+
.send({ ok: true, name: identity.name, role: identity.role });
|
|
119
160
|
});
|
|
120
|
-
// Every /api route except /api/login requires a session
|
|
161
|
+
// Every /api route except /api/login requires a session, and the routes on
|
|
162
|
+
// the admin list require an admin. Both decided here, once: a permission
|
|
163
|
+
// expressed as an `if` inside a handler is one nobody finds during an audit.
|
|
121
164
|
app.addHook("onRequest", async (req, reply) => {
|
|
122
|
-
if (!req.url.startsWith("/api") || req.url === "/api/login")
|
|
165
|
+
if (!req.url.startsWith("/api") || req.url.split("?")[0] === "/api/login")
|
|
123
166
|
return;
|
|
124
|
-
|
|
167
|
+
const session = ctx.sessions.get(req.cookies[SESSION_COOKIE]);
|
|
168
|
+
// The session holds who someone was when they signed in; the configuration
|
|
169
|
+
// holds who they are. They part company whenever config.yml is edited —
|
|
170
|
+
// from `laneyard user add`, which is another process entirely, or by hand.
|
|
171
|
+
// So the account is looked up again on every request: an account that is
|
|
172
|
+
// gone has no session, and a demotion takes effect at once rather than at
|
|
173
|
+
// the next restart. It is one find over a handful of entries.
|
|
174
|
+
const account = session && ctx.config.server()?.users.find((u) => u.name === session.name);
|
|
175
|
+
if (!session || !account) {
|
|
125
176
|
return reply.code(401).send({ error: "Session required" });
|
|
126
177
|
}
|
|
178
|
+
const identity = { name: account.name, role: account.role };
|
|
179
|
+
req.identity = identity;
|
|
180
|
+
if (identity.role !== "admin" && requiresAdmin(req.method, req.url)) {
|
|
181
|
+
return reply.code(403).send({ error: "This action requires the admin role" });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
/**
|
|
185
|
+
* Signing out ends this session and no other.
|
|
186
|
+
*
|
|
187
|
+
* The same person may be signed in on a laptop and on a phone; pressing sign
|
|
188
|
+
* out on one of them must not be an act on the other. Only removing the
|
|
189
|
+
* account ends every session it has, and that is a different action with a
|
|
190
|
+
* different name.
|
|
191
|
+
*/
|
|
192
|
+
app.post("/api/logout", async (req, reply) => {
|
|
193
|
+
const token = req.cookies[SESSION_COOKIE];
|
|
194
|
+
if (token)
|
|
195
|
+
ctx.sessions.revoke(token);
|
|
196
|
+
return reply.clearCookie(SESSION_COOKIE, { path: "/" }).code(204).send();
|
|
197
|
+
});
|
|
198
|
+
app.get("/api/me", async (req) => {
|
|
199
|
+
// Non-null because the hook above rejected every request that has no
|
|
200
|
+
// identity before this handler could be reached.
|
|
201
|
+
const { name, role } = req.identity;
|
|
202
|
+
return { name, role };
|
|
127
203
|
});
|
|
128
204
|
ctx.sockets = await registerWebSocket(app, ctx);
|
|
129
205
|
await registerProjectRoutes(app, ctx);
|
|
130
206
|
await registerRunRoutes(app, ctx);
|
|
131
207
|
await registerSecretRoutes(app, ctx);
|
|
208
|
+
await registerCredentialRoutes(app, ctx);
|
|
132
209
|
await registerReadinessRoutes(app, ctx);
|
|
133
210
|
await registerFastfileRoutes(app, ctx);
|
|
211
|
+
await registerUserRoutes(app, ctx);
|
|
212
|
+
await registerAccountRoutes(app, ctx);
|
|
134
213
|
// Resolved from the module's location, not from the data folder:
|
|
135
214
|
// `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
|
|
136
215
|
// relative positions, depending on whether we're running on the sources
|
package/dist/src/server/auth.js
CHANGED
|
@@ -37,45 +37,153 @@ export async function verifyPassword(password, stored) {
|
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* A hash no password matches, used to spend the same work on an unknown name.
|
|
42
|
+
*
|
|
43
|
+
* Built from random bytes rather than by hashing something: `hashPassword` is
|
|
44
|
+
* synchronous and would freeze the server for its duration. The shape is what
|
|
45
|
+
* matters — `verifyPassword` derives a 32-byte key with the same parameters and
|
|
46
|
+
* then fails the comparison, which is exactly what a wrong password costs.
|
|
47
|
+
*/
|
|
48
|
+
const DECOY_HASH = `scrypt$${randomBytes(16).toString("hex")}$${randomBytes(32).toString("hex")}`;
|
|
49
|
+
/**
|
|
50
|
+
* Turns a name and a password into who that is, or into nothing.
|
|
51
|
+
*
|
|
52
|
+
* An unknown name is verified against a decoy hash instead of returning early.
|
|
53
|
+
* Without that, a refusal for a name that does not exist comes back in
|
|
54
|
+
* microseconds while a wrong password takes the thirty milliseconds scrypt
|
|
55
|
+
* costs — and the login form becomes a way to enumerate accounts before trying
|
|
56
|
+
* a single password against them.
|
|
57
|
+
*/
|
|
58
|
+
export async function authenticate(users, name, password) {
|
|
59
|
+
const user = users.find((u) => u.name === name);
|
|
60
|
+
if (!user) {
|
|
61
|
+
await verifyPassword(password, DECOY_HASH);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (!(await verifyPassword(password, user.password_hash)))
|
|
65
|
+
return null;
|
|
66
|
+
return { name: user.name, role: user.role };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* How long a session lasts, and how long the cookie is kept for.
|
|
70
|
+
*
|
|
71
|
+
* Thirty days, not forever: a browser left on a desk is the reason the password
|
|
72
|
+
* change asks for the current password, and a session that never ended would
|
|
73
|
+
* make that precaution pointless. The cookie carries the same figure, so the
|
|
74
|
+
* browser stops presenting a token the server would refuse anyway.
|
|
75
|
+
*/
|
|
76
|
+
export const SESSION_TTL_DAYS = 30;
|
|
77
|
+
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
78
|
+
/**
|
|
79
|
+
* Sessions, kept in the database so they outlive a restart.
|
|
80
|
+
*
|
|
81
|
+
* They used to be a Map, with a comment saying they did not survive a restart
|
|
82
|
+
* "and that's just fine". It was not: restarting the server to pick up an edit
|
|
83
|
+
* to config.yml signed everybody out, which on a machine still being set up is
|
|
84
|
+
* several times an hour. The API is unchanged — every caller still issues, gets
|
|
85
|
+
* and revokes — only the shelf underneath is different.
|
|
86
|
+
*/
|
|
41
87
|
export class SessionStore {
|
|
42
|
-
|
|
43
|
-
|
|
88
|
+
records;
|
|
89
|
+
constructor(records) {
|
|
90
|
+
this.records = records;
|
|
91
|
+
}
|
|
92
|
+
issue(identity, now = new Date()) {
|
|
44
93
|
const token = randomBytes(32).toString("hex");
|
|
45
|
-
this.
|
|
94
|
+
this.records.insert(token, identity, new Date(now.getTime() + SESSION_TTL_MS));
|
|
46
95
|
return token;
|
|
47
96
|
}
|
|
97
|
+
/** Who the token belongs to, or undefined if it belongs to nobody. */
|
|
98
|
+
get(token) {
|
|
99
|
+
if (token === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const owner = this.records.find(token);
|
|
102
|
+
return owner ? { name: owner.name, role: owner.role } : undefined;
|
|
103
|
+
}
|
|
48
104
|
valid(token) {
|
|
49
|
-
return token !== undefined
|
|
105
|
+
return this.get(token) !== undefined;
|
|
50
106
|
}
|
|
51
107
|
revoke(token) {
|
|
52
|
-
this.
|
|
108
|
+
this.records.remove(token);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Drops every session belonging to a name.
|
|
112
|
+
*
|
|
113
|
+
* The identity is snapshotted when the session is issued, which is what makes
|
|
114
|
+
* a request cheap — but it also means removing an account or changing its role
|
|
115
|
+
* leaves the old answer live in whatever browsers already had it. Without this,
|
|
116
|
+
* "remove the account" and "revoke access" are two different things, while
|
|
117
|
+
* every interface that offers the first implies the second.
|
|
118
|
+
*/
|
|
119
|
+
revokeAllFor(name) {
|
|
120
|
+
this.records.removeAllFor(name);
|
|
53
121
|
}
|
|
54
122
|
}
|
|
55
123
|
export const SESSION_COOKIE = "laneyard_session";
|
|
56
124
|
/**
|
|
57
|
-
*
|
|
125
|
+
* How the session cookie is written, in one place so the two routes that issue
|
|
126
|
+
* one cannot disagree.
|
|
127
|
+
*
|
|
128
|
+
* `maxAge` is the half that was missing: without it the browser treats this as
|
|
129
|
+
* a session cookie and forgets it the moment the window closes, so signing in
|
|
130
|
+
* again was the price of quitting Chrome. It matches the server's own TTL, so
|
|
131
|
+
* the browser stops presenting a token the server would refuse anyway.
|
|
132
|
+
*
|
|
133
|
+
* Not `secure`: Laneyard is commonly reached over plain HTTP on a local
|
|
134
|
+
* network, and a cookie the browser refuses to send is a login page that never
|
|
135
|
+
* goes away.
|
|
136
|
+
*/
|
|
137
|
+
export const COOKIE_OPTIONS = {
|
|
138
|
+
path: "/",
|
|
139
|
+
httpOnly: true,
|
|
140
|
+
sameSite: "lax",
|
|
141
|
+
maxAge: SESSION_TTL_MS / 1000,
|
|
142
|
+
};
|
|
143
|
+
/** Past this many tracked names, the entries that no longer delay anyone go. */
|
|
144
|
+
const THROTTLE_PRUNE_ABOVE = 1_000;
|
|
145
|
+
/**
|
|
146
|
+
* Slows down repeated login attempts, one account at a time.
|
|
58
147
|
*
|
|
59
148
|
* Without this, a network neighbour could try passwords as fast as the
|
|
60
149
|
* server responds. The delay grows with failures and resets to zero on a
|
|
61
150
|
* success: the legitimate user who mistypes once doesn't feel it.
|
|
151
|
+
*
|
|
152
|
+
* Counted per name rather than globally: a single counter means anyone able to
|
|
153
|
+
* reach the login form can lock out every account on the server by hammering
|
|
154
|
+
* one name — a denial of service disguised as a security measure.
|
|
62
155
|
*/
|
|
63
156
|
export class LoginThrottle {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return Math.max(0, this.until - now);
|
|
157
|
+
perName = new Map();
|
|
158
|
+
/** Milliseconds left to wait for that name, 0 if the way is clear. */
|
|
159
|
+
retryAfterMs(name, now = Date.now()) {
|
|
160
|
+
return Math.max(0, (this.perName.get(name)?.until ?? 0) - now);
|
|
69
161
|
}
|
|
70
|
-
recordFailure(now = Date.now()) {
|
|
71
|
-
this.failures
|
|
162
|
+
recordFailure(name, now = Date.now()) {
|
|
163
|
+
const state = this.perName.get(name) ?? { failures: 0, until: 0 };
|
|
164
|
+
state.failures += 1;
|
|
72
165
|
// 0, 0, 0, then 1s, 2s, 4s… capped at one minute.
|
|
73
|
-
if (
|
|
74
|
-
|
|
166
|
+
if (state.failures > 3) {
|
|
167
|
+
state.until = now + Math.min(60_000, 2 ** (state.failures - 4) * 1000);
|
|
75
168
|
}
|
|
169
|
+
this.perName.set(name, state);
|
|
170
|
+
// The key is whatever the caller sent, so an attacker chooses how many
|
|
171
|
+
// entries exist. Dropping those whose delay has run out bounds the map by
|
|
172
|
+
// how fast someone can be delayed, not by how many names they can invent.
|
|
173
|
+
if (this.perName.size > THROTTLE_PRUNE_ABOVE)
|
|
174
|
+
this.prune(now);
|
|
76
175
|
}
|
|
77
|
-
recordSuccess() {
|
|
78
|
-
this.
|
|
79
|
-
|
|
176
|
+
recordSuccess(name) {
|
|
177
|
+
this.perName.delete(name);
|
|
178
|
+
}
|
|
179
|
+
/** Number of names currently tracked. Exposed so the pruning can be tested. */
|
|
180
|
+
size() {
|
|
181
|
+
return this.perName.size;
|
|
182
|
+
}
|
|
183
|
+
prune(now) {
|
|
184
|
+
for (const [name, state] of this.perName) {
|
|
185
|
+
if (state.until <= now)
|
|
186
|
+
this.perName.delete(name);
|
|
187
|
+
}
|
|
80
188
|
}
|
|
81
189
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who can do what — the whole answer, in one file.
|
|
3
|
+
*
|
|
4
|
+
* A permission expressed as an `if` inside a handler is a permission nobody
|
|
5
|
+
* finds when it matters: during an audit, or after an incident. So there are no
|
|
6
|
+
* such `if`s. Every route that needs more than a session is named below, and the
|
|
7
|
+
* single hook in `app.ts` is the only thing that reads this.
|
|
8
|
+
*
|
|
9
|
+
* Two roles, and only two. `admin` may do everything; `builder` may start a
|
|
10
|
+
* build, watch it, cancel it and download what it produced — nothing that
|
|
11
|
+
* changes what a build does, and nothing that reveals a credential.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* What each route needs. Everything absent from this list needs only a session.
|
|
15
|
+
*
|
|
16
|
+
* Reading the Fastfile is deliberately not here: a builder who can start a lane
|
|
17
|
+
* benefits from seeing what it does, and it contains no credential — anything
|
|
18
|
+
* that does is in the vault, which is.
|
|
19
|
+
*/
|
|
20
|
+
export const REQUIRES_ADMIN = [
|
|
21
|
+
{ method: "*", path: "/api/secrets" },
|
|
22
|
+
{ method: "*", path: "/api/projects/:slug/secrets" },
|
|
23
|
+
{ method: "*", path: "/api/credentials" },
|
|
24
|
+
{ method: "*", path: "/api/projects/:slug/credentials" },
|
|
25
|
+
{ method: "PUT", path: "/api/projects/:slug/fastfile" },
|
|
26
|
+
{ method: "POST", path: "/api/projects/:slug/commit" },
|
|
27
|
+
{ method: "POST", path: "/api/projects/:slug/push" },
|
|
28
|
+
{ method: "DELETE", path: "/api/projects/:slug" },
|
|
29
|
+
{ method: "*", path: "/api/users" },
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* `/api/secrets/APP_KEY?x=1` → `["api", "secrets", "APP_KEY"]`.
|
|
33
|
+
*
|
|
34
|
+
* Each segment is percent-decoded, because the router decodes before it matches
|
|
35
|
+
* and this must see the same path the router did. Comparing the raw text sent
|
|
36
|
+
* `GET /api/%73ecrets` straight past the admin list and into the vault: Fastify
|
|
37
|
+
* routed it to `/api/secrets`, and this function had been looking at `%73ecrets`.
|
|
38
|
+
*/
|
|
39
|
+
function segments(url) {
|
|
40
|
+
return (url.split("?")[0] ?? "")
|
|
41
|
+
.split("/")
|
|
42
|
+
.filter((s) => s !== "")
|
|
43
|
+
.map((s) => {
|
|
44
|
+
try {
|
|
45
|
+
return decodeURIComponent(s);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Malformed escapes reach no route either; left as-is rather than
|
|
49
|
+
// swallowed, so a segment is never silently emptied into a match.
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Does this request need an admin?
|
|
56
|
+
*
|
|
57
|
+
* A pattern matches a request whose path *starts with* it, segment by segment,
|
|
58
|
+
* with `:name` standing for any one segment. The prefix is deliberate:
|
|
59
|
+
* `/api/secrets` means the vault, not one URL, and a table naming every key's
|
|
60
|
+
* route separately would be a table someone forgets to extend the next time a
|
|
61
|
+
* route is added under it. It only ever errs towards refusing, since every
|
|
62
|
+
* entry here is a restriction.
|
|
63
|
+
*/
|
|
64
|
+
export function requiresAdmin(method, url) {
|
|
65
|
+
const parts = segments(url);
|
|
66
|
+
const verb = method.toUpperCase();
|
|
67
|
+
return REQUIRES_ADMIN.some((pattern) => {
|
|
68
|
+
if (pattern.method !== "*" && pattern.method.toUpperCase() !== verb)
|
|
69
|
+
return false;
|
|
70
|
+
const expected = segments(pattern.path);
|
|
71
|
+
if (parts.length < expected.length)
|
|
72
|
+
return false;
|
|
73
|
+
return expected.every((seg, i) => seg.startsWith(":") || seg === parts[i]);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseEnvExample } from "../heuristics/env-example.js";
|
|
4
|
+
export async function requiredSecrets(input) {
|
|
5
|
+
const fromExample = await envExampleNames(input.workspacePath, input.fastlaneDir);
|
|
6
|
+
const required = [
|
|
7
|
+
...new Set([...input.lanes.flatMap((l) => l.env ?? []), ...input.declared, ...fromExample]),
|
|
8
|
+
].sort();
|
|
9
|
+
const supplied = new Set([...input.vaultKeys, ...input.blockNames, ...input.serverEnv]);
|
|
10
|
+
return {
|
|
11
|
+
required,
|
|
12
|
+
missing: required.filter((name) => !supplied.has(name)),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The names a committed `.env.example` advertises, if there is one.
|
|
17
|
+
*
|
|
18
|
+
* Two conventional filenames and no more: a convenience for the common case,
|
|
19
|
+
* not a search. Never throws — an absent file is the ordinary situation and
|
|
20
|
+
* means "nothing declared", not a failure.
|
|
21
|
+
*/
|
|
22
|
+
export async function envExampleNames(workspacePath, fastlaneDir) {
|
|
23
|
+
const names = [];
|
|
24
|
+
for (const file of [".env.example", ".env.sample"]) {
|
|
25
|
+
const text = await readFile(join(workspacePath, fastlaneDir, file), "utf8").catch(() => null);
|
|
26
|
+
if (text !== null)
|
|
27
|
+
names.push(...parseEnvExample(text));
|
|
28
|
+
}
|
|
29
|
+
return names;
|
|
30
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { MIN_PASSWORD_LENGTH, upsertUserInConfig } from "../../config/accounts.js";
|
|
2
|
+
import { COOKIE_OPTIONS, SESSION_COOKIE, authenticate } from "../auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* What someone may do to their own account, whatever their role.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately not under `/api/users`: that prefix is on the admin list in
|
|
7
|
+
* `permissions.ts`, and it means "the accounts on this machine". This is the
|
|
8
|
+
* other thing entirely — one person, their own password — and a builder must be
|
|
9
|
+
* able to reach it. Changing the password of somebody *else* stays admin-only,
|
|
10
|
+
* and stays over there.
|
|
11
|
+
*
|
|
12
|
+
* The current password is required even though the session already proves who
|
|
13
|
+
* this is. A session is a cookie in a browser that may have been left open on a
|
|
14
|
+
* desk; a password is the one thing that says the person at the keyboard is
|
|
15
|
+
* still the person who signed in.
|
|
16
|
+
*/
|
|
17
|
+
export async function registerAccountRoutes(app, ctx) {
|
|
18
|
+
app.post("/api/account/password", async (req, reply) => {
|
|
19
|
+
// Non-null: the hook in `app.ts` rejected every request without a session
|
|
20
|
+
// before this handler could run.
|
|
21
|
+
const { name, role } = req.identity;
|
|
22
|
+
const { current, next } = (req.body ?? {});
|
|
23
|
+
if (typeof next !== "string" || next.length < MIN_PASSWORD_LENGTH) {
|
|
24
|
+
return reply
|
|
25
|
+
.code(400)
|
|
26
|
+
.send({ error: `A password is at least ${MIN_PASSWORD_LENGTH} characters.` });
|
|
27
|
+
}
|
|
28
|
+
const users = ctx.config.server()?.users ?? [];
|
|
29
|
+
if (typeof current !== "string" || !(await authenticate(users, name, current))) {
|
|
30
|
+
// No throttle here, unlike `/api/login`: reaching this route already costs
|
|
31
|
+
// a valid session, so there is nobody to slow down who is not already in.
|
|
32
|
+
return reply.code(401).send({ error: "That is not your current password." });
|
|
33
|
+
}
|
|
34
|
+
if (next === current) {
|
|
35
|
+
return reply.code(400).send({ error: "That is the password you already have." });
|
|
36
|
+
}
|
|
37
|
+
// The role is carried over rather than read from the body: this route
|
|
38
|
+
// changes a password and nothing else, and an account that could hand
|
|
39
|
+
// itself `admin` in a field named `role` would make the whole permission
|
|
40
|
+
// table decorative.
|
|
41
|
+
await upsertUserInConfig(ctx.config.configPath(), { name, role, password: next });
|
|
42
|
+
// Every session this account has, gone — including this one. That is the
|
|
43
|
+
// point: changing a password is what someone does when they think another
|
|
44
|
+
// browser somewhere should stop being signed in. A fresh session is issued
|
|
45
|
+
// immediately afterwards, so the person who just did it is not signed out
|
|
46
|
+
// of the page they did it on.
|
|
47
|
+
ctx.sessions.revokeAllFor(name);
|
|
48
|
+
const token = ctx.sessions.issue({ name, role });
|
|
49
|
+
// The file is watched on a debounce; loading here is what makes the very
|
|
50
|
+
// next request see the new hash instead of the old one.
|
|
51
|
+
await ctx.config.load();
|
|
52
|
+
return reply
|
|
53
|
+
.setCookie(SESSION_COOKIE, token, COOKIE_OPTIONS)
|
|
54
|
+
.code(204)
|
|
55
|
+
.send();
|
|
56
|
+
});
|
|
57
|
+
}
|