@klhapp/skillmux 1.9.1 → 1.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/docs/configuration.md +51 -0
- package/docs/deployment.md +10 -0
- package/docs/skill-management.md +54 -0
- package/package.json +1 -1
- package/src/cli.ts +87 -34
- package/src/clients.ts +17 -0
- package/src/commands/audit.ts +9 -3
- package/src/commands/outdated.ts +6 -4
- package/src/commands/update.ts +9 -4
- package/src/concurrency-limiter.ts +61 -0
- package/src/config.ts +17 -0
- package/src/db.ts +114 -6
- package/src/doctor.ts +25 -1
- package/src/install.ts +15 -0
- package/src/rate-limiter.ts +29 -2
- package/src/redact.ts +52 -0
- package/src/server.ts +488 -275
- package/src/types.ts +7 -0
package/src/commands/update.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { rmSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { expandHome, loadConfig } from "../config";
|
|
4
4
|
import {
|
|
5
|
+
assertHostAllowed,
|
|
5
6
|
cloneToTemp,
|
|
6
7
|
installIntoVault,
|
|
7
8
|
isLocalFileUrl,
|
|
@@ -37,6 +38,7 @@ async function resolveCandidateOrigins(
|
|
|
37
38
|
vaultPath: string,
|
|
38
39
|
skillId: string | undefined,
|
|
39
40
|
allowLocalSource: boolean,
|
|
41
|
+
allowedHosts: string[] | undefined,
|
|
40
42
|
): Promise<{ skillId: string; origin: SkillOrigin }[]> {
|
|
41
43
|
if (skillId) {
|
|
42
44
|
// skillId (the CLI's positional <skill-id>) is joined straight into vaultPath
|
|
@@ -65,7 +67,7 @@ async function resolveCandidateOrigins(
|
|
|
65
67
|
}
|
|
66
68
|
return [{ skillId, origin }];
|
|
67
69
|
}
|
|
68
|
-
const outdated = await checkOutdated(vaultPath, { allowLocalSource });
|
|
70
|
+
const outdated = await checkOutdated(vaultPath, { allowLocalSource, allowedHosts });
|
|
69
71
|
return outdated
|
|
70
72
|
.filter((result) => result.status === "outdated")
|
|
71
73
|
.map((result) => ({ skillId: result.skill_id, origin: readSkillOrigin(join(vaultPath, result.skill_id))! }));
|
|
@@ -76,6 +78,7 @@ async function buildPlan(
|
|
|
76
78
|
candidates: { skillId: string; origin: SkillOrigin }[],
|
|
77
79
|
failOn: ScanSeverity | undefined,
|
|
78
80
|
force: boolean,
|
|
81
|
+
allowedHosts: string[] | undefined,
|
|
79
82
|
): Promise<UpdatePlanItem[]> {
|
|
80
83
|
const plan: UpdatePlanItem[] = [];
|
|
81
84
|
for (const { skillId, origin } of candidates) {
|
|
@@ -118,6 +121,7 @@ async function buildPlan(
|
|
|
118
121
|
continue;
|
|
119
122
|
}
|
|
120
123
|
|
|
124
|
+
assertHostAllowed(origin.source_url, allowedHosts);
|
|
121
125
|
const cloneDir = await cloneToTemp(origin.source_url);
|
|
122
126
|
const resolved = resolveSkillDir(cloneDir, skillId, origin.skill_path);
|
|
123
127
|
const base = {
|
|
@@ -206,10 +210,11 @@ function parseUpdateArgs(args: string[]): {
|
|
|
206
210
|
|
|
207
211
|
export async function runUpdate(args: string[], options: { isJson: boolean }): Promise<void> {
|
|
208
212
|
const { skillId, yes, dryRun, force, failOn, allowLocalSource } = parseUpdateArgs(args);
|
|
209
|
-
const
|
|
213
|
+
const config = await loadConfig();
|
|
214
|
+
const vaultPath = expandHome(config.vault_path);
|
|
210
215
|
|
|
211
|
-
const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource);
|
|
212
|
-
const plan = await buildPlan(vaultPath, candidates, failOn, force);
|
|
216
|
+
const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource, config.egress?.allowed_hosts);
|
|
217
|
+
const plan = await buildPlan(vaultPath, candidates, failOn, force, config.egress?.allowed_hosts);
|
|
213
218
|
try {
|
|
214
219
|
const toWrite = plan.filter((item) => item.kind === "update");
|
|
215
220
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export class ConcurrencyLimiter {
|
|
2
|
+
private inFlight = 0;
|
|
3
|
+
|
|
4
|
+
constructor(private readonly max: number) {}
|
|
5
|
+
|
|
6
|
+
tryAcquire(): boolean {
|
|
7
|
+
if (this.inFlight >= this.max) return false;
|
|
8
|
+
this.inFlight++;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
release(): void {
|
|
13
|
+
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wraps a response body so `release` fires when the stream actually finishes
|
|
19
|
+
* (fully drained or cancelled by a client disconnect) instead of as soon as
|
|
20
|
+
* the Response object is constructed. For a buffered body this happens almost
|
|
21
|
+
* immediately; for an open SSE stream it defers release until the connection
|
|
22
|
+
* really closes, so a concurrency limiter reflects true connection lifetime.
|
|
23
|
+
*/
|
|
24
|
+
export function releaseOnStreamClose(
|
|
25
|
+
body: ReadableStream<Uint8Array> | null,
|
|
26
|
+
release: () => void,
|
|
27
|
+
): ReadableStream<Uint8Array> | null {
|
|
28
|
+
if (!body) {
|
|
29
|
+
release();
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const reader = body.getReader();
|
|
34
|
+
let released = false;
|
|
35
|
+
const releaseOnce = () => {
|
|
36
|
+
if (released) return;
|
|
37
|
+
released = true;
|
|
38
|
+
release();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return new ReadableStream<Uint8Array>({
|
|
42
|
+
async pull(controller) {
|
|
43
|
+
try {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done) {
|
|
46
|
+
controller.close();
|
|
47
|
+
releaseOnce();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
controller.enqueue(value);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
controller.error(error);
|
|
53
|
+
releaseOnce();
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
cancel(reason) {
|
|
57
|
+
releaseOnce();
|
|
58
|
+
return reader.cancel(reason);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -87,10 +87,15 @@ const configSchema = z.object({
|
|
|
87
87
|
enabled: z.boolean(),
|
|
88
88
|
token_env: z.string().min(1),
|
|
89
89
|
}).strict().optional(),
|
|
90
|
+
max_body_bytes: z.number().int().positive().optional(),
|
|
91
|
+
max_concurrent_requests: z.number().int().min(0).optional(),
|
|
90
92
|
}).strict().optional(),
|
|
91
93
|
audit: z.object({
|
|
92
94
|
retention_days: z.number().int().min(0).default(90),
|
|
93
95
|
}).strict().default({ retention_days: 90 }),
|
|
96
|
+
egress: z.object({
|
|
97
|
+
allowed_hosts: z.array(z.string().min(1)).optional(),
|
|
98
|
+
}).strict().optional(),
|
|
94
99
|
}).strict().refine((cfg) => {
|
|
95
100
|
const hasReranker = cfg.inference.mode === "remote" && !!cfg.inference.reranker;
|
|
96
101
|
if (hasReranker && cfg.output.max_top_k > cfg.recall.k_rerank) {
|
|
@@ -170,6 +175,18 @@ export function expandHome(path: string): string {
|
|
|
170
175
|
return path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
/**
|
|
179
|
+
* True only for hostnames the HTTP server can bind while staying unreachable
|
|
180
|
+
* from outside this machine. Deliberately narrower than adapters.ts's
|
|
181
|
+
* isLoopbackHost, which treats "0.0.0.0" as loopback for a different question
|
|
182
|
+
* (whether an admin *client* is talking to the local machine) — here "0.0.0.0"
|
|
183
|
+
* (and any other wildcard/public address) must read as non-loopback, since
|
|
184
|
+
* binding it is exactly what makes the server reachable from outside (SMX-91).
|
|
185
|
+
*/
|
|
186
|
+
export function isLoopbackBindHost(hostname: string): boolean {
|
|
187
|
+
return hostname === "localhost" || hostname === "::1" || hostname === "127.0.0.1" || hostname.startsWith("127.");
|
|
188
|
+
}
|
|
189
|
+
|
|
173
190
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
174
191
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
175
192
|
}
|
package/src/db.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { existsSync, mkdirSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import type { AuditCandidate, AuditRow } from "./types";
|
|
@@ -48,6 +49,14 @@ export function openAudit(stateDir: string): Database {
|
|
|
48
49
|
resolve_audit_id INTEGER,
|
|
49
50
|
rank_at_resolve INTEGER
|
|
50
51
|
)`);
|
|
52
|
+
db.run(`CREATE TABLE IF NOT EXISTS admin_audit (
|
|
53
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
54
|
+
ts TEXT NOT NULL,
|
|
55
|
+
changes TEXT NOT NULL,
|
|
56
|
+
resulting_revision TEXT NOT NULL,
|
|
57
|
+
row_hash TEXT NOT NULL,
|
|
58
|
+
prev_row_hash TEXT
|
|
59
|
+
)`);
|
|
51
60
|
adoptAuditFromIndex(db, stateDir);
|
|
52
61
|
return db;
|
|
53
62
|
}
|
|
@@ -382,32 +391,131 @@ export function insertFetch(db: Database, row: FetchInsert): void {
|
|
|
382
391
|
export interface PruneResult {
|
|
383
392
|
audit_deleted: number;
|
|
384
393
|
fetch_deleted: number;
|
|
394
|
+
admin_audit_deleted: number;
|
|
385
395
|
}
|
|
386
396
|
|
|
387
397
|
/**
|
|
388
|
-
* Deletes resolve and
|
|
389
|
-
* timestamp; no FK ties them, so a fetch outliving its
|
|
390
|
-
* reads back uncorrelated (AC7's existing null path).
|
|
398
|
+
* Deletes resolve, fetch, and admin_audit rows with ts before `cutoffIso`,
|
|
399
|
+
* each by its own timestamp; no FK ties them, so a fetch outliving its
|
|
400
|
+
* resolve row simply reads back uncorrelated (AC7's existing null path).
|
|
401
|
+
* admin_audit shares this cutoff rather than a separate retention config
|
|
402
|
+
* (AC10) — its hash chain is unaffected since pruning only ever removes the
|
|
403
|
+
* oldest rows, never rows in the middle of the chain. Reclaims the freed
|
|
391
404
|
* pages with an incremental vacuum, which only touches audit.sqlite3 (AC16).
|
|
392
405
|
*/
|
|
393
406
|
export function pruneAuditBefore(db: Database, cutoffIso: string): PruneResult {
|
|
394
407
|
const auditResult = db.run("DELETE FROM audit WHERE ts < ?", [cutoffIso]);
|
|
395
408
|
const fetchResult = db.run("DELETE FROM fetch WHERE ts < ?", [cutoffIso]);
|
|
409
|
+
const adminAuditResult = db.run("DELETE FROM admin_audit WHERE ts < ?", [cutoffIso]);
|
|
396
410
|
db.run("PRAGMA incremental_vacuum");
|
|
397
411
|
|
|
398
|
-
return {
|
|
412
|
+
return {
|
|
413
|
+
audit_deleted: auditResult.changes,
|
|
414
|
+
fetch_deleted: fetchResult.changes,
|
|
415
|
+
admin_audit_deleted: adminAuditResult.changes,
|
|
416
|
+
};
|
|
399
417
|
}
|
|
400
418
|
|
|
401
419
|
/** AC12: retentionDays <= 0 disables pruning entirely. */
|
|
402
420
|
export function pruneAudit(db: Database, retentionDays: number, now: Date = new Date()): PruneResult {
|
|
403
|
-
if (retentionDays <= 0) return { audit_deleted: 0, fetch_deleted: 0 };
|
|
421
|
+
if (retentionDays <= 0) return { audit_deleted: 0, fetch_deleted: 0, admin_audit_deleted: 0 };
|
|
404
422
|
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
|
|
405
423
|
return pruneAuditBefore(db, cutoff);
|
|
406
424
|
}
|
|
407
425
|
|
|
426
|
+
export interface AdminAuditChange {
|
|
427
|
+
key: string;
|
|
428
|
+
old_value: unknown;
|
|
429
|
+
new_value: unknown;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export interface AdminAuditInsert {
|
|
433
|
+
ts: string;
|
|
434
|
+
changes: AdminAuditChange[];
|
|
435
|
+
resulting_revision: string;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export interface AdminAuditRow {
|
|
439
|
+
id: number;
|
|
440
|
+
ts: string;
|
|
441
|
+
changes: AdminAuditChange[];
|
|
442
|
+
resulting_revision: string;
|
|
443
|
+
row_hash: string;
|
|
444
|
+
prev_row_hash: string | null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function computeAdminAuditRowHash(
|
|
448
|
+
prevRowHash: string | null,
|
|
449
|
+
fields: { ts: string; changes: AdminAuditChange[]; resulting_revision: string },
|
|
450
|
+
): string {
|
|
451
|
+
const payload = JSON.stringify({ prev_row_hash: prevRowHash, ...fields });
|
|
452
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Appends one tamper-evident admin_audit row, chaining its hash to the
|
|
457
|
+
* previous row's hash (or null for the first row) so any out-of-band
|
|
458
|
+
* edit/delete breaks the chain — see verifyAdminAuditChain.
|
|
459
|
+
*/
|
|
460
|
+
export function insertAdminAuditRow(db: Database, row: AdminAuditInsert): AdminAuditRow {
|
|
461
|
+
const prevRow = db
|
|
462
|
+
.query("SELECT row_hash FROM admin_audit ORDER BY id DESC LIMIT 1")
|
|
463
|
+
.get() as { row_hash: string } | null;
|
|
464
|
+
const prevRowHash = prevRow?.row_hash ?? null;
|
|
465
|
+
const rowHash = computeAdminAuditRowHash(prevRowHash, row);
|
|
466
|
+
|
|
467
|
+
db.run(
|
|
468
|
+
`INSERT INTO admin_audit (ts, changes, resulting_revision, row_hash, prev_row_hash)
|
|
469
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
470
|
+
[row.ts, JSON.stringify(row.changes), row.resulting_revision, rowHash, prevRowHash],
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const inserted = db.query("SELECT last_insert_rowid() AS id").get() as { id: number };
|
|
474
|
+
return {
|
|
475
|
+
id: inserted.id,
|
|
476
|
+
ts: row.ts,
|
|
477
|
+
changes: row.changes,
|
|
478
|
+
resulting_revision: row.resulting_revision,
|
|
479
|
+
row_hash: rowHash,
|
|
480
|
+
prev_row_hash: prevRowHash,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export interface AdminAuditChainResult {
|
|
485
|
+
valid: boolean;
|
|
486
|
+
broken_at_id: number | null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Walks admin_audit in insertion order and reports whether the hash chain is unbroken. */
|
|
490
|
+
export function verifyAdminAuditChain(db: Database): AdminAuditChainResult {
|
|
491
|
+
const rows = db
|
|
492
|
+
.query("SELECT id, ts, changes, resulting_revision, row_hash, prev_row_hash FROM admin_audit ORDER BY id ASC")
|
|
493
|
+
.all() as { id: number; ts: string; changes: string; resulting_revision: string; row_hash: string; prev_row_hash: string | null }[];
|
|
494
|
+
|
|
495
|
+
let expectedPrevHash: string | null = null;
|
|
496
|
+
for (const row of rows) {
|
|
497
|
+
if (row.prev_row_hash !== expectedPrevHash) {
|
|
498
|
+
return { valid: false, broken_at_id: row.id };
|
|
499
|
+
}
|
|
500
|
+
const recomputed = computeAdminAuditRowHash(expectedPrevHash, {
|
|
501
|
+
ts: row.ts,
|
|
502
|
+
changes: JSON.parse(row.changes),
|
|
503
|
+
resulting_revision: row.resulting_revision,
|
|
504
|
+
});
|
|
505
|
+
if (recomputed !== row.row_hash) {
|
|
506
|
+
return { valid: false, broken_at_id: row.id };
|
|
507
|
+
}
|
|
508
|
+
expectedPrevHash = row.row_hash;
|
|
509
|
+
}
|
|
510
|
+
return { valid: true, broken_at_id: null };
|
|
511
|
+
}
|
|
512
|
+
|
|
408
513
|
/** Dry-run counterpart of pruneAuditBefore: counts without deleting (AC15). */
|
|
409
514
|
export function countPrunable(db: Database, cutoffIso: string): PruneResult {
|
|
410
515
|
const auditRow = db.query("SELECT count(*) AS n FROM audit WHERE ts < ?").get(cutoffIso) as { n: number };
|
|
411
516
|
const fetchRow = db.query("SELECT count(*) AS n FROM fetch WHERE ts < ?").get(cutoffIso) as { n: number };
|
|
412
|
-
|
|
517
|
+
const adminAuditRow = db
|
|
518
|
+
.query("SELECT count(*) AS n FROM admin_audit WHERE ts < ?")
|
|
519
|
+
.get(cutoffIso) as { n: number };
|
|
520
|
+
return { audit_deleted: auditRow.n, fetch_deleted: fetchRow.n, admin_audit_deleted: adminAuditRow.n };
|
|
413
521
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { createClients, RemoteInferenceError } from "./clients";
|
|
3
|
-
import { embeddingDimension, expandHome } from "./config";
|
|
3
|
+
import { embeddingDimension, expandHome, isLoopbackBindHost } from "./config";
|
|
4
4
|
import { describeDeployment, type DeploymentIdentity } from "./deployment";
|
|
5
5
|
import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
|
|
6
6
|
import { readSkillmuxMarker } from "./sync";
|
|
@@ -51,6 +51,30 @@ export async function diagnose(
|
|
|
51
51
|
}
|
|
52
52
|
checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
|
|
53
53
|
|
|
54
|
+
// SMX-91: `serve --transport http` itself refuses to start over this combination
|
|
55
|
+
// (assertSafeBindPosture in server.ts) unless SKILLMUX_ALLOW_INSECURE_BIND is set —
|
|
56
|
+
// surface it here too so it's visible without having to start the HTTP server first.
|
|
57
|
+
// An operator who has already set that env var has made an informed choice, so
|
|
58
|
+
// doctor treats it the same way the server does (ok, not a standing failure) —
|
|
59
|
+
// it doesn't re-litigate a decision the server itself already accepted.
|
|
60
|
+
if (config.server) {
|
|
61
|
+
const hostname = config.server.hostname ?? "127.0.0.1";
|
|
62
|
+
const insecureBindAcknowledged = environment.SKILLMUX_ALLOW_INSECURE_BIND === "true";
|
|
63
|
+
const bindIsSafe = isLoopbackBindHost(hostname) || config.server.auth_enabled || insecureBindAcknowledged;
|
|
64
|
+
checks.push({
|
|
65
|
+
name: "server_bind_posture",
|
|
66
|
+
ok: bindIsSafe,
|
|
67
|
+
detail: !bindIsSafe
|
|
68
|
+
? `${hostname} is reachable beyond this machine with auth_enabled=false — ` +
|
|
69
|
+
"MCP tools and /stats would be open to anyone who can reach this port; " +
|
|
70
|
+
"set server.auth_enabled=true, bind a loopback hostname, or set SKILLMUX_ALLOW_INSECURE_BIND=true"
|
|
71
|
+
: insecureBindAcknowledged && !isLoopbackBindHost(hostname) && !config.server.auth_enabled
|
|
72
|
+
? `${hostname}, auth_enabled=false, acknowledged via SKILLMUX_ALLOW_INSECURE_BIND`
|
|
73
|
+
: `${hostname}, auth_enabled=${config.server.auth_enabled}`,
|
|
74
|
+
failure_kind: bindIsSafe ? undefined : "configuration",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
for (const localPath of config.local_vault_paths) {
|
|
55
79
|
const expanded = expandHome(localPath);
|
|
56
80
|
checks.push({ name: `local_vault:${localPath}`, ok: existsSync(expanded), detail: expanded });
|
package/src/install.ts
CHANGED
|
@@ -45,6 +45,21 @@ export function resolveRepoSource(repo: string): RepoSource {
|
|
|
45
45
|
return rest.length > 0 ? { url, skillPath: rest.join("/") } : { url };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function extractHost(url: string): string {
|
|
49
|
+
const scpMatch = url.match(/^[^/\s]+@([^/\s]+):/);
|
|
50
|
+
if (scpMatch) return scpMatch[1]!;
|
|
51
|
+
return new URL(url).hostname;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function assertHostAllowed(url: string, allowedHosts: string[] | undefined): void {
|
|
55
|
+
if (!allowedHosts || allowedHosts.length === 0) return;
|
|
56
|
+
if (isLocalFileUrl(url)) return;
|
|
57
|
+
const host = extractHost(url);
|
|
58
|
+
if (!allowedHosts.map((h) => h.toLowerCase()).includes(host.toLowerCase())) {
|
|
59
|
+
throw new Error(`refusing to fetch from host "${host}" — not in [egress] allowed_hosts`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
export function deriveRepoName(url: string): string {
|
|
49
64
|
const cleaned = url.replace(/\.git$/, "");
|
|
50
65
|
const segment = cleaned.split(/[/:]/).filter(Boolean).pop();
|
package/src/rate-limiter.ts
CHANGED
|
@@ -3,6 +3,14 @@ interface Bucket {
|
|
|
3
3
|
lastRefillMs: number;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
// SMX-93: bounds memory even when an attacker (behind a trust_proxy-honored
|
|
7
|
+
// reverse proxy) mints unbounded distinct X-Forwarded-For values, or a
|
|
8
|
+
// long-running deployment simply accumulates many distinct legitimate
|
|
9
|
+
// clients over time. Past this many entries, the least-recently-used
|
|
10
|
+
// bucket is evicted to make room — active clients are never evicted ahead
|
|
11
|
+
// of idle ones.
|
|
12
|
+
const DEFAULT_MAX_BUCKETS = 10_000;
|
|
13
|
+
|
|
6
14
|
export interface RateLimitCheckInput {
|
|
7
15
|
nowMs: number;
|
|
8
16
|
auth_enabled: boolean;
|
|
@@ -22,12 +30,19 @@ export class RateLimiter {
|
|
|
22
30
|
private enabled: boolean;
|
|
23
31
|
private requests_per_minute: number;
|
|
24
32
|
private trust_proxy: boolean;
|
|
33
|
+
private max_buckets: number;
|
|
25
34
|
private buckets = new Map<string, Bucket>();
|
|
26
35
|
|
|
27
|
-
constructor(config: {
|
|
36
|
+
constructor(config: {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
requests_per_minute: number;
|
|
39
|
+
trust_proxy?: boolean;
|
|
40
|
+
max_buckets?: number;
|
|
41
|
+
}) {
|
|
28
42
|
this.enabled = config.enabled;
|
|
29
43
|
this.requests_per_minute = config.requests_per_minute;
|
|
30
44
|
this.trust_proxy = config.trust_proxy ?? false;
|
|
45
|
+
this.max_buckets = config.max_buckets ?? DEFAULT_MAX_BUCKETS;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
check(input: RateLimitCheckInput): RateLimitCheckResult {
|
|
@@ -59,7 +74,19 @@ export class RateLimiter {
|
|
|
59
74
|
|
|
60
75
|
// 2. Retrieve or initialize bucket
|
|
61
76
|
let bucket = this.buckets.get(id);
|
|
62
|
-
if (
|
|
77
|
+
if (bucket) {
|
|
78
|
+
// Map iteration order is insertion order, so re-inserting on touch
|
|
79
|
+
// marks this entry as most-recently-used and moves it out of the
|
|
80
|
+
// eviction path below.
|
|
81
|
+
this.buckets.delete(id);
|
|
82
|
+
this.buckets.set(id, bucket);
|
|
83
|
+
} else {
|
|
84
|
+
if (this.buckets.size >= this.max_buckets) {
|
|
85
|
+
const oldestId = this.buckets.keys().next().value;
|
|
86
|
+
if (oldestId !== undefined) {
|
|
87
|
+
this.buckets.delete(oldestId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
63
90
|
bucket = {
|
|
64
91
|
tokens: this.requests_per_minute,
|
|
65
92
|
lastRefillMs: input.nowMs,
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const PLACEHOLDER = "[REDACTED]";
|
|
2
|
+
|
|
3
|
+
// Strips userinfo (user:pass@) from URLs unconditionally, since a
|
|
4
|
+
// credential-bearing git URL (private-repo PAT auth) is typed by the user
|
|
5
|
+
// directly and never resolved from a config `*_env` key, so it can't be
|
|
6
|
+
// caught by the config-driven scrub below.
|
|
7
|
+
const URL_USERINFO = /:\/\/[^/\s@]*@/g;
|
|
8
|
+
|
|
9
|
+
function redactUrlCredentials(text: string): string {
|
|
10
|
+
return text.replace(URL_USERINFO, `://${PLACEHOLDER}@`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Walks the config tree for every string-valued key ending in `_env`
|
|
15
|
+
* (api_key_env, token_env, auth_token_env, and any future one) and resolves
|
|
16
|
+
* each to its current environment value.
|
|
17
|
+
*/
|
|
18
|
+
function collectSecretValues(value: unknown): string[] {
|
|
19
|
+
const secrets: string[] = [];
|
|
20
|
+
const visit = (node: unknown): void => {
|
|
21
|
+
if (Array.isArray(node)) {
|
|
22
|
+
for (const item of node) visit(item);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (node === null || typeof node !== "object") return;
|
|
26
|
+
for (const [key, val] of Object.entries(node as Record<string, unknown>)) {
|
|
27
|
+
if (key.endsWith("_env") && typeof val === "string") {
|
|
28
|
+
const resolved = process.env[val];
|
|
29
|
+
if (resolved) secrets.push(resolved);
|
|
30
|
+
} else {
|
|
31
|
+
visit(val);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
visit(value);
|
|
36
|
+
return secrets;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds a redactor bound to the currently effective config: a pure
|
|
41
|
+
* `(text) => text` closure that scrubs any resolved `*_env` secret value.
|
|
42
|
+
*/
|
|
43
|
+
export function buildRedactor(config: unknown): (text: string) => string {
|
|
44
|
+
const secrets = collectSecretValues(config);
|
|
45
|
+
return (text: string): string => {
|
|
46
|
+
let result = redactUrlCredentials(text);
|
|
47
|
+
for (const secret of secrets) {
|
|
48
|
+
result = result.split(secret).join(PLACEHOLDER);
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
}
|