@i4ctime/q-ring 0.9.7 → 0.10.1

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.
@@ -1,6 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/core/envelope.ts
4
+ import { z } from "zod";
5
+ var EntanglementLinkSchema = z.object({
6
+ service: z.string(),
7
+ key: z.string()
8
+ });
9
+ var SecretMetadataSchema = z.object({
10
+ createdAt: z.string(),
11
+ updatedAt: z.string(),
12
+ expiresAt: z.string().optional(),
13
+ ttlSeconds: z.number().optional(),
14
+ description: z.string().optional(),
15
+ tags: z.array(z.string()).optional(),
16
+ entangled: z.array(EntanglementLinkSchema).optional(),
17
+ accessCount: z.number(),
18
+ lastAccessedAt: z.string().optional(),
19
+ ephemeral: z.boolean().optional(),
20
+ rotationFormat: z.string().optional(),
21
+ rotationPrefix: z.string().optional(),
22
+ provider: z.string().optional(),
23
+ validationUrl: z.string().optional(),
24
+ requiresApproval: z.boolean().optional(),
25
+ jitProvider: z.string().optional(),
26
+ jitExpiresAt: z.string().optional()
27
+ });
28
+ var QuantumEnvelopeSchema = z.object({
29
+ v: z.literal(1),
30
+ value: z.string().optional(),
31
+ states: z.record(z.string(), z.string()).optional(),
32
+ defaultEnv: z.string().optional(),
33
+ meta: SecretMetadataSchema
34
+ });
4
35
  function createEnvelope(value, opts) {
5
36
  const now = (/* @__PURE__ */ new Date()).toISOString();
6
37
  let expiresAt = opts?.expiresAt;
@@ -32,8 +63,9 @@ function createEnvelope(value, opts) {
32
63
  function parseEnvelope(raw) {
33
64
  try {
34
65
  const parsed = JSON.parse(raw);
35
- if (parsed && typeof parsed === "object" && parsed.v === 1) {
36
- return parsed;
66
+ const r = QuantumEnvelopeSchema.safeParse(parsed);
67
+ if (r.success) {
68
+ return r.data;
37
69
  }
38
70
  } catch {
39
71
  }
@@ -84,6 +116,15 @@ function checkDecay(envelope) {
84
116
  const now = Date.now();
85
117
  const expires = new Date(envelope.meta.expiresAt).getTime();
86
118
  const created = new Date(envelope.meta.createdAt).getTime();
119
+ if (!Number.isFinite(expires) || !Number.isFinite(created)) {
120
+ return {
121
+ isExpired: true,
122
+ isStale: true,
123
+ lifetimePercent: 100,
124
+ secondsRemaining: null,
125
+ timeRemaining: "invalid date"
126
+ };
127
+ }
87
128
  const totalLifetime = expires - created;
88
129
  const elapsed = now - created;
89
130
  const remaining = expires - now;
@@ -188,12 +229,15 @@ function collapseEnvironment(ctx = {}) {
188
229
  }
189
230
  return null;
190
231
  }
232
+ var MAX_BRANCH_GLOB_PATTERN_LEN = 200;
233
+ var MAX_BRANCH_GLOB_REGEX_SOURCE = 400;
191
234
  function matchGlob(branchMap, branch) {
192
235
  for (const [pattern, env] of Object.entries(branchMap)) {
193
236
  if (!pattern.includes("*")) continue;
194
- const regex = new RegExp(
195
- "^" + pattern.replace(/\*/g, ".*") + "$"
196
- );
237
+ if (pattern.length > MAX_BRANCH_GLOB_PATTERN_LEN) continue;
238
+ const source = "^" + pattern.replace(/\*/g, ".*") + "$";
239
+ if (source.length > MAX_BRANCH_GLOB_REGEX_SOURCE) continue;
240
+ const regex = new RegExp(source);
197
241
  if (regex.test(branch)) return env;
198
242
  }
199
243
  return void 0;
@@ -206,11 +250,27 @@ function mapEnvName(raw) {
206
250
  }
207
251
 
208
252
  // src/core/observer.ts
209
- import { existsSync as existsSync2, mkdirSync, appendFileSync, readFileSync as readFileSync2, openSync, fstatSync, readSync, closeSync } from "fs";
253
+ import {
254
+ existsSync as existsSync2,
255
+ mkdirSync,
256
+ appendFileSync,
257
+ readFileSync as readFileSync2,
258
+ openSync,
259
+ fstatSync,
260
+ readSync,
261
+ closeSync,
262
+ statSync
263
+ } from "fs";
210
264
  import { join as join2 } from "path";
211
265
  import { homedir } from "os";
212
266
  import { createHash } from "crypto";
213
267
  function getAuditDir() {
268
+ if (process.env.QRING_AUDIT_DIR) {
269
+ if (!existsSync2(process.env.QRING_AUDIT_DIR)) {
270
+ mkdirSync(process.env.QRING_AUDIT_DIR, { recursive: true });
271
+ }
272
+ return process.env.QRING_AUDIT_DIR;
273
+ }
214
274
  const dir = join2(homedir(), ".config", "q-ring");
215
275
  if (!existsSync2(dir)) {
216
276
  mkdirSync(dir, { recursive: true });
@@ -256,11 +316,24 @@ function logAudit(event) {
256
316
  } catch {
257
317
  }
258
318
  }
319
+ var MAX_AUDIT_BYTES = 12 * 1024 * 1024;
259
320
  function queryAudit(query = {}) {
260
321
  const path = getAuditPath();
261
322
  if (!existsSync2(path)) return [];
262
323
  try {
263
- const lines = readFileSync2(path, "utf8").split("\n").filter((l) => l.trim());
324
+ const st = statSync(path);
325
+ const readStart = st.size > MAX_AUDIT_BYTES ? st.size - MAX_AUDIT_BYTES : 0;
326
+ const readLen = st.size > MAX_AUDIT_BYTES ? MAX_AUDIT_BYTES : st.size;
327
+ const buf = Buffer.alloc(readLen);
328
+ const fd = openSync(path, "r");
329
+ readSync(fd, buf, 0, readLen, readStart);
330
+ closeSync(fd);
331
+ let text = buf.toString("utf8");
332
+ if (readStart > 0) {
333
+ const firstNl = text.indexOf("\n");
334
+ if (firstNl !== -1) text = text.slice(firstNl + 1);
335
+ }
336
+ const lines = text.split("\n").filter((l) => l.trim());
264
337
  let events = lines.map((line) => {
265
338
  try {
266
339
  return JSON.parse(line);
@@ -393,7 +466,7 @@ function detectAnomalies(key) {
393
466
  }
394
467
 
395
468
  // src/core/entanglement.ts
396
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
469
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
397
470
  import { join as join3 } from "path";
398
471
  import { homedir as homedir2 } from "os";
399
472
  function getRegistryPath() {
@@ -415,7 +488,9 @@ function loadRegistry() {
415
488
  }
416
489
  }
417
490
  function saveRegistry(registry2) {
418
- writeFileSync2(getRegistryPath(), JSON.stringify(registry2, null, 2));
491
+ writeFileSync(getRegistryPath(), JSON.stringify(registry2, null, 2), {
492
+ mode: 384
493
+ });
419
494
  }
420
495
  function entangle(source, target) {
421
496
  const registry2 = loadRegistry();
@@ -454,6 +529,9 @@ function listEntanglements() {
454
529
  }
455
530
 
456
531
  // src/core/keyring.ts
532
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4, unlinkSync } from "fs";
533
+ import { homedir as homedir5 } from "os";
534
+ import { join as join6 } from "path";
457
535
  import { Entry, findCredentials } from "@napi-rs/keyring";
458
536
 
459
537
  // src/utils/hash.ts
@@ -509,19 +587,18 @@ function resolveScope(opts) {
509
587
  }
510
588
 
511
589
  // src/core/hooks.ts
512
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
590
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
513
591
  import { join as join4 } from "path";
514
592
  import { homedir as homedir3 } from "os";
515
- import { exec } from "child_process";
593
+ import { execFile, spawn } from "child_process";
516
594
  import { randomUUID } from "crypto";
517
- import { lookup } from "dns/promises";
518
595
 
519
596
  // src/utils/http-request.ts
520
597
  import { request as httpsRequest } from "https";
521
- import { request as httpRequest } from "http";
598
+ import { request as httpRequestPlain } from "http";
522
599
  var DEFAULT_TIMEOUT_MS = 1e4;
523
600
  var DEFAULT_MAX_RESPONSE_BYTES = 65536;
524
- function httpRequest_(opts) {
601
+ function httpRequest(opts) {
525
602
  const {
526
603
  url,
527
604
  method = "GET",
@@ -536,7 +613,7 @@ function httpRequest_(opts) {
536
613
  reject(new Error(`Unsupported URL protocol: ${parsed.protocol}`));
537
614
  return;
538
615
  }
539
- const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequest;
616
+ const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequestPlain;
540
617
  const reqHeaders = { ...headers };
541
618
  if (body && !reqHeaders["Content-Length"]) {
542
619
  reqHeaders["Content-Length"] = Buffer.byteLength(body);
@@ -597,6 +674,92 @@ function httpRequest_(opts) {
597
674
  });
598
675
  }
599
676
 
677
+ // src/core/ssrf.ts
678
+ import { lookup } from "dns/promises";
679
+ import * as dns from "dns";
680
+ import { isIPv4, isIPv6 } from "net";
681
+ function lookupAddressesSync(hostname) {
682
+ const lookupSync2 = dns.lookupSync;
683
+ return lookupSync2(hostname, { all: true });
684
+ }
685
+ function isHostnameIpLiteral(hostname) {
686
+ if (isIPv4(hostname)) return true;
687
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
688
+ return isIPv6(hostname.slice(1, -1));
689
+ }
690
+ return isIPv6(hostname);
691
+ }
692
+ function isPrivateIP(ip) {
693
+ const octet = "(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})";
694
+ const ipv4Re = new RegExp(`^::ffff:(${octet}\\.${octet}\\.${octet}\\.${octet})$`, "i");
695
+ const ipv4Mapped = ip.match(ipv4Re);
696
+ if (ipv4Mapped) return isPrivateIP(ipv4Mapped[1]);
697
+ if (/^127\./.test(ip)) return true;
698
+ if (/^10\./.test(ip)) return true;
699
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
700
+ if (/^192\.168\./.test(ip)) return true;
701
+ if (/^169\.254\./.test(ip)) return true;
702
+ if (ip === "0.0.0.0") return true;
703
+ if (ip === "::1" || ip === "::") return true;
704
+ if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return true;
705
+ if (/^fe80:/i.test(ip)) return true;
706
+ return false;
707
+ }
708
+ async function checkSSRF(url) {
709
+ if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
710
+ try {
711
+ const parsed = new URL(url);
712
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
713
+ if (isPrivateIP(hostname)) {
714
+ return `Blocked: URL resolves to private address (${hostname}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
715
+ }
716
+ const results = await lookup(hostname, { all: true });
717
+ for (const { address } of results) {
718
+ if (isPrivateIP(address)) {
719
+ return `Blocked: URL "${hostname}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
720
+ }
721
+ }
722
+ } catch {
723
+ }
724
+ return null;
725
+ }
726
+ function checkJitHttpProvisionUrl(url) {
727
+ if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
728
+ try {
729
+ const parsed = new URL(url);
730
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
731
+ return "Blocked: JIT HTTP provider only allows http: or https: URLs.";
732
+ }
733
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
734
+ if (!hostname) {
735
+ return "Blocked: empty hostname in JIT URL.";
736
+ }
737
+ if (isPrivateIP(hostname)) {
738
+ return `Blocked: URL resolves to private address (${hostname}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
739
+ }
740
+ if (isHostnameIpLiteral(hostname)) {
741
+ return null;
742
+ }
743
+ let results;
744
+ try {
745
+ results = lookupAddressesSync(hostname);
746
+ } catch {
747
+ return `Blocked: DNS resolution failed for "${hostname}" (JIT HTTP provisioning fails closed).`;
748
+ }
749
+ if (!results.length) {
750
+ return `Blocked: DNS returned no addresses for "${hostname}".`;
751
+ }
752
+ for (const { address } of results) {
753
+ if (isPrivateIP(address)) {
754
+ return `Blocked: URL "${hostname}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
755
+ }
756
+ }
757
+ } catch {
758
+ return "Blocked: malformed JIT HTTP URL.";
759
+ }
760
+ return null;
761
+ }
762
+
600
763
  // src/core/hooks.ts
601
764
  function getRegistryPath2() {
602
765
  const dir = join4(homedir3(), ".config", "q-ring");
@@ -617,7 +780,9 @@ function loadRegistry2() {
617
780
  }
618
781
  }
619
782
  function saveRegistry2(registry2) {
620
- writeFileSync3(getRegistryPath2(), JSON.stringify(registry2, null, 2));
783
+ writeFileSync2(getRegistryPath2(), JSON.stringify(registry2, null, 2), {
784
+ mode: 384
785
+ });
621
786
  }
622
787
  function registerHook(entry) {
623
788
  const registry2 = loadRegistry2();
@@ -649,16 +814,22 @@ function matchesHook(hook, payload, tags) {
649
814
  if (m.action?.length && !m.action.includes(payload.action)) return false;
650
815
  if (m.key && m.key !== payload.key) return false;
651
816
  if (m.keyPattern) {
652
- const regex = new RegExp(
653
- "^" + m.keyPattern.replace(/\*/g, ".*") + "$",
654
- "i"
655
- );
817
+ const escaped = m.keyPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
818
+ const regex = new RegExp("^" + escaped + "$", "i");
656
819
  if (!regex.test(payload.key)) return false;
657
820
  }
658
821
  if (m.tag && (!tags || !tags.includes(m.tag))) return false;
659
822
  if (m.scope && m.scope !== payload.scope) return false;
660
823
  return true;
661
824
  }
825
+ var SAFE_PGREP_NAME = /^[a-zA-Z0-9._:/-]{1,256}$/;
826
+ function shellRunner() {
827
+ if (process.platform === "win32") {
828
+ const comspec = process.env.ComSpec ?? "cmd.exe";
829
+ return { file: comspec, args: ["/d", "/s", "/c"] };
830
+ }
831
+ return { file: "/bin/sh", args: ["-c"] };
832
+ }
662
833
  function executeShell(command, payload) {
663
834
  return new Promise((resolve) => {
664
835
  const env = {
@@ -667,49 +838,35 @@ function executeShell(command, payload) {
667
838
  QRING_HOOK_ACTION: payload.action,
668
839
  QRING_HOOK_SCOPE: payload.scope
669
840
  };
670
- exec(command, { timeout: 3e4, env }, (err, stdout, stderr) => {
671
- if (err) {
672
- resolve({ hookId: "", success: false, message: `Shell error: ${err.message}` });
673
- } else {
674
- resolve({ hookId: "", success: true, message: stdout.trim() || "OK" });
841
+ const { file, args } = shellRunner();
842
+ execFile(
843
+ file,
844
+ [...args, command],
845
+ {
846
+ timeout: 3e4,
847
+ env,
848
+ windowsHide: true,
849
+ maxBuffer: 4 * 1024 * 1024,
850
+ encoding: "utf8"
851
+ },
852
+ (err, stdout) => {
853
+ if (err) {
854
+ resolve({
855
+ hookId: "",
856
+ success: false,
857
+ message: `Shell error: ${err.message}`
858
+ });
859
+ } else {
860
+ resolve({
861
+ hookId: "",
862
+ success: true,
863
+ message: (stdout ?? "").trim() || "OK"
864
+ });
865
+ }
675
866
  }
676
- });
867
+ );
677
868
  });
678
869
  }
679
- function isPrivateIP(ip) {
680
- const octet = "(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})";
681
- const ipv4Re = new RegExp(`^::ffff:(${octet}\\.${octet}\\.${octet}\\.${octet})$`, "i");
682
- const ipv4Mapped = ip.match(ipv4Re);
683
- if (ipv4Mapped) return isPrivateIP(ipv4Mapped[1]);
684
- if (/^127\./.test(ip)) return true;
685
- if (/^10\./.test(ip)) return true;
686
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
687
- if (/^192\.168\./.test(ip)) return true;
688
- if (/^169\.254\./.test(ip)) return true;
689
- if (ip === "0.0.0.0") return true;
690
- if (ip === "::1" || ip === "::") return true;
691
- if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return true;
692
- if (/^fe80:/i.test(ip)) return true;
693
- return false;
694
- }
695
- async function checkSSRF(url) {
696
- if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
697
- try {
698
- const parsed = new URL(url);
699
- const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
700
- if (isPrivateIP(hostname)) {
701
- return `Blocked: hook URL resolves to private address (${hostname}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
702
- }
703
- const results = await lookup(hostname, { all: true });
704
- for (const { address } of results) {
705
- if (isPrivateIP(address)) {
706
- return `Blocked: hook URL "${hostname}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
707
- }
708
- }
709
- } catch {
710
- }
711
- return null;
712
- }
713
870
  async function executeHttp(url, payload) {
714
871
  const ssrfBlock = await checkSSRF(url);
715
872
  if (ssrfBlock) {
@@ -724,7 +881,7 @@ async function executeHttp(url, payload) {
724
881
  }
725
882
  try {
726
883
  const body = JSON.stringify(payload);
727
- const res = await httpRequest_({
884
+ const res = await httpRequest({
728
885
  url,
729
886
  method: "POST",
730
887
  headers: {
@@ -749,19 +906,48 @@ async function executeHttp(url, payload) {
749
906
  }
750
907
  function executeSignal(target, signal = "SIGHUP") {
751
908
  return new Promise((resolve) => {
752
- const pid = parseInt(target, 10);
753
- if (!isNaN(pid)) {
909
+ const trimmed = target.trim();
910
+ if (/^\d+$/.test(trimmed)) {
911
+ const pid = parseInt(trimmed, 10);
754
912
  try {
755
913
  process.kill(pid, signal);
756
- resolve({ hookId: "", success: true, message: `Signal ${signal} sent to PID ${pid}` });
914
+ resolve({
915
+ hookId: "",
916
+ success: true,
917
+ message: `Signal ${signal} sent to PID ${pid}`
918
+ });
757
919
  } catch (err) {
758
- resolve({ hookId: "", success: false, message: `Signal error: ${err instanceof Error ? err.message : String(err)}` });
920
+ resolve({
921
+ hookId: "",
922
+ success: false,
923
+ message: `Signal error: ${err instanceof Error ? err.message : String(err)}`
924
+ });
759
925
  }
760
926
  return;
761
927
  }
762
- exec(`pgrep -f "${target}"`, { timeout: 5e3 }, (err, stdout) => {
763
- if (err || !stdout.trim()) {
764
- resolve({ hookId: "", success: false, message: `Process "${target}" not found` });
928
+ if (!SAFE_PGREP_NAME.test(trimmed)) {
929
+ resolve({
930
+ hookId: "",
931
+ success: false,
932
+ message: "Signal target must be a numeric PID, or a process name matching /^[a-zA-Z0-9._:/-]{1,256}$/ (no spaces or shell metacharacters)."
933
+ });
934
+ return;
935
+ }
936
+ const child = spawn("pgrep", ["-f", trimmed], {
937
+ timeout: 5e3,
938
+ stdio: ["ignore", "pipe", "pipe"]
939
+ });
940
+ let stdout = "";
941
+ child.stdout.on("data", (d) => {
942
+ stdout += d.toString();
943
+ });
944
+ child.on("close", (code) => {
945
+ if (code !== 0 || !stdout.trim()) {
946
+ resolve({
947
+ hookId: "",
948
+ success: false,
949
+ message: `Process "${trimmed}" not found`
950
+ });
765
951
  return;
766
952
  }
767
953
  const pids = stdout.trim().split("\n").map((p) => parseInt(p.trim(), 10)).filter((p) => !isNaN(p));
@@ -773,7 +959,18 @@ function executeSignal(target, signal = "SIGHUP") {
773
959
  } catch {
774
960
  }
775
961
  }
776
- resolve({ hookId: "", success: sent > 0, message: `Signal ${signal} sent to ${sent} process(es)` });
962
+ resolve({
963
+ hookId: "",
964
+ success: sent > 0,
965
+ message: `Signal ${signal} sent to ${sent} process(es)`
966
+ });
967
+ });
968
+ child.on("error", () => {
969
+ resolve({
970
+ hookId: "",
971
+ success: false,
972
+ message: `Process "${trimmed}" not found`
973
+ });
777
974
  });
778
975
  });
779
976
  }
@@ -830,33 +1027,50 @@ async function fireHooks(payload, tags) {
830
1027
  }
831
1028
 
832
1029
  // src/core/approval.ts
833
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
1030
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
834
1031
  import { join as join5 } from "path";
835
1032
  import { homedir as homedir4 } from "os";
836
- import { createHmac, randomBytes } from "crypto";
1033
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
837
1034
  function getHmacSecret() {
838
- const secretPath = join5(homedir4(), ".config", "q-ring", ".approval-key");
839
1035
  const dir = join5(homedir4(), ".config", "q-ring");
840
- if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true });
1036
+ const secretPath = join5(dir, ".approval-key");
1037
+ if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
841
1038
  if (existsSync5(secretPath)) {
842
1039
  return readFileSync5(secretPath, "utf8").trim();
843
1040
  }
844
1041
  const secret = randomBytes(32).toString("hex");
845
- writeFileSync4(secretPath, secret, { mode: 384 });
1042
+ writeFileSync3(secretPath, secret, { mode: 384 });
846
1043
  return secret;
847
1044
  }
848
1045
  function computeHmac(entry) {
849
- const payload = `${entry.id}|${entry.key}|${entry.scope}|${entry.reason}|${entry.grantedBy}|${entry.grantedAt}|${entry.expiresAt}`;
1046
+ const payload = [
1047
+ entry.id,
1048
+ entry.key,
1049
+ entry.scope,
1050
+ entry.reason,
1051
+ entry.grantedBy,
1052
+ entry.grantedAt,
1053
+ entry.expiresAt,
1054
+ entry.workspace ?? "",
1055
+ entry.sessionId ?? ""
1056
+ ].join("|");
850
1057
  return createHmac("sha256", getHmacSecret()).update(payload).digest("hex");
851
1058
  }
852
1059
  function verifyHmac(entry) {
853
1060
  const expected = computeHmac(entry);
854
- return expected === entry.hmac;
1061
+ const a = Buffer.from(expected, "utf8");
1062
+ const b = Buffer.from(entry.hmac, "utf8");
1063
+ if (a.length !== b.length) return false;
1064
+ try {
1065
+ return timingSafeEqual(a, b);
1066
+ } catch {
1067
+ return false;
1068
+ }
855
1069
  }
856
1070
  function getRegistryPath3() {
857
1071
  const dir = join5(homedir4(), ".config", "q-ring");
858
1072
  if (!existsSync5(dir)) {
859
- mkdirSync4(dir, { recursive: true });
1073
+ mkdirSync4(dir, { recursive: true, mode: 448 });
860
1074
  }
861
1075
  return join5(dir, "approvals.json");
862
1076
  }
@@ -884,6 +1098,20 @@ function hasApproval(key, scope) {
884
1098
 
885
1099
  // src/core/provision.ts
886
1100
  import { execFileSync, spawnSync } from "child_process";
1101
+ import { z as z2 } from "zod";
1102
+ var AwsStsConfigSchema = z2.object({
1103
+ roleArn: z2.string(),
1104
+ sessionName: z2.string().optional(),
1105
+ durationSeconds: z2.number().optional()
1106
+ });
1107
+ var HttpJitConfigSchema = z2.object({
1108
+ url: z2.string(),
1109
+ method: z2.string().optional(),
1110
+ valuePath: z2.string().optional(),
1111
+ expiresInSeconds: z2.number().optional(),
1112
+ headers: z2.record(z2.string(), z2.string()).optional(),
1113
+ body: z2.unknown().optional()
1114
+ });
887
1115
  var ProvisionRegistry = class {
888
1116
  providers = /* @__PURE__ */ new Map();
889
1117
  register(provider) {
@@ -900,16 +1128,20 @@ var awsStsProvider = {
900
1128
  name: "aws-sts",
901
1129
  description: "AWS STS AssumeRole (requires existing local AWS CLI credentials)",
902
1130
  provision(configRaw) {
903
- let config;
1131
+ let raw;
904
1132
  try {
905
- config = JSON.parse(configRaw);
1133
+ raw = JSON.parse(configRaw);
906
1134
  } catch {
907
1135
  throw new Error('aws-sts requires valid JSON config (e.g. {"roleArn":"arn:aws:..."})');
908
1136
  }
1137
+ const parsed = AwsStsConfigSchema.safeParse(raw);
1138
+ if (!parsed.success) {
1139
+ throw new Error(`aws-sts invalid config: ${parsed.error.message}`);
1140
+ }
1141
+ const config = parsed.data;
909
1142
  const roleArn = config.roleArn;
910
1143
  const sessionName = config.sessionName || "q-ring-agent";
911
1144
  const duration = config.durationSeconds || 3600;
912
- if (!roleArn) throw new Error("aws-sts requires roleArn in config");
913
1145
  try {
914
1146
  const output = execFileSync("aws", [
915
1147
  "sts",
@@ -923,8 +1155,8 @@ var awsStsProvider = {
923
1155
  "--output",
924
1156
  "json"
925
1157
  ], { encoding: "utf8" });
926
- const parsed = JSON.parse(output);
927
- const creds = parsed.Credentials;
1158
+ const parsed2 = JSON.parse(output);
1159
+ const creds = parsed2.Credentials;
928
1160
  const value = JSON.stringify({
929
1161
  AWS_ACCESS_KEY_ID: creds.AccessKeyId,
930
1162
  AWS_SECRET_ACCESS_KEY: creds.SecretAccessKey,
@@ -943,17 +1175,24 @@ var httpProvider = {
943
1175
  name: "http",
944
1176
  description: "Generic HTTP token endpoint using Node.js http/https",
945
1177
  provision(configRaw) {
946
- let config;
1178
+ let raw;
947
1179
  try {
948
- config = JSON.parse(configRaw);
1180
+ raw = JSON.parse(configRaw);
949
1181
  } catch {
950
1182
  throw new Error("http provider requires valid JSON config");
951
1183
  }
1184
+ const parsed = HttpJitConfigSchema.safeParse(raw);
1185
+ if (!parsed.success) {
1186
+ throw new Error(`http provider invalid config: ${parsed.error.message}`);
1187
+ }
1188
+ const config = parsed.data;
952
1189
  const url = config.url;
953
1190
  const method = config.method || "POST";
954
1191
  const valuePath = config.valuePath || "token";
955
1192
  const expiresInSeconds = config.expiresInSeconds || 3600;
956
1193
  if (!url) throw new Error("http provider requires url in config");
1194
+ const ssrfBlock = checkJitHttpProvisionUrl(url);
1195
+ if (ssrfBlock) throw new Error(`SSRF blocked: ${ssrfBlock}`);
957
1196
  const headers = {
958
1197
  "User-Agent": "q-ring-jit/1.0",
959
1198
  ...config.headers ?? {}
@@ -986,8 +1225,8 @@ req.end();
986
1225
  if (result.status !== 0) {
987
1226
  throw new Error(result.stderr || "HTTP request failed");
988
1227
  }
989
- const parsed = JSON.parse(result.stdout);
990
- let val = parsed;
1228
+ const parsed2 = JSON.parse(result.stdout);
1229
+ let val = parsed2;
991
1230
  for (const key of valuePath.split(".")) {
992
1231
  val = val[key];
993
1232
  }
@@ -1035,6 +1274,39 @@ function checkToolPolicy(toolName, projectPath) {
1035
1274
  }
1036
1275
  return { allowed: true, policySource: ".q-ring.json" };
1037
1276
  }
1277
+ function checkSecretLifecyclePolicy(input, projectPath) {
1278
+ const policy = loadPolicy(projectPath);
1279
+ if (!policy.secrets) return { allowed: true, policySource: "no-policy" };
1280
+ const s = policy.secrets;
1281
+ if (s.maxTtlSeconds != null && input.ttlSeconds != null && input.ttlSeconds > s.maxTtlSeconds) {
1282
+ return {
1283
+ allowed: false,
1284
+ reason: `TTL ${input.ttlSeconds}s exceeds policy maximum ${s.maxTtlSeconds}s`,
1285
+ policySource: ".q-ring.json policy.secrets.maxTtlSeconds"
1286
+ };
1287
+ }
1288
+ if (s.requireApprovalForTags?.length && input.tags?.length) {
1289
+ const hit = input.tags.find((t) => s.requireApprovalForTags.includes(t));
1290
+ if (hit && !input.requiresApproval) {
1291
+ return {
1292
+ allowed: false,
1293
+ reason: `Tag "${hit}" requires explicit approval metadata (set requiresApproval / --requires-approval)`,
1294
+ policySource: ".q-ring.json policy.secrets.requireApprovalForTags"
1295
+ };
1296
+ }
1297
+ }
1298
+ if (s.requireRotationFormatForTags?.length && input.tags?.length) {
1299
+ const hit = input.tags.find((t) => s.requireRotationFormatForTags.includes(t));
1300
+ if (hit && !input.rotationFormat) {
1301
+ return {
1302
+ allowed: false,
1303
+ reason: `Tag "${hit}" requires a rotationFormat to be set`,
1304
+ policySource: ".q-ring.json policy.secrets.requireRotationFormatForTags"
1305
+ };
1306
+ }
1307
+ }
1308
+ return { allowed: true, policySource: ".q-ring.json" };
1309
+ }
1038
1310
  function checkKeyReadPolicy(key, tags, projectPath) {
1039
1311
  const policy = loadPolicy(projectPath);
1040
1312
  if (!policy.mcp) return { allowed: true, policySource: "no-policy" };
@@ -1103,6 +1375,32 @@ function getPolicySummary(projectPath) {
1103
1375
  }
1104
1376
 
1105
1377
  // src/core/keyring.ts
1378
+ function withJitEnvelopeLock(service, key, fn) {
1379
+ const dir = join6(homedir5(), ".config", "q-ring", "jit-locks");
1380
+ mkdirSync5(dir, { recursive: true });
1381
+ const safe = Buffer.from(`${service}\0${key}`, "utf8").toString("base64url");
1382
+ const lockPath = join6(dir, `${safe}.lock`);
1383
+ const deadline = Date.now() + 8e3;
1384
+ while (Date.now() < deadline) {
1385
+ try {
1386
+ writeFileSync4(lockPath, `${process.pid}
1387
+ `, { flag: "wx", mode: 384 });
1388
+ try {
1389
+ return fn();
1390
+ } finally {
1391
+ try {
1392
+ unlinkSync(lockPath);
1393
+ } catch {
1394
+ }
1395
+ }
1396
+ } catch {
1397
+ const start = Date.now();
1398
+ while (Date.now() - start < 15) {
1399
+ }
1400
+ }
1401
+ }
1402
+ throw new Error("Could not acquire JIT envelope lock (timeout)");
1403
+ }
1106
1404
  function readEnvelope(service, key) {
1107
1405
  const entry = new Entry(service, key);
1108
1406
  const raw = entry.getPassword();
@@ -1121,7 +1419,7 @@ function resolveEnv(opts) {
1121
1419
  }
1122
1420
  function resolveTemplates(value, opts, seen) {
1123
1421
  if (!value.includes("{{") || !value.includes("}}")) return value;
1124
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1422
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1125
1423
  const refKey = refKeyRaw.trim();
1126
1424
  const refValue = getSecret(refKey, { ...opts, _seen: seen });
1127
1425
  if (refValue === null) {
@@ -1132,7 +1430,7 @@ function resolveTemplates(value, opts, seen) {
1132
1430
  }
1133
1431
  function resolveTemplatesOffline(value, rawValues, seen) {
1134
1432
  if (!value.includes("{{") || !value.includes("}}")) return value;
1135
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1433
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1136
1434
  const refKey = refKeyRaw.trim();
1137
1435
  if (seen.has(refKey)) {
1138
1436
  throw new Error(`Circular dependency detected: ${[...seen].join(" -> ")} -> ${refKey}`);
@@ -1202,11 +1500,27 @@ function getSecret(key, opts = {}) {
1202
1500
  isExpired = new Date(envelope.meta.jitExpiresAt).getTime() <= Date.now();
1203
1501
  }
1204
1502
  if (isExpired) {
1205
- const result = provider.provision(value);
1206
- envelope.states = envelope.states ?? {};
1207
- envelope.states["jit"] = result.value;
1208
- envelope.meta.jitExpiresAt = result.expiresAt;
1209
- writeEnvelope(service, key, envelope);
1503
+ const jitConfigSource = value;
1504
+ withJitEnvelopeLock(service, key, () => {
1505
+ const latest = readEnvelope(service, key);
1506
+ if (!latest?.meta.jitProvider) return;
1507
+ let innerExpired = true;
1508
+ if (latest.states && latest.states["jit"] && latest.meta.jitExpiresAt) {
1509
+ innerExpired = new Date(latest.meta.jitExpiresAt).getTime() <= Date.now();
1510
+ }
1511
+ if (!innerExpired) {
1512
+ envelope.states = latest.states;
1513
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1514
+ return;
1515
+ }
1516
+ const result = provider.provision(jitConfigSource);
1517
+ latest.states = latest.states ?? {};
1518
+ latest.states["jit"] = result.value;
1519
+ latest.meta.jitExpiresAt = result.expiresAt;
1520
+ writeEnvelope(service, key, latest);
1521
+ envelope.states = latest.states;
1522
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1523
+ });
1210
1524
  }
1211
1525
  value = envelope.states["jit"];
1212
1526
  }
@@ -1222,6 +1536,13 @@ function getSecret(key, opts = {}) {
1222
1536
  return null;
1223
1537
  }
1224
1538
  function getEnvelope(key, opts = {}) {
1539
+ const source = opts.source ?? "cli";
1540
+ if (source === "mcp") {
1541
+ const policyDecision = checkKeyReadPolicy(key, void 0, opts.projectPath);
1542
+ if (!policyDecision.allowed) {
1543
+ throw new Error(`Policy Denied: ${policyDecision.reason}`);
1544
+ }
1545
+ }
1225
1546
  const scopes = resolveScope(opts);
1226
1547
  for (const { service, scope } of scopes) {
1227
1548
  const envelope = readEnvelope(service, key);
@@ -1241,6 +1562,20 @@ function setSecret(key, value, opts = {}) {
1241
1562
  const prov = opts.provider ?? existing?.meta.provider;
1242
1563
  const reqApp = opts.requiresApproval ?? existing?.meta.requiresApproval;
1243
1564
  const jitProv = opts.jitProvider ?? existing?.meta.jitProvider;
1565
+ const mergedTags = opts.tags ?? existing?.meta.tags;
1566
+ const ttlForPolicy = opts.ttlSeconds ?? existing?.meta.ttlSeconds;
1567
+ const life = checkSecretLifecyclePolicy(
1568
+ {
1569
+ tags: mergedTags,
1570
+ ttlSeconds: ttlForPolicy,
1571
+ rotationFormat: rotFmt,
1572
+ requiresApproval: !!reqApp
1573
+ },
1574
+ opts.projectPath
1575
+ );
1576
+ if (!life.allowed) {
1577
+ throw new Error(life.reason ?? "Secret lifecycle policy denied");
1578
+ }
1244
1579
  if (opts.states) {
1245
1580
  envelope = createEnvelope("", {
1246
1581
  states: opts.states,
@@ -1311,6 +1646,12 @@ function setSecret(key, value, opts = {}) {
1311
1646
  function deleteSecret(key, opts = {}) {
1312
1647
  const scopes = resolveScope(opts);
1313
1648
  const source = opts.source ?? "cli";
1649
+ if (source === "mcp") {
1650
+ const policyDecision = checkKeyReadPolicy(key, void 0, opts.projectPath);
1651
+ if (!policyDecision.allowed) {
1652
+ throw new Error(`Policy Denied: ${policyDecision.reason}`);
1653
+ }
1654
+ }
1314
1655
  let deleted = false;
1315
1656
  for (const { service, scope } of scopes) {
1316
1657
  const entry = new Entry(service, key);
@@ -1333,6 +1674,11 @@ function deleteSecret(key, opts = {}) {
1333
1674
  return deleted;
1334
1675
  }
1335
1676
  function hasSecret(key, opts = {}) {
1677
+ const source = opts.source ?? "cli";
1678
+ if (source === "mcp") {
1679
+ const policyDecision = checkKeyReadPolicy(key, void 0, opts.projectPath);
1680
+ if (!policyDecision.allowed) return false;
1681
+ }
1336
1682
  const scopes = resolveScope(opts);
1337
1683
  for (const { service } of scopes) {
1338
1684
  const envelope = readEnvelope(service, key);
@@ -1385,7 +1731,14 @@ function listSecrets(opts = {}) {
1385
1731
  if (!opts.silent) {
1386
1732
  logAudit({ action: "list", source });
1387
1733
  }
1388
- return results.sort((a, b) => a.key.localeCompare(b.key));
1734
+ const sorted = results.sort((a, b) => a.key.localeCompare(b.key));
1735
+ if (source === "mcp") {
1736
+ return sorted.filter((e) => {
1737
+ const decision = checkKeyReadPolicy(e.key, void 0, opts.projectPath);
1738
+ return decision.allowed;
1739
+ });
1740
+ }
1741
+ return sorted;
1389
1742
  }
1390
1743
  function exportSecrets(opts = {}) {
1391
1744
  const format = opts.format ?? "env";
@@ -1468,6 +1821,7 @@ function disentangleSecrets(sourceKey, sourceOpts, targetKey, targetOpts) {
1468
1821
  }
1469
1822
 
1470
1823
  // src/core/tunnel.ts
1824
+ import { randomBytes as randomBytes2 } from "crypto";
1471
1825
  var tunnelStore = /* @__PURE__ */ new Map();
1472
1826
  var cleanupInterval = null;
1473
1827
  function ensureCleanup() {
@@ -1489,7 +1843,7 @@ function ensureCleanup() {
1489
1843
  }
1490
1844
  }
1491
1845
  function tunnelCreate(value, opts = {}) {
1492
- const id = `tun_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1846
+ const id = `tun_${Date.now().toString(36)}_${randomBytes2(6).toString("base64url")}`;
1493
1847
  const now = Date.now();
1494
1848
  tunnelStore.set(id, {
1495
1849
  value,
@@ -1548,7 +1902,8 @@ export {
1548
1902
  exportAudit,
1549
1903
  detectAnomalies,
1550
1904
  listEntanglements,
1551
- httpRequest_,
1905
+ httpRequest,
1906
+ checkSSRF,
1552
1907
  registerHook,
1553
1908
  removeHook,
1554
1909
  listHooks,
@@ -1573,4 +1928,4 @@ export {
1573
1928
  tunnelDestroy,
1574
1929
  tunnelList
1575
1930
  };
1576
- //# sourceMappingURL=chunk-5JBU7TWN.js.map
1931
+ //# sourceMappingURL=chunk-E46HG5NM.js.map