@sema-agent/settings-schema 6.1.0 → 6.3.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/CHANGELOG.md +265 -0
- package/README.md +40 -9
- package/conformance/VERSION +1 -1
- package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +166 -0
- package/conformance/cases/c41-s792-encoded-pem-identity/input.json +62 -0
- package/conformance/cases/c41-s792-encoded-pem-identity/meta.json +12 -0
- package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +120 -0
- package/conformance/cases/c42-s792-unknown-key-secret-redacted/input.json +25 -0
- package/conformance/cases/c42-s792-unknown-key-secret-redacted/meta.json +11 -0
- package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +137 -0
- package/conformance/cases/c43-s792-form-and-deep-encoded-pem/input.json +44 -0
- package/conformance/cases/c43-s792-form-and-deep-encoded-pem/meta.json +12 -0
- package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +115 -0
- package/conformance/cases/c44-s792-utf8-escape-glued-token/input.json +30 -0
- package/conformance/cases/c44-s792-utf8-escape-glued-token/meta.json +10 -0
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +185 -0
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/input.json +78 -0
- package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/meta.json +11 -0
- package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/expected.json +88 -0
- package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/input.json +18 -0
- package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/meta.json +6 -0
- package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/expected.json +98 -0
- package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/input.json +26 -0
- package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/meta.json +6 -0
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +0 -8
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/meta.json +2 -1
- package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +0 -8
- package/conformance/cases/cli02-control-and-zero-width-chars/meta.json +2 -1
- package/dist/config-fns.d.ts +62 -14
- package/dist/config-fns.js +105 -30
- package/dist/cross-session.d.ts +8 -5
- package/dist/cross-session.js +8 -5
- package/dist/fleet.d.ts +163 -0
- package/dist/fleet.js +126 -5
- package/dist/secret-scan.d.ts +39 -0
- package/dist/secret-scan.js +110 -0
- package/dist/types.d.ts +79 -72
- package/dist/types.js +17 -31
- package/package.json +2 -2
package/dist/fleet.js
CHANGED
|
@@ -62,21 +62,130 @@ export const RestartSignal = z.object({
|
|
|
62
62
|
since: z.number().int().nonnegative(),
|
|
63
63
|
});
|
|
64
64
|
/**
|
|
65
|
-
* The /health
|
|
66
|
-
* key, a non-object body, even a throwing accessor ⇒
|
|
67
|
-
*
|
|
65
|
+
* The ONE /health reader behind every engine-owned signal ({@link readRestartSignal}, {@link readModelRefusals}): `health[key]`
|
|
66
|
+
* judged against that signal's one shape. Shape mismatch, a missing key, a non-object body, even a throwing accessor ⇒
|
|
67
|
+
* `undefined` — never throws (a probe must not die on one odd body).
|
|
68
68
|
*/
|
|
69
|
-
|
|
69
|
+
function readHealthSignal(health, key, schema) {
|
|
70
70
|
try {
|
|
71
71
|
if (health === null || typeof health !== "object" || Array.isArray(health))
|
|
72
72
|
return undefined;
|
|
73
|
-
const parsed =
|
|
73
|
+
const parsed = schema.safeParse(health[key]);
|
|
74
74
|
return parsed.success ? parsed.data : undefined;
|
|
75
75
|
}
|
|
76
76
|
catch {
|
|
77
77
|
return undefined;
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The /health exit of {@link RestartSignal}: `health.restart` judged against the one shape. Shape mismatch, a missing
|
|
82
|
+
* key, a non-object body, even a throwing accessor ⇒ `undefined` — never throws (a probe must not die on one odd body).
|
|
83
|
+
* Presence law: only `restart` counts; the top-level `restartRequired` boolean the server also ships is not read.
|
|
84
|
+
*/
|
|
85
|
+
export function readRestartSignal(health) {
|
|
86
|
+
return readHealthSignal(health, "restart", RestartSignal);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The model-refusal cause codes (S-768) — CLOSED sets, owned HERE, never a hand-copied second list. Adding a code is
|
|
90
|
+
* one edit in this file; who goes red at compile time: a reader that switches on the code exhaustively (a center), and
|
|
91
|
+
* a producer only where it holds a table KEYED BY the code (an exhaustive `switch` over the code, or a
|
|
92
|
+
* `Record<ModelEntryRefusalCode, …>`) — a producer table that merely `satisfies` these codes as values proves a subset,
|
|
93
|
+
* not equality, and stays green.
|
|
94
|
+
*
|
|
95
|
+
* - {@link MODEL_ENTRY_REFUSAL_CODES} — why ONE catalog entry is not served on this host (the other entries keep serving):
|
|
96
|
+
* `config.model_no_brain` — the entry's provider rides a brain this host has not mounted;
|
|
97
|
+
* `config.model_entry_invalid` — the entry does not match the `ModelEntry` shape (dropped by the read, or re-judged at
|
|
98
|
+
* the engine's own second boundary).
|
|
99
|
+
* - {@link MODEL_PLANE_REFUSAL_CODES} — why the model plane of the catalog the engine was handed was refused AS A GROUP
|
|
100
|
+
* (the engine keeps serving the model plane it had). This set mirrors the engine's group-refusal code TABLE as a
|
|
101
|
+
* whole family — never picked by which invariant is reachable on the hot path today (reachability moves, the table
|
|
102
|
+
* does not):
|
|
103
|
+
* `config.default_model_missing` — the candidate has no default model;
|
|
104
|
+
* `config.model_refused_name_minted` — a refused entry's name would be re-minted onto another entry by the
|
|
105
|
+
* tier / alias expansion (or the dropped-entry carrier cannot prove it would not be);
|
|
106
|
+
* `config.model_route_endpoint_missing` — an openai-compatible route of the candidate resolves to no endpoint;
|
|
107
|
+
* `config.gateway_fallback_without_primary` — fallback gateways are declared without a primary.
|
|
108
|
+
* Source (engine): the first two in `src/model-entry-refusal.ts`, the last two in `src/model-route-endpoint.ts:40,42`
|
|
109
|
+
* (invariant `model-route-endpoint`, judged by the model-plane stage in `src/config-center/apply-effective.ts:664-665`).
|
|
110
|
+
*/
|
|
111
|
+
export const MODEL_ENTRY_REFUSAL_CODES = ["config.model_no_brain", "config.model_entry_invalid"];
|
|
112
|
+
export const ModelEntryRefusalCode = z.enum(MODEL_ENTRY_REFUSAL_CODES);
|
|
113
|
+
export const MODEL_PLANE_REFUSAL_CODES = [
|
|
114
|
+
"config.default_model_missing",
|
|
115
|
+
"config.model_refused_name_minted",
|
|
116
|
+
"config.model_route_endpoint_missing",
|
|
117
|
+
"config.gateway_fallback_without_primary",
|
|
118
|
+
];
|
|
119
|
+
export const ModelPlaneRefusalCode = z.enum(MODEL_PLANE_REFUSAL_CODES);
|
|
120
|
+
/**
|
|
121
|
+
* "This instance does not serve part (or all) of the model catalog it was handed" — the engine's OWN per-host verdict
|
|
122
|
+
* (S-768), defined ONCE for both exits that carry it: the fleet heartbeat ({@link InstanceAnnouncement}`.modelRefusals`)
|
|
123
|
+
* and GET /health (`modelRefusals`; {@link InstanceHealth} keeps carrying it untyped, as it does `restart`). One mint point,
|
|
124
|
+
* two exits, ONE reader for both: {@link readModelRefusals}.
|
|
125
|
+
*
|
|
126
|
+
* Two faces, split on purpose — the failure direction must match the meaning (this is a DIAGNOSTIC key; an unknown word
|
|
127
|
+
* in it must never make a healthy instance unroutable):
|
|
128
|
+
* - {@link ModelRefusalsWire} — what rides the wire and what a stored heartbeat row holds: the SHAPE law only, codes as
|
|
129
|
+
* open non-empty strings. A code this package version does not know passes (it is "not said" to this reader, and the
|
|
130
|
+
* stored row still carries it for a reader that is upgraded later).
|
|
131
|
+
* - {@link ModelRefusalsSignal} — what {@link readModelRefusals} returns: the wire narrowed to the closed sets above (an
|
|
132
|
+
* unknown entry code or plane code is dropped; narrowed to nothing ⇒ `undefined`, rendered like "not said"). The
|
|
133
|
+
* closed-set types stay the compile-time red source for readers when a code is added.
|
|
134
|
+
*
|
|
135
|
+
* The wire shape:
|
|
136
|
+
* - `entries` — catalog entry NAME → its code. The value is ONLY the code, on purpose: the engine's refusal fact also
|
|
137
|
+
* holds the upstream `id`, the `provider` word, the bad field keys and an operator sentence, and none of them rides
|
|
138
|
+
* here — an `entry_invalid` row is by definition one nothing validated, so its `id` / `provider` may be whatever the
|
|
139
|
+
* broken row held, and the sentence is prose, not contract. "Which name, which reason" is the whole wire. A name that
|
|
140
|
+
* cannot ride out is treated as NAMELESS — the engine leaves it out of this map entirely (not keyed, not counted). The
|
|
141
|
+
* ONE predicate is `isReportableRefusalName(name)` (main barrel): this schema can carry the key (non-empty; `__proto__`
|
|
142
|
+
* is silently dropped by the record, so it cannot) AND `isDisclosableIdentity(name)` (an identifier in the closed allowlist
|
|
143
|
+
* `DISCLOSABLE_IDENTITY_RE` — `A-Z a-z 0-9 . _ : / @ + -`, 1–256 chars — AND not secret-shaped, a URL userinfo included; the
|
|
144
|
+
* rule the dropped-entry identity on read warnings already uses, 6.3.0). A producer calls it before keying; this schema deliberately
|
|
145
|
+
* does not re-judge secrecy (see the 6.2.0 notes).
|
|
146
|
+
* - `plane` — present ⇔ the model plane of the latest catalog the engine was handed was refused as a group (the engine
|
|
147
|
+
* keeps serving the model plane it had); ONE code, never a list — one candidate has exactly one group verdict (the
|
|
148
|
+
* engine judges the group invariants in a fixed order and stops at the first that fails; a refusal from the Runner
|
|
149
|
+
* swap step carries no code and is outside this signal).
|
|
150
|
+
* - `entries` and `plane` describe the same latest candidate catalog — the one the engine was handed last; with `plane`
|
|
151
|
+
* present, that is the refused candidate (its per-entry verdicts are usually why it has no default). The engine does
|
|
152
|
+
* not swap its own refusal table while the group stays refused (its selection channels still refuse by the previous
|
|
153
|
+
* generation), so with `plane` present read `entries` as "why this catalog cannot land", not "what a pick is refused
|
|
154
|
+
* right now".
|
|
155
|
+
*
|
|
156
|
+
* PRESENCE is the signal (same law as {@link RestartSignal}): present ⇔ at least one reportable refusal (an entry refusal
|
|
157
|
+
* under a name that rides out, or a group refusal). An empty signal (no entries and no `plane`) is refused by the shape
|
|
158
|
+
* law — "none" has exactly one spelling, the absent key. Inside, "no entry refusals" is spelled `entries: {}` (the key is
|
|
159
|
+
* required) — one spelling at each level.
|
|
160
|
+
*/
|
|
161
|
+
export const ModelRefusalsWire = z
|
|
162
|
+
.object({
|
|
163
|
+
entries: z.record(z.string().min(1), z.string().min(1)),
|
|
164
|
+
plane: z.string().min(1).optional(),
|
|
165
|
+
})
|
|
166
|
+
.refine((s) => Object.keys(s.entries).length > 0 || s.plane !== undefined, {
|
|
167
|
+
message: "an empty model-refusals signal is not a signal — omit the key instead (absent ⇔ no reportable refusals)",
|
|
168
|
+
});
|
|
169
|
+
const isModelEntryRefusalCode = (c) => MODEL_ENTRY_REFUSAL_CODES.includes(c);
|
|
170
|
+
const isModelPlaneRefusalCode = (c) => MODEL_PLANE_REFUSAL_CODES.includes(c);
|
|
171
|
+
/**
|
|
172
|
+
* The ONE reader of the model-refusals signal, for BOTH exits: pass a /health body or a parsed
|
|
173
|
+
* {@link InstanceAnnouncement} — it reads the `modelRefusals` key of whatever it is given. Shape mismatch, a missing key, a
|
|
174
|
+
* non-object body, even a throwing accessor ⇒ `undefined` — never throws. Then it narrows to the closed sets: an unknown
|
|
175
|
+
* entry code drops that entry, an unknown plane code drops `plane`; narrowed to nothing ⇒ `undefined`. So `undefined`
|
|
176
|
+
* means "nothing this reader can say"; whether that is "none" or "unknown" depends on whether the engine reports this key
|
|
177
|
+
* at all — it answers the key only and never judges the engine's version (see {@link InstanceAnnouncement}`.modelRefusals`).
|
|
178
|
+
*/
|
|
179
|
+
export function readModelRefusals(body) {
|
|
180
|
+
const wire = readHealthSignal(body, "modelRefusals", ModelRefusalsWire);
|
|
181
|
+
if (wire === undefined)
|
|
182
|
+
return undefined;
|
|
183
|
+
const entries = Object.fromEntries(Object.entries(wire.entries).filter((e) => isModelEntryRefusalCode(e[1])));
|
|
184
|
+
const plane = wire.plane !== undefined && isModelPlaneRefusalCode(wire.plane) ? wire.plane : undefined;
|
|
185
|
+
if (Object.keys(entries).length === 0 && plane === undefined)
|
|
186
|
+
return undefined;
|
|
187
|
+
return plane === undefined ? { entries } : { entries, plane };
|
|
188
|
+
}
|
|
80
189
|
/** The `/health` self-description every orchestrator reads (shipped shape, server 1.96.0). Liveness and
|
|
81
190
|
* readiness are deliberately SPLIT: `status` stays ok while draining (the process is alive); routability is
|
|
82
191
|
* `isReadyForTraffic`. Unknown fields pass through — health is a self-description, not a closed record. */
|
|
@@ -93,6 +202,7 @@ export const InstanceHealth = z
|
|
|
93
202
|
// UNTYPED on purpose — typing `restart` here would turn today's lenient read into a strict one (a /health body with
|
|
94
203
|
// an odd `restart` would stop parsing), which is a MAJOR by this package's release rules. Read it with
|
|
95
204
|
// {@link readRestartSignal}. Typing it on this schema is an item for the next major.
|
|
205
|
+
// S-768: `modelRefusals` rides the passthrough untyped for the same reason — read it with {@link readModelRefusals}.
|
|
96
206
|
})
|
|
97
207
|
.passthrough();
|
|
98
208
|
/** Readiness = the routability predicate (k8s readiness probe, gateway health filter, shell seam switch). */
|
|
@@ -126,6 +236,17 @@ export const InstanceAnnouncement = z.object({
|
|
|
126
236
|
* Every heartbeat re-announces the full row, so a later heartbeat WITHOUT it means "no longer required" — the
|
|
127
237
|
* stored row ({@link InstanceRegistration}) is replaced, never merged. */
|
|
128
238
|
restart: RestartSignal.optional(),
|
|
239
|
+
/** S-768: present ⇔ this instance has at least one reportable model refusal — the SAME {@link ModelRefusalsWire} /health
|
|
240
|
+
* carries, minted by the same getter; read it with {@link readModelRefusals}`(announcement)` (never off this field
|
|
241
|
+
* directly: the field is the open wire form, the reader narrows to the closed sets). Every heartbeat re-announces the
|
|
242
|
+
* full row, so a later heartbeat WITHOUT it means "no reportable refusals any more" (the stored row is replaced, never
|
|
243
|
+
* merged). Reading absence: from an engine that reports this key, absent = none reportable; an engine that predates
|
|
244
|
+
* the key never sends it, so there absence is UNKNOWN, not "none" — tell the two apart by the announced `version`.
|
|
245
|
+
* Failure direction = meaning direction (this is a diagnostic key): a code this package version does not know passes
|
|
246
|
+
* (it is "not said" to the reader), and a value that breaks the {@link ModelRefusalsWire} shape law is dropped to
|
|
247
|
+
* ABSENT (`.catch`) — it never fails the announcement, so it can never make a healthy instance unroutable (unlike
|
|
248
|
+
* `restart`, whose shape still gates the row). The key may then be present with the value `undefined`. */
|
|
249
|
+
modelRefusals: ModelRefusalsWire.optional().catch(undefined),
|
|
129
250
|
});
|
|
130
251
|
/** Explicit goodbye (stop signal handler) — removes the row immediately instead of waiting out staleness. */
|
|
131
252
|
export const InstanceDeregistration = z.object({
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 秘密扫描读的**归一化**,外加「URL 的 userinfo 就是凭据」那一只判定({@link carriesUserinfo})—— 包内私有模块(6.3.0):不进主 barrel、
|
|
3
|
+
* 不进 `package.json` 的 `exports`,消费方拿不到。
|
|
4
|
+
* 校验侧探测器 `containsSecretToken`(`./types`)只读 {@link percentLayers};展示侧脱敏器 `redactSecrets`(`./config-fns`)
|
|
5
|
+
* 读 {@link secretScanForms}。展示侧的读法(边界读法、`+` 读法、「没走完」那一位)刻意不做公开契约:它们以后要随新发现的
|
|
6
|
+
* 编码形调整,调整不该是契约变更(opus 独立腿 L7)。
|
|
7
|
+
*
|
|
8
|
+
* **单遍、零异常**(opus 独立腿 M1,对 6.2.0 的读路径 CPU 回归):6.2.0 起的宽容解码对每个 `%HH` 各 `try decodeURIComponent`,
|
|
9
|
+
* 非 ASCII 的转义每个都抛一次异常(≈ 20–30 µs / 字节,同步阻塞);6.3.0 首稿又让探测器顺带算它不用的展示形。这里两处解码都
|
|
10
|
+
* 不靠异常:单个转义的判据写成字符集(只有 ASCII 的 `%00`–`%7F` 单独解得开),转义串的判据写成 UTF-8 良构判定 ——
|
|
11
|
+
* 结果与原来的 try/catch 逐字相同(`test/disclosable-identity-6.3.0.test.ts` 的两只冻结 oracle 钉着)。
|
|
12
|
+
*
|
|
13
|
+
* 本模块零 import(`./types` 引它,它不回引,不成环)。
|
|
14
|
+
*/
|
|
15
|
+
/** How many percent-decoded layers a secret scan reads beyond the raw string (a multi-encoded `%2573%256B…` →
|
|
16
|
+
* `%73%6B…` → `sk-…`). Bounded → deterministic. */
|
|
17
|
+
export declare const SECRET_SCAN_DECODE_LAYERS = 3;
|
|
18
|
+
/** The raw string first, then up to {@link SECRET_SCAN_DECODE_LAYERS} tolerant percent-decoded layers, stopping at the first
|
|
19
|
+
* layer that decodes nothing — the forms the validation detector `containsSecretToken` reads, and nothing else. */
|
|
20
|
+
export declare function percentLayers(text: string): readonly string[];
|
|
21
|
+
/** What {@link secretScanForms} returns — plain data, no behavior. */
|
|
22
|
+
export interface SecretScanForms {
|
|
23
|
+
/** {@link percentLayers} of the text. */
|
|
24
|
+
readonly percentLayers: readonly string[];
|
|
25
|
+
/** The forms the display-side redactor judges a whole string on: `percentLayers`, each layer's {@link boundaryReading}, and
|
|
26
|
+
* each of those read the application/x-www-form-urlencoded way (`+` as a space); duplicates dropped, first seen first. */
|
|
27
|
+
readonly forms: readonly string[];
|
|
28
|
+
/** Normalization did not finish: the layer budget ran out while one more layer would still decode something. A caller that
|
|
29
|
+
* shows text withholds it whole — what could not be normalized does not go out. (A lone undecodable escape is a fixed
|
|
30
|
+
* point, not an unfinished one: `%E4%B8%AD` stays as it is and is not flagged.) */
|
|
31
|
+
readonly unresolved: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** The display-side normalization — only `redactSecrets` (config-fns) calls it. */
|
|
34
|
+
export declare function secretScanForms(text: string): SecretScanForms;
|
|
35
|
+
/** 「URL 的 userinfo 就是凭据」的**唯一**判定(6.3.0,opus 腿 L1):username / password 任一在场即是。校验侧 `NON_SECRET_URL` 与 https
|
|
36
|
+
* 插件源据此拒写(`./types`),身份面据此否决(`./config-fns` 的 `isDisclosableIdentity` —— `https://admin:hunter2@db…` /
|
|
37
|
+
* `postgres://svc:pw@h` 的字符全在身份白名单里)。串解析不成 URL ⇒ false:这只判定只说「一个 URL 带没带 userinfo」,
|
|
38
|
+
* 不是 URL 的串不在它的射程里(无 scheme 的 `svc:pw@host` 解析成不透明路径,同样答 false —— 如实登记的边界)。 */
|
|
39
|
+
export declare function carriesUserinfo(u: URL | string): boolean;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 秘密扫描读的**归一化**,外加「URL 的 userinfo 就是凭据」那一只判定({@link carriesUserinfo})—— 包内私有模块(6.3.0):不进主 barrel、
|
|
3
|
+
* 不进 `package.json` 的 `exports`,消费方拿不到。
|
|
4
|
+
* 校验侧探测器 `containsSecretToken`(`./types`)只读 {@link percentLayers};展示侧脱敏器 `redactSecrets`(`./config-fns`)
|
|
5
|
+
* 读 {@link secretScanForms}。展示侧的读法(边界读法、`+` 读法、「没走完」那一位)刻意不做公开契约:它们以后要随新发现的
|
|
6
|
+
* 编码形调整,调整不该是契约变更(opus 独立腿 L7)。
|
|
7
|
+
*
|
|
8
|
+
* **单遍、零异常**(opus 独立腿 M1,对 6.2.0 的读路径 CPU 回归):6.2.0 起的宽容解码对每个 `%HH` 各 `try decodeURIComponent`,
|
|
9
|
+
* 非 ASCII 的转义每个都抛一次异常(≈ 20–30 µs / 字节,同步阻塞);6.3.0 首稿又让探测器顺带算它不用的展示形。这里两处解码都
|
|
10
|
+
* 不靠异常:单个转义的判据写成字符集(只有 ASCII 的 `%00`–`%7F` 单独解得开),转义串的判据写成 UTF-8 良构判定 ——
|
|
11
|
+
* 结果与原来的 try/catch 逐字相同(`test/disclosable-identity-6.3.0.test.ts` 的两只冻结 oracle 钉着)。
|
|
12
|
+
*
|
|
13
|
+
* 本模块零 import(`./types` 引它,它不回引,不成环)。
|
|
14
|
+
*/
|
|
15
|
+
/** How many percent-decoded layers a secret scan reads beyond the raw string (a multi-encoded `%2573%256B…` →
|
|
16
|
+
* `%73%6B…` → `sk-…`). Bounded → deterministic. */
|
|
17
|
+
export const SECRET_SCAN_DECODE_LAYERS = 3;
|
|
18
|
+
/** One TOLERANT percent-decode layer: an escape decodes on its own iff `decodeURIComponent` would accept it alone — exactly
|
|
19
|
+
* the ASCII ones, `%00`–`%7F` — and every other escape (`%C2`, a lone `%E4`, `%ZZ` is not an escape at all) stays as it
|
|
20
|
+
* is, so one bad escape elsewhere can't abort the whole decode (R13's all-or-nothing decodeURIComponent did). Same result
|
|
21
|
+
* as the per-escape try/catch loop it replaces, with no exception per non-ASCII escape. */
|
|
22
|
+
function percentDecodeLayer(s) {
|
|
23
|
+
return s.indexOf("%") === -1 ? s : s.replace(/%([0-7][0-9A-Fa-f])/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
24
|
+
}
|
|
25
|
+
/** The raw string first, then up to {@link SECRET_SCAN_DECODE_LAYERS} tolerant percent-decoded layers, stopping at the first
|
|
26
|
+
* layer that decodes nothing — the forms the validation detector `containsSecretToken` reads, and nothing else. */
|
|
27
|
+
export function percentLayers(text) {
|
|
28
|
+
const layers = [text];
|
|
29
|
+
let cur = text;
|
|
30
|
+
for (let i = 0; i < SECRET_SCAN_DECODE_LAYERS; i++) {
|
|
31
|
+
const next = percentDecodeLayer(cur);
|
|
32
|
+
if (next === cur)
|
|
33
|
+
break;
|
|
34
|
+
layers.push(next);
|
|
35
|
+
cur = next;
|
|
36
|
+
}
|
|
37
|
+
return layers;
|
|
38
|
+
}
|
|
39
|
+
/** Is this byte sequence well-formed UTF-8 (Unicode Table 3-7: no overlongs, no surrogates, nothing above U+10FFFF)? That is
|
|
40
|
+
* exactly when `decodeURIComponent` accepts a run of escapes spelling these bytes — decided without throwing. */
|
|
41
|
+
function isWellFormedUtf8(bytes) {
|
|
42
|
+
for (let i = 0; i < bytes.length;) {
|
|
43
|
+
const lead = bytes[i];
|
|
44
|
+
if (lead < 0x80) {
|
|
45
|
+
i += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// [续字节个数, 第一个续字节的下界, 上界];其余续字节恒为 80..BF。
|
|
49
|
+
const shape = lead >= 0xc2 && lead <= 0xdf ? [1, 0x80, 0xbf]
|
|
50
|
+
: lead === 0xe0 ? [2, 0xa0, 0xbf]
|
|
51
|
+
: (lead >= 0xe1 && lead <= 0xec) || lead === 0xee || lead === 0xef ? [2, 0x80, 0xbf]
|
|
52
|
+
: lead === 0xed ? [2, 0x80, 0x9f]
|
|
53
|
+
: lead === 0xf0 ? [3, 0x90, 0xbf]
|
|
54
|
+
: lead >= 0xf1 && lead <= 0xf3 ? [3, 0x80, 0xbf]
|
|
55
|
+
: lead === 0xf4 ? [3, 0x80, 0x8f]
|
|
56
|
+
: undefined;
|
|
57
|
+
if (shape === undefined)
|
|
58
|
+
return false;
|
|
59
|
+
const [need, lo, hi] = shape;
|
|
60
|
+
for (let k = 1; k <= need; k++) {
|
|
61
|
+
const c = bytes[i + k];
|
|
62
|
+
if (c === undefined || c < (k === 1 ? lo : 0x80) || c > (k === 1 ? hi : 0xbf))
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
i += need + 1;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/** The display-side BOUNDARY reading of one layer (codex r2): every run of %HH escapes is decoded as UTF-8 in one go when it
|
|
70
|
+
* is well-formed (an encoded NBSP / zero-width space becomes the delimiter it really is); otherwise its ASCII escapes decode
|
|
71
|
+
* on their own and every other escape reads as a space — an escape never glues its hex digits onto the token after it
|
|
72
|
+
* (`%C2%A0sk-live-…` read escape by escape is `%C2%A0sk-…`, whose tokens are `C2` / `A0sk-…`: the `sk-` boundary is gone,
|
|
73
|
+
* though any standard decoder restores it). Display-only: the validation detector never reads it. */
|
|
74
|
+
function boundaryReading(s) {
|
|
75
|
+
if (s.indexOf("%") === -1)
|
|
76
|
+
return s;
|
|
77
|
+
return s.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
|
|
78
|
+
const bytes = [];
|
|
79
|
+
for (let i = 0; i < run.length; i += 3)
|
|
80
|
+
bytes.push(parseInt(run.slice(i + 1, i + 3), 16));
|
|
81
|
+
return isWellFormedUtf8(bytes) ? decodeURIComponent(run) : bytes.map((b) => (b < 0x80 ? String.fromCharCode(b) : " ")).join("");
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** The display-side normalization — only `redactSecrets` (config-fns) calls it. */
|
|
85
|
+
export function secretScanForms(text) {
|
|
86
|
+
const layers = percentLayers(text);
|
|
87
|
+
const last = layers[layers.length - 1];
|
|
88
|
+
const unresolved = layers.length === SECRET_SCAN_DECODE_LAYERS + 1 && percentDecodeLayer(last) !== last;
|
|
89
|
+
const readings = [...layers, ...layers.map((layer) => boundaryReading(layer))];
|
|
90
|
+
const forms = [...new Set([...readings, ...readings.map((reading) => (reading.indexOf("+") === -1 ? reading : reading.replace(/\+/g, " ")))])];
|
|
91
|
+
return { percentLayers: layers, forms, unresolved };
|
|
92
|
+
}
|
|
93
|
+
/** 「URL 的 userinfo 就是凭据」的**唯一**判定(6.3.0,opus 腿 L1):username / password 任一在场即是。校验侧 `NON_SECRET_URL` 与 https
|
|
94
|
+
* 插件源据此拒写(`./types`),身份面据此否决(`./config-fns` 的 `isDisclosableIdentity` —— `https://admin:hunter2@db…` /
|
|
95
|
+
* `postgres://svc:pw@h` 的字符全在身份白名单里)。串解析不成 URL ⇒ false:这只判定只说「一个 URL 带没带 userinfo」,
|
|
96
|
+
* 不是 URL 的串不在它的射程里(无 scheme 的 `svc:pw@host` 解析成不透明路径,同样答 false —— 如实登记的边界)。 */
|
|
97
|
+
export function carriesUserinfo(u) {
|
|
98
|
+
let url;
|
|
99
|
+
if (typeof u === "string") {
|
|
100
|
+
try {
|
|
101
|
+
url = new URL(u);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false; // 不是 URL:不在这只判定的射程里(见头注),不是回退
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
url = u;
|
|
109
|
+
return url.username !== "" || url.password !== "";
|
|
110
|
+
}
|