haechi 1.1.2 → 1.2.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/SECURITY.md +7 -1
- package/docs/README.md +2 -0
- package/docs/current/compliance-mapping.ko.md +53 -0
- package/docs/current/compliance-mapping.md +53 -0
- package/docs/current/config-version.ko.md +30 -0
- package/docs/current/config-version.md +51 -0
- package/docs/current/configuration.ko.md +147 -7
- package/docs/current/configuration.md +147 -7
- package/docs/current/operations-runbook.ko.md +121 -0
- package/docs/current/operations-runbook.md +204 -0
- package/docs/current/release-process.ko.md +1 -1
- package/docs/current/release-process.md +1 -1
- package/docs/current/risk-register-release-gate.ko.md +3 -2
- package/docs/current/risk-register-release-gate.md +11 -2
- package/docs/current/security-whitepaper.ko.md +102 -0
- package/docs/current/security-whitepaper.md +102 -0
- package/docs/current/shared-responsibility.ko.md +2 -2
- package/docs/current/shared-responsibility.md +2 -2
- package/docs/current/threat-model.ko.md +3 -2
- package/docs/current/threat-model.md +3 -2
- package/haechi.config.example.json +19 -3
- package/package.json +5 -2
- package/packages/audit/index.mjs +26 -2
- package/packages/cli/bin/haechi.mjs +54 -8
- package/packages/cli/runtime.mjs +391 -10
- package/packages/core/index.mjs +143 -8
- package/packages/filter/index.mjs +299 -9
- package/packages/metrics/index.mjs +181 -0
- package/packages/proxy/index.mjs +518 -39
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// WS4-A telemetry seam (reliability-hardening-track §WS4 "Telemetry").
|
|
2
|
+
//
|
|
3
|
+
// A minimal, zero-dependency in-memory metrics collector rendering the
|
|
4
|
+
// Prometheus text exposition format. It is an INJECTABLE collaborator
|
|
5
|
+
// (providers.metrics in createRuntime), mirroring auditSink/rateLimiter; an
|
|
6
|
+
// operator who wants a real metrics backend injects their own object exposing
|
|
7
|
+
// the same { increment, observe, render } contract.
|
|
8
|
+
//
|
|
9
|
+
// HARD INVARIANT (the no-plaintext-in-audit invariant, extended to telemetry):
|
|
10
|
+
// every metric name AND every label value is a BOUNDED ENUM — a route id, a
|
|
11
|
+
// policy mode, or a decision class. It is NEVER an identity id/subject, a token,
|
|
12
|
+
// a detected value, or any other unbounded/PII-bearing string. This module does
|
|
13
|
+
// not — and structurally cannot — accept a payload value: callers pass only
|
|
14
|
+
// pre-classified enum labels, and label values are coerced + length-capped here
|
|
15
|
+
// as defence in depth so an accidental high-cardinality value cannot explode
|
|
16
|
+
// the series set or leak content.
|
|
17
|
+
|
|
18
|
+
// Metric catalogue: name -> { type, help }. Counters and one histogram. Keeping
|
|
19
|
+
// the catalogue explicit (rather than letting callers invent metric names)
|
|
20
|
+
// bounds the metric-name dimension to this fixed set.
|
|
21
|
+
const COUNTERS = {
|
|
22
|
+
haechi_requests_total: "Proxy requests by route, mode, and decision class.",
|
|
23
|
+
haechi_blocks_total: "Requests blocked by a policy decision.",
|
|
24
|
+
haechi_auth_denied_total: "Requests denied at authentication.",
|
|
25
|
+
haechi_rate_limited_total: "Requests rejected by the rate limiter.",
|
|
26
|
+
haechi_upstream_timeout_total: "Upstream requests that timed out.",
|
|
27
|
+
haechi_upstream_error_total: "Upstream requests that failed (non-timeout).",
|
|
28
|
+
haechi_response_unprotected_total: "Responses forwarded without protection (size/encoding/parse).",
|
|
29
|
+
haechi_internal_error_total: "Unexpected internal proxy errors.",
|
|
30
|
+
haechi_overloaded_total: "Requests rejected by the max-in-flight backpressure ceiling (503)."
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const HISTOGRAMS = {
|
|
34
|
+
haechi_request_duration_seconds: "End-to-end proxy request handling duration in seconds."
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Default request-duration histogram buckets (seconds). Bounded, fixed set.
|
|
38
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
39
|
+
|
|
40
|
+
// Defence-in-depth label hygiene: a label value must be a short, bounded token.
|
|
41
|
+
// We coerce to string, trim, cap length, and collapse anything outside a safe
|
|
42
|
+
// charset to "_". This guarantees that even a caller mistake cannot place a raw
|
|
43
|
+
// payload value or a long identity string into a series label.
|
|
44
|
+
const MAX_LABEL_LENGTH = 64;
|
|
45
|
+
|
|
46
|
+
function safeLabelValue(value) {
|
|
47
|
+
if (value === undefined || value === null) {
|
|
48
|
+
return "none";
|
|
49
|
+
}
|
|
50
|
+
const text = String(value).slice(0, MAX_LABEL_LENGTH);
|
|
51
|
+
// Allow a conservative identifier charset only (route ids, modes, decisions
|
|
52
|
+
// are all of this shape). Everything else becomes "_".
|
|
53
|
+
return text.replace(/[^A-Za-z0-9_.:/-]/g, "_") || "none";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function seriesKey(name, labels) {
|
|
57
|
+
const parts = Object.keys(labels)
|
|
58
|
+
.sort()
|
|
59
|
+
.map((labelName) => `${labelName}="${escapeLabel(labels[labelName])}"`);
|
|
60
|
+
return parts.length > 0 ? `${name}{${parts.join(",")}}` : name;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function escapeLabel(value) {
|
|
64
|
+
return String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, "\\\"");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createMetrics({ buckets = DEFAULT_BUCKETS } = {}) {
|
|
68
|
+
// counterSeries: metricName -> Map(seriesKey -> { labels, value })
|
|
69
|
+
const counterSeries = new Map();
|
|
70
|
+
// histogramSeries: metricName -> Map(seriesKey -> { labels, bucketCounts, sum, count })
|
|
71
|
+
const histogramSeries = new Map();
|
|
72
|
+
const sortedBuckets = [...buckets].sort((a, b) => a - b);
|
|
73
|
+
|
|
74
|
+
function normalizeLabels(labels = {}) {
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
77
|
+
// Label NAMES are caller-fixed identifiers; coerce defensively anyway.
|
|
78
|
+
const labelName = String(key).replace(/[^A-Za-z0-9_]/g, "_");
|
|
79
|
+
out[labelName] = safeLabelValue(value);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
// Increment a known counter by `amount` (default 1), labelled by a bounded
|
|
86
|
+
// enum set. An unknown metric name is ignored (fail-soft for telemetry — a
|
|
87
|
+
// metric typo must never break a request path).
|
|
88
|
+
increment(name, labels = {}, amount = 1) {
|
|
89
|
+
if (!(name in COUNTERS)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const safe = normalizeLabels(labels);
|
|
93
|
+
const key = seriesKey(name, safe);
|
|
94
|
+
let series = counterSeries.get(name);
|
|
95
|
+
if (!series) {
|
|
96
|
+
series = new Map();
|
|
97
|
+
counterSeries.set(name, series);
|
|
98
|
+
}
|
|
99
|
+
const existing = series.get(key);
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.value += amount;
|
|
102
|
+
} else {
|
|
103
|
+
series.set(key, { labels: safe, value: amount });
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// Observe a value into a known histogram (request-duration seconds).
|
|
108
|
+
observe(name, value, labels = {}) {
|
|
109
|
+
if (!(name in HISTOGRAMS) || typeof value !== "number" || !Number.isFinite(value)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const safe = normalizeLabels(labels);
|
|
113
|
+
const key = seriesKey(name, safe);
|
|
114
|
+
let series = histogramSeries.get(name);
|
|
115
|
+
if (!series) {
|
|
116
|
+
series = new Map();
|
|
117
|
+
histogramSeries.set(name, series);
|
|
118
|
+
}
|
|
119
|
+
let entry = series.get(key);
|
|
120
|
+
if (!entry) {
|
|
121
|
+
entry = { labels: safe, bucketCounts: new Array(sortedBuckets.length).fill(0), sum: 0, count: 0 };
|
|
122
|
+
series.set(key, entry);
|
|
123
|
+
}
|
|
124
|
+
entry.sum += value;
|
|
125
|
+
entry.count += 1;
|
|
126
|
+
for (let i = 0; i < sortedBuckets.length; i += 1) {
|
|
127
|
+
if (value <= sortedBuckets[i]) {
|
|
128
|
+
entry.bucketCounts[i] += 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// Render the full Prometheus text exposition. Every declared counter and
|
|
134
|
+
// histogram emits its HELP/TYPE header even with no observations, so the
|
|
135
|
+
// surface is stable for scrapers.
|
|
136
|
+
render() {
|
|
137
|
+
const lines = [];
|
|
138
|
+
|
|
139
|
+
for (const [name, help] of Object.entries(COUNTERS)) {
|
|
140
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
141
|
+
lines.push(`# TYPE ${name} counter`);
|
|
142
|
+
const series = counterSeries.get(name);
|
|
143
|
+
if (series) {
|
|
144
|
+
for (const { labels, value } of series.values()) {
|
|
145
|
+
lines.push(`${seriesKey(name, labels)} ${value}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const [name, help] of Object.entries(HISTOGRAMS)) {
|
|
151
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
152
|
+
lines.push(`# TYPE ${name} histogram`);
|
|
153
|
+
const series = histogramSeries.get(name);
|
|
154
|
+
if (series) {
|
|
155
|
+
for (const entry of series.values()) {
|
|
156
|
+
// bucketCounts[i] already holds the cumulative count of observations
|
|
157
|
+
// with value <= sortedBuckets[i] (observe() increments every bucket
|
|
158
|
+
// the value falls under), which is exactly the Prometheus le="..."
|
|
159
|
+
// cumulative bucket semantics — emit it directly.
|
|
160
|
+
for (let i = 0; i < sortedBuckets.length; i += 1) {
|
|
161
|
+
const labels = { ...entry.labels, le: String(sortedBuckets[i]) };
|
|
162
|
+
lines.push(`${seriesKey(`${name}_bucket`, labels)} ${entry.bucketCounts[i]}`);
|
|
163
|
+
}
|
|
164
|
+
const infLabels = { ...entry.labels, le: "+Inf" };
|
|
165
|
+
lines.push(`${seriesKey(`${name}_bucket`, infLabels)} ${entry.count}`);
|
|
166
|
+
lines.push(`${seriesKey(`${name}_sum`, entry.labels)} ${entry.sum}`);
|
|
167
|
+
lines.push(`${seriesKey(`${name}_count`, entry.labels)} ${entry.count}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return `${lines.join("\n")}\n`;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Exported for tests / operators who want to assert the bounded metric surface.
|
|
178
|
+
export const METRIC_NAMES = Object.freeze({
|
|
179
|
+
counters: Object.keys(COUNTERS),
|
|
180
|
+
histograms: Object.keys(HISTOGRAMS)
|
|
181
|
+
});
|