@snagset/server 0.0.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.
- package/README.md +83 -0
- package/dist/app.js +453 -0
- package/dist/app.js.map +1 -0
- package/dist/bin.js +951 -0
- package/dist/bin.js.map +1 -0
- package/dist/boot.js +321 -0
- package/dist/boot.js.map +1 -0
- package/dist/config.generated.js +116 -0
- package/dist/config.generated.js.map +1 -0
- package/dist/config.js +180 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.js +26 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db/connect.js +49 -0
- package/dist/db/connect.js.map +1 -0
- package/dist/db/migrate.js +210 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/migrations/0001_init.sql +1060 -0
- package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
- package/dist/db/migrations/0003_email_provider.sql +35 -0
- package/dist/db/migrations/0004_unsubscribe.sql +24 -0
- package/dist/db/rls.js +58 -0
- package/dist/db/rls.js.map +1 -0
- package/dist/jobs/index.js +171 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/reaper.js +182 -0
- package/dist/jobs/reaper.js.map +1 -0
- package/dist/keys.js +77 -0
- package/dist/keys.js.map +1 -0
- package/dist/mail/index.js +75 -0
- package/dist/mail/index.js.map +1 -0
- package/dist/mail/mailer.js +27 -0
- package/dist/mail/mailer.js.map +1 -0
- package/dist/mail/notify.js +405 -0
- package/dist/mail/notify.js.map +1 -0
- package/dist/mail/resend.js +95 -0
- package/dist/mail/resend.js.map +1 -0
- package/dist/mail/smtp.js +315 -0
- package/dist/mail/smtp.js.map +1 -0
- package/dist/mail/unsubscribe.js +134 -0
- package/dist/mail/unsubscribe.js.map +1 -0
- package/dist/projectCache.js +50 -0
- package/dist/projectCache.js.map +1 -0
- package/dist/projects.js +101 -0
- package/dist/projects.js.map +1 -0
- package/dist/routes/attachments.js +159 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.js +55 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/commentEdit.js +201 -0
- package/dist/routes/commentEdit.js.map +1 -0
- package/dist/routes/comments.js +313 -0
- package/dist/routes/comments.js.map +1 -0
- package/dist/routes/cors.js +174 -0
- package/dist/routes/cors.js.map +1 -0
- package/dist/routes/jobs.js +56 -0
- package/dist/routes/jobs.js.map +1 -0
- package/dist/routes/mcpAuth.js +187 -0
- package/dist/routes/mcpAuth.js.map +1 -0
- package/dist/routes/mcpToken.js +94 -0
- package/dist/routes/mcpToken.js.map +1 -0
- package/dist/routes/owner.js +288 -0
- package/dist/routes/owner.js.map +1 -0
- package/dist/routes/reviewLinks.js +169 -0
- package/dist/routes/reviewLinks.js.map +1 -0
- package/dist/routes/rounds.js +137 -0
- package/dist/routes/rounds.js.map +1 -0
- package/dist/routes/sessions.js +349 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/sites.js +192 -0
- package/dist/routes/sites.js.map +1 -0
- package/dist/routes/threads.js +1080 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/widget.js +132 -0
- package/dist/routes/widget.js.map +1 -0
- package/dist/serverless.js +97 -0
- package/dist/serverless.js.map +1 -0
- package/dist/session.js +152 -0
- package/dist/session.js.map +1 -0
- package/dist/storage/bucket.js +116 -0
- package/dist/storage/bucket.js.map +1 -0
- package/dist/storage/settings.js +132 -0
- package/dist/storage/settings.js.map +1 -0
- package/dist/vendor/adapters/index.js +1 -0
- package/dist/vendor/adapters/outbound/fence.js +44 -0
- package/dist/vendor/adapters/outbound/sanitise.js +233 -0
- package/dist/vendor/types/index.js +4 -0
- package/dist/vendor/types/names.js +254 -0
- package/dist/vendor/widget/object.js +38 -0
- package/dist/vendor/widget/predicates.js +377 -0
- package/package.json +53 -0
- package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
- package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
- package/static/dashboard/index.html +14 -0
- package/static/widget/snag.anchor-ext.js +1 -0
- package/static/widget/snag.anchor-fuzzy.js +1 -0
- package/static/widget/snag.capture.js +4 -0
- package/static/widget/snag.crumbs.js +4 -0
- package/static/widget/snag.dock.js +5 -0
- package/static/widget/snag.js +5 -0
- package/static/widget/snag.panels.js +2 -0
- package/static/widget/snag.runtime.js +2 -0
- package/static/widget/snag.screenshot.js +1 -0
- package/static/widget/snag.text.js +2 -0
- package/vercel.json +6 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// GENERATED FILE — DO NOT EDIT BY HAND.
|
|
3
|
+
// Source of truth: packages/types/registry.json
|
|
4
|
+
// Regenerate: pnpm gen:names (CI runs `pnpm gen:names:check`)
|
|
5
|
+
//
|
|
6
|
+
// The Name Registry (specs/00-foundations.md §2) made executable. Every token
|
|
7
|
+
// below is DERIVED from registry.json's `brand` block — the product name lives
|
|
8
|
+
// in exactly one place. test/rename.test.ts proves it: flip the brand, and no
|
|
9
|
+
// token of the old one survives. SCHEMA_VERSION is the one deliberate literal.
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
/** The one place the product name exists. Everything else derives from this. */
|
|
12
|
+
export const BRAND = {
|
|
13
|
+
$comment: "The ONLY place the product name appears. `long` = display/global name. `short` = the terse prefix that goes on attributes, classes, params, storage keys and artefact filenames (a client's DOM should not carry a long word). `pkg` = the npm/lowercase form used for env vars, headers, metrics, DOM events and symbols.",
|
|
14
|
+
long: "Snagset",
|
|
15
|
+
short: "snag",
|
|
16
|
+
pkg: "snagset",
|
|
17
|
+
};
|
|
18
|
+
export const PRODUCT = "Snagset";
|
|
19
|
+
export const NPM_PACKAGE = "snagset";
|
|
20
|
+
export const NPM_SCOPE = "@snagset";
|
|
21
|
+
/** Frozen once v1 stores real data — stamped into exports, issue bodies and the MCP contract, so it must survive a rename (registry.frozen). */
|
|
22
|
+
export const SCHEMA_VERSION = "snagset.v1";
|
|
23
|
+
export const CSS_VAR_PREFIX = "--snag-";
|
|
24
|
+
export const HTTP_HEADER_PREFIX = "x-snagset-";
|
|
25
|
+
export const ENV_PREFIX = "SNAGSET_";
|
|
26
|
+
export const PG_TYPE_PREFIX = "snag_";
|
|
27
|
+
export const METRIC_PREFIX = "snagset_";
|
|
28
|
+
export const DOM_EVENT_PREFIX = "snagset:";
|
|
29
|
+
export const SYMBOL_PREFIX = "snagset.";
|
|
30
|
+
export const MENTION_SCHEME = "snag:user/";
|
|
31
|
+
export const SKIP_HEADER = "x-snagset-skip";
|
|
32
|
+
export const THREAD_REF_PREFIX = "SN-";
|
|
33
|
+
export const TERM_SET = "Snagset";
|
|
34
|
+
export const TERM_SET_PLURAL = "Snagsets";
|
|
35
|
+
export const TERM_ITEM = "Snag";
|
|
36
|
+
export const TERM_ITEM_PLURAL = "Snags";
|
|
37
|
+
export const RESERVED_CUSTOM_PREFIX = "cr.";
|
|
38
|
+
/** @returns e.g. `x-snagset-idempotency-key` */
|
|
39
|
+
export const header = (name) => HTTP_HEADER_PREFIX + name;
|
|
40
|
+
/** @returns e.g. `SNAGSET_SESSION_TTL_S` */
|
|
41
|
+
export const env = (name) => ENV_PREFIX + name;
|
|
42
|
+
/** @returns e.g. `snag_attachment_kind` */
|
|
43
|
+
export const pgType = (name) => PG_TYPE_PREFIX + name;
|
|
44
|
+
/** @returns e.g. `snagset_http_requests_total` */
|
|
45
|
+
export const metric = (name) => METRIC_PREFIX + name;
|
|
46
|
+
/** window.* names (§2.2). Dunders are internal/test affordances, not public API. */
|
|
47
|
+
export const GLOBALS = {
|
|
48
|
+
api: "Snagset",
|
|
49
|
+
config: "SnagsetConfig",
|
|
50
|
+
disabled: "__SNAGSET_DISABLED__",
|
|
51
|
+
assetBase: "__SNAGSET_ASSET_BASE__",
|
|
52
|
+
collisionFallback: "__snagset__",
|
|
53
|
+
};
|
|
54
|
+
const scriptAttrToken = {
|
|
55
|
+
site: "data-snag-site",
|
|
56
|
+
host: "data-snag-host",
|
|
57
|
+
disabled: "data-snag-disabled",
|
|
58
|
+
env: "data-snag-env",
|
|
59
|
+
mode: "data-snag-mode",
|
|
60
|
+
badge: "data-snag-badge",
|
|
61
|
+
lang: "data-snag-lang",
|
|
62
|
+
theme: "data-snag-theme",
|
|
63
|
+
position: "data-snag-position",
|
|
64
|
+
offset: "data-snag-offset",
|
|
65
|
+
assetBase: "data-snag-asset-base",
|
|
66
|
+
branch: "data-snag-branch",
|
|
67
|
+
build: "data-snag-build",
|
|
68
|
+
storage: "data-snag-storage",
|
|
69
|
+
hotkey: "data-snag-hotkey",
|
|
70
|
+
allowAutomation: "data-snag-allow-automation",
|
|
71
|
+
screenshots: "data-snag-screenshots",
|
|
72
|
+
maskSelectors: "data-snag-mask-selectors",
|
|
73
|
+
nonce: "data-snag-nonce",
|
|
74
|
+
autoMount: "data-snag-auto-mount",
|
|
75
|
+
};
|
|
76
|
+
export const scriptAttr = (name) => scriptAttrToken[name];
|
|
77
|
+
const widgetAttrToken = {
|
|
78
|
+
root: "data-snag-root",
|
|
79
|
+
};
|
|
80
|
+
export const widgetAttr = (name) => widgetAttrToken[name];
|
|
81
|
+
const hostAttrToken = {
|
|
82
|
+
anchor: "data-snag-anchor",
|
|
83
|
+
ignore: "data-snag-ignore",
|
|
84
|
+
mask: "data-snag-mask",
|
|
85
|
+
nomask: "data-snag-nomask",
|
|
86
|
+
src: "data-snag-src",
|
|
87
|
+
component: "data-snag-component",
|
|
88
|
+
};
|
|
89
|
+
export const hostAttr = (name) => hostAttrToken[name];
|
|
90
|
+
const cssClassToken = {
|
|
91
|
+
mask: "snag-mask",
|
|
92
|
+
block: "snag-block",
|
|
93
|
+
};
|
|
94
|
+
export const cssClass = (name) => cssClassToken[name];
|
|
95
|
+
/** Custom properties (§2.1's prefix, enumerated). 06 §14 makes every one of these public SemVer surface, so they are registered rather than minted at the point of use. Values are NOT here — the registry governs names, never design. */
|
|
96
|
+
export const CSS_VAR = {
|
|
97
|
+
font: "--snag-font",
|
|
98
|
+
accent: "--snag-accent",
|
|
99
|
+
onAccent: "--snag-onAccent",
|
|
100
|
+
bg: "--snag-bg",
|
|
101
|
+
fg: "--snag-fg",
|
|
102
|
+
muted: "--snag-muted",
|
|
103
|
+
border: "--snag-border",
|
|
104
|
+
radius: "--snag-radius",
|
|
105
|
+
shadow: "--snag-shadow",
|
|
106
|
+
z: "--snag-z",
|
|
107
|
+
motion: "--snag-motion",
|
|
108
|
+
surface: "--snag-surface",
|
|
109
|
+
sunken: "--snag-sunken",
|
|
110
|
+
hover: "--snag-hover",
|
|
111
|
+
focus: "--snag-focus",
|
|
112
|
+
disabled: "--snag-disabled",
|
|
113
|
+
statusOpen: "--snag-status-open",
|
|
114
|
+
statusProgress: "--snag-status-progress",
|
|
115
|
+
statusReview: "--snag-status-review",
|
|
116
|
+
statusResolved: "--snag-status-resolved",
|
|
117
|
+
anchorOrphan: "--snag-anchor-orphan",
|
|
118
|
+
tbH: "--snag-tb-h",
|
|
119
|
+
dockW: "--snag-dock-w",
|
|
120
|
+
kb: "--snag-kb",
|
|
121
|
+
thumbX: "--snag-thumb-x",
|
|
122
|
+
thumbW: "--snag-thumb-w",
|
|
123
|
+
};
|
|
124
|
+
/** Which tokens belong to which role — the dark block must override theme members only. */
|
|
125
|
+
export const CSS_VAR_GROUPS = {
|
|
126
|
+
theme: ["font", "accent", "onAccent", "bg", "fg", "muted", "border", "radius", "shadow", "z", "motion"],
|
|
127
|
+
derived: ["surface", "sunken", "hover", "focus", "disabled"],
|
|
128
|
+
status: ["statusOpen", "statusProgress", "statusReview", "statusResolved"],
|
|
129
|
+
anchor: ["anchorOrphan"],
|
|
130
|
+
geometry: ["tbH", "dockW", "kb", "thumbX", "thumbW"],
|
|
131
|
+
};
|
|
132
|
+
/** @returns e.g. `--snag-bg` */
|
|
133
|
+
export const cssVar = (name) => CSS_VAR[name];
|
|
134
|
+
/** @returns e.g. `var(--snag-bg)` — what stylesheets actually interpolate. */
|
|
135
|
+
export const cssRef = (name) => `var(${CSS_VAR[name]})`;
|
|
136
|
+
/** @returns e.g. `snag_t`. The review-link secret is a FRAGMENT param (§2.5). */
|
|
137
|
+
export const param = (name) => "snag_" + name;
|
|
138
|
+
export const RESERVED_URL_PARAMS = {
|
|
139
|
+
query: ["snag_t", "snag_off", "snag_debug", "snag_theme"],
|
|
140
|
+
fragment: ["snag_k"],
|
|
141
|
+
};
|
|
142
|
+
/** Point consts for hot paths: a chunk needing ONE key imports it and
|
|
143
|
+
* drops the whole table — object properties cannot tree-shake, and the
|
|
144
|
+
* panels chunk was carrying ten keys to read two (M2-13). */
|
|
145
|
+
export const DISCLOSURE_KEY = "snag:disclosure:v1";
|
|
146
|
+
export const IDENTITY_KEY = "snag:identity";
|
|
147
|
+
export const THEME_ATTR = "data-snag-theme";
|
|
148
|
+
/** Web-storage keys (§2.6). Parameterised keys are functions. */
|
|
149
|
+
export const storageKey = {
|
|
150
|
+
session: (uid) => `snag:session:${uid}`,
|
|
151
|
+
identity: IDENTITY_KEY,
|
|
152
|
+
threads: (uid, env) => `snag:threads:${uid}:${env}`,
|
|
153
|
+
counts: (origin) => `snag:counts:${origin}`,
|
|
154
|
+
draft: (route, draftId) => `snag:draft:${route}:${draftId}`,
|
|
155
|
+
outbox: (uid) => `snag:outbox:${uid}`,
|
|
156
|
+
mode: "snag:mode",
|
|
157
|
+
handoff: "snag:handoff",
|
|
158
|
+
seenCoach: "snag:seen-coach",
|
|
159
|
+
disclosure: DISCLOSURE_KEY,
|
|
160
|
+
};
|
|
161
|
+
/** The v0.1 IndexedDB blob store (§2.6, 05 §10.1) — one database, one
|
|
162
|
+
* object store, shared by screenshots, reviewer images and the v2 outbox. */
|
|
163
|
+
export const IDB = {
|
|
164
|
+
database: "snag:blobs",
|
|
165
|
+
store: "blobs",
|
|
166
|
+
};
|
|
167
|
+
/** Anchored regex sources matching every registered key — AC-22's storage diff. */
|
|
168
|
+
export const STORAGE_KEY_PATTERNS = [
|
|
169
|
+
"^snag:session:.+$",
|
|
170
|
+
"^snag:identity$",
|
|
171
|
+
"^snag:threads:.+:.+$",
|
|
172
|
+
"^snag:counts:.+$",
|
|
173
|
+
"^snag:draft:.+:.+$",
|
|
174
|
+
"^snag:outbox:.+$",
|
|
175
|
+
"^snag:mode$",
|
|
176
|
+
"^snag:handoff$",
|
|
177
|
+
"^snag:seen-coach$",
|
|
178
|
+
"^snag:disclosure:v1$",
|
|
179
|
+
];
|
|
180
|
+
/** The WIDGET's two (§2.7). Neither carries a credential, and no cookie authenticates the widget API — it runs on somebody else's origin. */
|
|
181
|
+
export const COOKIE = {
|
|
182
|
+
off: "snag_off",
|
|
183
|
+
on: "snag_on",
|
|
184
|
+
};
|
|
185
|
+
/** The dashboard's session + CSRF pair (05 §4.7) — first-party, on the server's own origin, owner audience. */
|
|
186
|
+
export const DASHBOARD_COOKIE = {
|
|
187
|
+
session: "snag_session",
|
|
188
|
+
csrf: "snag_csrf",
|
|
189
|
+
};
|
|
190
|
+
/** 05 §4.7 names these two constants directly; both spellings resolve to the same string. */
|
|
191
|
+
export const DASHBOARD_SESSION_COOKIE = DASHBOARD_COOKIE.session;
|
|
192
|
+
export const DASHBOARD_CSRF_COOKIE = DASHBOARD_COOKIE.csrf;
|
|
193
|
+
/** Built widget chunks (§2.8). Preact lives only in `panels`. */
|
|
194
|
+
export const ARTIFACT = {
|
|
195
|
+
stub: "snag.js",
|
|
196
|
+
runtime: "snag.runtime.js",
|
|
197
|
+
panels: "snag.panels.js",
|
|
198
|
+
crumbs: "snag.crumbs.js",
|
|
199
|
+
capture: "snag.capture.js",
|
|
200
|
+
screenshot: "snag.screenshot.js",
|
|
201
|
+
anchorExt: "snag.anchor-ext.js",
|
|
202
|
+
anchorFuzzy: "snag.anchor-fuzzy.js",
|
|
203
|
+
dock: "snag.dock.js",
|
|
204
|
+
text: "snag.text.js",
|
|
205
|
+
};
|
|
206
|
+
/** Wire-form entity id prefixes (§2.10). Not brand-derived. */
|
|
207
|
+
export const ID_PREFIX = {
|
|
208
|
+
project: "prj_",
|
|
209
|
+
thread: "thr_",
|
|
210
|
+
comment: "cmt_",
|
|
211
|
+
reviewer: "rev_",
|
|
212
|
+
attachment: "att_",
|
|
213
|
+
link: "lnk_",
|
|
214
|
+
round: "rnd_",
|
|
215
|
+
};
|
|
216
|
+
/** Credential literals (07 §8.2). Brand-DERIVED, and deliberately not the three-letter row shape: the literal is what gets registered with secret scanning, so a token pasted into a public repo is reported. */
|
|
217
|
+
export const CREDENTIAL_PREFIX = {
|
|
218
|
+
mcp: "snagmcp_",
|
|
219
|
+
};
|
|
220
|
+
/** CustomEvents dispatched on document (§2.11). */
|
|
221
|
+
export const DOM_EVENT = {
|
|
222
|
+
ready: "snagset:ready",
|
|
223
|
+
activate: "snagset:activate",
|
|
224
|
+
deactivate: "snagset:deactivate",
|
|
225
|
+
threadCreated: "snagset:thread-created",
|
|
226
|
+
threadResolved: "snagset:thread-resolved",
|
|
227
|
+
};
|
|
228
|
+
/** Request/response header names inside §2.1's prefix (05 §2.3). */
|
|
229
|
+
export const HTTP_HEADER = {
|
|
230
|
+
client: "x-snagset-client",
|
|
231
|
+
schema: "x-snagset-schema",
|
|
232
|
+
idempotencyKey: "x-snagset-idempotency-key",
|
|
233
|
+
identity: "x-snagset-identity",
|
|
234
|
+
csrf: "x-snagset-csrf",
|
|
235
|
+
attachmentKind: "x-snagset-attachment-kind",
|
|
236
|
+
attachmentWidth: "x-snagset-attachment-width",
|
|
237
|
+
attachmentHeight: "x-snagset-attachment-height",
|
|
238
|
+
server: "x-snagset-server",
|
|
239
|
+
minClient: "x-snagset-min-client",
|
|
240
|
+
time: "x-snagset-time",
|
|
241
|
+
configVersion: "x-snagset-config-version",
|
|
242
|
+
idempotentReplay: "x-snagset-idempotent-replay",
|
|
243
|
+
requestId: "x-snagset-request-id",
|
|
244
|
+
};
|
|
245
|
+
/** Host-authored `<meta name>` keys we READ and never mint (§2.11). 03 §5.3: `route` is the only one. */
|
|
246
|
+
export const META_NAME = {
|
|
247
|
+
route: "snagset:route",
|
|
248
|
+
};
|
|
249
|
+
/** Keys for Symbol.for() patch bookkeeping (§2.11). */
|
|
250
|
+
export const SYMBOL_KEY = {
|
|
251
|
+
original: "snagset.original",
|
|
252
|
+
wrapped: "snagset.wrapped",
|
|
253
|
+
instance: "snagset.instance",
|
|
254
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isSensitiveKey, redactText, redactUrl } from "./predicates.js";
|
|
2
|
+
/**
|
|
3
|
+
* `redactObject` — 04 §4.5's rule for `Breadcrumb.data` (v0.1) and, later,
|
|
4
|
+
* `CaptureContext.custom` (v1): **key-first, then `redactText` on every leaf,
|
|
5
|
+
* URL-shaped leaves via `redactUrl`**.
|
|
6
|
+
*
|
|
7
|
+
* Key-first matters because the value under a bad key is often innocent-LOOKING
|
|
8
|
+
* — `{ password: "hunter2" }` has a value no shape rule would flag, and the key
|
|
9
|
+
* is the only evidence. When the key condemns it, the whole value goes,
|
|
10
|
+
* whatever its type: an object under `credentials:` is not walked for salvage.
|
|
11
|
+
*
|
|
12
|
+
* A SEPARATE FILE from predicates.ts, deliberately: the runtime re-exports the
|
|
13
|
+
* three predicate functions as resident surface (D48 residency), and keeping
|
|
14
|
+
* the walker out of that module means its bytes can never ride into the
|
|
15
|
+
* activation envelope on the strength of a re-export. Chunks that need it —
|
|
16
|
+
* crumbs, capture — bundle it from this one source.
|
|
17
|
+
*
|
|
18
|
+
* Depth and size discipline belongs to the SERIALIZER (04 §3.6b) — by the time
|
|
19
|
+
* data reaches here it is already bounded. The walker still carries its own
|
|
20
|
+
* depth stop so a caller who skips the serializer cannot make it spin.
|
|
21
|
+
*/
|
|
22
|
+
const URLISH = /^https?:\/\//i;
|
|
23
|
+
const MARK = "[redacted]";
|
|
24
|
+
export function redactObject(v, depth = 4) {
|
|
25
|
+
if (typeof v === "string")
|
|
26
|
+
return URLISH.test(v) ? redactUrl(v) : redactText(v);
|
|
27
|
+
if (v === null || typeof v !== "object")
|
|
28
|
+
return v; // numbers, booleans, undefined pass
|
|
29
|
+
if (depth <= 0)
|
|
30
|
+
return MARK; // deeper than the serializer allows — refuse, don't trust
|
|
31
|
+
if (Array.isArray(v))
|
|
32
|
+
return v.map((item) => redactObject(item, depth - 1));
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const [k, val] of Object.entries(v)) {
|
|
35
|
+
out[k] = isSensitiveKey(k) ? MARK : redactObject(val, depth - 1);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { RESERVED_URL_PARAMS } from "../types/names.js";
|
|
2
|
+
/**
|
|
3
|
+
* The redaction module (04 §4).
|
|
4
|
+
*
|
|
5
|
+
* 04 §4.1: "Redaction is not optional and cannot be disabled — only *extended*.
|
|
6
|
+
* There is no `redact: false`." v0 ships `redactText`, `redactUrl`, every shape
|
|
7
|
+
* rule in §4.3 and `isSensitiveValue` — because `CaptureContext.url` needs the
|
|
8
|
+
* first two and 03's selector generation needs the third. `redactObject` and the
|
|
9
|
+
* record-time pipeline arrive with breadcrumbs in v0.1.
|
|
10
|
+
*
|
|
11
|
+
* ── Why this file lives in the RUNTIME chunk ──
|
|
12
|
+
*
|
|
13
|
+
* 04 §1 says the module lives in `snag.capture.js`. It cannot at v0: 06 §1.1
|
|
14
|
+
* tags that artefact v0.1 so it does not exist, AC-P15 forbids activation
|
|
15
|
+
* fetching any capture chunk, and 03's `createElementAnchor` calls
|
|
16
|
+
* `isSensitiveValue` SYNCHRONOUSLY at pin time so it cannot await an import.
|
|
17
|
+
* plan/PLAN.md §1.4 rules on it: "One copy, in snag.runtime.js; snag.capture.js
|
|
18
|
+
* imports from it." The spec edit that records the inversion is still owed.
|
|
19
|
+
*
|
|
20
|
+
* ── The one thing to know before editing ──
|
|
21
|
+
*
|
|
22
|
+
* Every constant below carries `/g`, and `RegExp.prototype.test` on a global
|
|
23
|
+
* regex is STATEFUL — it advances `lastIndex` and the next call starts from
|
|
24
|
+
* there. `isSensitiveValue` is called once per candidate attribute per element,
|
|
25
|
+
* so a naive `EMAIL.test(v)` returns true, then false, then true on identical
|
|
26
|
+
* input. `hits()` exists for exactly that reason.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* The key vocabulary, byte-for-byte from 00 §9.
|
|
30
|
+
*
|
|
31
|
+
* 04 §4.3: "if the two ever differ, 00 wins." `pin` and `sig` are DELIBERATELY
|
|
32
|
+
* absent — 00 §9 gives the reason, and adding them is not a fix.
|
|
33
|
+
*/
|
|
34
|
+
const KEY = `pass(?:word|wd)?|pwd|secret|token|api[_-]?key|apikey|auth(?:orization)?|` +
|
|
35
|
+
`credential|session|sid|cookie|jwt|bearer|otp|mfa|totp|cvv|cvc|ssn|nino|iban|` +
|
|
36
|
+
`card(?:[_-]?number)?|account[_-]?number|private[_-]?key|signature|` +
|
|
37
|
+
`client[_-]?secret|refresh[_-]?token|access[_-]?token`;
|
|
38
|
+
/**
|
|
39
|
+
* key = value, in any of the shapes real payloads use. 00 §9, verbatim.
|
|
40
|
+
*
|
|
41
|
+
* THE LEADING BOUNDARY IS A LOOKBEHIND, NOT `\b`. `_` is a word character, so
|
|
42
|
+
* `\b` finds no boundary between the `B` and the `P` of `DB_PASSWORD` and the
|
|
43
|
+
* whole snake_case convention was invisible to this rule: `db_password=…`,
|
|
44
|
+
* `oauth_token=…`, `csrf_token=…`, `app_secret=…` and every SCREAMING_SNAKE
|
|
45
|
+
* environment variable came back byte-identical, while the bare `password=…`
|
|
46
|
+
* and the hyphenated `X-API-KEY=…` were redacted correctly. `refresh_token`,
|
|
47
|
+
* `access_token`, `client_secret`, `api_key`, `private_key`, `card_number` and
|
|
48
|
+
* `account_number` escaped only because KEY happens to spell those exact
|
|
49
|
+
* compounds out. `(?<![A-Za-z0-9])` allows a preceding `_`, `-`, `.`, quote,
|
|
50
|
+
* space or start-of-string and still refuses a letter or digit, so `mypassword`
|
|
51
|
+
* stays unmatched exactly as `\b` intended.
|
|
52
|
+
*
|
|
53
|
+
* Group 1 is the optional quote BEFORE the key; group 2 is the value's opening
|
|
54
|
+
* quote; group 3 the quoted value body; group 4 the unquoted value. The only
|
|
55
|
+
* backreference in the whole rule set is this one's `\2`, which is deliberate:
|
|
56
|
+
* 04 §4.3 makes catastrophic backtracking a hard review criterion, because it
|
|
57
|
+
* is a denial of service on the reviewer's own main thread.
|
|
58
|
+
*/
|
|
59
|
+
const KV = new RegExp(`(["'\`]?)(?<![A-Za-z0-9])(?:${KEY})[\\w-]*\\b\\1?\\s*[:=]\\s*` +
|
|
60
|
+
`(?:(["'\`])((?:\\\\.|(?!\\2)[^\\\\])*)\\2|([^\\s,;&}\\])]{3,}))`, "gi");
|
|
61
|
+
/** The auth schemes AUTH_SCHEME knows. Used by the carve-out below. */
|
|
62
|
+
const SCHEME_WORD = /^(?:Bearer|Basic|Digest|Negotiate|Token)$/i;
|
|
63
|
+
/**
|
|
64
|
+
* Replace the VALUE and keep everything else — the key, the separator, the
|
|
65
|
+
* whitespace and the quotes. A redaction that ate the key name would destroy
|
|
66
|
+
* the one part of the pair that is diagnostic rather than secret.
|
|
67
|
+
*
|
|
68
|
+
* THE CARVE-OUT, and the leak that forced it. KV's unquoted-value branch stops
|
|
69
|
+
* at whitespace, so on the single most canonical secret in existence —
|
|
70
|
+
*
|
|
71
|
+
* Authorization: Bearer abcdefgh12345
|
|
72
|
+
*
|
|
73
|
+
* — it matched the key `Authorization`, took `Bearer` as the whole value, and
|
|
74
|
+
* emitted `Authorization: [redacted] abcdefgh12345`. The word was redacted and
|
|
75
|
+
* THE TOKEN WAS PUBLISHED. AUTH_SCHEME could no longer help, because the scheme
|
|
76
|
+
* it keys on had just been replaced. Found by running the rules rather than
|
|
77
|
+
* reading them, which is what 00 §9's own preamble asks for.
|
|
78
|
+
*
|
|
79
|
+
* So AUTH_SCHEME runs first (see redactText), and KV then leaves a scheme word
|
|
80
|
+
* alone *only when the credential after it is already gone*. Both halves are
|
|
81
|
+
* needed: skipping unconditionally would let `Bearer abc` — too short for
|
|
82
|
+
* AUTH_SCHEME's 8-char floor — survive untouched, which is worse than the bug.
|
|
83
|
+
*/
|
|
84
|
+
const kvReplace = (m, _q0, q, qv, uv, offset, whole) => {
|
|
85
|
+
if (!q && SCHEME_WORD.test(uv) && whole.slice(offset + m.length).startsWith(" [redacted]")) {
|
|
86
|
+
return m;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* IDEMPOTENCE. The unquoted-value class `[^\s,;&}\])]{3,}` excludes `]`, so
|
|
90
|
+
* against an already-redacted `token=[redacted]` it matches `[redacted`,
|
|
91
|
+
* substitutes `[redacted]`, and strands the original bracket — producing
|
|
92
|
+
* `token=[redacted]]`. Found end-to-end: `redactUrl` redacts structurally and
|
|
93
|
+
* then runs the text rules over its own output, so this fired on every
|
|
94
|
+
* captured URL with a query string. A redaction that corrupts its own marker
|
|
95
|
+
* reads as a bug in the export rather than as a redaction.
|
|
96
|
+
*/
|
|
97
|
+
if ((q ? qv : uv).startsWith("[redacted"))
|
|
98
|
+
return m;
|
|
99
|
+
const val = q ? `${q}${qv}${q}` : uv;
|
|
100
|
+
const head = m.slice(0, m.length - val.length);
|
|
101
|
+
return head + (q ? `${q}[redacted]${q}` : "[redacted]");
|
|
102
|
+
};
|
|
103
|
+
// The shape rules (04 §4.3), verbatim.
|
|
104
|
+
const DATA_URL = /data:[a-z0-9.+-]*\/?[a-z0-9.+-]*;base64,[A-Za-z0-9+/=]{16,}/gi;
|
|
105
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{0,}/g;
|
|
106
|
+
const KEY_PREFIX = /\b(sk-[A-Za-z0-9]+-[A-Za-z0-9_-]{20,}|sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{20,}|(sk|pk|rk)_(live|test)_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|xapp-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|ya29\.[A-Za-z0-9_-]{20,}|SG\.[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{30,}|dop_v1_[a-f0-9]{40,}|shpat_[a-f0-9]{30,}|sbp_[a-f0-9]{30,}|hf_[A-Za-z0-9]{30,}|nvapi-[A-Za-z0-9_-]{20,})\b/g;
|
|
107
|
+
const AUTH_SCHEME = /\b(Bearer|Basic|Digest|Negotiate|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
108
|
+
const EMAIL = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z]{2,24})+\b/g;
|
|
109
|
+
const CARD_CAND = /\b(?:\d[ -]?){12,18}\d\b/g;
|
|
110
|
+
const B64 = /\b(?=[A-Za-z0-9+/]*[a-z])(?=[A-Za-z0-9+/]*[A-Z])(?=[A-Za-z0-9+/]*\d)[A-Za-z0-9+/]{40,}={0,2}\b/g;
|
|
111
|
+
const HEX = /\b[0-9a-f]{32,}\b/gi;
|
|
112
|
+
/**
|
|
113
|
+
* EXTENSIONS. 04 §4.1: redaction "cannot be disabled — only *extended*", which
|
|
114
|
+
* makes adding a rule the sanctioned move and rewriting a spec-verbatim one the
|
|
115
|
+
* unsanctioned one. Every constant above is copied from 00 §9 / 04 §4.3 and
|
|
116
|
+
* stays untouched; each rule below closes a hole an adversarial pass found by
|
|
117
|
+
* running the module, with the shape that got through named in its comment.
|
|
118
|
+
*/
|
|
119
|
+
/**
|
|
120
|
+
* Base64URL. B64's class is `[A-Za-z0-9+/]`, verbatim from 00 §9 — so a single
|
|
121
|
+
* `_` or `-` chops a token into runs under the 40-char floor and B64 never
|
|
122
|
+
* fires at all. A Cloudflare API token is exactly 40 characters of
|
|
123
|
+
* `[A-Za-z0-9_-]` with no vendor prefix for KEY_PREFIX to key on, and walked
|
|
124
|
+
* through untouched.
|
|
125
|
+
*/
|
|
126
|
+
const B64URL = /\b(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{40,}\b/g;
|
|
127
|
+
/**
|
|
128
|
+
* `vendor_prefix_PAYLOAD` — the shape KEY_PREFIX cannot enumerate.
|
|
129
|
+
*
|
|
130
|
+
* KEY_PREFIX lists vendors one by one, so every provider not on the list ships
|
|
131
|
+
* whole: Docker Hub PATs, Stripe webhook signing secrets, Supabase service
|
|
132
|
+
* keys, Netlify and RubyGems tokens. Their payloads are short enough to sit
|
|
133
|
+
* under B64's 40-char floor, so nothing else catches them either. The two
|
|
134
|
+
* lookaheads — payload must contain BOTH an uppercase letter and a digit — are
|
|
135
|
+
* what keep this off ordinary snake_case identifiers.
|
|
136
|
+
*/
|
|
137
|
+
const VENDOR_TOKEN = /\b[a-z][a-z0-9]{1,15}(?:_[a-z0-9]{1,15}){0,2}_(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{24,}\b/g;
|
|
138
|
+
/**
|
|
139
|
+
* Credentials in a URL authority: `scheme://user:pass@host`.
|
|
140
|
+
*
|
|
141
|
+
* EMAIL does not cover it — it requires a dotted TLD after the `@`, so a bare
|
|
142
|
+
* hostname, an IP or a `+` in the scheme all slip past. `redactUrl` strips
|
|
143
|
+
* these structurally, but a URL sitting inside free text never reaches that
|
|
144
|
+
* path, and neither does `isSensitiveValue`.
|
|
145
|
+
*/
|
|
146
|
+
const URL_CRED = /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/gi;
|
|
147
|
+
/**
|
|
148
|
+
* A whole value that IS one unbroken high-entropy token, whatever vendor minted
|
|
149
|
+
* it (used by `isSensitiveValue` only).
|
|
150
|
+
*
|
|
151
|
+
* ANCHORED, deliberately: it must fire on a value that is a credential and
|
|
152
|
+
* never on prose that merely contains one, or 04 §4.8's negative corpus —
|
|
153
|
+
* `/orders/2024`, `4 items`, `cardNumber` — starts failing and 03 loses
|
|
154
|
+
* selectors it should have kept. Mixed case AND a digit AND 24+ characters is
|
|
155
|
+
* what separates an Azure client secret from `product-card-item-12345678`.
|
|
156
|
+
*/
|
|
157
|
+
const OPAQUE = /^(?=[A-Za-z0-9_~.+/=-]*[a-z])(?=[A-Za-z0-9_~.+/=-]*[A-Z])(?=[A-Za-z0-9_~.+/=-]*\d)[A-Za-z0-9_~.+/=-]{24,}$/;
|
|
158
|
+
/**
|
|
159
|
+
* Does this string contain a match, without the caller inheriting `lastIndex`?
|
|
160
|
+
*
|
|
161
|
+
* `test()` on a `/g` regex mutates the regex. Resetting before each use is the
|
|
162
|
+
* cheap fix and keeps a single copy of every pattern, which AC C1c cares about:
|
|
163
|
+
* it greps for a SECOND definition of any of these shapes outside this
|
|
164
|
+
* directory, so cloning them per call would be the wrong kind of clever.
|
|
165
|
+
*/
|
|
166
|
+
function hits(re, s) {
|
|
167
|
+
re.lastIndex = 0;
|
|
168
|
+
const found = re.test(s);
|
|
169
|
+
re.lastIndex = 0;
|
|
170
|
+
return found;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The Luhn checksum. 04 §4.3: "The Luhn gate is not optional and not forkable."
|
|
174
|
+
*
|
|
175
|
+
* Three mechanics the spec asserts without specifying, decided here and written
|
|
176
|
+
* down rather than left as folklore:
|
|
177
|
+
* · separators are stripped before the checksum, because CARD_CAND matches
|
|
178
|
+
* digits interleaved with single spaces or hyphens and a checksum over
|
|
179
|
+
* "4111 1111" is meaningless;
|
|
180
|
+
* · the length window is 13–19, the range of real PANs;
|
|
181
|
+
* · a candidate that FAILS is left entirely untouched — no retry on a shorter
|
|
182
|
+
* window inside the same digit run. A 16-digit order number that happens to
|
|
183
|
+
* sit next to a date should survive as itself.
|
|
184
|
+
*/
|
|
185
|
+
function luhn(raw) {
|
|
186
|
+
const d = raw.replace(/[ -]/g, "");
|
|
187
|
+
if (d.length < 13 || d.length > 19)
|
|
188
|
+
return false;
|
|
189
|
+
let sum = 0;
|
|
190
|
+
let alt = false;
|
|
191
|
+
for (let i = d.length - 1; i >= 0; i--) {
|
|
192
|
+
let n = d.charCodeAt(i) - 48;
|
|
193
|
+
if (alt) {
|
|
194
|
+
n *= 2;
|
|
195
|
+
if (n > 9)
|
|
196
|
+
n -= 9;
|
|
197
|
+
}
|
|
198
|
+
sum += n;
|
|
199
|
+
alt = !alt;
|
|
200
|
+
}
|
|
201
|
+
return sum % 10 === 0;
|
|
202
|
+
}
|
|
203
|
+
/** CARD_CAND ∧ Luhn — the only card test anywhere. */
|
|
204
|
+
function cardHit(s) {
|
|
205
|
+
CARD_CAND.lastIndex = 0;
|
|
206
|
+
for (const m of s.matchAll(CARD_CAND))
|
|
207
|
+
if (luhn(m[0]))
|
|
208
|
+
return true;
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Free text — messages, serialised data, accessible names (04 §4.2).
|
|
213
|
+
*
|
|
214
|
+
* ORDER IS NORMATIVE and the reason is stated in 04 §4.2: "broader patterns run
|
|
215
|
+
* later so they cannot swallow the input a narrower pattern would have labelled
|
|
216
|
+
* precisely." A JWT is base64-ish and hex-ish; if B64 ran first every token
|
|
217
|
+
* would come out as `[redacted:b64:210]` and the reviewer would lose the one
|
|
218
|
+
* word that told them what leaked. KV runs at position 2, before every shape
|
|
219
|
+
* rule, and both always run.
|
|
220
|
+
*
|
|
221
|
+
* No caps are applied here. §4.6's pre/post caps are PER FIELD and this
|
|
222
|
+
* signature carries no field identity — the caller applies them, which at v0
|
|
223
|
+
* means exactly one call site (`CaptureContext.url`).
|
|
224
|
+
*/
|
|
225
|
+
export function redactText(s) {
|
|
226
|
+
let out = s;
|
|
227
|
+
out = out.replace(DATA_URL, "data:[redacted]"); // 1
|
|
228
|
+
// AUTH_SCHEME is MOVED AHEAD OF KV — 04 §4.2 numbers it 5. The letter of the
|
|
229
|
+
// table leaks (see kvReplace); its stated principle — "broader patterns run
|
|
230
|
+
// later so they cannot swallow the input a narrower pattern would have
|
|
231
|
+
// labelled precisely" — is exactly why it has to move, because KV is the
|
|
232
|
+
// broader of the two. Following the rule rather than the numbering.
|
|
233
|
+
//
|
|
234
|
+
// `$1 [redacted]`, NOT the literal "Bearer [redacted]" the table cell shows:
|
|
235
|
+
// the pattern captures the scheme, so `Basic …` must produce `Basic
|
|
236
|
+
// [redacted]`. The cell is an example.
|
|
237
|
+
out = out.replace(AUTH_SCHEME, "$1 [redacted]");
|
|
238
|
+
out = out.replace(KV, kvReplace); // 2
|
|
239
|
+
out = out.replace(JWT, "[redacted:jwt]"); // 3
|
|
240
|
+
out = out.replace(KEY_PREFIX, "[redacted:key]"); // 4
|
|
241
|
+
out = out.replace(VENDOR_TOKEN, "[redacted:key]"); // 4b — see EXTENSIONS
|
|
242
|
+
out = out.replace(URL_CRED, "[redacted:url-cred]@"); // 4c
|
|
243
|
+
out = out.replace(EMAIL, "[redacted:email]"); // 6
|
|
244
|
+
out = out.replace(CARD_CAND, (m) => (luhn(m) ? "[redacted:card]" : m)); // 7
|
|
245
|
+
// 8 and 9 carry the MATCHED length, which is the only length computable
|
|
246
|
+
// without decoding. It is fixture-visible output, so it is stated here.
|
|
247
|
+
out = out.replace(B64URL, (m) => `[redacted:b64:${m.length}]`); // 8b
|
|
248
|
+
out = out.replace(B64, (m) => `[redacted:b64:${m.length}]`);
|
|
249
|
+
out = out.replace(HEX, (m) => `[redacted:hex:${m.length}]`);
|
|
250
|
+
// 10 (UUID) is gated on `redact.redactUuids`, v0.1, default false.
|
|
251
|
+
// 11 (server-supplied patterns) is v1. Neither is reachable at v0.
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
/** Our own registered query parameters. Values are KEPT — see redactUrl. */
|
|
255
|
+
const OURS = RESERVED_URL_PARAMS.query;
|
|
256
|
+
/** The review-link secret (00 §2.5). It lives in the FRAGMENT and is redacted
|
|
257
|
+
* wherever it appears — the one parameter whose value is never displayable. */
|
|
258
|
+
const SECRET_PARAMS = RESERVED_URL_PARAMS.fragment;
|
|
259
|
+
/**
|
|
260
|
+
* A URL: a structured pass first, then `redactText` over the serialisation
|
|
261
|
+
* (04 §4.4).
|
|
262
|
+
*
|
|
263
|
+
* ALLOW-LIST, NOT DENY-LIST. Parameter *names* survive because the name alone is
|
|
264
|
+
* diagnostic; values do not. The default is empty and stays empty — 04 §4.4 is
|
|
265
|
+
* explicit that "the failure mode of a too-loose default (leaking a magic-link
|
|
266
|
+
* token in every captured URL) is catastrophic". A starter list worth
|
|
267
|
+
* documenting, never defaulting: page, tab, view, sort, filter, locale, lang.
|
|
268
|
+
*
|
|
269
|
+
* Our own parameters keep their values, because a report about the wrong
|
|
270
|
+
* permalink is unreadable without the permalink — except `snag_k`, which is the
|
|
271
|
+
* review-link secret and is always redacted.
|
|
272
|
+
*
|
|
273
|
+
* URL PATHS ARE NOT FULLY REDACTED (04 §6 item 4). Path segments get no
|
|
274
|
+
* structural treatment; they are covered only by the trailing `redactText`.
|
|
275
|
+
* That is deliberate and documented, not an oversight.
|
|
276
|
+
*/
|
|
277
|
+
export function redactUrl(raw, allow = []) {
|
|
278
|
+
let u;
|
|
279
|
+
try {
|
|
280
|
+
// `globalThis.location`, reached through a cast, rather than the bare
|
|
281
|
+
// `location` global. The runtime guard was always right; the TYPE was
|
|
282
|
+
// browser-only, and 07 §2.6.2 requires this module to be usable
|
|
283
|
+
// server-side ("publish that module as an isomorphic export usable
|
|
284
|
+
// server-side, not only inside widget-core"). Under Node's lib the bare
|
|
285
|
+
// global does not exist to name, so the file did not compile there at all.
|
|
286
|
+
const here = globalThis.location?.href;
|
|
287
|
+
u = new URL(raw, here);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Not parseable as a URL — it still goes through the text rules rather than
|
|
291
|
+
// through untouched. §4.7: nothing reaches a surface un-redacted.
|
|
292
|
+
return redactText(raw);
|
|
293
|
+
}
|
|
294
|
+
u.username = "";
|
|
295
|
+
u.password = "";
|
|
296
|
+
for (const k of [...u.searchParams.keys()]) {
|
|
297
|
+
// From the registry, not typed: `snag_k` is a registered name (00 §2.5) and
|
|
298
|
+
// a rename would have left this literal behind — so the ONE parameter that
|
|
299
|
+
// must always be redacted would quietly stop being.
|
|
300
|
+
if (SECRET_PARAMS.includes(k)) {
|
|
301
|
+
u.searchParams.set(k, "[redacted]");
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (OURS.includes(k))
|
|
305
|
+
continue;
|
|
306
|
+
if (!allow.includes(k)) {
|
|
307
|
+
u.searchParams.set(k, "[redacted]");
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
// AN ALLOWED PARAMETER IS NOT A LICENCE. The trailing text pass runs over
|
|
311
|
+
// the SERIALISED url, where the value is still percent-encoded — so
|
|
312
|
+
// `?filter=password%3DSuperSecret123` defeated KV outright, because `%3D`
|
|
313
|
+
// is not `=`. `searchParams.get` decodes, so checking here is the only
|
|
314
|
+
// place the rules see what the value actually says. If they recognise
|
|
315
|
+
// anything in it, the whole value goes: an operator allow-listing a
|
|
316
|
+
// parameter is saying its values are ordinarily safe, not promising it.
|
|
317
|
+
const decoded = u.searchParams.get(k) ?? "";
|
|
318
|
+
if (redactText(decoded) !== decoded)
|
|
319
|
+
u.searchParams.set(k, "[redacted]");
|
|
320
|
+
}
|
|
321
|
+
// The fragment is redacted WHOLE, not per key: it is where the review-link
|
|
322
|
+
// secret lives, and a per-key pass would leave its shape visible.
|
|
323
|
+
if (u.hash && u.hash.length > 1)
|
|
324
|
+
u.hash = allow.includes("#") ? u.hash : "#[redacted]";
|
|
325
|
+
// `URLSearchParams` serialises form-urlencoded, which escapes [ and ] — so
|
|
326
|
+
// `set(k, "[redacted]")` comes back out as `token=%5Bredacted%5D` and 04's own
|
|
327
|
+
// illustration (`?utm_source=[redacted]&token=[redacted]`) is unreachable.
|
|
328
|
+
// Un-escaping just our own marker is narrower than hand-building the query
|
|
329
|
+
// string and does not touch any value the host page owns.
|
|
330
|
+
return redactText(u.toString().replace(/%5Bredacted%5D/gi, "[redacted]"));
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Should this attribute value stay out of a generated selector? (04 §4.8)
|
|
334
|
+
*
|
|
335
|
+
* THE SINGLE NORMATIVE DEFINITION — 03 imports this symbol rather than carrying
|
|
336
|
+
* its own copy, and AC C1c greps for a second one. The 120-char clause is 04's
|
|
337
|
+
* value; 03's 100 is deleted. The hex threshold is 32; 03's 24 is deleted.
|
|
338
|
+
*
|
|
339
|
+
* Composed from exactly seven shapes plus the length clause — deliberately NOT
|
|
340
|
+
* from KV, AUTH_SCHEME or UUID, which describe text that contains a secret
|
|
341
|
+
* rather than a value that IS one.
|
|
342
|
+
*
|
|
343
|
+
* 04 §4.8 is careful about what this does not do: "This does not make selectors
|
|
344
|
+
* private" — `a[href="/users/jane.doe/invoices"]` still passes the guard. It
|
|
345
|
+
* keeps credentials out of selectors, not personal data.
|
|
346
|
+
*/
|
|
347
|
+
/**
|
|
348
|
+
* The key test for OBJECT keys — 04 §4.5's "key-first" step for
|
|
349
|
+
* `Breadcrumb.data` and (v1) `custom`. Same vocabulary, same boundary
|
|
350
|
+
* semantics as KV: the lookbehind is what lets `DB_PASSWORD` and the
|
|
351
|
+
* `[\w-]*` tail is what lets `refreshToken`/`token_count` match, exactly as
|
|
352
|
+
* the string form `db_password=…` would. One list, two syntaxes — a separate
|
|
353
|
+
* key list here is the fork 00 §14.2 forbids.
|
|
354
|
+
*/
|
|
355
|
+
const KEY_NAME = new RegExp(`(?<![A-Za-z0-9])(?:${KEY})[\\w-]*$`, "i");
|
|
356
|
+
export function isSensitiveKey(k) {
|
|
357
|
+
return KEY_NAME.test(k);
|
|
358
|
+
}
|
|
359
|
+
export function isSensitiveValue(v) {
|
|
360
|
+
if (v.length > 120)
|
|
361
|
+
return true;
|
|
362
|
+
return (hits(EMAIL, v) ||
|
|
363
|
+
hits(JWT, v) ||
|
|
364
|
+
hits(KEY_PREFIX, v) ||
|
|
365
|
+
hits(HEX, v) ||
|
|
366
|
+
hits(B64, v) ||
|
|
367
|
+
hits(DATA_URL, v) ||
|
|
368
|
+
// The extensions. This predicate is the ENTIRE defence for AnchorRecord
|
|
369
|
+
// .label — 04 §4.5 gives that field redaction "none" precisely because this
|
|
370
|
+
// gate stands in front of it — so a shape that gets past here goes verbatim
|
|
371
|
+
// into storage, the dock, the Markdown export and any issue built from it.
|
|
372
|
+
hits(B64URL, v) ||
|
|
373
|
+
hits(VENDOR_TOKEN, v) ||
|
|
374
|
+
hits(URL_CRED, v) ||
|
|
375
|
+
OPAQUE.test(v) ||
|
|
376
|
+
cardHit(v));
|
|
377
|
+
}
|