@i4ctime/q-ring 0.9.8 → 0.11.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 +24 -3
- package/dist/{chunk-JV4JFWV6.js → chunk-A4RZVP3P.js} +669 -223
- package/dist/chunk-A4RZVP3P.js.map +1 -0
- package/dist/{chunk-ZV7LRKBA.js → chunk-MQM4DLPI.js} +693 -241
- package/dist/chunk-MQM4DLPI.js.map +1 -0
- package/dist/dashboard-FSCJDLJX.js +1313 -0
- package/dist/dashboard-FSCJDLJX.js.map +1 -0
- package/dist/dashboard-OXUD62RV.js +1313 -0
- package/dist/dashboard-OXUD62RV.js.map +1 -0
- package/dist/index.js +2455 -1993
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +1798 -1661
- package/dist/mcp.js.map +1 -1
- package/package.json +15 -3
- package/dist/chunk-JV4JFWV6.js.map +0 -1
- package/dist/chunk-ZV7LRKBA.js.map +0 -1
- package/dist/dashboard-4H474M2N.js +0 -557
- package/dist/dashboard-4H474M2N.js.map +0 -1
- package/dist/dashboard-5L4ILERJ.js +0 -557
- package/dist/dashboard-5L4ILERJ.js.map +0 -1
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/version.ts
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
function readPackageVersion() {
|
|
8
|
+
try {
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkgPath = join(here, "..", "package.json");
|
|
11
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
12
|
+
const pkg = JSON.parse(raw);
|
|
13
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
14
|
+
} catch {
|
|
15
|
+
return "0.0.0";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
var PACKAGE_VERSION = readPackageVersion();
|
|
19
|
+
|
|
3
20
|
// src/core/envelope.ts
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
var EntanglementLinkSchema = z.object({
|
|
23
|
+
service: z.string(),
|
|
24
|
+
key: z.string()
|
|
25
|
+
});
|
|
26
|
+
var SecretMetadataSchema = z.object({
|
|
27
|
+
createdAt: z.string(),
|
|
28
|
+
updatedAt: z.string(),
|
|
29
|
+
expiresAt: z.string().optional(),
|
|
30
|
+
ttlSeconds: z.number().optional(),
|
|
31
|
+
description: z.string().optional(),
|
|
32
|
+
tags: z.array(z.string()).optional(),
|
|
33
|
+
entangled: z.array(EntanglementLinkSchema).optional(),
|
|
34
|
+
accessCount: z.number(),
|
|
35
|
+
lastAccessedAt: z.string().optional(),
|
|
36
|
+
ephemeral: z.boolean().optional(),
|
|
37
|
+
rotationFormat: z.string().optional(),
|
|
38
|
+
rotationPrefix: z.string().optional(),
|
|
39
|
+
provider: z.string().optional(),
|
|
40
|
+
validationUrl: z.string().optional(),
|
|
41
|
+
requiresApproval: z.boolean().optional(),
|
|
42
|
+
jitProvider: z.string().optional(),
|
|
43
|
+
jitExpiresAt: z.string().optional()
|
|
44
|
+
});
|
|
45
|
+
var QuantumEnvelopeSchema = z.object({
|
|
46
|
+
v: z.literal(1),
|
|
47
|
+
value: z.string().optional(),
|
|
48
|
+
states: z.record(z.string(), z.string()).optional(),
|
|
49
|
+
defaultEnv: z.string().optional(),
|
|
50
|
+
meta: SecretMetadataSchema
|
|
51
|
+
});
|
|
4
52
|
function createEnvelope(value, opts) {
|
|
5
53
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6
54
|
let expiresAt = opts?.expiresAt;
|
|
@@ -32,8 +80,9 @@ function createEnvelope(value, opts) {
|
|
|
32
80
|
function parseEnvelope(raw) {
|
|
33
81
|
try {
|
|
34
82
|
const parsed = JSON.parse(raw);
|
|
35
|
-
|
|
36
|
-
|
|
83
|
+
const r = QuantumEnvelopeSchema.safeParse(parsed);
|
|
84
|
+
if (r.success) {
|
|
85
|
+
return r.data;
|
|
37
86
|
}
|
|
38
87
|
} catch {
|
|
39
88
|
}
|
|
@@ -84,6 +133,15 @@ function checkDecay(envelope) {
|
|
|
84
133
|
const now = Date.now();
|
|
85
134
|
const expires = new Date(envelope.meta.expiresAt).getTime();
|
|
86
135
|
const created = new Date(envelope.meta.createdAt).getTime();
|
|
136
|
+
if (!Number.isFinite(expires) || !Number.isFinite(created)) {
|
|
137
|
+
return {
|
|
138
|
+
isExpired: true,
|
|
139
|
+
isStale: true,
|
|
140
|
+
lifetimePercent: 100,
|
|
141
|
+
secondsRemaining: null,
|
|
142
|
+
timeRemaining: "invalid date"
|
|
143
|
+
};
|
|
144
|
+
}
|
|
87
145
|
const totalLifetime = expires - created;
|
|
88
146
|
const elapsed = now - created;
|
|
89
147
|
const remaining = expires - now;
|
|
@@ -121,8 +179,8 @@ function recordAccess(envelope) {
|
|
|
121
179
|
|
|
122
180
|
// src/core/collapse.ts
|
|
123
181
|
import { execSync } from "child_process";
|
|
124
|
-
import { existsSync, readFileSync } from "fs";
|
|
125
|
-
import { join } from "path";
|
|
182
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
183
|
+
import { join as join2 } from "path";
|
|
126
184
|
var BRANCH_ENV_MAP = {
|
|
127
185
|
main: "prod",
|
|
128
186
|
master: "prod",
|
|
@@ -149,10 +207,10 @@ function detectGitBranch(cwd) {
|
|
|
149
207
|
}
|
|
150
208
|
}
|
|
151
209
|
function readProjectConfig(projectPath) {
|
|
152
|
-
const configPath =
|
|
210
|
+
const configPath = join2(projectPath ?? process.cwd(), ".q-ring.json");
|
|
153
211
|
try {
|
|
154
212
|
if (existsSync(configPath)) {
|
|
155
|
-
return JSON.parse(
|
|
213
|
+
return JSON.parse(readFileSync2(configPath, "utf8"));
|
|
156
214
|
}
|
|
157
215
|
} catch {
|
|
158
216
|
}
|
|
@@ -188,12 +246,15 @@ function collapseEnvironment(ctx = {}) {
|
|
|
188
246
|
}
|
|
189
247
|
return null;
|
|
190
248
|
}
|
|
249
|
+
var MAX_BRANCH_GLOB_PATTERN_LEN = 200;
|
|
250
|
+
var MAX_BRANCH_GLOB_REGEX_SOURCE = 400;
|
|
191
251
|
function matchGlob(branchMap, branch) {
|
|
192
252
|
for (const [pattern, env] of Object.entries(branchMap)) {
|
|
193
253
|
if (!pattern.includes("*")) continue;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
);
|
|
254
|
+
if (pattern.length > MAX_BRANCH_GLOB_PATTERN_LEN) continue;
|
|
255
|
+
const source = "^" + pattern.replace(/\*/g, ".*") + "$";
|
|
256
|
+
if (source.length > MAX_BRANCH_GLOB_REGEX_SOURCE) continue;
|
|
257
|
+
const regex = new RegExp(source);
|
|
197
258
|
if (regex.test(branch)) return env;
|
|
198
259
|
}
|
|
199
260
|
return void 0;
|
|
@@ -206,19 +267,35 @@ function mapEnvName(raw) {
|
|
|
206
267
|
}
|
|
207
268
|
|
|
208
269
|
// src/core/observer.ts
|
|
209
|
-
import {
|
|
210
|
-
|
|
270
|
+
import {
|
|
271
|
+
existsSync as existsSync2,
|
|
272
|
+
mkdirSync,
|
|
273
|
+
appendFileSync,
|
|
274
|
+
readFileSync as readFileSync3,
|
|
275
|
+
openSync,
|
|
276
|
+
fstatSync,
|
|
277
|
+
readSync,
|
|
278
|
+
closeSync,
|
|
279
|
+
statSync
|
|
280
|
+
} from "fs";
|
|
281
|
+
import { join as join3 } from "path";
|
|
211
282
|
import { homedir } from "os";
|
|
212
283
|
import { createHash } from "crypto";
|
|
213
284
|
function getAuditDir() {
|
|
214
|
-
|
|
285
|
+
if (process.env.QRING_AUDIT_DIR) {
|
|
286
|
+
if (!existsSync2(process.env.QRING_AUDIT_DIR)) {
|
|
287
|
+
mkdirSync(process.env.QRING_AUDIT_DIR, { recursive: true });
|
|
288
|
+
}
|
|
289
|
+
return process.env.QRING_AUDIT_DIR;
|
|
290
|
+
}
|
|
291
|
+
const dir = join3(homedir(), ".config", "q-ring");
|
|
215
292
|
if (!existsSync2(dir)) {
|
|
216
293
|
mkdirSync(dir, { recursive: true });
|
|
217
294
|
}
|
|
218
295
|
return dir;
|
|
219
296
|
}
|
|
220
297
|
function getAuditPath() {
|
|
221
|
-
return
|
|
298
|
+
return join3(getAuditDir(), "audit.jsonl");
|
|
222
299
|
}
|
|
223
300
|
function getLastLineHash() {
|
|
224
301
|
const path = getAuditPath();
|
|
@@ -256,11 +333,24 @@ function logAudit(event) {
|
|
|
256
333
|
} catch {
|
|
257
334
|
}
|
|
258
335
|
}
|
|
336
|
+
var MAX_AUDIT_BYTES = 12 * 1024 * 1024;
|
|
259
337
|
function queryAudit(query = {}) {
|
|
260
338
|
const path = getAuditPath();
|
|
261
339
|
if (!existsSync2(path)) return [];
|
|
262
340
|
try {
|
|
263
|
-
const
|
|
341
|
+
const st = statSync(path);
|
|
342
|
+
const readStart = st.size > MAX_AUDIT_BYTES ? st.size - MAX_AUDIT_BYTES : 0;
|
|
343
|
+
const readLen = st.size > MAX_AUDIT_BYTES ? MAX_AUDIT_BYTES : st.size;
|
|
344
|
+
const buf = Buffer.alloc(readLen);
|
|
345
|
+
const fd = openSync(path, "r");
|
|
346
|
+
readSync(fd, buf, 0, readLen, readStart);
|
|
347
|
+
closeSync(fd);
|
|
348
|
+
let text = buf.toString("utf8");
|
|
349
|
+
if (readStart > 0) {
|
|
350
|
+
const firstNl = text.indexOf("\n");
|
|
351
|
+
if (firstNl !== -1) text = text.slice(firstNl + 1);
|
|
352
|
+
}
|
|
353
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
264
354
|
let events = lines.map((line) => {
|
|
265
355
|
try {
|
|
266
356
|
return JSON.parse(line);
|
|
@@ -290,7 +380,7 @@ function verifyAuditChain() {
|
|
|
290
380
|
if (!existsSync2(path)) {
|
|
291
381
|
return { totalEvents: 0, validEvents: 0, intact: true };
|
|
292
382
|
}
|
|
293
|
-
const lines =
|
|
383
|
+
const lines = readFileSync3(path, "utf8").split("\n").filter((l) => l.trim());
|
|
294
384
|
if (lines.length === 0) {
|
|
295
385
|
return { totalEvents: 0, validEvents: 0, intact: true };
|
|
296
386
|
}
|
|
@@ -328,7 +418,7 @@ function verifyAuditChain() {
|
|
|
328
418
|
function exportAudit(opts = {}) {
|
|
329
419
|
const path = getAuditPath();
|
|
330
420
|
if (!existsSync2(path)) return opts.format === "json" ? "[]" : "";
|
|
331
|
-
const lines =
|
|
421
|
+
const lines = readFileSync3(path, "utf8").split("\n").filter((l) => l.trim());
|
|
332
422
|
let events = lines.map((l) => {
|
|
333
423
|
try {
|
|
334
424
|
return JSON.parse(l);
|
|
@@ -393,15 +483,15 @@ function detectAnomalies(key) {
|
|
|
393
483
|
}
|
|
394
484
|
|
|
395
485
|
// src/core/entanglement.ts
|
|
396
|
-
import { existsSync as existsSync3, readFileSync as
|
|
397
|
-
import { join as
|
|
486
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
487
|
+
import { join as join4 } from "path";
|
|
398
488
|
import { homedir as homedir2 } from "os";
|
|
399
489
|
function getRegistryPath() {
|
|
400
|
-
const dir =
|
|
490
|
+
const dir = join4(homedir2(), ".config", "q-ring");
|
|
401
491
|
if (!existsSync3(dir)) {
|
|
402
492
|
mkdirSync2(dir, { recursive: true });
|
|
403
493
|
}
|
|
404
|
-
return
|
|
494
|
+
return join4(dir, "entanglement.json");
|
|
405
495
|
}
|
|
406
496
|
function loadRegistry() {
|
|
407
497
|
const path = getRegistryPath();
|
|
@@ -409,13 +499,15 @@ function loadRegistry() {
|
|
|
409
499
|
return { pairs: [] };
|
|
410
500
|
}
|
|
411
501
|
try {
|
|
412
|
-
return JSON.parse(
|
|
502
|
+
return JSON.parse(readFileSync4(path, "utf8"));
|
|
413
503
|
} catch {
|
|
414
504
|
return { pairs: [] };
|
|
415
505
|
}
|
|
416
506
|
}
|
|
417
507
|
function saveRegistry(registry2) {
|
|
418
|
-
|
|
508
|
+
writeFileSync(getRegistryPath(), JSON.stringify(registry2, null, 2), {
|
|
509
|
+
mode: 384
|
|
510
|
+
});
|
|
419
511
|
}
|
|
420
512
|
function entangle(source, target) {
|
|
421
513
|
const registry2 = loadRegistry();
|
|
@@ -453,74 +545,19 @@ function listEntanglements() {
|
|
|
453
545
|
return loadRegistry().pairs;
|
|
454
546
|
}
|
|
455
547
|
|
|
456
|
-
// src/core/keyring.ts
|
|
457
|
-
import { Entry, findCredentials } from "@napi-rs/keyring";
|
|
458
|
-
|
|
459
|
-
// src/utils/hash.ts
|
|
460
|
-
import { createHash as createHash2 } from "crypto";
|
|
461
|
-
function hashProjectPath(projectPath) {
|
|
462
|
-
return createHash2("sha256").update(projectPath).digest("hex").slice(0, 12);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// src/core/scope.ts
|
|
466
|
-
var SERVICE_PREFIX = "q-ring";
|
|
467
|
-
function globalService() {
|
|
468
|
-
return `${SERVICE_PREFIX}:global`;
|
|
469
|
-
}
|
|
470
|
-
function projectService(projectPath) {
|
|
471
|
-
const hash = hashProjectPath(projectPath);
|
|
472
|
-
return `${SERVICE_PREFIX}:project:${hash}`;
|
|
473
|
-
}
|
|
474
|
-
function teamService(teamId) {
|
|
475
|
-
return `${SERVICE_PREFIX}:team:${teamId}`;
|
|
476
|
-
}
|
|
477
|
-
function orgService(orgId) {
|
|
478
|
-
return `${SERVICE_PREFIX}:org:${orgId}`;
|
|
479
|
-
}
|
|
480
|
-
function resolveScope(opts) {
|
|
481
|
-
const { scope, projectPath, teamId, orgId } = opts;
|
|
482
|
-
if (scope === "global") {
|
|
483
|
-
return [{ scope: "global", service: globalService() }];
|
|
484
|
-
}
|
|
485
|
-
if (scope === "project") {
|
|
486
|
-
if (!projectPath) throw new Error("Project path is required for project scope");
|
|
487
|
-
return [{ scope: "project", service: projectService(projectPath), projectPath }];
|
|
488
|
-
}
|
|
489
|
-
if (scope === "team") {
|
|
490
|
-
if (!teamId) throw new Error("Team ID is required for team scope");
|
|
491
|
-
return [{ scope: "team", service: teamService(teamId), teamId }];
|
|
492
|
-
}
|
|
493
|
-
if (scope === "org") {
|
|
494
|
-
if (!orgId) throw new Error("Org ID is required for org scope");
|
|
495
|
-
return [{ scope: "org", service: orgService(orgId), orgId }];
|
|
496
|
-
}
|
|
497
|
-
const chain = [];
|
|
498
|
-
if (projectPath) {
|
|
499
|
-
chain.push({ scope: "project", service: projectService(projectPath), projectPath });
|
|
500
|
-
}
|
|
501
|
-
if (teamId) {
|
|
502
|
-
chain.push({ scope: "team", service: teamService(teamId), teamId });
|
|
503
|
-
}
|
|
504
|
-
if (orgId) {
|
|
505
|
-
chain.push({ scope: "org", service: orgService(orgId), orgId });
|
|
506
|
-
}
|
|
507
|
-
chain.push({ scope: "global", service: globalService() });
|
|
508
|
-
return chain;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
548
|
// src/core/hooks.ts
|
|
512
|
-
import { existsSync as existsSync4, readFileSync as
|
|
513
|
-
import { join as
|
|
549
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
|
|
550
|
+
import { join as join5 } from "path";
|
|
514
551
|
import { homedir as homedir3 } from "os";
|
|
515
|
-
import {
|
|
552
|
+
import { execFile, spawn } from "child_process";
|
|
516
553
|
import { randomUUID } from "crypto";
|
|
517
554
|
|
|
518
555
|
// src/utils/http-request.ts
|
|
519
556
|
import { request as httpsRequest } from "https";
|
|
520
|
-
import { request as
|
|
557
|
+
import { request as httpRequestPlain } from "http";
|
|
521
558
|
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
522
559
|
var DEFAULT_MAX_RESPONSE_BYTES = 65536;
|
|
523
|
-
function
|
|
560
|
+
function httpRequest(opts) {
|
|
524
561
|
const {
|
|
525
562
|
url,
|
|
526
563
|
method = "GET",
|
|
@@ -535,7 +572,7 @@ function httpRequest_(opts) {
|
|
|
535
572
|
reject(new Error(`Unsupported URL protocol: ${parsed.protocol}`));
|
|
536
573
|
return;
|
|
537
574
|
}
|
|
538
|
-
const reqFn = parsed.protocol === "https:" ? httpsRequest :
|
|
575
|
+
const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequestPlain;
|
|
539
576
|
const reqHeaders = { ...headers };
|
|
540
577
|
if (body && !reqHeaders["Content-Length"]) {
|
|
541
578
|
reqHeaders["Content-Length"] = Buffer.byteLength(body);
|
|
@@ -598,6 +635,19 @@ function httpRequest_(opts) {
|
|
|
598
635
|
|
|
599
636
|
// src/core/ssrf.ts
|
|
600
637
|
import { lookup } from "dns/promises";
|
|
638
|
+
import * as dns from "dns";
|
|
639
|
+
import { isIPv4, isIPv6 } from "net";
|
|
640
|
+
function lookupAddressesSync(hostname2) {
|
|
641
|
+
const lookupSync2 = dns.lookupSync;
|
|
642
|
+
return lookupSync2(hostname2, { all: true });
|
|
643
|
+
}
|
|
644
|
+
function isHostnameIpLiteral(hostname2) {
|
|
645
|
+
if (isIPv4(hostname2)) return true;
|
|
646
|
+
if (hostname2.startsWith("[") && hostname2.endsWith("]")) {
|
|
647
|
+
return isIPv6(hostname2.slice(1, -1));
|
|
648
|
+
}
|
|
649
|
+
return isIPv6(hostname2);
|
|
650
|
+
}
|
|
601
651
|
function isPrivateIP(ip) {
|
|
602
652
|
const octet = "(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})";
|
|
603
653
|
const ipv4Re = new RegExp(`^::ffff:(${octet}\\.${octet}\\.${octet}\\.${octet})$`, "i");
|
|
@@ -618,40 +668,64 @@ async function checkSSRF(url) {
|
|
|
618
668
|
if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
|
|
619
669
|
try {
|
|
620
670
|
const parsed = new URL(url);
|
|
621
|
-
const
|
|
622
|
-
if (isPrivateIP(
|
|
623
|
-
return `Blocked: URL resolves to private address (${
|
|
671
|
+
const hostname2 = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
672
|
+
if (isPrivateIP(hostname2)) {
|
|
673
|
+
return `Blocked: URL resolves to private address (${hostname2}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
|
|
624
674
|
}
|
|
625
|
-
const results = await lookup(
|
|
675
|
+
const results = await lookup(hostname2, { all: true });
|
|
626
676
|
for (const { address } of results) {
|
|
627
677
|
if (isPrivateIP(address)) {
|
|
628
|
-
return `Blocked: URL "${
|
|
678
|
+
return `Blocked: URL "${hostname2}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
|
|
629
679
|
}
|
|
630
680
|
}
|
|
631
681
|
} catch {
|
|
632
682
|
}
|
|
633
683
|
return null;
|
|
634
684
|
}
|
|
635
|
-
function
|
|
685
|
+
function checkJitHttpProvisionUrl(url) {
|
|
636
686
|
if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
|
|
637
687
|
try {
|
|
638
688
|
const parsed = new URL(url);
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
689
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
690
|
+
return "Blocked: JIT HTTP provider only allows http: or https: URLs.";
|
|
691
|
+
}
|
|
692
|
+
const hostname2 = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
693
|
+
if (!hostname2) {
|
|
694
|
+
return "Blocked: empty hostname in JIT URL.";
|
|
695
|
+
}
|
|
696
|
+
if (isPrivateIP(hostname2)) {
|
|
697
|
+
return `Blocked: URL resolves to private address (${hostname2}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
|
|
698
|
+
}
|
|
699
|
+
if (isHostnameIpLiteral(hostname2)) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
let results;
|
|
703
|
+
try {
|
|
704
|
+
results = lookupAddressesSync(hostname2);
|
|
705
|
+
} catch {
|
|
706
|
+
return `Blocked: DNS resolution failed for "${hostname2}" (JIT HTTP provisioning fails closed).`;
|
|
707
|
+
}
|
|
708
|
+
if (!results.length) {
|
|
709
|
+
return `Blocked: DNS returned no addresses for "${hostname2}".`;
|
|
710
|
+
}
|
|
711
|
+
for (const { address } of results) {
|
|
712
|
+
if (isPrivateIP(address)) {
|
|
713
|
+
return `Blocked: URL "${hostname2}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
|
|
714
|
+
}
|
|
642
715
|
}
|
|
643
716
|
} catch {
|
|
717
|
+
return "Blocked: malformed JIT HTTP URL.";
|
|
644
718
|
}
|
|
645
719
|
return null;
|
|
646
720
|
}
|
|
647
721
|
|
|
648
722
|
// src/core/hooks.ts
|
|
649
723
|
function getRegistryPath2() {
|
|
650
|
-
const dir =
|
|
724
|
+
const dir = join5(homedir3(), ".config", "q-ring");
|
|
651
725
|
if (!existsSync4(dir)) {
|
|
652
726
|
mkdirSync3(dir, { recursive: true });
|
|
653
727
|
}
|
|
654
|
-
return
|
|
728
|
+
return join5(dir, "hooks.json");
|
|
655
729
|
}
|
|
656
730
|
function loadRegistry2() {
|
|
657
731
|
const path = getRegistryPath2();
|
|
@@ -659,13 +733,15 @@ function loadRegistry2() {
|
|
|
659
733
|
return { hooks: [] };
|
|
660
734
|
}
|
|
661
735
|
try {
|
|
662
|
-
return JSON.parse(
|
|
736
|
+
return JSON.parse(readFileSync5(path, "utf8"));
|
|
663
737
|
} catch {
|
|
664
738
|
return { hooks: [] };
|
|
665
739
|
}
|
|
666
740
|
}
|
|
667
741
|
function saveRegistry2(registry2) {
|
|
668
|
-
|
|
742
|
+
writeFileSync2(getRegistryPath2(), JSON.stringify(registry2, null, 2), {
|
|
743
|
+
mode: 384
|
|
744
|
+
});
|
|
669
745
|
}
|
|
670
746
|
function registerHook(entry) {
|
|
671
747
|
const registry2 = loadRegistry2();
|
|
@@ -721,6 +797,14 @@ function matchesHook(hook, payload, tags) {
|
|
|
721
797
|
if (m.scope && m.scope !== payload.scope) return false;
|
|
722
798
|
return true;
|
|
723
799
|
}
|
|
800
|
+
var SAFE_PGREP_NAME = /^[a-zA-Z0-9._:/-]{1,256}$/;
|
|
801
|
+
function shellRunner() {
|
|
802
|
+
if (process.platform === "win32") {
|
|
803
|
+
const comspec = process.env.ComSpec ?? "cmd.exe";
|
|
804
|
+
return { file: comspec, args: ["/d", "/s", "/c"] };
|
|
805
|
+
}
|
|
806
|
+
return { file: "/bin/sh", args: ["-c"] };
|
|
807
|
+
}
|
|
724
808
|
function executeShell(command, payload) {
|
|
725
809
|
return new Promise((resolve) => {
|
|
726
810
|
const env = {
|
|
@@ -729,13 +813,33 @@ function executeShell(command, payload) {
|
|
|
729
813
|
QRING_HOOK_ACTION: payload.action,
|
|
730
814
|
QRING_HOOK_SCOPE: payload.scope
|
|
731
815
|
};
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
816
|
+
const { file, args } = shellRunner();
|
|
817
|
+
execFile(
|
|
818
|
+
file,
|
|
819
|
+
[...args, command],
|
|
820
|
+
{
|
|
821
|
+
timeout: 3e4,
|
|
822
|
+
env,
|
|
823
|
+
windowsHide: true,
|
|
824
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
825
|
+
encoding: "utf8"
|
|
826
|
+
},
|
|
827
|
+
(err, stdout) => {
|
|
828
|
+
if (err) {
|
|
829
|
+
resolve({
|
|
830
|
+
hookId: "",
|
|
831
|
+
success: false,
|
|
832
|
+
message: `Shell error: ${err.message}`
|
|
833
|
+
});
|
|
834
|
+
} else {
|
|
835
|
+
resolve({
|
|
836
|
+
hookId: "",
|
|
837
|
+
success: true,
|
|
838
|
+
message: (stdout ?? "").trim() || "OK"
|
|
839
|
+
});
|
|
840
|
+
}
|
|
737
841
|
}
|
|
738
|
-
|
|
842
|
+
);
|
|
739
843
|
});
|
|
740
844
|
}
|
|
741
845
|
async function executeHttp(url, payload) {
|
|
@@ -752,7 +856,7 @@ async function executeHttp(url, payload) {
|
|
|
752
856
|
}
|
|
753
857
|
try {
|
|
754
858
|
const body = JSON.stringify(payload);
|
|
755
|
-
const res = await
|
|
859
|
+
const res = await httpRequest({
|
|
756
860
|
url,
|
|
757
861
|
method: "POST",
|
|
758
862
|
headers: {
|
|
@@ -777,24 +881,48 @@ async function executeHttp(url, payload) {
|
|
|
777
881
|
}
|
|
778
882
|
function executeSignal(target, signal = "SIGHUP") {
|
|
779
883
|
return new Promise((resolve) => {
|
|
780
|
-
const
|
|
781
|
-
if (
|
|
884
|
+
const trimmed = target.trim();
|
|
885
|
+
if (/^\d+$/.test(trimmed)) {
|
|
886
|
+
const pid = parseInt(trimmed, 10);
|
|
782
887
|
try {
|
|
783
888
|
process.kill(pid, signal);
|
|
784
|
-
resolve({
|
|
889
|
+
resolve({
|
|
890
|
+
hookId: "",
|
|
891
|
+
success: true,
|
|
892
|
+
message: `Signal ${signal} sent to PID ${pid}`
|
|
893
|
+
});
|
|
785
894
|
} catch (err) {
|
|
786
|
-
resolve({
|
|
895
|
+
resolve({
|
|
896
|
+
hookId: "",
|
|
897
|
+
success: false,
|
|
898
|
+
message: `Signal error: ${err instanceof Error ? err.message : String(err)}`
|
|
899
|
+
});
|
|
787
900
|
}
|
|
788
901
|
return;
|
|
789
902
|
}
|
|
790
|
-
|
|
903
|
+
if (!SAFE_PGREP_NAME.test(trimmed)) {
|
|
904
|
+
resolve({
|
|
905
|
+
hookId: "",
|
|
906
|
+
success: false,
|
|
907
|
+
message: "Signal target must be a numeric PID, or a process name matching /^[a-zA-Z0-9._:/-]{1,256}$/ (no spaces or shell metacharacters)."
|
|
908
|
+
});
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const child = spawn("pgrep", ["-f", trimmed], {
|
|
912
|
+
timeout: 5e3,
|
|
913
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
914
|
+
});
|
|
791
915
|
let stdout = "";
|
|
792
916
|
child.stdout.on("data", (d) => {
|
|
793
917
|
stdout += d.toString();
|
|
794
918
|
});
|
|
795
919
|
child.on("close", (code) => {
|
|
796
920
|
if (code !== 0 || !stdout.trim()) {
|
|
797
|
-
resolve({
|
|
921
|
+
resolve({
|
|
922
|
+
hookId: "",
|
|
923
|
+
success: false,
|
|
924
|
+
message: `Process "${trimmed}" not found`
|
|
925
|
+
});
|
|
798
926
|
return;
|
|
799
927
|
}
|
|
800
928
|
const pids = stdout.trim().split("\n").map((p) => parseInt(p.trim(), 10)).filter((p) => !isNaN(p));
|
|
@@ -806,10 +934,18 @@ function executeSignal(target, signal = "SIGHUP") {
|
|
|
806
934
|
} catch {
|
|
807
935
|
}
|
|
808
936
|
}
|
|
809
|
-
resolve({
|
|
937
|
+
resolve({
|
|
938
|
+
hookId: "",
|
|
939
|
+
success: sent > 0,
|
|
940
|
+
message: `Signal ${signal} sent to ${sent} process(es)`
|
|
941
|
+
});
|
|
810
942
|
});
|
|
811
943
|
child.on("error", () => {
|
|
812
|
-
resolve({
|
|
944
|
+
resolve({
|
|
945
|
+
hookId: "",
|
|
946
|
+
success: false,
|
|
947
|
+
message: `Process "${trimmed}" not found`
|
|
948
|
+
});
|
|
813
949
|
});
|
|
814
950
|
});
|
|
815
951
|
}
|
|
@@ -866,35 +1002,52 @@ async function fireHooks(payload, tags) {
|
|
|
866
1002
|
}
|
|
867
1003
|
|
|
868
1004
|
// src/core/approval.ts
|
|
869
|
-
import { existsSync as existsSync5, readFileSync as
|
|
870
|
-
import { join as
|
|
1005
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
|
|
1006
|
+
import { join as join6 } from "path";
|
|
871
1007
|
import { homedir as homedir4 } from "os";
|
|
872
|
-
import { createHmac, randomBytes } from "crypto";
|
|
1008
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
873
1009
|
function getHmacSecret() {
|
|
874
|
-
const
|
|
875
|
-
const
|
|
876
|
-
if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true });
|
|
1010
|
+
const dir = join6(homedir4(), ".config", "q-ring");
|
|
1011
|
+
const secretPath = join6(dir, ".approval-key");
|
|
1012
|
+
if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
|
|
877
1013
|
if (existsSync5(secretPath)) {
|
|
878
|
-
return
|
|
1014
|
+
return readFileSync6(secretPath, "utf8").trim();
|
|
879
1015
|
}
|
|
880
1016
|
const secret = randomBytes(32).toString("hex");
|
|
881
|
-
|
|
1017
|
+
writeFileSync3(secretPath, secret, { mode: 384 });
|
|
882
1018
|
return secret;
|
|
883
1019
|
}
|
|
884
1020
|
function computeHmac(entry) {
|
|
885
|
-
const payload =
|
|
1021
|
+
const payload = [
|
|
1022
|
+
entry.id,
|
|
1023
|
+
entry.key,
|
|
1024
|
+
entry.scope,
|
|
1025
|
+
entry.reason,
|
|
1026
|
+
entry.grantedBy,
|
|
1027
|
+
entry.grantedAt,
|
|
1028
|
+
entry.expiresAt,
|
|
1029
|
+
entry.workspace ?? "",
|
|
1030
|
+
entry.sessionId ?? ""
|
|
1031
|
+
].join("|");
|
|
886
1032
|
return createHmac("sha256", getHmacSecret()).update(payload).digest("hex");
|
|
887
1033
|
}
|
|
888
1034
|
function verifyHmac(entry) {
|
|
889
1035
|
const expected = computeHmac(entry);
|
|
890
|
-
|
|
1036
|
+
const a = Buffer.from(expected, "utf8");
|
|
1037
|
+
const b = Buffer.from(entry.hmac, "utf8");
|
|
1038
|
+
if (a.length !== b.length) return false;
|
|
1039
|
+
try {
|
|
1040
|
+
return timingSafeEqual(a, b);
|
|
1041
|
+
} catch {
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
891
1044
|
}
|
|
892
1045
|
function getRegistryPath3() {
|
|
893
|
-
const dir =
|
|
1046
|
+
const dir = join6(homedir4(), ".config", "q-ring");
|
|
894
1047
|
if (!existsSync5(dir)) {
|
|
895
|
-
mkdirSync4(dir, { recursive: true });
|
|
1048
|
+
mkdirSync4(dir, { recursive: true, mode: 448 });
|
|
896
1049
|
}
|
|
897
|
-
return
|
|
1050
|
+
return join6(dir, "approvals.json");
|
|
898
1051
|
}
|
|
899
1052
|
function loadRegistry3() {
|
|
900
1053
|
const path = getRegistryPath3();
|
|
@@ -902,13 +1055,13 @@ function loadRegistry3() {
|
|
|
902
1055
|
return { approvals: [] };
|
|
903
1056
|
}
|
|
904
1057
|
try {
|
|
905
|
-
return JSON.parse(
|
|
1058
|
+
return JSON.parse(readFileSync6(path, "utf8"));
|
|
906
1059
|
} catch {
|
|
907
1060
|
return { approvals: [] };
|
|
908
1061
|
}
|
|
909
1062
|
}
|
|
910
1063
|
function saveRegistry3(registry2) {
|
|
911
|
-
|
|
1064
|
+
writeFileSync3(getRegistryPath3(), JSON.stringify(registry2, null, 2), { mode: 384 });
|
|
912
1065
|
}
|
|
913
1066
|
function cleanup(registry2) {
|
|
914
1067
|
const now = Date.now();
|
|
@@ -974,8 +1127,192 @@ function listApprovals() {
|
|
|
974
1127
|
}));
|
|
975
1128
|
}
|
|
976
1129
|
|
|
1130
|
+
// src/core/policy.ts
|
|
1131
|
+
var cachedPolicy = null;
|
|
1132
|
+
function loadPolicy(projectPath) {
|
|
1133
|
+
const pp = projectPath ?? process.cwd();
|
|
1134
|
+
if (cachedPolicy && cachedPolicy.path === pp) {
|
|
1135
|
+
return cachedPolicy.policy;
|
|
1136
|
+
}
|
|
1137
|
+
const config = readProjectConfig(pp);
|
|
1138
|
+
const policy = config?.policy ?? {};
|
|
1139
|
+
cachedPolicy = { path: pp, policy };
|
|
1140
|
+
return policy;
|
|
1141
|
+
}
|
|
1142
|
+
function checkSecretLifecyclePolicy(input, projectPath) {
|
|
1143
|
+
const policy = loadPolicy(projectPath);
|
|
1144
|
+
if (!policy.secrets) return { allowed: true, policySource: "no-policy" };
|
|
1145
|
+
const s = policy.secrets;
|
|
1146
|
+
if (s.maxTtlSeconds != null && input.ttlSeconds != null && input.ttlSeconds > s.maxTtlSeconds) {
|
|
1147
|
+
return {
|
|
1148
|
+
allowed: false,
|
|
1149
|
+
reason: `TTL ${input.ttlSeconds}s exceeds policy maximum ${s.maxTtlSeconds}s`,
|
|
1150
|
+
policySource: ".q-ring.json policy.secrets.maxTtlSeconds"
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
if (s.requireApprovalForTags?.length && input.tags?.length) {
|
|
1154
|
+
const hit = input.tags.find((t) => s.requireApprovalForTags.includes(t));
|
|
1155
|
+
if (hit && !input.requiresApproval) {
|
|
1156
|
+
return {
|
|
1157
|
+
allowed: false,
|
|
1158
|
+
reason: `Tag "${hit}" requires explicit approval metadata (set requiresApproval / --requires-approval)`,
|
|
1159
|
+
policySource: ".q-ring.json policy.secrets.requireApprovalForTags"
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if (s.requireRotationFormatForTags?.length && input.tags?.length) {
|
|
1164
|
+
const hit = input.tags.find((t) => s.requireRotationFormatForTags.includes(t));
|
|
1165
|
+
if (hit && !input.rotationFormat) {
|
|
1166
|
+
return {
|
|
1167
|
+
allowed: false,
|
|
1168
|
+
reason: `Tag "${hit}" requires a rotationFormat to be set`,
|
|
1169
|
+
policySource: ".q-ring.json policy.secrets.requireRotationFormatForTags"
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return { allowed: true, policySource: ".q-ring.json" };
|
|
1174
|
+
}
|
|
1175
|
+
function checkKeyReadPolicy(key, tags, projectPath) {
|
|
1176
|
+
const policy = loadPolicy(projectPath);
|
|
1177
|
+
if (!policy.mcp) return { allowed: true, policySource: "no-policy" };
|
|
1178
|
+
if (policy.mcp.deniedKeys?.includes(key)) {
|
|
1179
|
+
return {
|
|
1180
|
+
allowed: false,
|
|
1181
|
+
reason: `Key "${key}" is denied by project policy`,
|
|
1182
|
+
policySource: ".q-ring.json policy.mcp.deniedKeys"
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
if (policy.mcp.readableKeys && !policy.mcp.readableKeys.includes(key)) {
|
|
1186
|
+
return {
|
|
1187
|
+
allowed: false,
|
|
1188
|
+
reason: `Key "${key}" is not in the readable keys allowlist`,
|
|
1189
|
+
policySource: ".q-ring.json policy.mcp.readableKeys"
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
if (tags && policy.mcp.deniedTags) {
|
|
1193
|
+
const blocked = tags.find((t) => policy.mcp.deniedTags.includes(t));
|
|
1194
|
+
if (blocked) {
|
|
1195
|
+
return {
|
|
1196
|
+
allowed: false,
|
|
1197
|
+
reason: `Tag "${blocked}" is denied by project policy`,
|
|
1198
|
+
policySource: ".q-ring.json policy.mcp.deniedTags"
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return { allowed: true, policySource: ".q-ring.json" };
|
|
1203
|
+
}
|
|
1204
|
+
function checkExecPolicy(command, projectPath) {
|
|
1205
|
+
const policy = loadPolicy(projectPath);
|
|
1206
|
+
if (!policy.exec) return { allowed: true, policySource: "no-policy" };
|
|
1207
|
+
if (policy.exec.denyCommands) {
|
|
1208
|
+
const denied = policy.exec.denyCommands.find((d) => command.includes(d));
|
|
1209
|
+
if (denied) {
|
|
1210
|
+
return {
|
|
1211
|
+
allowed: false,
|
|
1212
|
+
reason: `Command containing "${denied}" is denied by project policy`,
|
|
1213
|
+
policySource: ".q-ring.json policy.exec.denyCommands"
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (policy.exec.allowCommands) {
|
|
1218
|
+
const allowed = policy.exec.allowCommands.some((a) => command.startsWith(a));
|
|
1219
|
+
if (!allowed) {
|
|
1220
|
+
return {
|
|
1221
|
+
allowed: false,
|
|
1222
|
+
reason: `Command "${command}" is not in the exec allowlist`,
|
|
1223
|
+
policySource: ".q-ring.json policy.exec.allowCommands"
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
return { allowed: true, policySource: ".q-ring.json" };
|
|
1228
|
+
}
|
|
1229
|
+
function getExecMaxRuntime(projectPath) {
|
|
1230
|
+
return loadPolicy(projectPath).exec?.maxRuntimeSeconds;
|
|
1231
|
+
}
|
|
1232
|
+
function getPolicySummary(projectPath) {
|
|
1233
|
+
const policy = loadPolicy(projectPath);
|
|
1234
|
+
return {
|
|
1235
|
+
hasMcpPolicy: !!policy.mcp,
|
|
1236
|
+
hasExecPolicy: !!policy.exec,
|
|
1237
|
+
hasSecretPolicy: !!policy.secrets,
|
|
1238
|
+
details: policy
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// src/core/keyring.ts
|
|
1243
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, unlinkSync } from "fs";
|
|
1244
|
+
import { homedir as homedir5 } from "os";
|
|
1245
|
+
import { join as join7 } from "path";
|
|
1246
|
+
import { Entry, findCredentials } from "@napi-rs/keyring";
|
|
1247
|
+
|
|
1248
|
+
// src/utils/hash.ts
|
|
1249
|
+
import { createHash as createHash2 } from "crypto";
|
|
1250
|
+
function hashProjectPath(projectPath) {
|
|
1251
|
+
return createHash2("sha256").update(projectPath).digest("hex").slice(0, 12);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/core/scope.ts
|
|
1255
|
+
var SERVICE_PREFIX = "q-ring";
|
|
1256
|
+
function globalService() {
|
|
1257
|
+
return `${SERVICE_PREFIX}:global`;
|
|
1258
|
+
}
|
|
1259
|
+
function projectService(projectPath) {
|
|
1260
|
+
const hash = hashProjectPath(projectPath);
|
|
1261
|
+
return `${SERVICE_PREFIX}:project:${hash}`;
|
|
1262
|
+
}
|
|
1263
|
+
function teamService(teamId) {
|
|
1264
|
+
return `${SERVICE_PREFIX}:team:${teamId}`;
|
|
1265
|
+
}
|
|
1266
|
+
function orgService(orgId) {
|
|
1267
|
+
return `${SERVICE_PREFIX}:org:${orgId}`;
|
|
1268
|
+
}
|
|
1269
|
+
function resolveScope(opts) {
|
|
1270
|
+
const { scope, projectPath, teamId, orgId } = opts;
|
|
1271
|
+
if (scope === "global") {
|
|
1272
|
+
return [{ scope: "global", service: globalService() }];
|
|
1273
|
+
}
|
|
1274
|
+
if (scope === "project") {
|
|
1275
|
+
if (!projectPath) throw new Error("Project path is required for project scope");
|
|
1276
|
+
return [{ scope: "project", service: projectService(projectPath), projectPath }];
|
|
1277
|
+
}
|
|
1278
|
+
if (scope === "team") {
|
|
1279
|
+
if (!teamId) throw new Error("Team ID is required for team scope");
|
|
1280
|
+
return [{ scope: "team", service: teamService(teamId), teamId }];
|
|
1281
|
+
}
|
|
1282
|
+
if (scope === "org") {
|
|
1283
|
+
if (!orgId) throw new Error("Org ID is required for org scope");
|
|
1284
|
+
return [{ scope: "org", service: orgService(orgId), orgId }];
|
|
1285
|
+
}
|
|
1286
|
+
const chain = [];
|
|
1287
|
+
if (projectPath) {
|
|
1288
|
+
chain.push({ scope: "project", service: projectService(projectPath), projectPath });
|
|
1289
|
+
}
|
|
1290
|
+
if (teamId) {
|
|
1291
|
+
chain.push({ scope: "team", service: teamService(teamId), teamId });
|
|
1292
|
+
}
|
|
1293
|
+
if (orgId) {
|
|
1294
|
+
chain.push({ scope: "org", service: orgService(orgId), orgId });
|
|
1295
|
+
}
|
|
1296
|
+
chain.push({ scope: "global", service: globalService() });
|
|
1297
|
+
return chain;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
977
1300
|
// src/core/provision.ts
|
|
978
1301
|
import { execFileSync, spawnSync } from "child_process";
|
|
1302
|
+
import { z as z2 } from "zod";
|
|
1303
|
+
var AwsStsConfigSchema = z2.object({
|
|
1304
|
+
roleArn: z2.string(),
|
|
1305
|
+
sessionName: z2.string().optional(),
|
|
1306
|
+
durationSeconds: z2.number().optional()
|
|
1307
|
+
});
|
|
1308
|
+
var HttpJitConfigSchema = z2.object({
|
|
1309
|
+
url: z2.string(),
|
|
1310
|
+
method: z2.string().optional(),
|
|
1311
|
+
valuePath: z2.string().optional(),
|
|
1312
|
+
expiresInSeconds: z2.number().optional(),
|
|
1313
|
+
headers: z2.record(z2.string(), z2.string()).optional(),
|
|
1314
|
+
body: z2.unknown().optional()
|
|
1315
|
+
});
|
|
979
1316
|
var ProvisionRegistry = class {
|
|
980
1317
|
providers = /* @__PURE__ */ new Map();
|
|
981
1318
|
register(provider) {
|
|
@@ -992,16 +1329,20 @@ var awsStsProvider = {
|
|
|
992
1329
|
name: "aws-sts",
|
|
993
1330
|
description: "AWS STS AssumeRole (requires existing local AWS CLI credentials)",
|
|
994
1331
|
provision(configRaw) {
|
|
995
|
-
let
|
|
1332
|
+
let raw;
|
|
996
1333
|
try {
|
|
997
|
-
|
|
1334
|
+
raw = JSON.parse(configRaw);
|
|
998
1335
|
} catch {
|
|
999
1336
|
throw new Error('aws-sts requires valid JSON config (e.g. {"roleArn":"arn:aws:..."})');
|
|
1000
1337
|
}
|
|
1338
|
+
const parsed = AwsStsConfigSchema.safeParse(raw);
|
|
1339
|
+
if (!parsed.success) {
|
|
1340
|
+
throw new Error(`aws-sts invalid config: ${parsed.error.message}`);
|
|
1341
|
+
}
|
|
1342
|
+
const config = parsed.data;
|
|
1001
1343
|
const roleArn = config.roleArn;
|
|
1002
1344
|
const sessionName = config.sessionName || "q-ring-agent";
|
|
1003
1345
|
const duration = config.durationSeconds || 3600;
|
|
1004
|
-
if (!roleArn) throw new Error("aws-sts requires roleArn in config");
|
|
1005
1346
|
try {
|
|
1006
1347
|
const output = execFileSync("aws", [
|
|
1007
1348
|
"sts",
|
|
@@ -1015,8 +1356,8 @@ var awsStsProvider = {
|
|
|
1015
1356
|
"--output",
|
|
1016
1357
|
"json"
|
|
1017
1358
|
], { encoding: "utf8" });
|
|
1018
|
-
const
|
|
1019
|
-
const creds =
|
|
1359
|
+
const parsed2 = JSON.parse(output);
|
|
1360
|
+
const creds = parsed2.Credentials;
|
|
1020
1361
|
const value = JSON.stringify({
|
|
1021
1362
|
AWS_ACCESS_KEY_ID: creds.AccessKeyId,
|
|
1022
1363
|
AWS_SECRET_ACCESS_KEY: creds.SecretAccessKey,
|
|
@@ -1035,18 +1376,23 @@ var httpProvider = {
|
|
|
1035
1376
|
name: "http",
|
|
1036
1377
|
description: "Generic HTTP token endpoint using Node.js http/https",
|
|
1037
1378
|
provision(configRaw) {
|
|
1038
|
-
let
|
|
1379
|
+
let raw;
|
|
1039
1380
|
try {
|
|
1040
|
-
|
|
1381
|
+
raw = JSON.parse(configRaw);
|
|
1041
1382
|
} catch {
|
|
1042
1383
|
throw new Error("http provider requires valid JSON config");
|
|
1043
1384
|
}
|
|
1385
|
+
const parsed = HttpJitConfigSchema.safeParse(raw);
|
|
1386
|
+
if (!parsed.success) {
|
|
1387
|
+
throw new Error(`http provider invalid config: ${parsed.error.message}`);
|
|
1388
|
+
}
|
|
1389
|
+
const config = parsed.data;
|
|
1044
1390
|
const url = config.url;
|
|
1045
1391
|
const method = config.method || "POST";
|
|
1046
1392
|
const valuePath = config.valuePath || "token";
|
|
1047
1393
|
const expiresInSeconds = config.expiresInSeconds || 3600;
|
|
1048
1394
|
if (!url) throw new Error("http provider requires url in config");
|
|
1049
|
-
const ssrfBlock =
|
|
1395
|
+
const ssrfBlock = checkJitHttpProvisionUrl(url);
|
|
1050
1396
|
if (ssrfBlock) throw new Error(`SSRF blocked: ${ssrfBlock}`);
|
|
1051
1397
|
const headers = {
|
|
1052
1398
|
"User-Agent": "q-ring-jit/1.0",
|
|
@@ -1080,8 +1426,8 @@ req.end();
|
|
|
1080
1426
|
if (result.status !== 0) {
|
|
1081
1427
|
throw new Error(result.stderr || "HTTP request failed");
|
|
1082
1428
|
}
|
|
1083
|
-
const
|
|
1084
|
-
let val =
|
|
1429
|
+
const parsed2 = JSON.parse(result.stdout);
|
|
1430
|
+
let val = parsed2;
|
|
1085
1431
|
for (const key of valuePath.split(".")) {
|
|
1086
1432
|
val = val[key];
|
|
1087
1433
|
}
|
|
@@ -1098,86 +1444,33 @@ var registry = new ProvisionRegistry();
|
|
|
1098
1444
|
registry.register(awsStsProvider);
|
|
1099
1445
|
registry.register(httpProvider);
|
|
1100
1446
|
|
|
1101
|
-
// src/core/
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
return {
|
|
1125
|
-
allowed: false,
|
|
1126
|
-
reason: `Key "${key}" is not in the readable keys allowlist`,
|
|
1127
|
-
policySource: ".q-ring.json policy.mcp.readableKeys"
|
|
1128
|
-
};
|
|
1129
|
-
}
|
|
1130
|
-
if (tags && policy.mcp.deniedTags) {
|
|
1131
|
-
const blocked = tags.find((t) => policy.mcp.deniedTags.includes(t));
|
|
1132
|
-
if (blocked) {
|
|
1133
|
-
return {
|
|
1134
|
-
allowed: false,
|
|
1135
|
-
reason: `Tag "${blocked}" is denied by project policy`,
|
|
1136
|
-
policySource: ".q-ring.json policy.mcp.deniedTags"
|
|
1137
|
-
};
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
return { allowed: true, policySource: ".q-ring.json" };
|
|
1141
|
-
}
|
|
1142
|
-
function checkExecPolicy(command, projectPath) {
|
|
1143
|
-
const policy = loadPolicy(projectPath);
|
|
1144
|
-
if (!policy.exec) return { allowed: true, policySource: "no-policy" };
|
|
1145
|
-
if (policy.exec.denyCommands) {
|
|
1146
|
-
const denied = policy.exec.denyCommands.find((d) => command.includes(d));
|
|
1147
|
-
if (denied) {
|
|
1148
|
-
return {
|
|
1149
|
-
allowed: false,
|
|
1150
|
-
reason: `Command containing "${denied}" is denied by project policy`,
|
|
1151
|
-
policySource: ".q-ring.json policy.exec.denyCommands"
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
if (policy.exec.allowCommands) {
|
|
1156
|
-
const allowed = policy.exec.allowCommands.some((a) => command.startsWith(a));
|
|
1157
|
-
if (!allowed) {
|
|
1158
|
-
return {
|
|
1159
|
-
allowed: false,
|
|
1160
|
-
reason: `Command "${command}" is not in the exec allowlist`,
|
|
1161
|
-
policySource: ".q-ring.json policy.exec.allowCommands"
|
|
1162
|
-
};
|
|
1447
|
+
// src/core/keyring.ts
|
|
1448
|
+
function withJitEnvelopeLock(service, key, fn) {
|
|
1449
|
+
const dir = join7(homedir5(), ".config", "q-ring", "jit-locks");
|
|
1450
|
+
mkdirSync5(dir, { recursive: true });
|
|
1451
|
+
const safe = Buffer.from(`${service}\0${key}`, "utf8").toString("base64url");
|
|
1452
|
+
const lockPath = join7(dir, `${safe}.lock`);
|
|
1453
|
+
const deadline = Date.now() + 8e3;
|
|
1454
|
+
while (Date.now() < deadline) {
|
|
1455
|
+
try {
|
|
1456
|
+
writeFileSync4(lockPath, `${process.pid}
|
|
1457
|
+
`, { flag: "wx", mode: 384 });
|
|
1458
|
+
try {
|
|
1459
|
+
return fn();
|
|
1460
|
+
} finally {
|
|
1461
|
+
try {
|
|
1462
|
+
unlinkSync(lockPath);
|
|
1463
|
+
} catch {
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
} catch {
|
|
1467
|
+
const start = Date.now();
|
|
1468
|
+
while (Date.now() - start < 15) {
|
|
1469
|
+
}
|
|
1163
1470
|
}
|
|
1164
1471
|
}
|
|
1165
|
-
|
|
1472
|
+
throw new Error("Could not acquire JIT envelope lock (timeout)");
|
|
1166
1473
|
}
|
|
1167
|
-
function getExecMaxRuntime(projectPath) {
|
|
1168
|
-
return loadPolicy(projectPath).exec?.maxRuntimeSeconds;
|
|
1169
|
-
}
|
|
1170
|
-
function getPolicySummary(projectPath) {
|
|
1171
|
-
const policy = loadPolicy(projectPath);
|
|
1172
|
-
return {
|
|
1173
|
-
hasMcpPolicy: !!policy.mcp,
|
|
1174
|
-
hasExecPolicy: !!policy.exec,
|
|
1175
|
-
hasSecretPolicy: !!policy.secrets,
|
|
1176
|
-
details: policy
|
|
1177
|
-
};
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
// src/core/keyring.ts
|
|
1181
1474
|
function readEnvelope(service, key) {
|
|
1182
1475
|
const entry = new Entry(service, key);
|
|
1183
1476
|
const raw = entry.getPassword();
|
|
@@ -1196,7 +1489,7 @@ function resolveEnv(opts) {
|
|
|
1196
1489
|
}
|
|
1197
1490
|
function resolveTemplates(value, opts, seen) {
|
|
1198
1491
|
if (!value.includes("{{") || !value.includes("}}")) return value;
|
|
1199
|
-
return value.replace(/\{\{([^}]+)\}\}/g, (
|
|
1492
|
+
return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
|
|
1200
1493
|
const refKey = refKeyRaw.trim();
|
|
1201
1494
|
const refValue = getSecret(refKey, { ...opts, _seen: seen });
|
|
1202
1495
|
if (refValue === null) {
|
|
@@ -1207,7 +1500,7 @@ function resolveTemplates(value, opts, seen) {
|
|
|
1207
1500
|
}
|
|
1208
1501
|
function resolveTemplatesOffline(value, rawValues, seen) {
|
|
1209
1502
|
if (!value.includes("{{") || !value.includes("}}")) return value;
|
|
1210
|
-
return value.replace(/\{\{([^}]+)\}\}/g, (
|
|
1503
|
+
return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
|
|
1211
1504
|
const refKey = refKeyRaw.trim();
|
|
1212
1505
|
if (seen.has(refKey)) {
|
|
1213
1506
|
throw new Error(`Circular dependency detected: ${[...seen].join(" -> ")} -> ${refKey}`);
|
|
@@ -1277,11 +1570,27 @@ function getSecret(key, opts = {}) {
|
|
|
1277
1570
|
isExpired = new Date(envelope.meta.jitExpiresAt).getTime() <= Date.now();
|
|
1278
1571
|
}
|
|
1279
1572
|
if (isExpired) {
|
|
1280
|
-
const
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1573
|
+
const jitConfigSource = value;
|
|
1574
|
+
withJitEnvelopeLock(service, key, () => {
|
|
1575
|
+
const latest = readEnvelope(service, key);
|
|
1576
|
+
if (!latest?.meta.jitProvider) return;
|
|
1577
|
+
let innerExpired = true;
|
|
1578
|
+
if (latest.states && latest.states["jit"] && latest.meta.jitExpiresAt) {
|
|
1579
|
+
innerExpired = new Date(latest.meta.jitExpiresAt).getTime() <= Date.now();
|
|
1580
|
+
}
|
|
1581
|
+
if (!innerExpired) {
|
|
1582
|
+
envelope.states = latest.states;
|
|
1583
|
+
envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
const result = provider.provision(jitConfigSource);
|
|
1587
|
+
latest.states = latest.states ?? {};
|
|
1588
|
+
latest.states["jit"] = result.value;
|
|
1589
|
+
latest.meta.jitExpiresAt = result.expiresAt;
|
|
1590
|
+
writeEnvelope(service, key, latest);
|
|
1591
|
+
envelope.states = latest.states;
|
|
1592
|
+
envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
|
|
1593
|
+
});
|
|
1285
1594
|
}
|
|
1286
1595
|
value = envelope.states["jit"];
|
|
1287
1596
|
}
|
|
@@ -1323,6 +1632,20 @@ function setSecret(key, value, opts = {}) {
|
|
|
1323
1632
|
const prov = opts.provider ?? existing?.meta.provider;
|
|
1324
1633
|
const reqApp = opts.requiresApproval ?? existing?.meta.requiresApproval;
|
|
1325
1634
|
const jitProv = opts.jitProvider ?? existing?.meta.jitProvider;
|
|
1635
|
+
const mergedTags = opts.tags ?? existing?.meta.tags;
|
|
1636
|
+
const ttlForPolicy = opts.ttlSeconds ?? existing?.meta.ttlSeconds;
|
|
1637
|
+
const life = checkSecretLifecyclePolicy(
|
|
1638
|
+
{
|
|
1639
|
+
tags: mergedTags,
|
|
1640
|
+
ttlSeconds: ttlForPolicy,
|
|
1641
|
+
rotationFormat: rotFmt,
|
|
1642
|
+
requiresApproval: !!reqApp
|
|
1643
|
+
},
|
|
1644
|
+
opts.projectPath
|
|
1645
|
+
);
|
|
1646
|
+
if (!life.allowed) {
|
|
1647
|
+
throw new Error(life.reason ?? "Secret lifecycle policy denied");
|
|
1648
|
+
}
|
|
1326
1649
|
if (opts.states) {
|
|
1327
1650
|
envelope = createEnvelope("", {
|
|
1328
1651
|
states: opts.states,
|
|
@@ -1639,7 +1962,125 @@ function tunnelList() {
|
|
|
1639
1962
|
return result;
|
|
1640
1963
|
}
|
|
1641
1964
|
|
|
1965
|
+
// src/core/memory.ts
|
|
1966
|
+
import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6 } from "fs";
|
|
1967
|
+
import { join as join8 } from "path";
|
|
1968
|
+
import { homedir as homedir6, hostname, userInfo } from "os";
|
|
1969
|
+
import { createCipheriv, createDecipheriv, createHash as createHash3, randomBytes as randomBytes3 } from "crypto";
|
|
1970
|
+
import { Entry as Entry2 } from "@napi-rs/keyring";
|
|
1971
|
+
var MEMORY_FILE = "agent-memory.enc";
|
|
1972
|
+
var KEYRING_SERVICE = "qring-memory-key";
|
|
1973
|
+
var KEYRING_ACCOUNT = "encryption-key";
|
|
1974
|
+
function getMemoryDir() {
|
|
1975
|
+
const dir = join8(homedir6(), ".config", "q-ring");
|
|
1976
|
+
if (!existsSync6(dir)) {
|
|
1977
|
+
mkdirSync6(dir, { recursive: true });
|
|
1978
|
+
}
|
|
1979
|
+
return dir;
|
|
1980
|
+
}
|
|
1981
|
+
function getMemoryPath() {
|
|
1982
|
+
return join8(getMemoryDir(), MEMORY_FILE);
|
|
1983
|
+
}
|
|
1984
|
+
function deriveLegacyKey() {
|
|
1985
|
+
const fingerprint = `qring-memory:${hostname()}:${userInfo().username}`;
|
|
1986
|
+
return createHash3("sha256").update(fingerprint).digest();
|
|
1987
|
+
}
|
|
1988
|
+
function getOrCreateKey() {
|
|
1989
|
+
try {
|
|
1990
|
+
const entry = new Entry2(KEYRING_SERVICE, KEYRING_ACCOUNT);
|
|
1991
|
+
const stored = entry.getPassword();
|
|
1992
|
+
if (stored) return Buffer.from(stored, "base64");
|
|
1993
|
+
const key = randomBytes3(32);
|
|
1994
|
+
entry.setPassword(key.toString("base64"));
|
|
1995
|
+
return key;
|
|
1996
|
+
} catch {
|
|
1997
|
+
console.warn("[q-ring] OS keyring unavailable for memory key \u2014 falling back to machine-derived key");
|
|
1998
|
+
return deriveLegacyKey();
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
function encryptWith(data, key) {
|
|
2002
|
+
const iv = randomBytes3(12);
|
|
2003
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
2004
|
+
const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
|
|
2005
|
+
const tag = cipher.getAuthTag();
|
|
2006
|
+
return `${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
|
|
2007
|
+
}
|
|
2008
|
+
function decryptWith(blob, key) {
|
|
2009
|
+
const parts = blob.split(":");
|
|
2010
|
+
if (parts.length !== 3) throw new Error("Invalid encrypted format");
|
|
2011
|
+
const iv = Buffer.from(parts[0], "base64");
|
|
2012
|
+
const tag = Buffer.from(parts[1], "base64");
|
|
2013
|
+
const encrypted = Buffer.from(parts[2], "base64");
|
|
2014
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
2015
|
+
decipher.setAuthTag(tag);
|
|
2016
|
+
return decipher.update(encrypted) + decipher.final("utf8");
|
|
2017
|
+
}
|
|
2018
|
+
function encrypt(data) {
|
|
2019
|
+
return encryptWith(data, getOrCreateKey());
|
|
2020
|
+
}
|
|
2021
|
+
function decrypt(blob) {
|
|
2022
|
+
const key = getOrCreateKey();
|
|
2023
|
+
try {
|
|
2024
|
+
return decryptWith(blob, key);
|
|
2025
|
+
} catch {
|
|
2026
|
+
const legacy = deriveLegacyKey();
|
|
2027
|
+
const plain = decryptWith(blob, legacy);
|
|
2028
|
+
writeFileSync5(getMemoryPath(), encryptWith(plain, key), "utf8");
|
|
2029
|
+
return plain;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
function loadStore() {
|
|
2033
|
+
const path = getMemoryPath();
|
|
2034
|
+
if (!existsSync6(path)) {
|
|
2035
|
+
return { entries: {} };
|
|
2036
|
+
}
|
|
2037
|
+
try {
|
|
2038
|
+
const raw = readFileSync7(path, "utf8");
|
|
2039
|
+
const decrypted = decrypt(raw);
|
|
2040
|
+
return JSON.parse(decrypted);
|
|
2041
|
+
} catch {
|
|
2042
|
+
return { entries: {} };
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
function saveStore(store) {
|
|
2046
|
+
const json = JSON.stringify(store);
|
|
2047
|
+
const encrypted = encrypt(json);
|
|
2048
|
+
writeFileSync5(getMemoryPath(), encrypted, "utf8");
|
|
2049
|
+
}
|
|
2050
|
+
function remember(key, value) {
|
|
2051
|
+
const store = loadStore();
|
|
2052
|
+
store.entries[key] = {
|
|
2053
|
+
value,
|
|
2054
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2055
|
+
};
|
|
2056
|
+
saveStore(store);
|
|
2057
|
+
}
|
|
2058
|
+
function recall(key) {
|
|
2059
|
+
const store = loadStore();
|
|
2060
|
+
return store.entries[key]?.value ?? null;
|
|
2061
|
+
}
|
|
2062
|
+
function listMemory() {
|
|
2063
|
+
const store = loadStore();
|
|
2064
|
+
return Object.entries(store.entries).map(([key, entry]) => ({
|
|
2065
|
+
key,
|
|
2066
|
+
updatedAt: entry.updatedAt
|
|
2067
|
+
}));
|
|
2068
|
+
}
|
|
2069
|
+
function forget(key) {
|
|
2070
|
+
const store = loadStore();
|
|
2071
|
+
if (key in store.entries) {
|
|
2072
|
+
delete store.entries[key];
|
|
2073
|
+
saveStore(store);
|
|
2074
|
+
return true;
|
|
2075
|
+
}
|
|
2076
|
+
return false;
|
|
2077
|
+
}
|
|
2078
|
+
function clearMemory() {
|
|
2079
|
+
saveStore({ entries: {} });
|
|
2080
|
+
}
|
|
2081
|
+
|
|
1642
2082
|
export {
|
|
2083
|
+
PACKAGE_VERSION,
|
|
1643
2084
|
checkDecay,
|
|
1644
2085
|
readProjectConfig,
|
|
1645
2086
|
collapseEnvironment,
|
|
@@ -1649,7 +2090,7 @@ export {
|
|
|
1649
2090
|
exportAudit,
|
|
1650
2091
|
detectAnomalies,
|
|
1651
2092
|
listEntanglements,
|
|
1652
|
-
|
|
2093
|
+
httpRequest,
|
|
1653
2094
|
checkSSRF,
|
|
1654
2095
|
registerHook,
|
|
1655
2096
|
removeHook,
|
|
@@ -1676,6 +2117,11 @@ export {
|
|
|
1676
2117
|
tunnelCreate,
|
|
1677
2118
|
tunnelRead,
|
|
1678
2119
|
tunnelDestroy,
|
|
1679
|
-
tunnelList
|
|
2120
|
+
tunnelList,
|
|
2121
|
+
remember,
|
|
2122
|
+
recall,
|
|
2123
|
+
listMemory,
|
|
2124
|
+
forget,
|
|
2125
|
+
clearMemory
|
|
1680
2126
|
};
|
|
1681
|
-
//# sourceMappingURL=chunk-
|
|
2127
|
+
//# sourceMappingURL=chunk-A4RZVP3P.js.map
|