@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();
@@ -665,16 +830,22 @@ function matchesHook(hook, payload, tags) {
665
830
  if (m.action?.length && !m.action.includes(payload.action)) return false;
666
831
  if (m.key && m.key !== payload.key) return false;
667
832
  if (m.keyPattern) {
668
- const regex = new RegExp(
669
- "^" + m.keyPattern.replace(/\*/g, ".*") + "$",
670
- "i"
671
- );
833
+ const escaped = m.keyPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
834
+ const regex = new RegExp("^" + escaped + "$", "i");
672
835
  if (!regex.test(payload.key)) return false;
673
836
  }
674
837
  if (m.tag && (!tags || !tags.includes(m.tag))) return false;
675
838
  if (m.scope && m.scope !== payload.scope) return false;
676
839
  return true;
677
840
  }
841
+ var SAFE_PGREP_NAME = /^[a-zA-Z0-9._:/-]{1,256}$/;
842
+ function shellRunner() {
843
+ if (process.platform === "win32") {
844
+ const comspec = process.env.ComSpec ?? "cmd.exe";
845
+ return { file: comspec, args: ["/d", "/s", "/c"] };
846
+ }
847
+ return { file: "/bin/sh", args: ["-c"] };
848
+ }
678
849
  function executeShell(command, payload) {
679
850
  return new Promise((resolve) => {
680
851
  const env = {
@@ -683,49 +854,35 @@ function executeShell(command, payload) {
683
854
  QRING_HOOK_ACTION: payload.action,
684
855
  QRING_HOOK_SCOPE: payload.scope
685
856
  };
686
- exec(command, { timeout: 3e4, env }, (err, stdout, stderr) => {
687
- if (err) {
688
- resolve({ hookId: "", success: false, message: `Shell error: ${err.message}` });
689
- } else {
690
- resolve({ hookId: "", success: true, message: stdout.trim() || "OK" });
857
+ const { file, args } = shellRunner();
858
+ execFile(
859
+ file,
860
+ [...args, command],
861
+ {
862
+ timeout: 3e4,
863
+ env,
864
+ windowsHide: true,
865
+ maxBuffer: 4 * 1024 * 1024,
866
+ encoding: "utf8"
867
+ },
868
+ (err, stdout) => {
869
+ if (err) {
870
+ resolve({
871
+ hookId: "",
872
+ success: false,
873
+ message: `Shell error: ${err.message}`
874
+ });
875
+ } else {
876
+ resolve({
877
+ hookId: "",
878
+ success: true,
879
+ message: (stdout ?? "").trim() || "OK"
880
+ });
881
+ }
691
882
  }
692
- });
883
+ );
693
884
  });
694
885
  }
695
- function isPrivateIP(ip) {
696
- const octet = "(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})";
697
- const ipv4Re = new RegExp(`^::ffff:(${octet}\\.${octet}\\.${octet}\\.${octet})$`, "i");
698
- const ipv4Mapped = ip.match(ipv4Re);
699
- if (ipv4Mapped) return isPrivateIP(ipv4Mapped[1]);
700
- if (/^127\./.test(ip)) return true;
701
- if (/^10\./.test(ip)) return true;
702
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
703
- if (/^192\.168\./.test(ip)) return true;
704
- if (/^169\.254\./.test(ip)) return true;
705
- if (ip === "0.0.0.0") return true;
706
- if (ip === "::1" || ip === "::") return true;
707
- if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return true;
708
- if (/^fe80:/i.test(ip)) return true;
709
- return false;
710
- }
711
- async function checkSSRF(url) {
712
- if (process.env.Q_RING_ALLOW_PRIVATE_HOOKS === "1") return null;
713
- try {
714
- const parsed = new URL(url);
715
- const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
716
- if (isPrivateIP(hostname)) {
717
- return `Blocked: hook URL resolves to private address (${hostname}). Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
718
- }
719
- const results = await lookup(hostname, { all: true });
720
- for (const { address } of results) {
721
- if (isPrivateIP(address)) {
722
- return `Blocked: hook URL "${hostname}" resolves to private address ${address}. Set Q_RING_ALLOW_PRIVATE_HOOKS=1 to override.`;
723
- }
724
- }
725
- } catch {
726
- }
727
- return null;
728
- }
729
886
  async function executeHttp(url, payload) {
730
887
  const ssrfBlock = await checkSSRF(url);
731
888
  if (ssrfBlock) {
@@ -740,7 +897,7 @@ async function executeHttp(url, payload) {
740
897
  }
741
898
  try {
742
899
  const body = JSON.stringify(payload);
743
- const res = await httpRequest_({
900
+ const res = await httpRequest({
744
901
  url,
745
902
  method: "POST",
746
903
  headers: {
@@ -765,19 +922,48 @@ async function executeHttp(url, payload) {
765
922
  }
766
923
  function executeSignal(target, signal = "SIGHUP") {
767
924
  return new Promise((resolve) => {
768
- const pid = parseInt(target, 10);
769
- if (!isNaN(pid)) {
925
+ const trimmed = target.trim();
926
+ if (/^\d+$/.test(trimmed)) {
927
+ const pid = parseInt(trimmed, 10);
770
928
  try {
771
929
  process.kill(pid, signal);
772
- resolve({ hookId: "", success: true, message: `Signal ${signal} sent to PID ${pid}` });
930
+ resolve({
931
+ hookId: "",
932
+ success: true,
933
+ message: `Signal ${signal} sent to PID ${pid}`
934
+ });
773
935
  } catch (err) {
774
- resolve({ hookId: "", success: false, message: `Signal error: ${err instanceof Error ? err.message : String(err)}` });
936
+ resolve({
937
+ hookId: "",
938
+ success: false,
939
+ message: `Signal error: ${err instanceof Error ? err.message : String(err)}`
940
+ });
775
941
  }
776
942
  return;
777
943
  }
778
- exec(`pgrep -f "${target}"`, { timeout: 5e3 }, (err, stdout) => {
779
- if (err || !stdout.trim()) {
780
- resolve({ hookId: "", success: false, message: `Process "${target}" not found` });
944
+ if (!SAFE_PGREP_NAME.test(trimmed)) {
945
+ resolve({
946
+ hookId: "",
947
+ success: false,
948
+ message: "Signal target must be a numeric PID, or a process name matching /^[a-zA-Z0-9._:/-]{1,256}$/ (no spaces or shell metacharacters)."
949
+ });
950
+ return;
951
+ }
952
+ const child = spawn("pgrep", ["-f", trimmed], {
953
+ timeout: 5e3,
954
+ stdio: ["ignore", "pipe", "pipe"]
955
+ });
956
+ let stdout = "";
957
+ child.stdout.on("data", (d) => {
958
+ stdout += d.toString();
959
+ });
960
+ child.on("close", (code) => {
961
+ if (code !== 0 || !stdout.trim()) {
962
+ resolve({
963
+ hookId: "",
964
+ success: false,
965
+ message: `Process "${trimmed}" not found`
966
+ });
781
967
  return;
782
968
  }
783
969
  const pids = stdout.trim().split("\n").map((p) => parseInt(p.trim(), 10)).filter((p) => !isNaN(p));
@@ -789,7 +975,18 @@ function executeSignal(target, signal = "SIGHUP") {
789
975
  } catch {
790
976
  }
791
977
  }
792
- resolve({ hookId: "", success: sent > 0, message: `Signal ${signal} sent to ${sent} process(es)` });
978
+ resolve({
979
+ hookId: "",
980
+ success: sent > 0,
981
+ message: `Signal ${signal} sent to ${sent} process(es)`
982
+ });
983
+ });
984
+ child.on("error", () => {
985
+ resolve({
986
+ hookId: "",
987
+ success: false,
988
+ message: `Process "${trimmed}" not found`
989
+ });
793
990
  });
794
991
  });
795
992
  }
@@ -846,33 +1043,50 @@ async function fireHooks(payload, tags) {
846
1043
  }
847
1044
 
848
1045
  // src/core/approval.ts
849
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4 } from "fs";
1046
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
850
1047
  import { join as join5 } from "path";
851
1048
  import { homedir as homedir4 } from "os";
852
- import { createHmac, randomBytes } from "crypto";
1049
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
853
1050
  function getHmacSecret() {
854
- const secretPath = join5(homedir4(), ".config", "q-ring", ".approval-key");
855
1051
  const dir = join5(homedir4(), ".config", "q-ring");
856
- if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true });
1052
+ const secretPath = join5(dir, ".approval-key");
1053
+ if (!existsSync5(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
857
1054
  if (existsSync5(secretPath)) {
858
1055
  return readFileSync5(secretPath, "utf8").trim();
859
1056
  }
860
1057
  const secret = randomBytes(32).toString("hex");
861
- writeFileSync4(secretPath, secret, { mode: 384 });
1058
+ writeFileSync3(secretPath, secret, { mode: 384 });
862
1059
  return secret;
863
1060
  }
864
1061
  function computeHmac(entry) {
865
- const payload = `${entry.id}|${entry.key}|${entry.scope}|${entry.reason}|${entry.grantedBy}|${entry.grantedAt}|${entry.expiresAt}`;
1062
+ const payload = [
1063
+ entry.id,
1064
+ entry.key,
1065
+ entry.scope,
1066
+ entry.reason,
1067
+ entry.grantedBy,
1068
+ entry.grantedAt,
1069
+ entry.expiresAt,
1070
+ entry.workspace ?? "",
1071
+ entry.sessionId ?? ""
1072
+ ].join("|");
866
1073
  return createHmac("sha256", getHmacSecret()).update(payload).digest("hex");
867
1074
  }
868
1075
  function verifyHmac(entry) {
869
1076
  const expected = computeHmac(entry);
870
- return expected === entry.hmac;
1077
+ const a = Buffer.from(expected, "utf8");
1078
+ const b = Buffer.from(entry.hmac, "utf8");
1079
+ if (a.length !== b.length) return false;
1080
+ try {
1081
+ return timingSafeEqual(a, b);
1082
+ } catch {
1083
+ return false;
1084
+ }
871
1085
  }
872
1086
  function getRegistryPath3() {
873
1087
  const dir = join5(homedir4(), ".config", "q-ring");
874
1088
  if (!existsSync5(dir)) {
875
- mkdirSync4(dir, { recursive: true });
1089
+ mkdirSync4(dir, { recursive: true, mode: 448 });
876
1090
  }
877
1091
  return join5(dir, "approvals.json");
878
1092
  }
@@ -888,7 +1102,7 @@ function loadRegistry3() {
888
1102
  }
889
1103
  }
890
1104
  function saveRegistry3(registry2) {
891
- writeFileSync4(getRegistryPath3(), JSON.stringify(registry2, null, 2), { mode: 384 });
1105
+ writeFileSync3(getRegistryPath3(), JSON.stringify(registry2, null, 2), { mode: 384 });
892
1106
  }
893
1107
  function cleanup(registry2) {
894
1108
  const now = Date.now();
@@ -956,6 +1170,20 @@ function listApprovals() {
956
1170
 
957
1171
  // src/core/provision.ts
958
1172
  import { execFileSync, spawnSync } from "child_process";
1173
+ import { z as z2 } from "zod";
1174
+ var AwsStsConfigSchema = z2.object({
1175
+ roleArn: z2.string(),
1176
+ sessionName: z2.string().optional(),
1177
+ durationSeconds: z2.number().optional()
1178
+ });
1179
+ var HttpJitConfigSchema = z2.object({
1180
+ url: z2.string(),
1181
+ method: z2.string().optional(),
1182
+ valuePath: z2.string().optional(),
1183
+ expiresInSeconds: z2.number().optional(),
1184
+ headers: z2.record(z2.string(), z2.string()).optional(),
1185
+ body: z2.unknown().optional()
1186
+ });
959
1187
  var ProvisionRegistry = class {
960
1188
  providers = /* @__PURE__ */ new Map();
961
1189
  register(provider) {
@@ -972,16 +1200,20 @@ var awsStsProvider = {
972
1200
  name: "aws-sts",
973
1201
  description: "AWS STS AssumeRole (requires existing local AWS CLI credentials)",
974
1202
  provision(configRaw) {
975
- let config;
1203
+ let raw;
976
1204
  try {
977
- config = JSON.parse(configRaw);
1205
+ raw = JSON.parse(configRaw);
978
1206
  } catch {
979
1207
  throw new Error('aws-sts requires valid JSON config (e.g. {"roleArn":"arn:aws:..."})');
980
1208
  }
1209
+ const parsed = AwsStsConfigSchema.safeParse(raw);
1210
+ if (!parsed.success) {
1211
+ throw new Error(`aws-sts invalid config: ${parsed.error.message}`);
1212
+ }
1213
+ const config = parsed.data;
981
1214
  const roleArn = config.roleArn;
982
1215
  const sessionName = config.sessionName || "q-ring-agent";
983
1216
  const duration = config.durationSeconds || 3600;
984
- if (!roleArn) throw new Error("aws-sts requires roleArn in config");
985
1217
  try {
986
1218
  const output = execFileSync("aws", [
987
1219
  "sts",
@@ -995,8 +1227,8 @@ var awsStsProvider = {
995
1227
  "--output",
996
1228
  "json"
997
1229
  ], { encoding: "utf8" });
998
- const parsed = JSON.parse(output);
999
- const creds = parsed.Credentials;
1230
+ const parsed2 = JSON.parse(output);
1231
+ const creds = parsed2.Credentials;
1000
1232
  const value = JSON.stringify({
1001
1233
  AWS_ACCESS_KEY_ID: creds.AccessKeyId,
1002
1234
  AWS_SECRET_ACCESS_KEY: creds.SecretAccessKey,
@@ -1015,17 +1247,24 @@ var httpProvider = {
1015
1247
  name: "http",
1016
1248
  description: "Generic HTTP token endpoint using Node.js http/https",
1017
1249
  provision(configRaw) {
1018
- let config;
1250
+ let raw;
1019
1251
  try {
1020
- config = JSON.parse(configRaw);
1252
+ raw = JSON.parse(configRaw);
1021
1253
  } catch {
1022
1254
  throw new Error("http provider requires valid JSON config");
1023
1255
  }
1256
+ const parsed = HttpJitConfigSchema.safeParse(raw);
1257
+ if (!parsed.success) {
1258
+ throw new Error(`http provider invalid config: ${parsed.error.message}`);
1259
+ }
1260
+ const config = parsed.data;
1024
1261
  const url = config.url;
1025
1262
  const method = config.method || "POST";
1026
1263
  const valuePath = config.valuePath || "token";
1027
1264
  const expiresInSeconds = config.expiresInSeconds || 3600;
1028
1265
  if (!url) throw new Error("http provider requires url in config");
1266
+ const ssrfBlock = checkJitHttpProvisionUrl(url);
1267
+ if (ssrfBlock) throw new Error(`SSRF blocked: ${ssrfBlock}`);
1029
1268
  const headers = {
1030
1269
  "User-Agent": "q-ring-jit/1.0",
1031
1270
  ...config.headers ?? {}
@@ -1058,8 +1297,8 @@ req.end();
1058
1297
  if (result.status !== 0) {
1059
1298
  throw new Error(result.stderr || "HTTP request failed");
1060
1299
  }
1061
- const parsed = JSON.parse(result.stdout);
1062
- let val = parsed;
1300
+ const parsed2 = JSON.parse(result.stdout);
1301
+ let val = parsed2;
1063
1302
  for (const key of valuePath.split(".")) {
1064
1303
  val = val[key];
1065
1304
  }
@@ -1088,6 +1327,39 @@ function loadPolicy(projectPath) {
1088
1327
  cachedPolicy = { path: pp, policy };
1089
1328
  return policy;
1090
1329
  }
1330
+ function checkSecretLifecyclePolicy(input, projectPath) {
1331
+ const policy = loadPolicy(projectPath);
1332
+ if (!policy.secrets) return { allowed: true, policySource: "no-policy" };
1333
+ const s = policy.secrets;
1334
+ if (s.maxTtlSeconds != null && input.ttlSeconds != null && input.ttlSeconds > s.maxTtlSeconds) {
1335
+ return {
1336
+ allowed: false,
1337
+ reason: `TTL ${input.ttlSeconds}s exceeds policy maximum ${s.maxTtlSeconds}s`,
1338
+ policySource: ".q-ring.json policy.secrets.maxTtlSeconds"
1339
+ };
1340
+ }
1341
+ if (s.requireApprovalForTags?.length && input.tags?.length) {
1342
+ const hit = input.tags.find((t) => s.requireApprovalForTags.includes(t));
1343
+ if (hit && !input.requiresApproval) {
1344
+ return {
1345
+ allowed: false,
1346
+ reason: `Tag "${hit}" requires explicit approval metadata (set requiresApproval / --requires-approval)`,
1347
+ policySource: ".q-ring.json policy.secrets.requireApprovalForTags"
1348
+ };
1349
+ }
1350
+ }
1351
+ if (s.requireRotationFormatForTags?.length && input.tags?.length) {
1352
+ const hit = input.tags.find((t) => s.requireRotationFormatForTags.includes(t));
1353
+ if (hit && !input.rotationFormat) {
1354
+ return {
1355
+ allowed: false,
1356
+ reason: `Tag "${hit}" requires a rotationFormat to be set`,
1357
+ policySource: ".q-ring.json policy.secrets.requireRotationFormatForTags"
1358
+ };
1359
+ }
1360
+ }
1361
+ return { allowed: true, policySource: ".q-ring.json" };
1362
+ }
1091
1363
  function checkKeyReadPolicy(key, tags, projectPath) {
1092
1364
  const policy = loadPolicy(projectPath);
1093
1365
  if (!policy.mcp) return { allowed: true, policySource: "no-policy" };
@@ -1156,6 +1428,32 @@ function getPolicySummary(projectPath) {
1156
1428
  }
1157
1429
 
1158
1430
  // src/core/keyring.ts
1431
+ function withJitEnvelopeLock(service, key, fn) {
1432
+ const dir = join6(homedir5(), ".config", "q-ring", "jit-locks");
1433
+ mkdirSync5(dir, { recursive: true });
1434
+ const safe = Buffer.from(`${service}\0${key}`, "utf8").toString("base64url");
1435
+ const lockPath = join6(dir, `${safe}.lock`);
1436
+ const deadline = Date.now() + 8e3;
1437
+ while (Date.now() < deadline) {
1438
+ try {
1439
+ writeFileSync4(lockPath, `${process.pid}
1440
+ `, { flag: "wx", mode: 384 });
1441
+ try {
1442
+ return fn();
1443
+ } finally {
1444
+ try {
1445
+ unlinkSync(lockPath);
1446
+ } catch {
1447
+ }
1448
+ }
1449
+ } catch {
1450
+ const start = Date.now();
1451
+ while (Date.now() - start < 15) {
1452
+ }
1453
+ }
1454
+ }
1455
+ throw new Error("Could not acquire JIT envelope lock (timeout)");
1456
+ }
1159
1457
  function readEnvelope(service, key) {
1160
1458
  const entry = new Entry(service, key);
1161
1459
  const raw = entry.getPassword();
@@ -1174,7 +1472,7 @@ function resolveEnv(opts) {
1174
1472
  }
1175
1473
  function resolveTemplates(value, opts, seen) {
1176
1474
  if (!value.includes("{{") || !value.includes("}}")) return value;
1177
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1475
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1178
1476
  const refKey = refKeyRaw.trim();
1179
1477
  const refValue = getSecret(refKey, { ...opts, _seen: seen });
1180
1478
  if (refValue === null) {
@@ -1185,7 +1483,7 @@ function resolveTemplates(value, opts, seen) {
1185
1483
  }
1186
1484
  function resolveTemplatesOffline(value, rawValues, seen) {
1187
1485
  if (!value.includes("{{") || !value.includes("}}")) return value;
1188
- return value.replace(/\{\{([^}]+)\}\}/g, (match, refKeyRaw) => {
1486
+ return value.replace(/\{\{([^}]+)\}\}/g, (_match, refKeyRaw) => {
1189
1487
  const refKey = refKeyRaw.trim();
1190
1488
  if (seen.has(refKey)) {
1191
1489
  throw new Error(`Circular dependency detected: ${[...seen].join(" -> ")} -> ${refKey}`);
@@ -1255,11 +1553,27 @@ function getSecret(key, opts = {}) {
1255
1553
  isExpired = new Date(envelope.meta.jitExpiresAt).getTime() <= Date.now();
1256
1554
  }
1257
1555
  if (isExpired) {
1258
- const result = provider.provision(value);
1259
- envelope.states = envelope.states ?? {};
1260
- envelope.states["jit"] = result.value;
1261
- envelope.meta.jitExpiresAt = result.expiresAt;
1262
- writeEnvelope(service, key, envelope);
1556
+ const jitConfigSource = value;
1557
+ withJitEnvelopeLock(service, key, () => {
1558
+ const latest = readEnvelope(service, key);
1559
+ if (!latest?.meta.jitProvider) return;
1560
+ let innerExpired = true;
1561
+ if (latest.states && latest.states["jit"] && latest.meta.jitExpiresAt) {
1562
+ innerExpired = new Date(latest.meta.jitExpiresAt).getTime() <= Date.now();
1563
+ }
1564
+ if (!innerExpired) {
1565
+ envelope.states = latest.states;
1566
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1567
+ return;
1568
+ }
1569
+ const result = provider.provision(jitConfigSource);
1570
+ latest.states = latest.states ?? {};
1571
+ latest.states["jit"] = result.value;
1572
+ latest.meta.jitExpiresAt = result.expiresAt;
1573
+ writeEnvelope(service, key, latest);
1574
+ envelope.states = latest.states;
1575
+ envelope.meta.jitExpiresAt = latest.meta.jitExpiresAt;
1576
+ });
1263
1577
  }
1264
1578
  value = envelope.states["jit"];
1265
1579
  }
@@ -1275,6 +1589,13 @@ function getSecret(key, opts = {}) {
1275
1589
  return null;
1276
1590
  }
1277
1591
  function getEnvelope(key, opts = {}) {
1592
+ const source = opts.source ?? "cli";
1593
+ if (source === "mcp") {
1594
+ const policyDecision = checkKeyReadPolicy(key, void 0, opts.projectPath);
1595
+ if (!policyDecision.allowed) {
1596
+ throw new Error(`Policy Denied: ${policyDecision.reason}`);
1597
+ }
1598
+ }
1278
1599
  const scopes = resolveScope(opts);
1279
1600
  for (const { service, scope } of scopes) {
1280
1601
  const envelope = readEnvelope(service, key);
@@ -1294,6 +1615,20 @@ function setSecret(key, value, opts = {}) {
1294
1615
  const prov = opts.provider ?? existing?.meta.provider;
1295
1616
  const reqApp = opts.requiresApproval ?? existing?.meta.requiresApproval;
1296
1617
  const jitProv = opts.jitProvider ?? existing?.meta.jitProvider;
1618
+ const mergedTags = opts.tags ?? existing?.meta.tags;
1619
+ const ttlForPolicy = opts.ttlSeconds ?? existing?.meta.ttlSeconds;
1620
+ const life = checkSecretLifecyclePolicy(
1621
+ {
1622
+ tags: mergedTags,
1623
+ ttlSeconds: ttlForPolicy,
1624
+ rotationFormat: rotFmt,
1625
+ requiresApproval: !!reqApp
1626
+ },
1627
+ opts.projectPath
1628
+ );
1629
+ if (!life.allowed) {
1630
+ throw new Error(life.reason ?? "Secret lifecycle policy denied");
1631
+ }
1297
1632
  if (opts.states) {
1298
1633
  envelope = createEnvelope("", {
1299
1634
  states: opts.states,
@@ -1364,6 +1699,12 @@ function setSecret(key, value, opts = {}) {
1364
1699
  function deleteSecret(key, opts = {}) {
1365
1700
  const scopes = resolveScope(opts);
1366
1701
  const source = opts.source ?? "cli";
1702
+ if (source === "mcp") {
1703
+ const policyDecision = checkKeyReadPolicy(key, void 0, opts.projectPath);
1704
+ if (!policyDecision.allowed) {
1705
+ throw new Error(`Policy Denied: ${policyDecision.reason}`);
1706
+ }
1707
+ }
1367
1708
  let deleted = false;
1368
1709
  for (const { service, scope } of scopes) {
1369
1710
  const entry = new Entry(service, key);
@@ -1386,6 +1727,11 @@ function deleteSecret(key, opts = {}) {
1386
1727
  return deleted;
1387
1728
  }
1388
1729
  function hasSecret(key, opts = {}) {
1730
+ const source = opts.source ?? "cli";
1731
+ if (source === "mcp") {
1732
+ const policyDecision = checkKeyReadPolicy(key, void 0, opts.projectPath);
1733
+ if (!policyDecision.allowed) return false;
1734
+ }
1389
1735
  const scopes = resolveScope(opts);
1390
1736
  for (const { service } of scopes) {
1391
1737
  const envelope = readEnvelope(service, key);
@@ -1438,7 +1784,14 @@ function listSecrets(opts = {}) {
1438
1784
  if (!opts.silent) {
1439
1785
  logAudit({ action: "list", source });
1440
1786
  }
1441
- return results.sort((a, b) => a.key.localeCompare(b.key));
1787
+ const sorted = results.sort((a, b) => a.key.localeCompare(b.key));
1788
+ if (source === "mcp") {
1789
+ return sorted.filter((e) => {
1790
+ const decision = checkKeyReadPolicy(e.key, void 0, opts.projectPath);
1791
+ return decision.allowed;
1792
+ });
1793
+ }
1794
+ return sorted;
1442
1795
  }
1443
1796
  function exportSecrets(opts = {}) {
1444
1797
  const format = opts.format ?? "env";
@@ -1521,6 +1874,7 @@ function disentangleSecrets(sourceKey, sourceOpts, targetKey, targetOpts) {
1521
1874
  }
1522
1875
 
1523
1876
  // src/core/tunnel.ts
1877
+ import { randomBytes as randomBytes2 } from "crypto";
1524
1878
  var tunnelStore = /* @__PURE__ */ new Map();
1525
1879
  var cleanupInterval = null;
1526
1880
  function ensureCleanup() {
@@ -1542,7 +1896,7 @@ function ensureCleanup() {
1542
1896
  }
1543
1897
  }
1544
1898
  function tunnelCreate(value, opts = {}) {
1545
- const id = `tun_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
1899
+ const id = `tun_${Date.now().toString(36)}_${randomBytes2(6).toString("base64url")}`;
1546
1900
  const now = Date.now();
1547
1901
  tunnelStore.set(id, {
1548
1902
  value,
@@ -1601,7 +1955,8 @@ export {
1601
1955
  exportAudit,
1602
1956
  detectAnomalies,
1603
1957
  listEntanglements,
1604
- httpRequest_,
1958
+ httpRequest,
1959
+ checkSSRF,
1605
1960
  registerHook,
1606
1961
  removeHook,
1607
1962
  listHooks,
@@ -1629,4 +1984,4 @@ export {
1629
1984
  tunnelDestroy,
1630
1985
  tunnelList
1631
1986
  };
1632
- //# sourceMappingURL=chunk-WG4ZKN7Q.js.map
1987
+ //# sourceMappingURL=chunk-LLMO64FE.js.map