usertrust 1.4.0 → 2.0.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 +17 -1
- package/dist/anomaly/detector.d.ts.map +1 -1
- package/dist/anomaly/detector.js +36 -11
- package/dist/anomaly/detector.js.map +1 -1
- package/dist/anomaly/index.d.ts +2 -2
- package/dist/anomaly/index.d.ts.map +1 -1
- package/dist/anomaly/index.js +1 -1
- package/dist/anomaly/index.js.map +1 -1
- package/dist/anomaly/signals/spend-velocity.d.ts +39 -12
- package/dist/anomaly/signals/spend-velocity.d.ts.map +1 -1
- package/dist/anomaly/signals/spend-velocity.js +66 -43
- package/dist/anomaly/signals/spend-velocity.js.map +1 -1
- package/dist/anomaly/signals/token-rate.d.ts +33 -13
- package/dist/anomaly/signals/token-rate.d.ts.map +1 -1
- package/dist/anomaly/signals/token-rate.js +124 -38
- package/dist/anomaly/signals/token-rate.js.map +1 -1
- package/dist/anomaly/types.d.ts +28 -6
- package/dist/anomaly/types.d.ts.map +1 -1
- package/dist/audit/anchor-doctor.d.ts +42 -0
- package/dist/audit/anchor-doctor.d.ts.map +1 -0
- package/dist/audit/anchor-doctor.js +285 -0
- package/dist/audit/anchor-doctor.js.map +1 -0
- package/dist/audit/anchor-verify.d.ts +229 -0
- package/dist/audit/anchor-verify.d.ts.map +1 -0
- package/dist/audit/anchor-verify.js +809 -0
- package/dist/audit/anchor-verify.js.map +1 -0
- package/dist/audit/anchor.d.ts +177 -0
- package/dist/audit/anchor.d.ts.map +1 -0
- package/dist/audit/anchor.js +922 -0
- package/dist/audit/anchor.js.map +1 -0
- package/dist/audit/canonical.js +1 -1
- package/dist/audit/canonical.js.map +1 -1
- package/dist/audit/chain.d.ts.map +1 -1
- package/dist/audit/chain.js +21 -7
- package/dist/audit/chain.js.map +1 -1
- package/dist/audit/read.d.ts +19 -0
- package/dist/audit/read.d.ts.map +1 -0
- package/dist/audit/read.js +94 -0
- package/dist/audit/read.js.map +1 -0
- package/dist/audit/rekor-verify.d.ts +116 -0
- package/dist/audit/rekor-verify.d.ts.map +1 -0
- package/dist/audit/rekor-verify.js +533 -0
- package/dist/audit/rekor-verify.js.map +1 -0
- package/dist/audit/rekor.d.ts +71 -0
- package/dist/audit/rekor.d.ts.map +1 -0
- package/dist/audit/rekor.js +556 -0
- package/dist/audit/rekor.js.map +1 -0
- package/dist/audit/rotation.js +1 -1
- package/dist/audit/rotation.js.map +1 -1
- package/dist/audit/sigv4.d.ts +44 -0
- package/dist/audit/sigv4.d.ts.map +1 -0
- package/dist/audit/sigv4.js +134 -0
- package/dist/audit/sigv4.js.map +1 -0
- package/dist/audit/verify.d.ts +78 -0
- package/dist/audit/verify.d.ts.map +1 -1
- package/dist/audit/verify.js +195 -1
- package/dist/audit/verify.js.map +1 -1
- package/dist/cli/anchor.d.ts +3 -0
- package/dist/cli/anchor.d.ts.map +1 -0
- package/dist/cli/anchor.js +626 -0
- package/dist/cli/anchor.js.map +1 -0
- package/dist/cli/completions.js +1 -1
- package/dist/cli/export.d.ts +3 -0
- package/dist/cli/export.d.ts.map +1 -0
- package/dist/cli/export.js +58 -0
- package/dist/cli/export.js.map +1 -0
- package/dist/cli/health.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +99 -2
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +4 -34
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/main.d.ts +2 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +20 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/pricing.js +1 -1
- package/dist/cli/pricing.js.map +1 -1
- package/dist/cli/ui.d.ts +3 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +35 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/cli/verify.d.ts.map +1 -1
- package/dist/cli/verify.js +342 -3
- package/dist/cli/verify.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/detect.d.ts +71 -16
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +141 -6
- package/dist/detect.js.map +1 -1
- package/dist/export/markdown.d.ts +12 -0
- package/dist/export/markdown.d.ts.map +1 -0
- package/dist/export/markdown.js +142 -0
- package/dist/export/markdown.js.map +1 -0
- package/dist/govern.d.ts +67 -3
- package/dist/govern.d.ts.map +1 -1
- package/dist/govern.js +1011 -219
- package/dist/govern.js.map +1 -1
- package/dist/headless.d.ts +41 -2
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +58 -7
- package/dist/headless.js.map +1 -1
- package/dist/index.d.ts +33 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -23
- package/dist/index.js.map +1 -1
- package/dist/ledger/client.d.ts.map +1 -1
- package/dist/ledger/client.js +57 -46
- package/dist/ledger/client.js.map +1 -1
- package/dist/ledger/engine.d.ts.map +1 -1
- package/dist/ledger/engine.js +22 -13
- package/dist/ledger/engine.js.map +1 -1
- package/dist/ledger/pricing.d.ts +54 -0
- package/dist/ledger/pricing.d.ts.map +1 -1
- package/dist/ledger/pricing.js +128 -8
- package/dist/ledger/pricing.js.map +1 -1
- package/dist/shared/types.d.ts +154 -340
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +67 -17
- package/dist/shared/types.js.map +1 -1
- package/dist/snapshot/checkpoint.d.ts.map +1 -1
- package/dist/snapshot/checkpoint.js +20 -2
- package/dist/snapshot/checkpoint.js.map +1 -1
- package/dist/streaming.d.ts +13 -3
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +88 -30
- package/dist/streaming.js.map +1 -1
- package/dist/supply-chain/sign.d.ts.map +1 -1
- package/dist/supply-chain/sign.js +3 -0
- package/dist/supply-chain/sign.js.map +1 -1
- package/package.json +6 -4
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright 2026 Usertools, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* token-rate.ts — Token-rate anomaly signal.
|
|
5
|
+
*
|
|
6
|
+
* Buckets observed chunks into fixed-width windows (default 2 s) and counts
|
|
7
|
+
* tokens-per-second within each window. Trips when N consecutive windows
|
|
8
|
+
* exceed the effective threshold (default 500 tok/s, 3 windows).
|
|
9
|
+
*
|
|
10
|
+
* Brief spikes (1-2 hot windows) are ignored — only sustained runaway
|
|
11
|
+
* behavior trips the circuit.
|
|
12
|
+
*
|
|
13
|
+
* M2 scoped thresholds: each observation may carry the event's model and
|
|
14
|
+
* endpointClass. Local-scope events select `perModel` (trailing-* glob,
|
|
15
|
+
* matched via matchModelPattern) first, then `localThresholdTokPerSec`
|
|
16
|
+
* (default 5000 — fast local GPU inference legitimately exceeds the cloud
|
|
17
|
+
* 500). Cloud/unscoped events keep the legacy `thresholdTokPerSec`. The
|
|
18
|
+
* check() result names which threshold applied via `thresholdLabel`.
|
|
19
|
+
*
|
|
20
|
+
* Cross-scope isolation: cloud and local keep SEPARATE rolling windows, each
|
|
21
|
+
* judged against its own threshold. A shared detector observes interleaved
|
|
22
|
+
* cloud+local streams, so a single mutable threshold/window would let one
|
|
23
|
+
* scope's chunks contaminate the other's verdict — a cloud runaway inheriting
|
|
24
|
+
* the 10x local threshold (evasion), or a local burst false-tripping the cloud
|
|
25
|
+
* 500. Mirrors the dual-series pattern in spend-velocity.ts.
|
|
26
|
+
*/
|
|
27
|
+
import { matchModelPattern } from "../../ledger/pricing.js";
|
|
28
|
+
/** Default local-scope threshold, applied where consumed (resolved config keeps it optional). */
|
|
29
|
+
const LOCAL_THRESHOLD_TOK_PER_SEC_DEFAULT = 5_000;
|
|
3
30
|
export const TOKEN_RATE_DEFAULTS = {
|
|
4
31
|
thresholdTokPerSec: 500,
|
|
5
32
|
windowMs: 2_000,
|
|
@@ -8,82 +35,141 @@ export const TOKEN_RATE_DEFAULTS = {
|
|
|
8
35
|
export function resolveTokenRateConfig(cfg) {
|
|
9
36
|
return {
|
|
10
37
|
thresholdTokPerSec: cfg?.thresholdTokPerSec ?? TOKEN_RATE_DEFAULTS.thresholdTokPerSec,
|
|
38
|
+
// M2 fields stay optional in resolved config — defaulted where consumed.
|
|
39
|
+
localThresholdTokPerSec: cfg?.localThresholdTokPerSec,
|
|
40
|
+
perModel: cfg?.perModel,
|
|
11
41
|
windowMs: cfg?.windowMs ?? TOKEN_RATE_DEFAULTS.windowMs,
|
|
12
42
|
consecutiveWindows: cfg?.consecutiveWindows ?? TOKEN_RATE_DEFAULTS.consecutiveWindows,
|
|
13
43
|
};
|
|
14
44
|
}
|
|
15
45
|
export function createTokenRateSignal(cfg) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
const localDefaultThreshold = cfg.localThresholdTokPerSec ?? LOCAL_THRESHOLD_TOK_PER_SEC_DEFAULT;
|
|
47
|
+
function makeWindow(threshold, label) {
|
|
48
|
+
return {
|
|
49
|
+
currentWindowTokens: 0,
|
|
50
|
+
currentWindowStartMs: 0,
|
|
51
|
+
consecutiveHotWindows: 0,
|
|
52
|
+
lastWindowRateTokPerSec: 0,
|
|
53
|
+
initialized: false,
|
|
54
|
+
threshold,
|
|
55
|
+
thresholdLabel: label,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Independent per-scope windows: cloud/unscoped events accumulate against the
|
|
59
|
+
// legacy threshold, local events against the local/perModel threshold. Neither
|
|
60
|
+
// can move the other's threshold or window (evasion + false-trip fix).
|
|
61
|
+
const cloud = makeWindow(cfg.thresholdTokPerSec, "thresholdTokPerSec");
|
|
62
|
+
const local = makeWindow(localDefaultThreshold, "localThresholdTokPerSec");
|
|
63
|
+
const state = { cloud, local };
|
|
64
|
+
/** Resolve the local-scope threshold/label for an event's model (perModel glob first). */
|
|
65
|
+
function applyLocalThreshold(model) {
|
|
66
|
+
if (model !== undefined && cfg.perModel !== undefined) {
|
|
67
|
+
const match = matchModelPattern(model, cfg.perModel);
|
|
68
|
+
if (match !== undefined) {
|
|
69
|
+
local.threshold = match.value;
|
|
70
|
+
local.thresholdLabel = `perModel["${match.pattern}"]`;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
local.threshold = localDefaultThreshold;
|
|
75
|
+
local.thresholdLabel = "localThresholdTokPerSec";
|
|
76
|
+
}
|
|
77
|
+
function rollWindowsTo(w, nowMs) {
|
|
24
78
|
// Roll forward: close completed windows, evaluate threshold, advance.
|
|
25
|
-
while (nowMs -
|
|
26
|
-
const windowEndMs =
|
|
79
|
+
while (nowMs - w.currentWindowStartMs >= cfg.windowMs) {
|
|
80
|
+
const windowEndMs = w.currentWindowStartMs + cfg.windowMs;
|
|
27
81
|
const seconds = cfg.windowMs / 1_000;
|
|
28
|
-
const rate =
|
|
29
|
-
|
|
30
|
-
if (rate >=
|
|
31
|
-
|
|
82
|
+
const rate = w.currentWindowTokens / seconds;
|
|
83
|
+
w.lastWindowRateTokPerSec = rate;
|
|
84
|
+
if (rate >= w.threshold) {
|
|
85
|
+
w.consecutiveHotWindows += 1;
|
|
32
86
|
}
|
|
33
87
|
else {
|
|
34
|
-
|
|
88
|
+
w.consecutiveHotWindows = 0;
|
|
35
89
|
}
|
|
36
|
-
|
|
37
|
-
|
|
90
|
+
w.currentWindowStartMs = windowEndMs;
|
|
91
|
+
w.currentWindowTokens = 0;
|
|
38
92
|
}
|
|
39
93
|
}
|
|
40
|
-
function observe(deltaTokens, nowMs) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
94
|
+
function observe(deltaTokens, nowMs, context) {
|
|
95
|
+
const w = context?.endpointClass === "local" ? local : cloud;
|
|
96
|
+
if (w === local) {
|
|
97
|
+
applyLocalThreshold(context?.model);
|
|
98
|
+
}
|
|
99
|
+
if (!w.initialized) {
|
|
100
|
+
w.currentWindowStartMs = nowMs;
|
|
101
|
+
w.initialized = true;
|
|
44
102
|
}
|
|
45
|
-
rollWindowsTo(nowMs);
|
|
46
|
-
|
|
103
|
+
rollWindowsTo(w, nowMs);
|
|
104
|
+
w.currentWindowTokens += Math.max(0, deltaTokens);
|
|
47
105
|
}
|
|
48
|
-
|
|
49
|
-
|
|
106
|
+
/** Evaluate one scope's window against its own threshold. */
|
|
107
|
+
function evaluate(w, nowMs) {
|
|
108
|
+
if (!w.initialized) {
|
|
50
109
|
return {
|
|
51
110
|
tripped: false,
|
|
52
111
|
metric: 0,
|
|
53
|
-
threshold:
|
|
112
|
+
threshold: w.threshold,
|
|
54
113
|
hotWindows: 0,
|
|
114
|
+
thresholdLabel: w.thresholdLabel,
|
|
55
115
|
};
|
|
56
116
|
}
|
|
57
|
-
rollWindowsTo(nowMs);
|
|
117
|
+
rollWindowsTo(w, nowMs);
|
|
58
118
|
// Also evaluate the current (incomplete) window if it has surpassed threshold
|
|
59
119
|
// by enough margin to count: only if it would already be a "hot" window.
|
|
60
|
-
const elapsedMs = nowMs -
|
|
120
|
+
const elapsedMs = nowMs - w.currentWindowStartMs;
|
|
61
121
|
let inFlightHot = false;
|
|
62
122
|
let inFlightRate = 0;
|
|
63
123
|
if (elapsedMs > 0) {
|
|
64
|
-
inFlightRate =
|
|
124
|
+
inFlightRate = w.currentWindowTokens / (elapsedMs / 1_000);
|
|
65
125
|
// Only consider in-flight as "hot" once the window has accumulated meaningful
|
|
66
126
|
// data (>=50% elapsed) to avoid false positives from one large early chunk.
|
|
67
|
-
if (elapsedMs >= cfg.windowMs / 2 && inFlightRate >=
|
|
127
|
+
if (elapsedMs >= cfg.windowMs / 2 && inFlightRate >= w.threshold) {
|
|
68
128
|
inFlightHot = true;
|
|
69
129
|
}
|
|
70
130
|
}
|
|
71
|
-
const effectiveHot =
|
|
131
|
+
const effectiveHot = w.consecutiveHotWindows + (inFlightHot ? 1 : 0);
|
|
72
132
|
const tripped = effectiveHot >= cfg.consecutiveWindows;
|
|
73
|
-
const metric = Math.max(
|
|
133
|
+
const metric = Math.max(w.lastWindowRateTokPerSec, inFlightRate);
|
|
74
134
|
return {
|
|
75
135
|
tripped,
|
|
76
136
|
metric,
|
|
77
|
-
threshold:
|
|
137
|
+
threshold: w.threshold,
|
|
78
138
|
hotWindows: effectiveHot,
|
|
139
|
+
thresholdLabel: w.thresholdLabel,
|
|
79
140
|
};
|
|
80
141
|
}
|
|
142
|
+
function check(nowMs) {
|
|
143
|
+
// Cloud is evaluated first so a cloud runaway is reported with the cloud
|
|
144
|
+
// threshold even while a slower local stream interleaves.
|
|
145
|
+
const cloudResult = evaluate(cloud, nowMs);
|
|
146
|
+
if (cloudResult.tripped)
|
|
147
|
+
return cloudResult;
|
|
148
|
+
const localResult = evaluate(local, nowMs);
|
|
149
|
+
if (localResult.tripped)
|
|
150
|
+
return localResult;
|
|
151
|
+
// Neither tripped: report the initialized scope closer to its threshold
|
|
152
|
+
// (higher metric/threshold ratio); default to cloud (legacy) otherwise.
|
|
153
|
+
if (!local.initialized)
|
|
154
|
+
return cloudResult;
|
|
155
|
+
if (!cloud.initialized)
|
|
156
|
+
return localResult;
|
|
157
|
+
const cloudRatio = cloudResult.threshold > 0 ? cloudResult.metric / cloudResult.threshold : 0;
|
|
158
|
+
const localRatio = localResult.threshold > 0 ? localResult.metric / localResult.threshold : 0;
|
|
159
|
+
return localRatio > cloudRatio ? localResult : cloudResult;
|
|
160
|
+
}
|
|
81
161
|
function reset() {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
162
|
+
for (const w of [cloud, local]) {
|
|
163
|
+
w.currentWindowTokens = 0;
|
|
164
|
+
w.currentWindowStartMs = 0;
|
|
165
|
+
w.consecutiveHotWindows = 0;
|
|
166
|
+
w.lastWindowRateTokPerSec = 0;
|
|
167
|
+
w.initialized = false;
|
|
168
|
+
}
|
|
169
|
+
cloud.threshold = cfg.thresholdTokPerSec;
|
|
170
|
+
cloud.thresholdLabel = "thresholdTokPerSec";
|
|
171
|
+
local.threshold = localDefaultThreshold;
|
|
172
|
+
local.thresholdLabel = "localThresholdTokPerSec";
|
|
87
173
|
}
|
|
88
174
|
return { state, observe, check, reset };
|
|
89
175
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token-rate.js","sourceRoot":"","sources":["../../../src/anomaly/signals/token-rate.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,iCAAiC;
|
|
1
|
+
{"version":3,"file":"token-rate.js","sourceRoot":"","sources":["../../../src/anomaly/signals/token-rate.ts"],"names":[],"mappings":"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"}
|
package/dist/anomaly/types.d.ts
CHANGED
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
* should VOID the PENDING hold, throw `AnomalyError`, and emit a hash-chained
|
|
8
8
|
* `anomaly_detected` audit event.
|
|
9
9
|
*/
|
|
10
|
-
import type { LLMClientKind } from "../shared/types.js";
|
|
10
|
+
import type { EndpointClass, LLMClientKind } from "../shared/types.js";
|
|
11
11
|
/** The three signal types the detector watches. */
|
|
12
12
|
export type AnomalyKind = "token_rate" | "spend_velocity" | "injection_cascade";
|
|
13
13
|
/** Token-rate signal config. */
|
|
14
14
|
export interface TokenRateConfig {
|
|
15
15
|
/** Threshold tokens-per-second above which a window is "hot". Default 500. */
|
|
16
16
|
thresholdTokPerSec?: number;
|
|
17
|
+
/** Local-scope threshold tokens-per-second (M2). Default 5000. */
|
|
18
|
+
localThresholdTokPerSec?: number | undefined;
|
|
19
|
+
/** Per-model threshold overrides. Keys: exact model or trailing-* glob (M2). */
|
|
20
|
+
perModel?: Record<string, number> | undefined;
|
|
17
21
|
/** Window size in milliseconds. Default 2000. */
|
|
18
22
|
windowMs?: number;
|
|
19
23
|
/** Number of consecutive hot windows required to trip. Default 3. */
|
|
@@ -23,6 +27,8 @@ export interface TokenRateConfig {
|
|
|
23
27
|
export interface SpendVelocityConfig {
|
|
24
28
|
/** Maximum dollars per minute (rolling). Default 1.00. */
|
|
25
29
|
thresholdDollarsPerMin?: number;
|
|
30
|
+
/** Local-scope threshold in nominal usertokens per minute (M2). Default 10000. */
|
|
31
|
+
localThresholdUsertokensPerMin?: number | undefined;
|
|
26
32
|
/** Rolling window in milliseconds. Default 10000. */
|
|
27
33
|
windowMs?: number;
|
|
28
34
|
}
|
|
@@ -43,11 +49,17 @@ export interface AnomalyConfig {
|
|
|
43
49
|
/** Cooldown after a trip before auto-reset. Default 30000 ms. */
|
|
44
50
|
cooldownMs?: number;
|
|
45
51
|
}
|
|
46
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Resolved (defaulted) config used internally.
|
|
54
|
+
*
|
|
55
|
+
* Legacy fields are fully defaulted; the M2 local-scope fields
|
|
56
|
+
* (localThresholdTokPerSec, perModel, localThresholdUsertokensPerMin) stay
|
|
57
|
+
* optional here and are defaulted at the signal when absent.
|
|
58
|
+
*/
|
|
47
59
|
export interface ResolvedAnomalyConfig {
|
|
48
60
|
enabled: boolean;
|
|
49
|
-
tokenRate: Required<TokenRateConfig>;
|
|
50
|
-
spendVelocity: Required<SpendVelocityConfig>;
|
|
61
|
+
tokenRate: Required<Omit<TokenRateConfig, "localThresholdTokPerSec" | "perModel">> & Pick<TokenRateConfig, "localThresholdTokPerSec" | "perModel">;
|
|
62
|
+
spendVelocity: Required<Omit<SpendVelocityConfig, "localThresholdUsertokensPerMin">> & Pick<SpendVelocityConfig, "localThresholdUsertokensPerMin">;
|
|
51
63
|
injectionCascade: Required<InjectionCascadeConfig>;
|
|
52
64
|
cooldownMs: number;
|
|
53
65
|
}
|
|
@@ -62,6 +74,10 @@ export interface AnomalyChunkEvent {
|
|
|
62
74
|
cumulativeOutputTokens: number;
|
|
63
75
|
/** Wall-clock timestamp. Defaults to Date.now() if omitted. */
|
|
64
76
|
at?: number;
|
|
77
|
+
/** Model of the call this chunk belongs to. Overrides options.model when present (M2). */
|
|
78
|
+
model?: string | undefined;
|
|
79
|
+
/** Endpoint scope of the call this chunk belongs to. Default scope: cloud (M2). */
|
|
80
|
+
endpointClass?: EndpointClass | undefined;
|
|
65
81
|
}
|
|
66
82
|
/** Out-of-band injection event (fired when detect.injection finds something). */
|
|
67
83
|
export interface AnomalyInjectionEvent {
|
|
@@ -87,8 +103,14 @@ export interface AnomalyDetectorOptions {
|
|
|
87
103
|
provider?: LLMClientKind;
|
|
88
104
|
/** Model name being observed (used for cost calculation in spend-velocity). */
|
|
89
105
|
model?: string;
|
|
90
|
-
/**
|
|
91
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Override the cost calculator. Returns the cost for the given cumulative tokens —
|
|
108
|
+
* dollars for cloud-scope events, nominal usertokens for local-scope events.
|
|
109
|
+
* The observed chunk event is passed as the optional 4th argument so an injected
|
|
110
|
+
* calculator can price per-event (scoped by event.model/event.endpointClass); 3-arg
|
|
111
|
+
* implementations remain assignable (backward compatible).
|
|
112
|
+
*/
|
|
113
|
+
costCalculator?: (model: string, inputTokens: number, outputTokens: number, event?: AnomalyChunkEvent) => number;
|
|
92
114
|
/** Time source override (for deterministic tests). */
|
|
93
115
|
now?: () => number;
|
|
94
116
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/anomaly/types.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/anomaly/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface DoctorCheck {
|
|
2
|
+
name: string;
|
|
3
|
+
status: "pass" | "fail" | "info";
|
|
4
|
+
detail: string;
|
|
5
|
+
}
|
|
6
|
+
export interface DoctorReport {
|
|
7
|
+
sink: string;
|
|
8
|
+
checks: DoctorCheck[];
|
|
9
|
+
failed: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Injectable HTTP transport — the probe's only impure edge, so tests script
|
|
13
|
+
* store responses instead of reaching the network. Response `headers` are
|
|
14
|
+
* optional because only the S3 versioning probe reads one.
|
|
15
|
+
*/
|
|
16
|
+
export type HttpTransport = (opts: {
|
|
17
|
+
method: string;
|
|
18
|
+
url: string;
|
|
19
|
+
headers: Record<string, string>;
|
|
20
|
+
body: Buffer;
|
|
21
|
+
}) => Promise<{
|
|
22
|
+
status: number;
|
|
23
|
+
body: string;
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
}>;
|
|
26
|
+
export interface S3ProbeConfig {
|
|
27
|
+
bucket: string;
|
|
28
|
+
region: string;
|
|
29
|
+
prefix?: string;
|
|
30
|
+
endpoint?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Probe the directory holding a file sink. The probe object is created next to
|
|
34
|
+
* the store file (same directory, hence same permissions) and removed again.
|
|
35
|
+
*/
|
|
36
|
+
export declare function doctorFileSink(sinkPath: string): DoctorReport;
|
|
37
|
+
/**
|
|
38
|
+
* Probe an S3-compatible store: write a probe object, then attempt the two
|
|
39
|
+
* operations an append-only store must refuse against that same key.
|
|
40
|
+
*/
|
|
41
|
+
export declare function doctorS3Sink(cfg: S3ProbeConfig, transport?: HttpTransport): Promise<DoctorReport>;
|
|
42
|
+
//# sourceMappingURL=anchor-doctor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"anchor-doctor.d.ts","sourceRoot":"","sources":["../../src/audit/anchor-doctor.ts"],"names":[],"mappings":"AAkCA,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACjC,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,MAAM,EAAE,OAAO,CAAC;CAChB;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,IAAI,EAAE;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;CACb,KAAK,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC,CAAC;AAElF,MAAM,WAAW,aAAa;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AA2CD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,CAmD7D;AA6BD;;;GAGG;AACH,wBAAsB,YAAY,CACjC,GAAG,EAAE,aAAa,EAClB,SAAS,GAAE,aAA8B,GACvC,OAAO,CAAC,YAAY,CAAC,CAsCvB"}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Usertools, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* `anchor doctor` — append-only store PERMISSION PROBES.
|
|
5
|
+
*
|
|
6
|
+
* What this module establishes is narrow and worth stating precisely: it writes
|
|
7
|
+
* a throwaway probe object to the store and reports whether THIS identity, with
|
|
8
|
+
* the credentials in THIS environment, could delete or overwrite that object
|
|
9
|
+
* RIGHT NOW. It does not prove the store is append-only. A store can deny these
|
|
10
|
+
* operations today and allow them tomorrow, deny them for this identity and
|
|
11
|
+
* allow them for an admin one, or accept an overwrite as a new object version.
|
|
12
|
+
* Immutability comes from the store's own configuration (S3 Object Lock
|
|
13
|
+
* retention, POSIX directory permissions, an appliance's WORM mode) — the
|
|
14
|
+
* doctor only tells an operator whether their configuration is doing anything
|
|
15
|
+
* at all, which is the failure mode worth catching before an audit.
|
|
16
|
+
*
|
|
17
|
+
* The two probes are independent on purpose: on POSIX, deletion is governed by
|
|
18
|
+
* write permission on the DIRECTORY while in-place truncation is governed by
|
|
19
|
+
* permission on the FILE, so a store can deny one and allow the other.
|
|
20
|
+
*
|
|
21
|
+
* The S3 probe leaves its probe object behind whenever the store denies
|
|
22
|
+
* deletion — that is the store working as intended, not a leak, and the probe
|
|
23
|
+
* key is named in every check detail so it can be reconciled or lifecycled.
|
|
24
|
+
*
|
|
25
|
+
* Never in the output: credentials, the `authorization` header, or the session
|
|
26
|
+
* token — a diagnostic that gets pasted into an issue tracker must be safe to
|
|
27
|
+
* paste (plan delta D6).
|
|
28
|
+
*/
|
|
29
|
+
import { closeSync, openSync, unlinkSync, writeSync } from "node:fs";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
import { sigV4Headers } from "./sigv4.js";
|
|
32
|
+
const PROBE_BODY = Buffer.from(`${JSON.stringify({ probe: "usertrust-anchor-doctor", note: "safe to delete" })}\n`, "utf8");
|
|
33
|
+
// ── Report helpers ──
|
|
34
|
+
const pass = (name, detail) => ({ name, status: "pass", detail });
|
|
35
|
+
const fail = (name, detail) => ({ name, status: "fail", detail });
|
|
36
|
+
const info = (name, detail) => ({ name, status: "info", detail });
|
|
37
|
+
function report(sink, checks) {
|
|
38
|
+
return { sink, checks, failed: checks.some((c) => c.status === "fail") };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Untrusted text (store error bodies, transport error messages) on its way into
|
|
42
|
+
* a check detail. Anything that could carry a signed header is dropped to the
|
|
43
|
+
* end of its line rather than partially masked — a diagnostic is worth less
|
|
44
|
+
* than a credential.
|
|
45
|
+
*/
|
|
46
|
+
function redact(text) {
|
|
47
|
+
const scrubbed = text
|
|
48
|
+
.replace(/authorization[^\n]*/gi, "[redacted]")
|
|
49
|
+
.replace(/x-amz-security-token[^\n]*/gi, "[redacted]")
|
|
50
|
+
.replace(/AWS4-HMAC-SHA256[^\n]*/g, "[redacted]")
|
|
51
|
+
.replace(/Credential=[^\s,]*/g, "[redacted]")
|
|
52
|
+
.replace(/Signature=[^\s,]*/g, "[redacted]")
|
|
53
|
+
.replace(/\s+/g, " ")
|
|
54
|
+
.trim();
|
|
55
|
+
return scrubbed.length > 120 ? `${scrubbed.slice(0, 120)}…` : scrubbed;
|
|
56
|
+
}
|
|
57
|
+
const messageOf = (err) => redact(err instanceof Error ? err.message : String(err));
|
|
58
|
+
const errnoOf = (err) => typeof err.code === "string" ? err.code : "unknown";
|
|
59
|
+
// ── File sink ──
|
|
60
|
+
/**
|
|
61
|
+
* Probe the directory holding a file sink. The probe object is created next to
|
|
62
|
+
* the store file (same directory, hence same permissions) and removed again.
|
|
63
|
+
*/
|
|
64
|
+
export function doctorFileSink(sinkPath) {
|
|
65
|
+
const sink = `file:${sinkPath}`;
|
|
66
|
+
const dir = dirname(sinkPath);
|
|
67
|
+
// PID-scoped so concurrent doctors never probe each other's object; opened
|
|
68
|
+
// with "w" so a stale probe from a crashed run is reused, not fatal.
|
|
69
|
+
const probe = join(dir, `.doctor-probe-${process.pid}`);
|
|
70
|
+
const created = writeProbe(probe);
|
|
71
|
+
if (created !== null) {
|
|
72
|
+
return report(sink, [
|
|
73
|
+
info("probe", `cannot probe: this identity could not create a probe object in ${dir} (${created}) — ` +
|
|
74
|
+
"the store's delete and overwrite permissions were not exercised"),
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
const checks = [];
|
|
78
|
+
let deleted = false;
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(probe);
|
|
81
|
+
deleted = true;
|
|
82
|
+
checks.push(fail("delete-denied", `this identity could delete a probe object at ${probe} right now`));
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
checks.push(pass("delete-denied", `this identity could not delete a probe object at ${probe} right now (${errnoOf(err)})`));
|
|
86
|
+
}
|
|
87
|
+
// The delete probe consumed the object when it succeeded; the overwrite
|
|
88
|
+
// probe needs one to truncate.
|
|
89
|
+
const recreated = deleted ? writeProbe(probe) : null;
|
|
90
|
+
if (recreated !== null) {
|
|
91
|
+
checks.push(info("overwrite-denied", `cannot probe overwrite: ${recreated}`));
|
|
92
|
+
return report(sink, checks);
|
|
93
|
+
}
|
|
94
|
+
checks.push(overwriteProbe(probe));
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(probe);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Best effort: a store that refuses the delete is the passing case, and
|
|
100
|
+
// the probe object is named in the check detail either way.
|
|
101
|
+
}
|
|
102
|
+
return report(sink, checks);
|
|
103
|
+
}
|
|
104
|
+
/** Creates or truncates the probe object; returns an errno on failure, else null. */
|
|
105
|
+
function writeProbe(probe) {
|
|
106
|
+
try {
|
|
107
|
+
const fd = openSync(probe, "w", 0o600);
|
|
108
|
+
try {
|
|
109
|
+
writeSync(fd, PROBE_BODY);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
closeSync(fd);
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return errnoOf(err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function overwriteProbe(probe) {
|
|
121
|
+
const errno = writeProbe(probe);
|
|
122
|
+
return errno === null
|
|
123
|
+
? fail("overwrite-denied", `this identity could overwrite a probe object at ${probe} right now`)
|
|
124
|
+
: pass("overwrite-denied", `this identity could not overwrite a probe object at ${probe} right now (${errno})`);
|
|
125
|
+
}
|
|
126
|
+
// ── S3 sink ──
|
|
127
|
+
/**
|
|
128
|
+
* Probe an S3-compatible store: write a probe object, then attempt the two
|
|
129
|
+
* operations an append-only store must refuse against that same key.
|
|
130
|
+
*/
|
|
131
|
+
export async function doctorS3Sink(cfg, transport = httpsTransport) {
|
|
132
|
+
const prefix = (cfg.prefix ?? "anchors").replace(/^\/+|\/+$/g, "");
|
|
133
|
+
const sink = `s3:${cfg.bucket}/${prefix}`;
|
|
134
|
+
const creds = credentialsFromEnv();
|
|
135
|
+
if (creds === null) {
|
|
136
|
+
return report(sink, [
|
|
137
|
+
info("probe", "cannot probe: AWS credentials not found in the environment " +
|
|
138
|
+
"(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)"),
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
const target = probeTarget(cfg, prefix);
|
|
142
|
+
const loc = `s3://${cfg.bucket}/${target.key}`;
|
|
143
|
+
const send = (method, payload) => s3Request(transport, target, cfg.region, method, payload, creds);
|
|
144
|
+
const put = await attempt(() => send("PUT", PROBE_BODY));
|
|
145
|
+
if (put.error !== null) {
|
|
146
|
+
return report(sink, [info("probe", `cannot probe: writing ${loc} failed — ${put.error}`)]);
|
|
147
|
+
}
|
|
148
|
+
if (!is2xx(put.status)) {
|
|
149
|
+
return report(sink, [
|
|
150
|
+
info("probe", `cannot probe: writing ${loc} returned HTTP ${put.status}${snippet(put.body)} — ` +
|
|
151
|
+
"the store's delete and overwrite permissions were not exercised"),
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
return report(sink, [
|
|
155
|
+
verdict("delete-denied", "delete", loc, await attempt(() => send("DELETE", Buffer.alloc(0)))),
|
|
156
|
+
verdict("overwrite-denied", "overwrite", loc, await attempt(() => send("PUT", PROBE_BODY))),
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
async function attempt(run) {
|
|
160
|
+
try {
|
|
161
|
+
const res = await run();
|
|
162
|
+
const headers = res.headers ?? {};
|
|
163
|
+
const versionId = Object.entries(headers).find(([k]) => k.toLowerCase() === "x-amz-version-id")?.[1] ?? null;
|
|
164
|
+
return { status: res.status, body: res.body, versionId, error: null };
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
return { status: 0, body: "", versionId: null, error: messageOf(err) };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Turn one store response into a verdict. 403 is the shape a store takes when
|
|
172
|
+
* its policy refuses; a 2xx that carries `x-amz-version-id` is neither a
|
|
173
|
+
* refusal nor a true overwrite, so it reports as info rather than a verdict.
|
|
174
|
+
*/
|
|
175
|
+
function verdict(name, op, loc, res) {
|
|
176
|
+
if (res.error !== null) {
|
|
177
|
+
return info(name, `cannot probe ${op}: ${loc} — ${res.error}`);
|
|
178
|
+
}
|
|
179
|
+
if (res.status === 403) {
|
|
180
|
+
return pass(name, `this identity could not ${op} a probe object at ${loc} right now (HTTP 403)`);
|
|
181
|
+
}
|
|
182
|
+
if (!is2xx(res.status)) {
|
|
183
|
+
return info(name, `inconclusive: ${op} of ${loc} returned HTTP ${res.status}${snippet(res.body)}`);
|
|
184
|
+
}
|
|
185
|
+
if (op === "overwrite" && res.versionId !== null) {
|
|
186
|
+
return info(name, `this identity could write a new version of a probe object at ${loc} right now ` +
|
|
187
|
+
`(HTTP ${res.status}, x-amz-version-id) — a new version does not remove the previous ` +
|
|
188
|
+
"one; confirm Object Lock retention denies deletion of individual versions");
|
|
189
|
+
}
|
|
190
|
+
return fail(name, `this identity could ${op} a probe object at ${loc} right now (HTTP ${res.status})`);
|
|
191
|
+
}
|
|
192
|
+
const is2xx = (status) => status >= 200 && status < 300;
|
|
193
|
+
const snippet = (body) => (body === "" ? "" : `: ${redact(body)}`);
|
|
194
|
+
function credentialsFromEnv() {
|
|
195
|
+
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
|
196
|
+
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
|
197
|
+
if (!accessKeyId || !secretAccessKey)
|
|
198
|
+
return null;
|
|
199
|
+
const sessionToken = process.env.AWS_SESSION_TOKEN;
|
|
200
|
+
return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) };
|
|
201
|
+
}
|
|
202
|
+
/** Virtual-host addressing by default; path-style when an endpoint is configured. */
|
|
203
|
+
function probeTarget(cfg, prefix) {
|
|
204
|
+
const key = `${prefix === "" ? "" : `${prefix}/`}doctor-probe/${process.pid}-${amzDate()}.json`;
|
|
205
|
+
const encoded = key.split("/").map(encodeURIComponent).join("/");
|
|
206
|
+
if (cfg.endpoint !== undefined) {
|
|
207
|
+
const base = parseEndpoint(cfg.endpoint);
|
|
208
|
+
const path = `/${encodeURIComponent(cfg.bucket)}/${encoded}`;
|
|
209
|
+
return { host: base.host, path, url: `${base.protocol}//${base.host}${path}`, key };
|
|
210
|
+
}
|
|
211
|
+
const host = `${cfg.bucket}.s3.${cfg.region}.amazonaws.com`;
|
|
212
|
+
return { host, path: `/${encoded}`, url: `https://${host}/${encoded}`, key };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Endpoint for an S3-compatible store, under the SAME rule as the sink
|
|
216
|
+
* (rekor.ts / s3-sink): plaintext is refused off-loopback. The doctor's probes
|
|
217
|
+
* are SigV4-signed like any other request, so an http endpoint on the network
|
|
218
|
+
* would put the credential-bearing headers on the wire — and a diagnostic
|
|
219
|
+
* command is exactly where an operator would paste an attacker-suggested URL.
|
|
220
|
+
* Loopback stays allowed for dev MinIO/LocalStack.
|
|
221
|
+
*/
|
|
222
|
+
function parseEndpoint(endpoint) {
|
|
223
|
+
let url;
|
|
224
|
+
try {
|
|
225
|
+
url = new URL(endpoint);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
throw new Error(`s3 doctor: endpoint must include a scheme, got "${endpoint.slice(0, 60)}"`);
|
|
229
|
+
}
|
|
230
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
231
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
232
|
+
throw new Error(`s3 doctor: endpoint must be https (http is allowed only for localhost/127.0.0.1), got "${url.protocol}//${url.host}"`);
|
|
233
|
+
}
|
|
234
|
+
return url;
|
|
235
|
+
}
|
|
236
|
+
/** `YYYYMMDDTHHMMSSZ` — also the probe key's uniqueness across runs. */
|
|
237
|
+
function amzDate() {
|
|
238
|
+
return `${new Date().toISOString().replace(/[-:]/g, "").slice(0, 15)}Z`;
|
|
239
|
+
}
|
|
240
|
+
function s3Request(transport, target, region, method, payload, creds) {
|
|
241
|
+
const extra = payload.length > 0 ? { "content-type": "application/json" } : {};
|
|
242
|
+
const signed = sigV4Headers({
|
|
243
|
+
method,
|
|
244
|
+
host: target.host,
|
|
245
|
+
path: target.path,
|
|
246
|
+
headers: extra,
|
|
247
|
+
payload,
|
|
248
|
+
region,
|
|
249
|
+
service: "s3",
|
|
250
|
+
amzDate: amzDate(),
|
|
251
|
+
}, creds);
|
|
252
|
+
// sigV4Headers signs caller headers but returns only its own — the caller
|
|
253
|
+
// must send both, or the signature covers headers that never arrive.
|
|
254
|
+
return transport({ method, url: target.url, headers: { ...extra, ...signed }, body: payload });
|
|
255
|
+
}
|
|
256
|
+
/** Default transport. `http://` endpoints (dev MinIO) require an injected one. */
|
|
257
|
+
async function httpsTransport(opts) {
|
|
258
|
+
const { request } = await import("node:https");
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
const req = request(opts.url, {
|
|
261
|
+
method: opts.method,
|
|
262
|
+
headers: { ...opts.headers, "content-length": String(opts.body.length) },
|
|
263
|
+
timeout: 15_000,
|
|
264
|
+
}, (res) => {
|
|
265
|
+
const chunks = [];
|
|
266
|
+
res.on("data", (c) => chunks.push(c));
|
|
267
|
+
res.on("end", () => {
|
|
268
|
+
const headers = {};
|
|
269
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
270
|
+
if (typeof value === "string")
|
|
271
|
+
headers[name] = value;
|
|
272
|
+
}
|
|
273
|
+
resolve({
|
|
274
|
+
status: res.statusCode ?? 0,
|
|
275
|
+
body: Buffer.concat(chunks).toString("utf8").slice(0, 4096),
|
|
276
|
+
headers,
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
req.on("timeout", () => req.destroy(new Error("s3 doctor: request timeout")));
|
|
281
|
+
req.on("error", reject);
|
|
282
|
+
req.end(opts.body);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
//# sourceMappingURL=anchor-doctor.js.map
|